summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places')
-rw-r--r--toolkit/components/places/BookmarkHTMLUtils.jsm1188
-rw-r--r--toolkit/components/places/BookmarkJSONUtils.jsm589
-rw-r--r--toolkit/components/places/Bookmarks.jsm1536
-rw-r--r--toolkit/components/places/ClusterLib.js248
-rw-r--r--toolkit/components/places/ColorAnalyzer.js90
-rw-r--r--toolkit/components/places/ColorAnalyzer_worker.js392
-rw-r--r--toolkit/components/places/ColorConversion.js64
-rw-r--r--toolkit/components/places/Database.cpp2333
-rw-r--r--toolkit/components/places/Database.h331
-rw-r--r--toolkit/components/places/ExtensionSearchHandler.jsm292
-rw-r--r--toolkit/components/places/FaviconHelpers.cpp934
-rw-r--r--toolkit/components/places/FaviconHelpers.h273
-rw-r--r--toolkit/components/places/Helpers.cpp395
-rw-r--r--toolkit/components/places/Helpers.h296
-rw-r--r--toolkit/components/places/History.cpp2977
-rw-r--r--toolkit/components/places/History.h224
-rw-r--r--toolkit/components/places/History.jsm1049
-rw-r--r--toolkit/components/places/PageIconProtocolHandler.js128
-rw-r--r--toolkit/components/places/PlaceInfo.cpp137
-rw-r--r--toolkit/components/places/PlaceInfo.h50
-rw-r--r--toolkit/components/places/PlacesBackups.jsm550
-rw-r--r--toolkit/components/places/PlacesCategoriesStarter.js110
-rw-r--r--toolkit/components/places/PlacesDBUtils.jsm1138
-rw-r--r--toolkit/components/places/PlacesRemoteTabsAutocompleteProvider.jsm148
-rw-r--r--toolkit/components/places/PlacesSearchAutocompleteProvider.jsm295
-rw-r--r--toolkit/components/places/PlacesSyncUtils.jsm1155
-rw-r--r--toolkit/components/places/PlacesTransactions.jsm1645
-rw-r--r--toolkit/components/places/PlacesUtils.jsm3863
-rw-r--r--toolkit/components/places/SQLFunctions.cpp941
-rw-r--r--toolkit/components/places/SQLFunctions.h394
-rw-r--r--toolkit/components/places/Shutdown.cpp233
-rw-r--r--toolkit/components/places/Shutdown.h171
-rw-r--r--toolkit/components/places/UnifiedComplete.js2149
-rw-r--r--toolkit/components/places/VisitInfo.cpp69
-rw-r--r--toolkit/components/places/VisitInfo.h37
-rw-r--r--toolkit/components/places/moz.build97
-rw-r--r--toolkit/components/places/mozIAsyncFavicons.idl174
-rw-r--r--toolkit/components/places/mozIAsyncHistory.idl188
-rw-r--r--toolkit/components/places/mozIAsyncLivemarks.idl190
-rw-r--r--toolkit/components/places/mozIColorAnalyzer.idl52
-rw-r--r--toolkit/components/places/mozIPlacesAutoComplete.idl138
-rw-r--r--toolkit/components/places/mozIPlacesPendingOperation.idl14
-rw-r--r--toolkit/components/places/nsAnnoProtocolHandler.cpp367
-rw-r--r--toolkit/components/places/nsAnnoProtocolHandler.h54
-rw-r--r--toolkit/components/places/nsAnnotationService.cpp1990
-rw-r--r--toolkit/components/places/nsAnnotationService.h161
-rw-r--r--toolkit/components/places/nsFaviconService.cpp716
-rw-r--r--toolkit/components/places/nsFaviconService.h147
-rw-r--r--toolkit/components/places/nsIAnnotationService.idl422
-rw-r--r--toolkit/components/places/nsIBrowserHistory.idl70
-rw-r--r--toolkit/components/places/nsIFaviconService.idl145
-rw-r--r--toolkit/components/places/nsINavBookmarksService.idl697
-rw-r--r--toolkit/components/places/nsINavHistoryService.idl1451
-rw-r--r--toolkit/components/places/nsITaggingService.idl95
-rw-r--r--toolkit/components/places/nsLivemarkService.js891
-rw-r--r--toolkit/components/places/nsMaybeWeakPtr.h145
-rw-r--r--toolkit/components/places/nsNavBookmarks.cpp2926
-rw-r--r--toolkit/components/places/nsNavBookmarks.h445
-rw-r--r--toolkit/components/places/nsNavHistory.cpp4523
-rw-r--r--toolkit/components/places/nsNavHistory.h659
-rw-r--r--toolkit/components/places/nsNavHistoryQuery.cpp1694
-rw-r--r--toolkit/components/places/nsNavHistoryQuery.h160
-rw-r--r--toolkit/components/places/nsNavHistoryResult.cpp4813
-rw-r--r--toolkit/components/places/nsNavHistoryResult.h782
-rw-r--r--toolkit/components/places/nsPIPlacesDatabase.idl52
-rw-r--r--toolkit/components/places/nsPlacesExpiration.js1105
-rw-r--r--toolkit/components/places/nsPlacesIndexes.h124
-rw-r--r--toolkit/components/places/nsPlacesMacros.h82
-rw-r--r--toolkit/components/places/nsPlacesModule.cpp70
-rw-r--r--toolkit/components/places/nsPlacesTables.h154
-rw-r--r--toolkit/components/places/nsPlacesTriggers.h267
-rw-r--r--toolkit/components/places/nsTaggingService.js709
-rw-r--r--toolkit/components/places/tests/.eslintrc.js9
-rw-r--r--toolkit/components/places/tests/PlacesTestUtils.jsm163
-rw-r--r--toolkit/components/places/tests/bookmarks/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/bookmarks/head_bookmarks.js20
-rw-r--r--toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js103
-rw-r--r--toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js112
-rw-r--r--toolkit/components/places/tests/bookmarks/test_1129529.js76
-rw-r--r--toolkit/components/places/tests/bookmarks/test_384228.js98
-rw-r--r--toolkit/components/places/tests/bookmarks/test_385829.js182
-rw-r--r--toolkit/components/places/tests/bookmarks/test_388695.js52
-rw-r--r--toolkit/components/places/tests/bookmarks/test_393498.js102
-rw-r--r--toolkit/components/places/tests/bookmarks/test_395101.js87
-rw-r--r--toolkit/components/places/tests/bookmarks/test_395593.js69
-rw-r--r--toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js221
-rw-r--r--toolkit/components/places/tests/bookmarks/test_417228-exclude-from-backup.js141
-rw-r--r--toolkit/components/places/tests/bookmarks/test_417228-other-roots.js158
-rw-r--r--toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js91
-rw-r--r--toolkit/components/places/tests/bookmarks/test_448584.js113
-rw-r--r--toolkit/components/places/tests/bookmarks/test_458683.js131
-rw-r--r--toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js124
-rw-r--r--toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js56
-rw-r--r--toolkit/components/places/tests/bookmarks/test_675416.js56
-rw-r--r--toolkit/components/places/tests/bookmarks/test_711914.js56
-rw-r--r--toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js59
-rw-r--r--toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js57
-rw-r--r--toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js57
-rw-r--r--toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js48
-rw-r--r--toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js37
-rw-r--r--toolkit/components/places/tests/bookmarks/test_async_observers.js177
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bmindex.js124
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks.js718
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js116
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js310
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js44
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js264
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js527
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js204
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js177
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_search.js223
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarks_update.js414
-rw-r--r--toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js18
-rw-r--r--toolkit/components/places/tests/bookmarks/test_changeBookmarkURI.js68
-rw-r--r--toolkit/components/places/tests/bookmarks/test_getBookmarkedURIFor.js84
-rw-r--r--toolkit/components/places/tests/bookmarks/test_keywords.js310
-rw-r--r--toolkit/components/places/tests/bookmarks/test_nsINavBookmarkObserver.js640
-rw-r--r--toolkit/components/places/tests/bookmarks/test_protectRoots.js37
-rw-r--r--toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js70
-rw-r--r--toolkit/components/places/tests/bookmarks/test_removeItem.js30
-rw-r--r--toolkit/components/places/tests/bookmarks/test_savedsearches.js209
-rw-r--r--toolkit/components/places/tests/bookmarks/xpcshell.ini50
-rw-r--r--toolkit/components/places/tests/browser/.eslintrc.js8
-rw-r--r--toolkit/components/places/tests/browser/399606-history.go-0.html11
-rw-r--r--toolkit/components/places/tests/browser/399606-httprefresh.html8
-rw-r--r--toolkit/components/places/tests/browser/399606-location.reload.html11
-rw-r--r--toolkit/components/places/tests/browser/399606-location.replace.html11
-rw-r--r--toolkit/components/places/tests/browser/399606-window.location.href.html11
-rw-r--r--toolkit/components/places/tests/browser/399606-window.location.html11
-rw-r--r--toolkit/components/places/tests/browser/461710_iframe.html8
-rw-r--r--toolkit/components/places/tests/browser/461710_link_page-2.html13
-rw-r--r--toolkit/components/places/tests/browser/461710_link_page-3.html13
-rw-r--r--toolkit/components/places/tests/browser/461710_link_page.html13
-rw-r--r--toolkit/components/places/tests/browser/461710_visited_page.html9
-rw-r--r--toolkit/components/places/tests/browser/begin.html10
-rw-r--r--toolkit/components/places/tests/browser/browser.ini26
-rw-r--r--toolkit/components/places/tests/browser/browser_bug248970.js152
-rw-r--r--toolkit/components/places/tests/browser/browser_bug399606.js77
-rw-r--r--toolkit/components/places/tests/browser/browser_bug461710.js82
-rw-r--r--toolkit/components/places/tests/browser/browser_bug646422.js51
-rw-r--r--toolkit/components/places/tests/browser/browser_bug680727.js109
-rw-r--r--toolkit/components/places/tests/browser/browser_colorAnalyzer.js259
-rw-r--r--toolkit/components/places/tests/browser/browser_double_redirect.js63
-rw-r--r--toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js43
-rw-r--r--toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage.js152
-rw-r--r--toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage_failures.js261
-rw-r--r--toolkit/components/places/tests/browser/browser_history_post.js23
-rw-r--r--toolkit/components/places/tests/browser/browser_notfound.js46
-rw-r--r--toolkit/components/places/tests/browser/browser_redirect.js61
-rw-r--r--toolkit/components/places/tests/browser/browser_settitle.js76
-rw-r--r--toolkit/components/places/tests/browser/browser_visited_notfound.js51
-rw-r--r--toolkit/components/places/tests/browser/browser_visituri.js84
-rw-r--r--toolkit/components/places/tests/browser/browser_visituri_nohistory.js42
-rw-r--r--toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js73
-rw-r--r--toolkit/components/places/tests/browser/colorAnalyzer/category-discover.pngbin0 -> 1324 bytes
-rw-r--r--toolkit/components/places/tests/browser/colorAnalyzer/dictionaryGeneric-16.pngbin0 -> 742 bytes
-rw-r--r--toolkit/components/places/tests/browser/colorAnalyzer/extensionGeneric-16.pngbin0 -> 554 bytes
-rw-r--r--toolkit/components/places/tests/browser/colorAnalyzer/localeGeneric.pngbin0 -> 2410 bytes
-rw-r--r--toolkit/components/places/tests/browser/favicon-normal16.pngbin0 -> 286 bytes
-rw-r--r--toolkit/components/places/tests/browser/favicon-normal32.pngbin0 -> 344 bytes
-rw-r--r--toolkit/components/places/tests/browser/favicon.html13
-rw-r--r--toolkit/components/places/tests/browser/final.html10
-rw-r--r--toolkit/components/places/tests/browser/head.js319
-rw-r--r--toolkit/components/places/tests/browser/history_post.html12
-rw-r--r--toolkit/components/places/tests/browser/history_post.sjs6
-rw-r--r--toolkit/components/places/tests/browser/redirect-target.html1
-rw-r--r--toolkit/components/places/tests/browser/redirect.sjs14
-rw-r--r--toolkit/components/places/tests/browser/redirect_once.sjs9
-rw-r--r--toolkit/components/places/tests/browser/redirect_twice.sjs9
-rw-r--r--toolkit/components/places/tests/browser/title1.html12
-rw-r--r--toolkit/components/places/tests/browser/title2.html14
-rw-r--r--toolkit/components/places/tests/chrome/.eslintrc.js8
-rw-r--r--toolkit/components/places/tests/chrome/bad_links.atom74
-rw-r--r--toolkit/components/places/tests/chrome/browser_disableglobalhistory.xul44
-rw-r--r--toolkit/components/places/tests/chrome/chrome.ini12
-rw-r--r--toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss18
-rw-r--r--toolkit/components/places/tests/chrome/link-less-items.rss19
-rw-r--r--toolkit/components/places/tests/chrome/rss_as_html.rss27
-rw-r--r--toolkit/components/places/tests/chrome/rss_as_html.rss^headers^2
-rw-r--r--toolkit/components/places/tests/chrome/sample_feed.atom23
-rw-r--r--toolkit/components/places/tests/chrome/test_303567.xul122
-rw-r--r--toolkit/components/places/tests/chrome/test_341972a.xul87
-rw-r--r--toolkit/components/places/tests/chrome/test_341972b.xul84
-rw-r--r--toolkit/components/places/tests/chrome/test_342484.xul88
-rw-r--r--toolkit/components/places/tests/chrome/test_371798.xul101
-rw-r--r--toolkit/components/places/tests/chrome/test_381357.xul85
-rw-r--r--toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xul26
-rw-r--r--toolkit/components/places/tests/chrome/test_favicon_annotations.xul168
-rw-r--r--toolkit/components/places/tests/chrome/test_reloadLivemarks.xul155
-rw-r--r--toolkit/components/places/tests/cpp/mock_Link.h229
-rw-r--r--toolkit/components/places/tests/cpp/moz.build14
-rw-r--r--toolkit/components/places/tests/cpp/places_test_harness.h413
-rw-r--r--toolkit/components/places/tests/cpp/places_test_harness_tail.h149
-rw-r--r--toolkit/components/places/tests/cpp/test_IHistory.cpp639
-rw-r--r--toolkit/components/places/tests/expiration/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/expiration/head_expiration.js124
-rw-r--r--toolkit/components/places/tests/expiration/test_analyze_runs.js118
-rw-r--r--toolkit/components/places/tests/expiration/test_annos_expire_history.js93
-rw-r--r--toolkit/components/places/tests/expiration/test_annos_expire_never.js95
-rw-r--r--toolkit/components/places/tests/expiration/test_annos_expire_policy.js189
-rw-r--r--toolkit/components/places/tests/expiration/test_annos_expire_session.js83
-rw-r--r--toolkit/components/places/tests/expiration/test_clearHistory.js157
-rw-r--r--toolkit/components/places/tests/expiration/test_debug_expiration.js225
-rw-r--r--toolkit/components/places/tests/expiration/test_idle_daily.js21
-rw-r--r--toolkit/components/places/tests/expiration/test_notifications.js38
-rw-r--r--toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js114
-rw-r--r--toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js142
-rw-r--r--toolkit/components/places/tests/expiration/test_outdated_analyze.js72
-rw-r--r--toolkit/components/places/tests/expiration/test_pref_interval.js61
-rw-r--r--toolkit/components/places/tests/expiration/test_pref_maxpages.js124
-rw-r--r--toolkit/components/places/tests/expiration/xpcshell.ini22
-rw-r--r--toolkit/components/places/tests/favicons/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.pngbin0 -> 3105 bytes
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.pngbin0 -> 563 bytes
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-big48.ico.pngbin0 -> 1425 bytes
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-big64.png.pngbin0 -> 3157 bytes
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.pngbin0 -> 175 bytes
-rw-r--r--toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.pngbin0 -> 169 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-big16.icobin0 -> 1406 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-big32.jpgbin0 -> 3494 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-big4.jpgbin0 -> 4751 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-big48.icobin0 -> 56646 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-big64.pngbin0 -> 10698 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-normal16.pngbin0 -> 286 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-normal32.pngbin0 -> 344 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-scale160x3.jpgbin0 -> 5095 bytes
-rw-r--r--toolkit/components/places/tests/favicons/favicon-scale3x160.jpgbin0 -> 5059 bytes
-rw-r--r--toolkit/components/places/tests/favicons/head_favicons.js105
-rw-r--r--toolkit/components/places/tests/favicons/test_expireAllFavicons.js39
-rw-r--r--toolkit/components/places/tests/favicons/test_favicons_conversions.js131
-rw-r--r--toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js57
-rw-r--r--toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js51
-rw-r--r--toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js90
-rw-r--r--toolkit/components/places/tests/favicons/test_page-icon_protocol.js66
-rw-r--r--toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js74
-rw-r--r--toolkit/components/places/tests/favicons/test_replaceFaviconData.js264
-rw-r--r--toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js352
-rw-r--r--toolkit/components/places/tests/favicons/xpcshell.ini32
-rw-r--r--toolkit/components/places/tests/head_common.js869
-rw-r--r--toolkit/components/places/tests/history/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/history/head_history.js19
-rw-r--r--toolkit/components/places/tests/history/test_insert.js257
-rw-r--r--toolkit/components/places/tests/history/test_remove.js360
-rw-r--r--toolkit/components/places/tests/history/test_removeVisits.js316
-rw-r--r--toolkit/components/places/tests/history/test_removeVisitsByFilter.js345
-rw-r--r--toolkit/components/places/tests/history/test_updatePlaces_sameUri_titleChanged.js52
-rw-r--r--toolkit/components/places/tests/history/xpcshell.ini9
-rw-r--r--toolkit/components/places/tests/migration/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/migration/head_migration.js46
-rw-r--r--toolkit/components/places/tests/migration/places_v10.sqlitebin0 -> 172032 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v11.sqlitebin0 -> 1081344 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v17.sqlitebin0 -> 1212416 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v19.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v21.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v22.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v23.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v24.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v25.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v26.sqlitebin0 -> 1179648 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v27.sqlitebin0 -> 1212416 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v28.sqlitebin0 -> 1212416 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v29.sqlitebin0 -> 1245184 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v30.sqlitebin0 -> 1212416 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v31.sqlitebin0 -> 1146880 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v32.sqlitebin0 -> 1146880 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v33.sqlitebin0 -> 1146880 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v34.sqlitebin0 -> 1146880 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v35.sqlitebin0 -> 1146880 bytes
-rw-r--r--toolkit/components/places/tests/migration/places_v6.sqlitebin0 -> 155648 bytes
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_downgraded.js19
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v11.js48
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v19.js42
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v24.js36
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v25.js30
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v26.js98
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v27.js77
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v31.js46
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v34.js141
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v34_no_roots.js21
-rw-r--r--toolkit/components/places/tests/migration/test_current_from_v6.js38
-rw-r--r--toolkit/components/places/tests/migration/xpcshell.ini36
-rw-r--r--toolkit/components/places/tests/moz.build67
-rw-r--r--toolkit/components/places/tests/queries/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/queries/head_queries.js370
-rw-r--r--toolkit/components/places/tests/queries/readme.txt16
-rw-r--r--toolkit/components/places/tests/queries/test_415716.js108
-rw-r--r--toolkit/components/places/tests/queries/test_abstime-annotation-domain.js210
-rw-r--r--toolkit/components/places/tests/queries/test_abstime-annotation-uri.js162
-rw-r--r--toolkit/components/places/tests/queries/test_async.js371
-rw-r--r--toolkit/components/places/tests/queries/test_containersQueries_sorting.js411
-rw-r--r--toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js200
-rw-r--r--toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js210
-rw-r--r--toolkit/components/places/tests/queries/test_onlyBookmarked.js128
-rw-r--r--toolkit/components/places/tests/queries/test_queryMultipleFolder.js65
-rw-r--r--toolkit/components/places/tests/queries/test_querySerialization.js797
-rw-r--r--toolkit/components/places/tests/queries/test_redirects.js311
-rw-r--r--toolkit/components/places/tests/queries/test_results-as-tag-contents-query.js127
-rw-r--r--toolkit/components/places/tests/queries/test_results-as-visit.js119
-rw-r--r--toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js84
-rw-r--r--toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js70
-rw-r--r--toolkit/components/places/tests/queries/test_searchterms-domain.js125
-rw-r--r--toolkit/components/places/tests/queries/test_searchterms-uri.js87
-rw-r--r--toolkit/components/places/tests/queries/test_sort-date-site-grouping.js225
-rw-r--r--toolkit/components/places/tests/queries/test_sorting.js1265
-rw-r--r--toolkit/components/places/tests/queries/test_tags.js743
-rw-r--r--toolkit/components/places/tests/queries/test_transitions.js178
-rw-r--r--toolkit/components/places/tests/queries/xpcshell.ini34
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/data/engine-rel-searchform.xml5
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/data/engine-suggestions.xml14
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js505
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_416211.js22
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_416214.js39
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_417798.js51
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_418257.js67
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_422277.js19
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_autocomplete_functional.js171
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_autocomplete_on_value_removed_479089.js39
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_autofill_default_behavior.js310
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_avoid_middle_complete.js179
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_avoid_stripping_to_empty_tokens.js41
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_casing.js157
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_do_not_trim.js91
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_download_embed_bookmarks.js71
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_dupe_urls.js23
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_empty_search.js98
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_enabled.js68
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_escape_self.js31
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js384
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_ignore_protocol.js24
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js73
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_keyword_search_actions.js149
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_keywords.js78
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_match_beginning.js54
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_multi_word_search.js68
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_query_url.js68
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_remote_tab_matches.js203
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js51
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_search_engine_current.js45
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_search_engine_host.js49
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_search_engine_restyle.js43
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_search_suggestions.js651
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_special_search.js447
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_swap_protocol.js153
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_tab_matches.js164
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_trimming.js313
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_typed.js84
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_visit_url.js186
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_word_boundary_search.js175
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/test_zero_frecency.js35
-rw-r--r--toolkit/components/places/tests/unifiedcomplete/xpcshell.ini49
-rw-r--r--toolkit/components/places/tests/unit/.eslintrc.js7
-rw-r--r--toolkit/components/places/tests/unit/bookmarks.corrupt.html36
-rw-r--r--toolkit/components/places/tests/unit/bookmarks.json1
-rw-r--r--toolkit/components/places/tests/unit/bookmarks.preplaces.html35
-rw-r--r--toolkit/components/places/tests/unit/bookmarks_html_singleframe.html10
-rw-r--r--toolkit/components/places/tests/unit/bug476292.sqlitebin0 -> 139264 bytes
-rw-r--r--toolkit/components/places/tests/unit/corruptDB.sqlitebin0 -> 32772 bytes
-rw-r--r--toolkit/components/places/tests/unit/default.sqlitebin0 -> 1081344 bytes
-rw-r--r--toolkit/components/places/tests/unit/head_bookmarks.js20
-rw-r--r--toolkit/components/places/tests/unit/livemark.xml17
-rw-r--r--toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json1
-rw-r--r--toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json1
-rw-r--r--toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json1
-rw-r--r--toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json1
-rw-r--r--toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json1
-rw-r--r--toolkit/components/places/tests/unit/nsDummyObserver.js48
-rw-r--r--toolkit/components/places/tests/unit/nsDummyObserver.manifest4
-rw-r--r--toolkit/components/places/tests/unit/places.sparse.sqlitebin0 -> 221184 bytes
-rw-r--r--toolkit/components/places/tests/unit/test_000_frecency.js273
-rw-r--r--toolkit/components/places/tests/unit/test_1085291.js42
-rw-r--r--toolkit/components/places/tests/unit/test_1105208.js24
-rw-r--r--toolkit/components/places/tests/unit/test_1105866.js63
-rw-r--r--toolkit/components/places/tests/unit/test_317472.js65
-rw-r--r--toolkit/components/places/tests/unit/test_331487.js95
-rw-r--r--toolkit/components/places/tests/unit/test_384370.js173
-rw-r--r--toolkit/components/places/tests/unit/test_385397.js142
-rw-r--r--toolkit/components/places/tests/unit/test_399264_query_to_string.js51
-rw-r--r--toolkit/components/places/tests/unit/test_399264_string_to_query.js75
-rw-r--r--toolkit/components/places/tests/unit/test_399266.js78
-rw-r--r--toolkit/components/places/tests/unit/test_402799.js62
-rw-r--r--toolkit/components/places/tests/unit/test_405497.js57
-rw-r--r--toolkit/components/places/tests/unit/test_408221.js165
-rw-r--r--toolkit/components/places/tests/unit/test_412132.js136
-rw-r--r--toolkit/components/places/tests/unit/test_413784.js118
-rw-r--r--toolkit/components/places/tests/unit/test_415460.js43
-rw-r--r--toolkit/components/places/tests/unit/test_415757.js102
-rw-r--r--toolkit/components/places/tests/unit/test_418643_removeFolderChildren.js143
-rw-r--r--toolkit/components/places/tests/unit/test_419731.js96
-rw-r--r--toolkit/components/places/tests/unit/test_419792_node_tags_property.js49
-rw-r--r--toolkit/components/places/tests/unit/test_425563.js74
-rw-r--r--toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js35
-rw-r--r--toolkit/components/places/tests/unit/test_433317_query_title_update.js38
-rw-r--r--toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js56
-rw-r--r--toolkit/components/places/tests/unit/test_452777.js36
-rw-r--r--toolkit/components/places/tests/unit/test_454977.js124
-rw-r--r--toolkit/components/places/tests/unit/test_463863.js60
-rw-r--r--toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js21
-rw-r--r--toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js129
-rw-r--r--toolkit/components/places/tests/unit/test_536081.js56
-rw-r--r--toolkit/components/places/tests/unit/test_PlacesSearchAutocompleteProvider.js133
-rw-r--r--toolkit/components/places/tests/unit/test_PlacesUtils_asyncGetBookmarkIds.js77
-rw-r--r--toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js25
-rw-r--r--toolkit/components/places/tests/unit/test_PlacesUtils_lazyobservers.js47
-rw-r--r--toolkit/components/places/tests/unit/test_adaptive.js406
-rw-r--r--toolkit/components/places/tests/unit/test_adaptive_bug527311.js141
-rw-r--r--toolkit/components/places/tests/unit/test_analyze.js28
-rw-r--r--toolkit/components/places/tests/unit/test_annotations.js363
-rw-r--r--toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js95
-rw-r--r--toolkit/components/places/tests/unit/test_async_history_api.js1118
-rw-r--r--toolkit/components/places/tests/unit/test_async_in_batchmode.js55
-rw-r--r--toolkit/components/places/tests/unit/test_async_transactions.js1739
-rw-r--r--toolkit/components/places/tests/unit/test_autocomplete_stopSearch_no_throw.js39
-rw-r--r--toolkit/components/places/tests/unit/test_bookmark_catobs.js57
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_html.js385
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js143
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js57
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js32
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_json.js241
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js325
-rw-r--r--toolkit/components/places/tests/unit/test_bookmarks_setNullTitle.js44
-rw-r--r--toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js79
-rw-r--r--toolkit/components/places/tests/unit/test_browserhistory.js129
-rw-r--r--toolkit/components/places/tests/unit/test_bug636917_isLivemark.js35
-rw-r--r--toolkit/components/places/tests/unit/test_childlessTags.js117
-rw-r--r--toolkit/components/places/tests/unit/test_corrupt_telemetry.js31
-rw-r--r--toolkit/components/places/tests/unit/test_crash_476292.js28
-rw-r--r--toolkit/components/places/tests/unit/test_database_replaceOnStartup.js46
-rw-r--r--toolkit/components/places/tests/unit/test_download_history.js283
-rw-r--r--toolkit/components/places/tests/unit/test_frecency.js294
-rw-r--r--toolkit/components/places/tests/unit/test_frecency_observers.js84
-rw-r--r--toolkit/components/places/tests/unit/test_frecency_zero_updated.js30
-rw-r--r--toolkit/components/places/tests/unit/test_getChildIndex.js69
-rw-r--r--toolkit/components/places/tests/unit/test_getPlacesInfo.js112
-rw-r--r--toolkit/components/places/tests/unit/test_history.js184
-rw-r--r--toolkit/components/places/tests/unit/test_history_autocomplete_tags.js185
-rw-r--r--toolkit/components/places/tests/unit/test_history_catobs.js55
-rw-r--r--toolkit/components/places/tests/unit/test_history_clear.js169
-rw-r--r--toolkit/components/places/tests/unit/test_history_notifications.js38
-rw-r--r--toolkit/components/places/tests/unit/test_history_observer.js215
-rw-r--r--toolkit/components/places/tests/unit/test_history_sidebar.js447
-rw-r--r--toolkit/components/places/tests/unit/test_hosts_triggers.js226
-rw-r--r--toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js292
-rw-r--r--toolkit/components/places/tests/unit/test_isPageInDB.js10
-rw-r--r--toolkit/components/places/tests/unit/test_isURIVisited.js84
-rw-r--r--toolkit/components/places/tests/unit/test_isvisited.js75
-rw-r--r--toolkit/components/places/tests/unit/test_keywords.js548
-rw-r--r--toolkit/components/places/tests/unit/test_lastModified.js34
-rw-r--r--toolkit/components/places/tests/unit/test_markpageas.js61
-rw-r--r--toolkit/components/places/tests/unit/test_mozIAsyncLivemarks.js514
-rw-r--r--toolkit/components/places/tests/unit/test_multi_queries.js53
-rw-r--r--toolkit/components/places/tests/unit/test_multi_word_tags.js150
-rw-r--r--toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js256
-rw-r--r--toolkit/components/places/tests/unit/test_null_interfaces.js98
-rw-r--r--toolkit/components/places/tests/unit/test_onItemChanged_tags.js52
-rw-r--r--toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js179
-rw-r--r--toolkit/components/places/tests/unit/test_placeURIs.js42
-rw-r--r--toolkit/components/places/tests/unit/test_placesTxn.js937
-rw-r--r--toolkit/components/places/tests/unit/test_preventive_maintenance.js1356
-rw-r--r--toolkit/components/places/tests/unit/test_preventive_maintenance_checkAndFixDatabase.js50
-rw-r--r--toolkit/components/places/tests/unit/test_preventive_maintenance_runTasks.js46
-rw-r--r--toolkit/components/places/tests/unit/test_promiseBookmarksTree.js256
-rw-r--r--toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js49
-rw-r--r--toolkit/components/places/tests/unit/test_result_sort.js139
-rw-r--r--toolkit/components/places/tests/unit/test_resultsAsVisit_details.js85
-rw-r--r--toolkit/components/places/tests/unit/test_sql_guid_functions.js106
-rw-r--r--toolkit/components/places/tests/unit/test_svg_favicon.js31
-rw-r--r--toolkit/components/places/tests/unit/test_sync_utils.js1150
-rw-r--r--toolkit/components/places/tests/unit/test_tag_autocomplete_search.js137
-rw-r--r--toolkit/components/places/tests/unit/test_tagging.js189
-rw-r--r--toolkit/components/places/tests/unit/test_telemetry.js166
-rw-r--r--toolkit/components/places/tests/unit/test_update_frecency_after_delete.js151
-rw-r--r--toolkit/components/places/tests/unit/test_utils_backups_create.js90
-rw-r--r--toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js180
-rw-r--r--toolkit/components/places/tests/unit/test_utils_setAnnotationsFor.js79
-rw-r--r--toolkit/components/places/tests/unit/test_visitsInDB.js12
-rw-r--r--toolkit/components/places/tests/unit/xpcshell.ini163
-rw-r--r--toolkit/components/places/toolkitplaces.manifest32
478 files changed, 102404 insertions, 0 deletions
diff --git a/toolkit/components/places/BookmarkHTMLUtils.jsm b/toolkit/components/places/BookmarkHTMLUtils.jsm
new file mode 100644
index 000000000..a009a5e7c
--- /dev/null
+++ b/toolkit/components/places/BookmarkHTMLUtils.jsm
@@ -0,0 +1,1188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file works on the old-style "bookmarks.html" file. It includes
+ * functions to import and export existing bookmarks to this file format.
+ *
+ * Format
+ * ------
+ *
+ * Primary heading := h1
+ * Old version used this to set attributes on the bookmarks RDF root, such
+ * as the last modified date. We only use H1 to check for the attribute
+ * PLACES_ROOT, which tells us that this hierarchy root is the places root.
+ * For backwards compatibility, if we don't find this, we assume that the
+ * hierarchy is rooted at the bookmarks menu.
+ * Heading := any heading other than h1
+ * Old version used this to set attributes on the current container. We only
+ * care about the content of the heading container, which contains the title
+ * of the bookmark container.
+ * Bookmark := a
+ * HREF is the destination of the bookmark
+ * FEEDURL is the URI of the RSS feed if this is a livemark.
+ * LAST_CHARSET is stored as an annotation so that the next time we go to
+ * that page we remember the user's preference.
+ * WEB_PANEL is set to "true" if the bookmark should be loaded in the sidebar.
+ * ICON will be stored in the favicon service
+ * ICON_URI is new for places bookmarks.html, it refers to the original
+ * URI of the favicon so we don't have to make up favicon URLs.
+ * Text of the <a> container is the name of the bookmark
+ * Ignored: LAST_VISIT, ID (writing out non-RDF IDs can confuse Firefox 2)
+ * Bookmark comment := dd
+ * This affects the previosly added bookmark
+ * Separator := hr
+ * Insert a separator into the current container
+ * The folder hierarchy is defined by <dl>/<ul>/<menu> (the old importing code
+ * handles all these cases, when we write, use <dl>).
+ *
+ * Overall design
+ * --------------
+ *
+ * We need to emulate a recursive parser. A "Bookmark import frame" is created
+ * corresponding to each folder we encounter. These are arranged in a stack,
+ * and contain all the state we need to keep track of.
+ *
+ * A frame is created when we find a heading, which defines a new container.
+ * The frame also keeps track of the nesting of <DL>s, (in well-formed
+ * bookmarks files, these will have a 1-1 correspondence with frames, but we
+ * try to be a little more flexible here). When the nesting count decreases
+ * to 0, then we know a frame is complete and to pop back to the previous
+ * frame.
+ *
+ * Note that a lot of things happen when tags are CLOSED because we need to
+ * get the text from the content of the tag. For example, link and heading tags
+ * both require the content (= title) before actually creating it.
+ */
+
+this.EXPORTED_SYMBOLS = [ "BookmarkHTMLUtils" ];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups",
+ "resource://gre/modules/PlacesBackups.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+
+const Container_Normal = 0;
+const Container_Toolbar = 1;
+const Container_Menu = 2;
+const Container_Unfiled = 3;
+const Container_Places = 4;
+
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+
+const MICROSEC_PER_SEC = 1000000;
+
+const EXPORT_INDENT = " "; // four spaces
+
+// Counter used to build fake favicon urls.
+var serialNumber = 0;
+
+function base64EncodeString(aString) {
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"]
+ .createInstance(Ci.nsIStringInputStream);
+ stream.setData(aString, aString.length);
+ let encoder = Cc["@mozilla.org/scriptablebase64encoder;1"]
+ .createInstance(Ci.nsIScriptableBase64Encoder);
+ return encoder.encodeToString(stream, aString.length);
+}
+
+/**
+ * Provides HTML escaping for use in HTML attributes and body of the bookmarks
+ * file, compatible with the old bookmarks system.
+ */
+function escapeHtmlEntities(aText) {
+ return (aText || "").replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
+
+/**
+ * Provides URL escaping for use in HTML attributes of the bookmarks file,
+ * compatible with the old bookmarks system.
+ */
+function escapeUrl(aText) {
+ return (aText || "").replace(/"/g, "%22");
+}
+
+function notifyObservers(aTopic, aInitialImport) {
+ Services.obs.notifyObservers(null, aTopic, aInitialImport ? "html-initial"
+ : "html");
+}
+
+this.BookmarkHTMLUtils = Object.freeze({
+ /**
+ * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
+ *
+ * @param aSpec
+ * String containing the "file:" URI for the existing "bookmarks.html"
+ * file to be loaded.
+ * @param aInitialImport
+ * Whether this is the initial import executed on a new profile.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ */
+ importFromURL: function BHU_importFromURL(aSpec, aInitialImport) {
+ return Task.spawn(function* () {
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
+ try {
+ let importer = new BookmarkImporter(aInitialImport);
+ yield importer.importFromURL(aSpec);
+
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
+ } catch (ex) {
+ Cu.reportError("Failed to import bookmarks from " + aSpec + ": " + ex);
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
+ throw ex;
+ }
+ });
+ },
+
+ /**
+ * Loads the current bookmarks hierarchy from a "bookmarks.html" file.
+ *
+ * @param aFilePath
+ * OS.File path string of the "bookmarks.html" file to be loaded.
+ * @param aInitialImport
+ * Whether this is the initial import executed on a new profile.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ importFromFile: function BHU_importFromFile(aFilePath, aInitialImport) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " +
+ "is deprecated. Please use an OS.File path string instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+
+ return Task.spawn(function* () {
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN, aInitialImport);
+ try {
+ if (!(yield OS.File.exists(aFilePath))) {
+ throw new Error("Cannot import from nonexisting html file: " + aFilePath);
+ }
+ let importer = new BookmarkImporter(aInitialImport);
+ yield importer.importFromURL(OS.Path.toFileURI(aFilePath));
+
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS, aInitialImport);
+ } catch (ex) {
+ Cu.reportError("Failed to import bookmarks from " + aFilePath + ": " + ex);
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED, aInitialImport);
+ throw ex;
+ }
+ });
+ },
+
+ /**
+ * Saves the current bookmarks hierarchy to a "bookmarks.html" file.
+ *
+ * @param aFilePath
+ * OS.File path string for the "bookmarks.html" file to be created.
+ *
+ * @return {Promise}
+ * @resolves To the exported bookmarks count when the file has been created.
+ * @rejects JavaScript exception.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ exportToFile: function BHU_exportToFile(aFilePath) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to BookmarksHTMLUtils.exportToFile " +
+ "is deprecated. Please use an OS.File path string instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+ return Task.spawn(function* () {
+ let [bookmarks, count] = yield PlacesBackups.getBookmarksTree();
+ let startTime = Date.now();
+
+ // Report the time taken to convert the tree to HTML.
+ let exporter = new BookmarkExporter(bookmarks);
+ yield exporter.exportToFile(aFilePath);
+
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_EXPORT_TOHTML_MS")
+ .add(Date.now() - startTime);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ }
+
+ return count;
+ });
+ },
+
+ get defaultPath() {
+ try {
+ return Services.prefs.getCharPref("browser.bookmarks.file");
+ } catch (ex) {}
+ return OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html")
+ }
+});
+
+function Frame(aFrameId) {
+ this.containerId = aFrameId;
+
+ /**
+ * How many <dl>s have been nested. Each frame/container should start
+ * with a heading, and is then followed by a <dl>, <ul>, or <menu>. When
+ * that list is complete, then it is the end of this container and we need
+ * to pop back up one level for new items. If we never get an open tag for
+ * one of these things, we should assume that the container is empty and
+ * that things we find should be siblings of it. Normally, these <dl>s won't
+ * be nested so this will be 0 or 1.
+ */
+ this.containerNesting = 0;
+
+ /**
+ * when we find a heading tag, it actually affects the title of the NEXT
+ * container in the list. This stores that heading tag and whether it was
+ * special. 'consumeHeading' resets this._
+ */
+ this.lastContainerType = Container_Normal;
+
+ /**
+ * this contains the text from the last begin tag until now. It is reset
+ * at every begin tag. We can check it when we see a </a>, or </h3>
+ * to see what the text content of that node should be.
+ */
+ this.previousText = "";
+
+ /**
+ * true when we hit a <dd>, which contains the description for the preceding
+ * <a> tag. We can't just check for </dd> like we can for </a> or </h3>
+ * because if there is a sub-folder, it is actually a child of the <dd>
+ * because the tag is never explicitly closed. If this is true and we see a
+ * new open tag, that means to commit the description to the previous
+ * bookmark.
+ *
+ * Additional weirdness happens when the previous <dt> tag contains a <h3>:
+ * this means there is a new folder with the given description, and whose
+ * children are contained in the following <dl> list.
+ *
+ * This is handled in openContainer(), which commits previous text if
+ * necessary.
+ */
+ this.inDescription = false;
+
+ /**
+ * contains the URL of the previous bookmark created. This is used so that
+ * when we encounter a <dd>, we know what bookmark to associate the text with.
+ * This is cleared whenever we hit a <h3>, so that we know NOT to save this
+ * with a bookmark, but to keep it until
+ */
+ this.previousLink = null; // nsIURI
+
+ /**
+ * contains the URL of the previous livemark, so that when the link ends,
+ * and the livemark title is known, we can create it.
+ */
+ this.previousFeed = null; // nsIURI
+
+ /**
+ * Contains the id of an imported, or newly created bookmark.
+ */
+ this.previousId = 0;
+
+ /**
+ * Contains the date-added and last-modified-date of an imported item.
+ * Used to override the values set by insertBookmark, createFolder, etc.
+ */
+ this.previousDateAdded = 0;
+ this.previousLastModifiedDate = 0;
+}
+
+function BookmarkImporter(aInitialImport) {
+ this._isImportDefaults = aInitialImport;
+ // The bookmark change source, used to determine the sync status and change
+ // counter.
+ this._source = aInitialImport ? PlacesUtils.bookmarks.SOURCE_IMPORT_REPLACE :
+ PlacesUtils.bookmarks.SOURCE_IMPORT;
+ this._frames = new Array();
+ this._frames.push(new Frame(PlacesUtils.bookmarksMenuFolderId));
+}
+
+BookmarkImporter.prototype = {
+
+ _safeTrim: function safeTrim(aStr) {
+ return aStr ? aStr.trim() : aStr;
+ },
+
+ get _curFrame() {
+ return this._frames[this._frames.length - 1];
+ },
+
+ get _previousFrame() {
+ return this._frames[this._frames.length - 2];
+ },
+
+ /**
+ * This is called when there is a new folder found. The folder takes the
+ * name from the previous frame's heading.
+ */
+ _newFrame: function newFrame() {
+ let containerId = -1;
+ let frame = this._curFrame;
+ let containerTitle = frame.previousText;
+ frame.previousText = "";
+ let containerType = frame.lastContainerType;
+
+ switch (containerType) {
+ case Container_Normal:
+ // append a new folder
+ containerId =
+ PlacesUtils.bookmarks.createFolder(frame.containerId,
+ containerTitle,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ /* aGuid */ null, this._source);
+ break;
+ case Container_Places:
+ containerId = PlacesUtils.placesRootId;
+ break;
+ case Container_Menu:
+ containerId = PlacesUtils.bookmarksMenuFolderId;
+ break;
+ case Container_Unfiled:
+ containerId = PlacesUtils.unfiledBookmarksFolderId;
+ break;
+ case Container_Toolbar:
+ containerId = PlacesUtils.toolbarFolderId;
+ break;
+ default:
+ // NOT REACHED
+ throw new Error("Unreached");
+ }
+
+ if (frame.previousDateAdded > 0) {
+ try {
+ PlacesUtils.bookmarks.setItemDateAdded(containerId, frame.previousDateAdded, this._source);
+ } catch (e) {
+ }
+ frame.previousDateAdded = 0;
+ }
+ if (frame.previousLastModifiedDate > 0) {
+ try {
+ PlacesUtils.bookmarks.setItemLastModified(containerId, frame.previousLastModifiedDate, this._source);
+ } catch (e) {
+ }
+ // don't clear last-modified, in case there's a description
+ }
+
+ frame.previousId = containerId;
+
+ this._frames.push(new Frame(containerId));
+ },
+
+ /**
+ * Handles <hr> as a separator.
+ *
+ * @note Separators may have a title in old html files, though Places dropped
+ * support for them.
+ * We also don't import ADD_DATE or LAST_MODIFIED for separators because
+ * pre-Places bookmarks did not support them.
+ */
+ _handleSeparator: function handleSeparator(aElt) {
+ let frame = this._curFrame;
+ try {
+ frame.previousId =
+ PlacesUtils.bookmarks.insertSeparator(frame.containerId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ /* aGuid */ null,
+ this._source);
+ } catch (e) {}
+ },
+
+ /**
+ * Handles <H1>. We check for the attribute PLACES_ROOT and reset the
+ * container id if it's found. Otherwise, the default bookmark menu
+ * root is assumed and imported things will go into the bookmarks menu.
+ */
+ _handleHead1Begin: function handleHead1Begin(aElt) {
+ if (this._frames.length > 1) {
+ return;
+ }
+ if (aElt.hasAttribute("places_root")) {
+ this._curFrame.containerId = PlacesUtils.placesRootId;
+ }
+ },
+
+ /**
+ * Called for h2,h3,h4,h5,h6. This just stores the correct information in
+ * the current frame; the actual new frame corresponding to the container
+ * associated with the heading will be created when the tag has been closed
+ * and we know the title (we don't know to create a new folder or to merge
+ * with an existing one until we have the title).
+ */
+ _handleHeadBegin: function handleHeadBegin(aElt) {
+ let frame = this._curFrame;
+
+ // after a heading, a previous bookmark is not applicable (for example, for
+ // the descriptions contained in a <dd>). Neither is any previous head type
+ frame.previousLink = null;
+ frame.lastContainerType = Container_Normal;
+
+ // It is syntactically possible for a heading to appear after another heading
+ // but before the <dl> that encloses that folder's contents. This should not
+ // happen in practice, as the file will contain "<dl></dl>" sequence for
+ // empty containers.
+ //
+ // Just to be on the safe side, if we encounter
+ // <h3>FOO</h3>
+ // <h3>BAR</h3>
+ // <dl>...content 1...</dl>
+ // <dl>...content 2...</dl>
+ // we'll pop the stack when we find the h3 for BAR, treating that as an
+ // implicit ending of the FOO container. The output will be FOO and BAR as
+ // siblings. If there's another <dl> following (as in "content 2"), those
+ // items will be treated as further siblings of FOO and BAR
+ // This special frame popping business, of course, only happens when our
+ // frame array has more than one element so we can avoid situations where
+ // we don't have a frame to parse into anymore.
+ if (frame.containerNesting == 0 && this._frames.length > 1) {
+ this._frames.pop();
+ }
+
+ // We have to check for some attributes to see if this is a "special"
+ // folder, which will have different creation rules when the end tag is
+ // processed.
+ if (aElt.hasAttribute("personal_toolbar_folder")) {
+ if (this._isImportDefaults) {
+ frame.lastContainerType = Container_Toolbar;
+ }
+ } else if (aElt.hasAttribute("bookmarks_menu")) {
+ if (this._isImportDefaults) {
+ frame.lastContainerType = Container_Menu;
+ }
+ } else if (aElt.hasAttribute("unfiled_bookmarks_folder")) {
+ if (this._isImportDefaults) {
+ frame.lastContainerType = Container_Unfiled;
+ }
+ } else if (aElt.hasAttribute("places_root")) {
+ if (this._isImportDefaults) {
+ frame.lastContainerType = Container_Places;
+ }
+ } else {
+ let addDate = aElt.getAttribute("add_date");
+ if (addDate) {
+ frame.previousDateAdded =
+ this._convertImportedDateToInternalDate(addDate);
+ }
+ let modDate = aElt.getAttribute("last_modified");
+ if (modDate) {
+ frame.previousLastModifiedDate =
+ this._convertImportedDateToInternalDate(modDate);
+ }
+ }
+ this._curFrame.previousText = "";
+ },
+
+ /*
+ * Handles "<a" tags by creating a new bookmark. The title of the bookmark
+ * will be the text content, which will be stuffed in previousText for us
+ * and which will be saved by handleLinkEnd
+ */
+ _handleLinkBegin: function handleLinkBegin(aElt) {
+ let frame = this._curFrame;
+
+ // Make sure that the feed URIs from previous frames are emptied.
+ frame.previousFeed = null;
+ // Make sure that the bookmark id from previous frames are emptied.
+ frame.previousId = 0;
+ // mPreviousText will hold link text, clear it.
+ frame.previousText = "";
+
+ // Get the attributes we care about.
+ let href = this._safeTrim(aElt.getAttribute("href"));
+ let feedUrl = this._safeTrim(aElt.getAttribute("feedurl"));
+ let icon = this._safeTrim(aElt.getAttribute("icon"));
+ let iconUri = this._safeTrim(aElt.getAttribute("icon_uri"));
+ let lastCharset = this._safeTrim(aElt.getAttribute("last_charset"));
+ let keyword = this._safeTrim(aElt.getAttribute("shortcuturl"));
+ let postData = this._safeTrim(aElt.getAttribute("post_data"));
+ let webPanel = this._safeTrim(aElt.getAttribute("web_panel"));
+ let dateAdded = this._safeTrim(aElt.getAttribute("add_date"));
+ let lastModified = this._safeTrim(aElt.getAttribute("last_modified"));
+ let tags = this._safeTrim(aElt.getAttribute("tags"));
+
+ // For feeds, get the feed URL. If it is invalid, mPreviousFeed will be
+ // NULL and we'll create it as a normal bookmark.
+ if (feedUrl) {
+ frame.previousFeed = NetUtil.newURI(feedUrl);
+ }
+
+ // Ignore <a> tags that have no href.
+ if (href) {
+ // Save the address if it's valid. Note that we ignore errors if this is a
+ // feed since href is optional for them.
+ try {
+ frame.previousLink = NetUtil.newURI(href);
+ } catch (e) {
+ if (!frame.previousFeed) {
+ frame.previousLink = null;
+ return;
+ }
+ }
+ } else {
+ frame.previousLink = null;
+ // The exception is for feeds, where the href is an optional component
+ // indicating the source web site.
+ if (!frame.previousFeed) {
+ return;
+ }
+ }
+
+ // Save bookmark's last modified date.
+ if (lastModified) {
+ frame.previousLastModifiedDate =
+ this._convertImportedDateToInternalDate(lastModified);
+ }
+
+ // If this is a live bookmark, we will handle it in HandleLinkEnd(), so we
+ // can skip bookmark creation.
+ if (frame.previousFeed) {
+ return;
+ }
+
+ // Create the bookmark. The title is unknown for now, we will set it later.
+ try {
+ frame.previousId =
+ PlacesUtils.bookmarks.insertBookmark(frame.containerId,
+ frame.previousLink,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ /* aTitle */ "",
+ /* aGuid */ null,
+ this._source);
+ } catch (e) {
+ return;
+ }
+
+ // Set the date added value, if we have it.
+ if (dateAdded) {
+ try {
+ PlacesUtils.bookmarks.setItemDateAdded(frame.previousId,
+ this._convertImportedDateToInternalDate(dateAdded), this._source);
+ } catch (e) {
+ }
+ }
+
+ // Adds tags to the URI, if there are any.
+ if (tags) {
+ try {
+ let tagsArray = tags.split(",");
+ PlacesUtils.tagging.tagURI(frame.previousLink, tagsArray, this._source);
+ } catch (e) {
+ }
+ }
+
+ // Save the favicon.
+ if (icon || iconUri) {
+ let iconUriObject;
+ try {
+ iconUriObject = NetUtil.newURI(iconUri);
+ } catch (e) {
+ }
+ if (icon || iconUriObject) {
+ try {
+ this._setFaviconForURI(frame.previousLink, iconUriObject, icon);
+ } catch (e) {
+ }
+ }
+ }
+
+ // Save the keyword.
+ if (keyword) {
+ let kwPromise = PlacesUtils.keywords.insert({ keyword,
+ url: frame.previousLink.spec,
+ postData,
+ source: this._source });
+ this._importPromises.push(kwPromise);
+ }
+
+ // Set load-in-sidebar annotation for the bookmark.
+ if (webPanel && webPanel.toLowerCase() == "true") {
+ try {
+ PlacesUtils.annotations.setItemAnnotation(frame.previousId,
+ LOAD_IN_SIDEBAR_ANNO,
+ 1,
+ 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ this._source);
+ } catch (e) {
+ }
+ }
+
+ // Import last charset.
+ if (lastCharset) {
+ let chPromise = PlacesUtils.setCharsetForURI(frame.previousLink, lastCharset, this._source);
+ this._importPromises.push(chPromise);
+ }
+ },
+
+ _handleContainerBegin: function handleContainerBegin() {
+ this._curFrame.containerNesting++;
+ },
+
+ /**
+ * Our "indent" count has decreased, and when we hit 0 that means that this
+ * container is complete and we need to pop back to the outer frame. Never
+ * pop the toplevel frame
+ */
+ _handleContainerEnd: function handleContainerEnd() {
+ let frame = this._curFrame;
+ if (frame.containerNesting > 0)
+ frame.containerNesting --;
+ if (this._frames.length > 1 && frame.containerNesting == 0) {
+ // we also need to re-set the imported last-modified date here. Otherwise
+ // the addition of items will override the imported field.
+ let prevFrame = this._previousFrame;
+ if (prevFrame.previousLastModifiedDate > 0) {
+ PlacesUtils.bookmarks.setItemLastModified(frame.containerId,
+ prevFrame.previousLastModifiedDate,
+ this._source);
+ }
+ this._frames.pop();
+ }
+ },
+
+ /**
+ * Creates the new frame for this heading now that we know the name of the
+ * container (tokens since the heading open tag will have been placed in
+ * previousText).
+ */
+ _handleHeadEnd: function handleHeadEnd() {
+ this._newFrame();
+ },
+
+ /**
+ * Saves the title for the given bookmark.
+ */
+ _handleLinkEnd: function handleLinkEnd() {
+ let frame = this._curFrame;
+ frame.previousText = frame.previousText.trim();
+
+ try {
+ if (frame.previousFeed) {
+ // The is a live bookmark. We create it here since in HandleLinkBegin we
+ // don't know the title.
+ let lmPromise = PlacesUtils.livemarks.addLivemark({
+ "title": frame.previousText,
+ "parentId": frame.containerId,
+ "index": PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "feedURI": frame.previousFeed,
+ "siteURI": frame.previousLink,
+ "source": this._source,
+ });
+ this._importPromises.push(lmPromise);
+ } else if (frame.previousLink) {
+ // This is a common bookmark.
+ PlacesUtils.bookmarks.setItemTitle(frame.previousId,
+ frame.previousText,
+ this._source);
+ }
+ } catch (e) {
+ }
+
+
+ // Set last modified date as the last change.
+ if (frame.previousId > 0 && frame.previousLastModifiedDate > 0) {
+ try {
+ PlacesUtils.bookmarks.setItemLastModified(frame.previousId,
+ frame.previousLastModifiedDate,
+ this._source);
+ } catch (e) {
+ }
+ // Note: don't clear previousLastModifiedDate, because if this item has a
+ // description, we'll need to set it again.
+ }
+
+ frame.previousText = "";
+
+ },
+
+ _openContainer: function openContainer(aElt) {
+ if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
+ return;
+ }
+ switch (aElt.localName) {
+ case "h1":
+ this._handleHead1Begin(aElt);
+ break;
+ case "h2":
+ case "h3":
+ case "h4":
+ case "h5":
+ case "h6":
+ this._handleHeadBegin(aElt);
+ break;
+ case "a":
+ this._handleLinkBegin(aElt);
+ break;
+ case "dl":
+ case "ul":
+ case "menu":
+ this._handleContainerBegin();
+ break;
+ case "dd":
+ this._curFrame.inDescription = true;
+ break;
+ case "hr":
+ this._handleSeparator(aElt);
+ break;
+ }
+ },
+
+ _closeContainer: function closeContainer(aElt) {
+ let frame = this._curFrame;
+
+ // see the comment for the definition of inDescription. Basically, we commit
+ // any text in previousText to the description of the node/folder if there
+ // is any.
+ if (frame.inDescription) {
+ // NOTE ES5 trim trims more than the previous C++ trim.
+ frame.previousText = frame.previousText.trim(); // important
+ if (frame.previousText) {
+
+ let itemId = !frame.previousLink ? frame.containerId
+ : frame.previousId;
+
+ try {
+ if (!PlacesUtils.annotations.itemHasAnnotation(itemId, DESCRIPTION_ANNO)) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ DESCRIPTION_ANNO,
+ frame.previousText,
+ 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ this._source);
+ }
+ } catch (e) {
+ }
+ frame.previousText = "";
+
+ // Set last-modified a 2nd time for all items with descriptions
+ // we need to set last-modified as the *last* step in processing
+ // any item type in the bookmarks.html file, so that we do
+ // not overwrite the imported value. for items without descriptions,
+ // setting this value after setting the item title is that
+ // last point at which we can save this value before it gets reset.
+ // for items with descriptions, it must set after that point.
+ // however, at the point at which we set the title, there's no way
+ // to determine if there will be a description following,
+ // so we need to set the last-modified-date at both places.
+
+ let lastModified;
+ if (!frame.previousLink) {
+ lastModified = this._previousFrame.previousLastModifiedDate;
+ } else {
+ lastModified = frame.previousLastModifiedDate;
+ }
+
+ if (itemId > 0 && lastModified > 0) {
+ PlacesUtils.bookmarks.setItemLastModified(itemId, lastModified,
+ this._source);
+ }
+ }
+ frame.inDescription = false;
+ }
+
+ if (aElt.namespaceURI != "http://www.w3.org/1999/xhtml") {
+ return;
+ }
+ switch (aElt.localName) {
+ case "dl":
+ case "ul":
+ case "menu":
+ this._handleContainerEnd();
+ break;
+ case "dt":
+ break;
+ case "h1":
+ // ignore
+ break;
+ case "h2":
+ case "h3":
+ case "h4":
+ case "h5":
+ case "h6":
+ this._handleHeadEnd();
+ break;
+ case "a":
+ this._handleLinkEnd();
+ break;
+ default:
+ break;
+ }
+ },
+
+ _appendText: function appendText(str) {
+ this._curFrame.previousText += str;
+ },
+
+ /**
+ * data is a string that is a data URI for the favicon. Our job is to
+ * decode it and store it in the favicon service.
+ *
+ * When aIconURI is non-null, we will use that as the URI of the favicon
+ * when storing in the favicon service.
+ *
+ * When aIconURI is null, we have to make up a URI for this favicon so that
+ * it can be stored in the service. The real one will be set the next time
+ * the user visits the page. Our made up one should get expired when the
+ * page no longer references it.
+ */
+ _setFaviconForURI: function setFaviconForURI(aPageURI, aIconURI, aData) {
+ // if the input favicon URI is a chrome: URI, then we just save it and don't
+ // worry about data
+ if (aIconURI) {
+ if (aIconURI.schemeIs("chrome")) {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, aIconURI, false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ return;
+ }
+ }
+
+ // some bookmarks have placeholder URIs that contain just "data:"
+ // ignore these
+ if (aData.length <= 5) {
+ return;
+ }
+
+ let faviconURI;
+ if (aIconURI) {
+ faviconURI = aIconURI;
+ } else {
+ // Make up a favicon URI for this page. Later, we'll make sure that this
+ // favicon URI is always associated with local favicon data, so that we
+ // don't load this URI from the network.
+ let faviconSpec = "http://www.mozilla.org/2005/made-up-favicon/"
+ + serialNumber
+ + "-"
+ + new Date().getTime();
+ faviconURI = NetUtil.newURI(faviconSpec);
+ serialNumber++;
+ }
+
+ // This could fail if the favicon is bigger than defined limit, in such a
+ // case neither the favicon URI nor the favicon data will be saved. If the
+ // bookmark is visited again later, the URI and data will be fetched.
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(faviconURI, aData, 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ PlacesUtils.favicons.setAndFetchFaviconForPage(aPageURI, faviconURI, false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ },
+
+ /**
+ * Converts a string date in seconds to an int date in microseconds
+ */
+ _convertImportedDateToInternalDate: function convertImportedDateToInternalDate(aDate) {
+ if (aDate && !isNaN(aDate)) {
+ return parseInt(aDate) * 1000000; // in bookmarks.html this value is in seconds, not microseconds
+ }
+ return Date.now();
+ },
+
+ runBatched: function runBatched(aDoc) {
+ if (!aDoc) {
+ return;
+ }
+
+ if (this._isImportDefaults) {
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarksMenuFolderId, this._source);
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.toolbarFolderId, this._source);
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId, this._source);
+ }
+
+ let current = aDoc;
+ let next;
+ for (;;) {
+ switch (current.nodeType) {
+ case Ci.nsIDOMNode.ELEMENT_NODE:
+ this._openContainer(current);
+ break;
+ case Ci.nsIDOMNode.TEXT_NODE:
+ this._appendText(current.data);
+ break;
+ }
+ if ((next = current.firstChild)) {
+ current = next;
+ continue;
+ }
+ for (;;) {
+ if (current.nodeType == Ci.nsIDOMNode.ELEMENT_NODE) {
+ this._closeContainer(current);
+ }
+ if (current == aDoc) {
+ return;
+ }
+ if ((next = current.nextSibling)) {
+ current = next;
+ break;
+ }
+ current = current.parentNode;
+ }
+ }
+ },
+
+ _walkTreeForImport: function walkTreeForImport(aDoc) {
+ PlacesUtils.bookmarks.runInBatchMode(this, aDoc);
+ },
+
+ importFromURL: Task.async(function* (href) {
+ this._importPromises = [];
+ yield new Promise((resolve, reject) => {
+ let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+ .createInstance(Ci.nsIXMLHttpRequest);
+ xhr.onload = () => {
+ try {
+ this._walkTreeForImport(xhr.responseXML);
+ resolve();
+ } catch (e) {
+ reject(e);
+ }
+ };
+ xhr.onabort = xhr.onerror = xhr.ontimeout = () => {
+ reject(new Error("xmlhttprequest failed"));
+ };
+ xhr.open("GET", href);
+ xhr.responseType = "document";
+ xhr.overrideMimeType("text/html");
+ xhr.send();
+ });
+ // TODO (bug 1095427) once converted to the new bookmarks API, methods will
+ // yield, so this hack should not be needed anymore.
+ try {
+ yield Promise.all(this._importPromises);
+ } finally {
+ delete this._importPromises;
+ }
+ }),
+};
+
+function BookmarkExporter(aBookmarksTree) {
+ // Create a map of the roots.
+ let rootsMap = new Map();
+ for (let child of aBookmarksTree.children) {
+ if (child.root)
+ rootsMap.set(child.root, child);
+ }
+
+ // For backwards compatibility reasons the bookmarks menu is the root, while
+ // the bookmarks toolbar and unfiled bookmarks will be child items.
+ this._root = rootsMap.get("bookmarksMenuFolder");
+
+ for (let key of [ "toolbarFolder", "unfiledBookmarksFolder" ]) {
+ let root = rootsMap.get(key);
+ if (root.children && root.children.length > 0) {
+ if (!this._root.children)
+ this._root.children = [];
+ this._root.children.push(root);
+ }
+ }
+}
+
+BookmarkExporter.prototype = {
+ exportToFile: function exportToFile(aFilePath) {
+ return Task.spawn(function* () {
+ // Create a file that can be accessed by the current user only.
+ let out = FileUtils.openAtomicFileOutputStream(new FileUtils.File(aFilePath));
+ try {
+ // We need a buffered output stream for performance. See bug 202477.
+ let bufferedOut = Cc["@mozilla.org/network/buffered-output-stream;1"]
+ .createInstance(Ci.nsIBufferedOutputStream);
+ bufferedOut.init(out, 4096);
+ try {
+ // Write bookmarks in UTF-8.
+ this._converterOut = Cc["@mozilla.org/intl/converter-output-stream;1"]
+ .createInstance(Ci.nsIConverterOutputStream);
+ this._converterOut.init(bufferedOut, "utf-8", 0, 0);
+ try {
+ this._writeHeader();
+ yield this._writeContainer(this._root);
+ // Retain the target file on success only.
+ bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish();
+ } finally {
+ this._converterOut.close();
+ this._converterOut = null;
+ }
+ } finally {
+ bufferedOut.close();
+ }
+ } finally {
+ out.close();
+ }
+ }.bind(this));
+ },
+
+ _converterOut: null,
+
+ _write: function (aText) {
+ this._converterOut.writeString(aText || "");
+ },
+
+ _writeAttribute: function (aName, aValue) {
+ this._write(' ' + aName + '="' + aValue + '"');
+ },
+
+ _writeLine: function (aText) {
+ this._write(aText + "\n");
+ },
+
+ _writeHeader: function () {
+ this._writeLine("<!DOCTYPE NETSCAPE-Bookmark-file-1>");
+ this._writeLine("<!-- This is an automatically generated file.");
+ this._writeLine(" It will be read and overwritten.");
+ this._writeLine(" DO NOT EDIT! -->");
+ this._writeLine('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; ' +
+ 'charset=UTF-8">');
+ this._writeLine("<TITLE>Bookmarks</TITLE>");
+ },
+
+ *_writeContainer(aItem, aIndent = "") {
+ if (aItem == this._root) {
+ this._writeLine("<H1>" + escapeHtmlEntities(this._root.title) + "</H1>");
+ this._writeLine("");
+ }
+ else {
+ this._write(aIndent + "<DT><H3");
+ this._writeDateAttributes(aItem);
+
+ if (aItem.root === "toolbarFolder")
+ this._writeAttribute("PERSONAL_TOOLBAR_FOLDER", "true");
+ else if (aItem.root === "unfiledBookmarksFolder")
+ this._writeAttribute("UNFILED_BOOKMARKS_FOLDER", "true");
+ this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</H3>");
+ }
+
+ this._writeDescription(aItem, aIndent);
+
+ this._writeLine(aIndent + "<DL><p>");
+ if (aItem.children)
+ yield this._writeContainerContents(aItem, aIndent);
+ if (aItem == this._root)
+ this._writeLine(aIndent + "</DL>");
+ else
+ this._writeLine(aIndent + "</DL><p>");
+ },
+
+ *_writeContainerContents(aItem, aIndent) {
+ let localIndent = aIndent + EXPORT_INDENT;
+
+ for (let child of aItem.children) {
+ if (child.annos && child.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) {
+ this._writeLivemark(child, localIndent);
+ } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) {
+ yield this._writeContainer(child, localIndent);
+ } else if (child.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) {
+ this._writeSeparator(child, localIndent);
+ } else {
+ yield this._writeItem(child, localIndent);
+ }
+ }
+ },
+
+ _writeSeparator: function (aItem, aIndent) {
+ this._write(aIndent + "<HR");
+ // We keep exporting separator titles, but don't support them anymore.
+ if (aItem.title)
+ this._writeAttribute("NAME", escapeHtmlEntities(aItem.title));
+ this._write(">");
+ },
+
+ _writeLivemark: function (aItem, aIndent) {
+ this._write(aIndent + "<DT><A");
+ let feedSpec = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_FEEDURI).value;
+ this._writeAttribute("FEEDURL", escapeUrl(feedSpec));
+ let siteSpecAnno = aItem.annos.find(anno => anno.name == PlacesUtils.LMANNO_SITEURI);
+ if (siteSpecAnno)
+ this._writeAttribute("HREF", escapeUrl(siteSpecAnno.value));
+ this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>");
+ this._writeDescription(aItem, aIndent);
+ },
+
+ *_writeItem(aItem, aIndent) {
+ try {
+ NetUtil.newURI(aItem.uri);
+ } catch (ex) {
+ // If the item URI is invalid, skip the item instead of failing later.
+ return;
+ }
+
+ this._write(aIndent + "<DT><A");
+ this._writeAttribute("HREF", escapeUrl(aItem.uri));
+ this._writeDateAttributes(aItem);
+ yield this._writeFaviconAttribute(aItem);
+
+ if (aItem.keyword) {
+ this._writeAttribute("SHORTCUTURL", escapeHtmlEntities(aItem.keyword));
+ if (aItem.postData)
+ this._writeAttribute("POST_DATA", escapeHtmlEntities(aItem.postData));
+ }
+
+ if (aItem.annos && aItem.annos.some(anno => anno.name == LOAD_IN_SIDEBAR_ANNO))
+ this._writeAttribute("WEB_PANEL", "true");
+ if (aItem.charset)
+ this._writeAttribute("LAST_CHARSET", escapeHtmlEntities(aItem.charset));
+ if (aItem.tags)
+ this._writeAttribute("TAGS", aItem.tags);
+ this._writeLine(">" + escapeHtmlEntities(aItem.title) + "</A>");
+ this._writeDescription(aItem, aIndent);
+ },
+
+ _writeDateAttributes: function (aItem) {
+ if (aItem.dateAdded)
+ this._writeAttribute("ADD_DATE",
+ Math.floor(aItem.dateAdded / MICROSEC_PER_SEC));
+ if (aItem.lastModified)
+ this._writeAttribute("LAST_MODIFIED",
+ Math.floor(aItem.lastModified / MICROSEC_PER_SEC));
+ },
+
+ *_writeFaviconAttribute(aItem) {
+ if (!aItem.iconuri)
+ return;
+ let favicon;
+ try {
+ favicon = yield PlacesUtils.promiseFaviconData(aItem.uri);
+ } catch (ex) {
+ Components.utils.reportError("Unexpected Error trying to fetch icon data");
+ return;
+ }
+
+ this._writeAttribute("ICON_URI", escapeUrl(favicon.uri.spec));
+
+ if (!favicon.uri.schemeIs("chrome") && favicon.dataLen > 0) {
+ let faviconContents = "data:image/png;base64," +
+ base64EncodeString(String.fromCharCode.apply(String, favicon.data));
+ this._writeAttribute("ICON", faviconContents);
+ }
+ },
+
+ _writeDescription: function (aItem, aIndent) {
+ let descriptionAnno = aItem.annos &&
+ aItem.annos.find(anno => anno.name == DESCRIPTION_ANNO);
+ if (descriptionAnno)
+ this._writeLine(aIndent + "<DD>" + escapeHtmlEntities(descriptionAnno.value));
+ }
+};
diff --git a/toolkit/components/places/BookmarkJSONUtils.jsm b/toolkit/components/places/BookmarkJSONUtils.jsm
new file mode 100644
index 000000000..7f8d3fd8f
--- /dev/null
+++ b/toolkit/components/places/BookmarkJSONUtils.jsm
@@ -0,0 +1,589 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = [ "BookmarkJSONUtils" ];
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/PromiseUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups",
+ "resource://gre/modules/PlacesBackups.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gTextDecoder", () => new TextDecoder());
+XPCOMUtils.defineLazyGetter(this, "gTextEncoder", () => new TextEncoder());
+
+/**
+ * Generates an hash for the given string.
+ *
+ * @note The generated hash is returned in base64 form. Mind the fact base64
+ * is case-sensitive if you are going to reuse this code.
+ */
+function generateHash(aString) {
+ let cryptoHash = Cc["@mozilla.org/security/hash;1"]
+ .createInstance(Ci.nsICryptoHash);
+ cryptoHash.init(Ci.nsICryptoHash.MD5);
+ let stringStream = Cc["@mozilla.org/io/string-input-stream;1"]
+ .createInstance(Ci.nsIStringInputStream);
+ stringStream.data = aString;
+ cryptoHash.updateFromStream(stringStream, -1);
+ // base64 allows the '/' char, but we can't use it for filenames.
+ return cryptoHash.finish(true).replace(/\//g, "-");
+}
+
+this.BookmarkJSONUtils = Object.freeze({
+ /**
+ * Import bookmarks from a url.
+ *
+ * @param aSpec
+ * url of the bookmark data.
+ * @param aReplace
+ * Boolean if true, replace existing bookmarks, else merge.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ */
+ importFromURL: function BJU_importFromURL(aSpec, aReplace) {
+ return Task.spawn(function* () {
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN);
+ try {
+ let importer = new BookmarkImporter(aReplace);
+ yield importer.importFromURL(aSpec);
+
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS);
+ } catch (ex) {
+ Cu.reportError("Failed to restore bookmarks from " + aSpec + ": " + ex);
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED);
+ }
+ });
+ },
+
+ /**
+ * Restores bookmarks and tags from a JSON file.
+ * @note any item annotated with "places/excludeFromBackup" won't be removed
+ * before executing the restore.
+ *
+ * @param aFilePath
+ * OS.File path string of bookmarks in JSON or JSONlz4 format to be restored.
+ * @param aReplace
+ * Boolean if true, replace existing bookmarks, else merge.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ importFromFile: function BJU_importFromFile(aFilePath, aReplace) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.importFromFile " +
+ "is deprecated. Please use an OS.File path string instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+
+ return Task.spawn(function* () {
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_BEGIN);
+ try {
+ if (!(yield OS.File.exists(aFilePath)))
+ throw new Error("Cannot restore from nonexisting json file");
+
+ let importer = new BookmarkImporter(aReplace);
+ if (aFilePath.endsWith("jsonlz4")) {
+ yield importer.importFromCompressedFile(aFilePath);
+ } else {
+ yield importer.importFromURL(OS.Path.toFileURI(aFilePath));
+ }
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_SUCCESS);
+ } catch (ex) {
+ Cu.reportError("Failed to restore bookmarks from " + aFilePath + ": " + ex);
+ notifyObservers(PlacesUtils.TOPIC_BOOKMARKS_RESTORE_FAILED);
+ throw ex;
+ }
+ });
+ },
+
+ /**
+ * Serializes bookmarks using JSON, and writes to the supplied file path.
+ *
+ * @param aFilePath
+ * OS.File path string for the bookmarks file to be created.
+ * @param [optional] aOptions
+ * Object containing options for the export:
+ * - failIfHashIs: if the generated file would have the same hash
+ * defined here, will reject with ex.becauseSameHash
+ * - compress: if true, writes file using lz4 compression
+ * @return {Promise}
+ * @resolves once the file has been created, to an object with the
+ * following properties:
+ * - count: number of exported bookmarks
+ * - hash: file hash for contents comparison
+ * @rejects JavaScript exception.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ exportToFile: function BJU_exportToFile(aFilePath, aOptions={}) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to BookmarksJSONUtils.exportToFile " +
+ "is deprecated. Please use an OS.File path string instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+ return Task.spawn(function* () {
+ let [bookmarks, count] = yield PlacesBackups.getBookmarksTree();
+ let startTime = Date.now();
+ let jsonString = JSON.stringify(bookmarks);
+ // Report the time taken to convert the tree to JSON.
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_BACKUPS_TOJSON_MS")
+ .add(Date.now() - startTime);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ }
+
+ let hash = generateHash(jsonString);
+
+ if (hash === aOptions.failIfHashIs) {
+ let e = new Error("Hash conflict");
+ e.becauseSameHash = true;
+ throw e;
+ }
+
+ // Do not write to the tmp folder, otherwise if it has a different
+ // filesystem writeAtomic will fail. Eventual dangling .tmp files should
+ // be cleaned up by the caller.
+ let writeOptions = { tmpPath: OS.Path.join(aFilePath + ".tmp") };
+ if (aOptions.compress)
+ writeOptions.compression = "lz4";
+
+ yield OS.File.writeAtomic(aFilePath, jsonString, writeOptions);
+ return { count: count, hash: hash };
+ });
+ }
+});
+
+function BookmarkImporter(aReplace) {
+ this._replace = aReplace;
+ // The bookmark change source, used to determine the sync status and change
+ // counter.
+ this._source = aReplace ? PlacesUtils.bookmarks.SOURCE_IMPORT_REPLACE :
+ PlacesUtils.bookmarks.SOURCE_IMPORT;
+}
+BookmarkImporter.prototype = {
+ /**
+ * Import bookmarks from a url.
+ *
+ * @param aSpec
+ * url of the bookmark data.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ */
+ importFromURL(spec) {
+ return new Promise((resolve, reject) => {
+ let streamObserver = {
+ onStreamComplete: (aLoader, aContext, aStatus, aLength, aResult) => {
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ try {
+ let jsonString = converter.convertFromByteArray(aResult,
+ aResult.length);
+ resolve(this.importFromJSON(jsonString));
+ } catch (ex) {
+ Cu.reportError("Failed to import from URL: " + ex);
+ reject(ex);
+ }
+ }
+ };
+
+ let uri = NetUtil.newURI(spec);
+ let channel = NetUtil.newChannel({
+ uri: uri,
+ loadUsingSystemPrincipal: true
+ });
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"]
+ .createInstance(Ci.nsIStreamLoader);
+ streamLoader.init(streamObserver);
+ channel.asyncOpen2(streamLoader);
+ });
+ },
+
+ /**
+ * Import bookmarks from a compressed file.
+ *
+ * @param aFilePath
+ * OS.File path string of the bookmark data.
+ *
+ * @return {Promise}
+ * @resolves When the new bookmarks have been created.
+ * @rejects JavaScript exception.
+ */
+ importFromCompressedFile: function* BI_importFromCompressedFile(aFilePath) {
+ let aResult = yield OS.File.read(aFilePath, { compression: "lz4" });
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ let jsonString = converter.convertFromByteArray(aResult, aResult.length);
+ yield this.importFromJSON(jsonString);
+ },
+
+ /**
+ * Import bookmarks from a JSON string.
+ *
+ * @param aString
+ * JSON string of serialized bookmark data.
+ */
+ importFromJSON: Task.async(function* (aString) {
+ this._importPromises = [];
+ let deferred = PromiseUtils.defer();
+ let nodes =
+ PlacesUtils.unwrapNodes(aString, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER);
+
+ if (nodes.length == 0 || !nodes[0].children ||
+ nodes[0].children.length == 0) {
+ deferred.resolve(); // Nothing to restore
+ } else {
+ // Ensure tag folder gets processed last
+ nodes[0].children.sort(function sortRoots(aNode, bNode) {
+ if (aNode.root && aNode.root == "tagsFolder")
+ return 1;
+ if (bNode.root && bNode.root == "tagsFolder")
+ return -1;
+ return 0;
+ });
+
+ let batch = {
+ nodes: nodes[0].children,
+ runBatched: function runBatched() {
+ if (this._replace) {
+ // Get roots excluded from the backup, we will not remove them
+ // before restoring.
+ let excludeItems = PlacesUtils.annotations.getItemsWithAnnotation(
+ PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO);
+ // Delete existing children of the root node, excepting:
+ // 1. special folders: delete the child nodes
+ // 2. tags folder: untag via the tagging api
+ let root = PlacesUtils.getFolderContents(PlacesUtils.placesRootId,
+ false, false).root;
+ let childIds = [];
+ for (let i = 0; i < root.childCount; i++) {
+ let childId = root.getChild(i).itemId;
+ if (!excludeItems.includes(childId) &&
+ childId != PlacesUtils.tagsFolderId) {
+ childIds.push(childId);
+ }
+ }
+ root.containerOpen = false;
+
+ for (let i = 0; i < childIds.length; i++) {
+ let rootItemId = childIds[i];
+ if (PlacesUtils.isRootItem(rootItemId)) {
+ PlacesUtils.bookmarks.removeFolderChildren(rootItemId,
+ this._source);
+ } else {
+ PlacesUtils.bookmarks.removeItem(rootItemId, this._source);
+ }
+ }
+ }
+
+ let searchIds = [];
+ let folderIdMap = [];
+
+ for (let node of batch.nodes) {
+ if (!node.children || node.children.length == 0)
+ continue; // Nothing to restore for this root
+
+ if (node.root) {
+ let container = PlacesUtils.placesRootId; // Default to places root
+ switch (node.root) {
+ case "bookmarksMenuFolder":
+ container = PlacesUtils.bookmarksMenuFolderId;
+ break;
+ case "tagsFolder":
+ container = PlacesUtils.tagsFolderId;
+ break;
+ case "unfiledBookmarksFolder":
+ container = PlacesUtils.unfiledBookmarksFolderId;
+ break;
+ case "toolbarFolder":
+ container = PlacesUtils.toolbarFolderId;
+ break;
+ case "mobileFolder":
+ container = PlacesUtils.mobileFolderId;
+ break;
+ }
+
+ // Insert the data into the db
+ for (let child of node.children) {
+ let index = child.index;
+ let [folders, searches] =
+ this.importJSONNode(child, container, index, 0);
+ for (let i = 0; i < folders.length; i++) {
+ if (folders[i])
+ folderIdMap[i] = folders[i];
+ }
+ searchIds = searchIds.concat(searches);
+ }
+ } else {
+ let [folders, searches] = this.importJSONNode(
+ node, PlacesUtils.placesRootId, node.index, 0);
+ for (let i = 0; i < folders.length; i++) {
+ if (folders[i])
+ folderIdMap[i] = folders[i];
+ }
+ searchIds = searchIds.concat(searches);
+ }
+ }
+
+ // Fixup imported place: uris that contain folders
+ for (let id of searchIds) {
+ let oldURI = PlacesUtils.bookmarks.getBookmarkURI(id);
+ let uri = fixupQuery(oldURI, folderIdMap);
+ if (!uri.equals(oldURI)) {
+ PlacesUtils.bookmarks.changeBookmarkURI(id, uri, this._source);
+ }
+ }
+
+ deferred.resolve();
+ }.bind(this)
+ };
+
+ PlacesUtils.bookmarks.runInBatchMode(batch, null);
+ }
+ yield deferred.promise;
+ // TODO (bug 1095426) once converted to the new bookmarks API, methods will
+ // yield, so this hack should not be needed anymore.
+ try {
+ yield Promise.all(this._importPromises);
+ } finally {
+ delete this._importPromises;
+ }
+ }),
+
+ /**
+ * Takes a JSON-serialized node and inserts it into the db.
+ *
+ * @param aData
+ * The unwrapped data blob of dropped or pasted data.
+ * @param aContainer
+ * The container the data was dropped or pasted into
+ * @param aIndex
+ * The index within the container the item was dropped or pasted at
+ * @return an array containing of maps of old folder ids to new folder ids,
+ * and an array of saved search ids that need to be fixed up.
+ * eg: [[[oldFolder1, newFolder1]], [search1]]
+ */
+ importJSONNode: function BI_importJSONNode(aData, aContainer, aIndex,
+ aGrandParentId) {
+ let folderIdMap = [];
+ let searchIds = [];
+ let id = -1;
+ switch (aData.type) {
+ case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER:
+ if (aContainer == PlacesUtils.tagsFolderId) {
+ // Node is a tag
+ if (aData.children) {
+ for (let child of aData.children) {
+ try {
+ PlacesUtils.tagging.tagURI(
+ NetUtil.newURI(child.uri), [aData.title], this._source);
+ } catch (ex) {
+ // Invalid tag child, skip it
+ }
+ }
+ return [folderIdMap, searchIds];
+ }
+ } else if (aData.annos &&
+ aData.annos.some(anno => anno.name == PlacesUtils.LMANNO_FEEDURI)) {
+ // Node is a livemark
+ let feedURI = null;
+ let siteURI = null;
+ aData.annos = aData.annos.filter(function(aAnno) {
+ switch (aAnno.name) {
+ case PlacesUtils.LMANNO_FEEDURI:
+ feedURI = NetUtil.newURI(aAnno.value);
+ return false;
+ case PlacesUtils.LMANNO_SITEURI:
+ siteURI = NetUtil.newURI(aAnno.value);
+ return false;
+ default:
+ return true;
+ }
+ });
+
+ if (feedURI) {
+ let lmPromise = PlacesUtils.livemarks.addLivemark({
+ title: aData.title,
+ feedURI: feedURI,
+ parentId: aContainer,
+ index: aIndex,
+ lastModified: aData.lastModified,
+ siteURI: siteURI,
+ guid: aData.guid,
+ source: this._source
+ }).then(aLivemark => {
+ let id = aLivemark.id;
+ if (aData.dateAdded)
+ PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded,
+ this._source);
+ if (aData.annos && aData.annos.length)
+ PlacesUtils.setAnnotationsForItem(id, aData.annos,
+ this._source);
+ });
+ this._importPromises.push(lmPromise);
+ }
+ } else {
+ let isMobileFolder = aData.annos &&
+ aData.annos.some(anno => anno.name == PlacesUtils.MOBILE_ROOT_ANNO);
+ if (isMobileFolder) {
+ // Mobile bookmark folders are special: we move their children to
+ // the mobile root instead of importing them. We also rewrite
+ // queries to use the special folder ID, and ignore generic
+ // properties like timestamps and annotations set on the folder.
+ id = PlacesUtils.mobileFolderId;
+ } else {
+ // For other folders, set `id` so that we can import timestamps
+ // and annotations at the end of this function.
+ id = PlacesUtils.bookmarks.createFolder(
+ aContainer, aData.title, aIndex, aData.guid, this._source);
+ }
+ folderIdMap[aData.id] = id;
+ // Process children
+ if (aData.children) {
+ for (let i = 0; i < aData.children.length; i++) {
+ let child = aData.children[i];
+ let [folders, searches] =
+ this.importJSONNode(child, id, i, aContainer);
+ for (let j = 0; j < folders.length; j++) {
+ if (folders[j])
+ folderIdMap[j] = folders[j];
+ }
+ searchIds = searchIds.concat(searches);
+ }
+ }
+ }
+ break;
+ case PlacesUtils.TYPE_X_MOZ_PLACE:
+ id = PlacesUtils.bookmarks.insertBookmark(
+ aContainer, NetUtil.newURI(aData.uri), aIndex, aData.title, aData.guid, this._source);
+ if (aData.keyword) {
+ // POST data could be set in 2 ways:
+ // 1. new backups have a postData property
+ // 2. old backups have an item annotation
+ let postDataAnno = aData.annos &&
+ aData.annos.find(anno => anno.name == PlacesUtils.POST_DATA_ANNO);
+ let postData = aData.postData || (postDataAnno && postDataAnno.value);
+ let kwPromise = PlacesUtils.keywords.insert({ keyword: aData.keyword,
+ url: aData.uri,
+ postData,
+ source: this._source });
+ this._importPromises.push(kwPromise);
+ }
+ if (aData.tags) {
+ let tags = aData.tags.split(",").filter(aTag =>
+ aTag.length <= Ci.nsITaggingService.MAX_TAG_LENGTH);
+ if (tags.length) {
+ try {
+ PlacesUtils.tagging.tagURI(NetUtil.newURI(aData.uri), tags, this._source);
+ } catch (ex) {
+ // Invalid tag child, skip it.
+ Cu.reportError(`Unable to set tags "${tags.join(", ")}" for ${aData.uri}: ${ex}`);
+ }
+ }
+ }
+ if (aData.charset) {
+ PlacesUtils.annotations.setPageAnnotation(
+ NetUtil.newURI(aData.uri), PlacesUtils.CHARSET_ANNO, aData.charset,
+ 0, Ci.nsIAnnotationService.EXPIRE_NEVER);
+ }
+ if (aData.uri.substr(0, 6) == "place:")
+ searchIds.push(id);
+ if (aData.icon) {
+ try {
+ // Create a fake faviconURI to use (FIXME: bug 523932)
+ let faviconURI = NetUtil.newURI("fake-favicon-uri:" + aData.uri);
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ faviconURI, aData.icon, 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI(aData.uri), faviconURI, false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ } catch (ex) {
+ Components.utils.reportError("Failed to import favicon data:" + ex);
+ }
+ }
+ if (aData.iconUri) {
+ try {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI(aData.uri), NetUtil.newURI(aData.iconUri), false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ } catch (ex) {
+ Components.utils.reportError("Failed to import favicon URI:" + ex);
+ }
+ }
+ break;
+ case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR:
+ id = PlacesUtils.bookmarks.insertSeparator(aContainer, aIndex, aData.guid, this._source);
+ break;
+ default:
+ // Unknown node type
+ }
+
+ // Set generic properties, valid for all nodes except tags and the mobile
+ // root.
+ if (id != -1 && id != PlacesUtils.mobileFolderId &&
+ aContainer != PlacesUtils.tagsFolderId &&
+ aGrandParentId != PlacesUtils.tagsFolderId) {
+ if (aData.dateAdded)
+ PlacesUtils.bookmarks.setItemDateAdded(id, aData.dateAdded,
+ this._source);
+ if (aData.lastModified)
+ PlacesUtils.bookmarks.setItemLastModified(id, aData.lastModified,
+ this._source);
+ if (aData.annos && aData.annos.length)
+ PlacesUtils.setAnnotationsForItem(id, aData.annos, this._source);
+ }
+
+ return [folderIdMap, searchIds];
+ }
+}
+
+function notifyObservers(topic) {
+ Services.obs.notifyObservers(null, topic, "json");
+}
+
+/**
+ * Replaces imported folder ids with their local counterparts in a place: URI.
+ *
+ * @param aURI
+ * A place: URI with folder ids.
+ * @param aFolderIdMap
+ * An array mapping old folder id to new folder ids.
+ * @returns the fixed up URI if all matched. If some matched, it returns
+ * the URI with only the matching folders included. If none matched
+ * it returns the input URI unchanged.
+ */
+function fixupQuery(aQueryURI, aFolderIdMap) {
+ let convert = function(str, p1, offset, s) {
+ return "folder=" + aFolderIdMap[p1];
+ }
+ let stringURI = aQueryURI.spec.replace(/folder=([0-9]+)/g, convert);
+
+ return NetUtil.newURI(stringURI);
+}
diff --git a/toolkit/components/places/Bookmarks.jsm b/toolkit/components/places/Bookmarks.jsm
new file mode 100644
index 000000000..835b4fc62
--- /dev/null
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -0,0 +1,1536 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This module provides an asynchronous API for managing bookmarks.
+ *
+ * Bookmarks are organized in a tree structure, and include URLs, folders and
+ * separators. Multiple bookmarks for the same URL are allowed.
+ *
+ * Note that if you are handling bookmarks operations in the UI, you should
+ * not use this API directly, but rather use PlacesTransactions.jsm, so that
+ * any operation is undo/redo-able.
+ *
+ * Each bookmark-item is represented by an object having the following
+ * properties:
+ *
+ * - guid (string)
+ * The globally unique identifier of the item.
+ * - parentGuid (string)
+ * The globally unique identifier of the folder containing the item.
+ * This will be an empty string for the Places root folder.
+ * - index (number)
+ * The 0-based position of the item in the parent folder.
+ * - dateAdded (Date)
+ * The time at which the item was added.
+ * - lastModified (Date)
+ * The time at which the item was last modified.
+ * - type (number)
+ * The item's type, either TYPE_BOOKMARK, TYPE_FOLDER or TYPE_SEPARATOR.
+ *
+ * The following properties are only valid for URLs or folders.
+ *
+ * - title (string)
+ * The item's title, if any. Empty titles and null titles are considered
+ * the same, and the property is unset on retrieval in such a case.
+ * Titles longer than DB_TITLE_LENGTH_MAX will be truncated.
+ *
+ * The following properties are only valid for URLs:
+ *
+ * - url (URL, href or nsIURI)
+ * The item's URL. Note that while input objects can contains either
+ * an URL object, an href string, or an nsIURI, output objects will always
+ * contain an URL object.
+ * An URL cannot be longer than DB_URL_LENGTH_MAX, methods will throw if a
+ * longer value is provided.
+ *
+ * Each successful operation notifies through the nsINavBookmarksObserver
+ * interface. To listen to such notifications you must register using
+ * nsINavBookmarksService addObserver and removeObserver methods.
+ * Note that bookmark addition or order changes won't notify onItemMoved for
+ * items that have their indexes changed.
+ * Similarly, lastModified changes not done explicitly (like changing another
+ * property) won't fire an onItemChanged notification for the lastModified
+ * property.
+ * @see nsINavBookmarkObserver
+ */
+
+this.EXPORTED_SYMBOLS = [ "Bookmarks" ];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
+ "resource://gre/modules/PlacesSyncUtils.jsm");
+
+const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED;
+const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
+
+var Bookmarks = Object.freeze({
+ /**
+ * Item's type constants.
+ * These should stay consistent with nsINavBookmarksService.idl
+ */
+ TYPE_BOOKMARK: 1,
+ TYPE_FOLDER: 2,
+ TYPE_SEPARATOR: 3,
+
+ /**
+ * Default index used to append a bookmark-item at the end of a folder.
+ * This should stay consistent with nsINavBookmarksService.idl
+ */
+ DEFAULT_INDEX: -1,
+
+ /**
+ * Bookmark change source constants, passed as optional properties and
+ * forwarded to observers. See nsINavBookmarksService.idl for an explanation.
+ */
+ SOURCES: {
+ DEFAULT: Ci.nsINavBookmarksService.SOURCE_DEFAULT,
+ SYNC: Ci.nsINavBookmarksService.SOURCE_SYNC,
+ IMPORT: Ci.nsINavBookmarksService.SOURCE_IMPORT,
+ IMPORT_REPLACE: Ci.nsINavBookmarksService.SOURCE_IMPORT_REPLACE,
+ },
+
+ /**
+ * Special GUIDs associated with bookmark roots.
+ * It's guaranteed that the roots will always have these guids.
+ */
+
+ rootGuid: "root________",
+ menuGuid: "menu________",
+ toolbarGuid: "toolbar_____",
+ unfiledGuid: "unfiled_____",
+ mobileGuid: "mobile______",
+
+ // With bug 424160, tags will stop being bookmarks, thus this root will
+ // be removed. Do not rely on this, rather use the tagging service API.
+ tagsGuid: "tags________",
+
+ /**
+ * Inserts a bookmark-item into the bookmarks tree.
+ *
+ * For creating a bookmark, the following set of properties is required:
+ * - type
+ * - parentGuid
+ * - url, only for bookmarked URLs
+ *
+ * If an index is not specified, it defaults to appending.
+ * It's also possible to pass a non-existent GUID to force creation of an
+ * item with the given GUID, but unless you have a very sound reason, such as
+ * an undo manager implementation or synchronization, don't do that.
+ *
+ * Note that any known properties that don't apply to the specific item type
+ * cause an exception.
+ *
+ * @param info
+ * object representing a bookmark-item.
+ *
+ * @return {Promise} resolved when the creation is complete.
+ * @resolves to an object representing the created bookmark.
+ * @rejects if it's not possible to create the requested bookmark.
+ * @throws if the arguments are invalid.
+ */
+ insert(info) {
+ // Ensure to use the same date for dateAdded and lastModified, even if
+ // dateAdded may be imposed by the caller.
+ let time = (info && info.dateAdded) || new Date();
+ let insertInfo = validateBookmarkObject(info,
+ { type: { defaultValue: this.TYPE_BOOKMARK }
+ , index: { defaultValue: this.DEFAULT_INDEX }
+ , url: { requiredIf: b => b.type == this.TYPE_BOOKMARK
+ , validIf: b => b.type == this.TYPE_BOOKMARK }
+ , parentGuid: { required: true }
+ , title: { validIf: b => [ this.TYPE_BOOKMARK
+ , this.TYPE_FOLDER ].includes(b.type) }
+ , dateAdded: { defaultValue: time
+ , validIf: b => !b.lastModified ||
+ b.dateAdded <= b.lastModified }
+ , lastModified: { defaultValue: time,
+ validIf: b => (!b.dateAdded && b.lastModified >= time) ||
+ (b.dateAdded && b.lastModified >= b.dateAdded) }
+ , source: { defaultValue: this.SOURCES.DEFAULT }
+ });
+
+ return Task.spawn(function* () {
+ // Ensure the parent exists.
+ let parent = yield fetchBookmark({ guid: insertInfo.parentGuid });
+ if (!parent)
+ throw new Error("parentGuid must be valid");
+
+ // Set index in the appending case.
+ if (insertInfo.index == this.DEFAULT_INDEX ||
+ insertInfo.index > parent._childCount) {
+ insertInfo.index = parent._childCount;
+ }
+
+ let item = yield insertBookmark(insertInfo, parent);
+
+ // Notify onItemAdded to listeners.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // We need the itemId to notify, though once the switch to guids is
+ // complete we may stop using it.
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ let itemId = yield PlacesUtils.promiseItemId(item.guid);
+
+ // Pass tagging information for the observers to skip over these notifications when needed.
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+ let isTagsFolder = parent._id == PlacesUtils.tagsFolderId;
+ notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
+ item.type, uri, item.title || null,
+ PlacesUtils.toPRTime(item.dateAdded), item.guid,
+ item.parentGuid, item.source ],
+ { isTagging: isTagging || isTagsFolder });
+
+ // If it's a tag, notify OnItemChanged to all bookmarks for this URL.
+ if (isTagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", item.source ]);
+ }
+ }
+
+ // Remove non-enumerable properties.
+ delete item.source;
+ return Object.assign({}, item);
+ }.bind(this));
+ },
+
+ /**
+ * Updates a bookmark-item.
+ *
+ * Only set the properties which should be changed (undefined properties
+ * won't be taken into account).
+ * Moreover, the item's type or dateAdded cannot be changed, since they are
+ * immutable after creation. Trying to change them will reject.
+ *
+ * Note that any known properties that don't apply to the specific item type
+ * cause an exception.
+ *
+ * @param info
+ * object representing a bookmark-item, as defined above.
+ *
+ * @return {Promise} resolved when the update is complete.
+ * @resolves to an object representing the updated bookmark.
+ * @rejects if it's not possible to update the given bookmark.
+ * @throws if the arguments are invalid.
+ */
+ update(info) {
+ // The info object is first validated here to ensure it's consistent, then
+ // it's compared to the existing item to remove any properties that don't
+ // need to be updated.
+ let updateInfo = validateBookmarkObject(info,
+ { guid: { required: true }
+ , index: { requiredIf: b => b.hasOwnProperty("parentGuid")
+ , validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX }
+ , source: { defaultValue: this.SOURCES.DEFAULT }
+ });
+
+ // There should be at last one more property in addition to guid and source.
+ if (Object.keys(updateInfo).length < 3)
+ throw new Error("Not enough properties to update");
+
+ return Task.spawn(function* () {
+ // Ensure the item exists.
+ let item = yield fetchBookmark(updateInfo);
+ if (!item)
+ throw new Error("No bookmarks found for the provided GUID");
+ if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type)
+ throw new Error("The bookmark type cannot be changed");
+ if (updateInfo.hasOwnProperty("dateAdded") &&
+ updateInfo.dateAdded.getTime() != item.dateAdded.getTime())
+ throw new Error("The bookmark dateAdded cannot be changed");
+
+ // Remove any property that will stay the same.
+ removeSameValueProperties(updateInfo, item);
+ // Check if anything should still be updated.
+ if (Object.keys(updateInfo).length < 3) {
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ }
+
+ updateInfo = validateBookmarkObject(updateInfo,
+ { url: { validIf: () => item.type == this.TYPE_BOOKMARK }
+ , title: { validIf: () => [ this.TYPE_BOOKMARK
+ , this.TYPE_FOLDER ].includes(item.type) }
+ , lastModified: { defaultValue: new Date()
+ , validIf: b => b.lastModified >= item.dateAdded }
+ });
+
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: update",
+ Task.async(function*(db) {
+ let parent;
+ if (updateInfo.hasOwnProperty("parentGuid")) {
+ if (item.type == this.TYPE_FOLDER) {
+ // Make sure we are not moving a folder into itself or one of its
+ // descendants.
+ let rows = yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ VALUES(:id)
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ WHERE type = :type
+ )
+ SELECT guid FROM moz_bookmarks
+ WHERE id IN descendants
+ `, { id: item._id, type: this.TYPE_FOLDER });
+ if (rows.map(r => r.getResultByName("guid")).includes(updateInfo.parentGuid))
+ throw new Error("Cannot insert a folder into itself or one of its descendants");
+ }
+
+ parent = yield fetchBookmark({ guid: updateInfo.parentGuid });
+ if (!parent)
+ throw new Error("No bookmarks found for the provided parentGuid");
+ }
+
+ if (updateInfo.hasOwnProperty("index")) {
+ // If at this point we don't have a parent yet, we are moving into
+ // the same container. Thus we know it exists.
+ if (!parent)
+ parent = yield fetchBookmark({ guid: item.parentGuid });
+
+ if (updateInfo.index >= parent._childCount ||
+ updateInfo.index == this.DEFAULT_INDEX) {
+ updateInfo.index = parent._childCount;
+
+ // Fix the index when moving within the same container.
+ if (parent.guid == item.parentGuid)
+ updateInfo.index--;
+ }
+ }
+
+ let updatedItem = yield updateBookmark(updateInfo, item, parent);
+
+ if (item.type == this.TYPE_BOOKMARK &&
+ item.url.href != updatedItem.url.href) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ updateFrecency(db, [updatedItem.url]).then(null, Cu.reportError);
+ }
+
+ // Notify onItemChanged to listeners.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // For lastModified, we only care about the original input, since we
+ // should not notify implciit lastModified changes.
+ if (info.hasOwnProperty("lastModified") &&
+ updateInfo.hasOwnProperty("lastModified") &&
+ item.lastModified != updatedItem.lastModified) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
+ false,
+ `${PlacesUtils.toPRTime(updatedItem.lastModified)}`,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid, "",
+ updatedItem.source ]);
+ }
+ if (updateInfo.hasOwnProperty("title")) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "title",
+ false, updatedItem.title,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid, "",
+ updatedItem.source ]);
+ }
+ if (updateInfo.hasOwnProperty("url")) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "uri",
+ false, updatedItem.url.href,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid,
+ item.url.href,
+ updatedItem.source ]);
+ }
+ // If the item was moved, notify onItemMoved.
+ if (item.parentGuid != updatedItem.parentGuid ||
+ item.index != updatedItem.index) {
+ notify(observers, "onItemMoved", [ updatedItem._id, item._parentId,
+ item.index, updatedItem._parentId,
+ updatedItem.index, updatedItem.type,
+ updatedItem.guid, item.parentGuid,
+ updatedItem.parentGuid,
+ updatedItem.source ]);
+ }
+
+ // Remove non-enumerable properties.
+ delete updatedItem.source;
+ return Object.assign({}, updatedItem);
+ }.bind(this)));
+ }.bind(this));
+ },
+
+ /**
+ * Removes a bookmark-item.
+ *
+ * @param guidOrInfo
+ * The globally unique identifier of the item to remove, or an
+ * object representing it, as defined above.
+ * @param {Object} [options={}]
+ * Additional options that can be passed to the function.
+ * Currently supports the following properties:
+ * - preventRemovalOfNonEmptyFolders: Causes an exception to be
+ * thrown when attempting to remove a folder that is not empty.
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when the removal is complete.
+ * @resolves to an object representing the removed bookmark.
+ * @rejects if the provided guid doesn't match any existing bookmark.
+ * @throws if the arguments are invalid.
+ */
+ remove(guidOrInfo, options={}) {
+ let info = guidOrInfo;
+ if (!info)
+ throw new Error("Input should be a valid object");
+ if (typeof(guidOrInfo) != "object")
+ info = { guid: guidOrInfo };
+
+ // Disallow removing the root folders.
+ if ([this.rootGuid, this.menuGuid, this.toolbarGuid, this.unfiledGuid,
+ this.tagsGuid, this.mobileGuid].includes(info.guid)) {
+ throw new Error("It's not possible to remove Places root folders.");
+ }
+
+ // Even if we ignore any other unneeded property, we still validate any
+ // known property to reduce likelihood of hidden bugs.
+ let removeInfo = validateBookmarkObject(info);
+
+ return Task.spawn(function* () {
+ let item = yield fetchBookmark(removeInfo);
+ if (!item)
+ throw new Error("No bookmarks found for the provided GUID.");
+
+ item = yield removeBookmark(item, options);
+
+ // Notify onItemRemoved to listeners.
+ let { source = Bookmarks.SOURCES.DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
+ item.type, uri, item.guid,
+ item.parentGuid,
+ source ],
+ { isTagging: isUntagging });
+
+ if (isUntagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", source ]);
+ }
+ }
+
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ });
+ },
+
+ /**
+ * Removes ALL bookmarks, resetting the bookmarks storage to an empty tree.
+ *
+ * Note that roots are preserved, only their children will be removed.
+ *
+ * @param {Object} [options={}]
+ * Additional options. Currently supports the following properties:
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when the removal is complete.
+ * @resolves once the removal is complete.
+ */
+ eraseEverything: function(options={}) {
+ const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid,
+ this.mobileGuid];
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
+ db => db.executeTransaction(function* () {
+ yield removeFoldersContents(db, folderGuids, options);
+ const time = PlacesUtils.toPRTime(new Date());
+ for (let folderGuid of folderGuids) {
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET lastModified = :time
+ WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
+ `, { folderGuid, time });
+ }
+ })
+ );
+ },
+
+ /**
+ * Returns a list of recently bookmarked items.
+ *
+ * @param {integer} numberOfItems
+ * The maximum number of bookmark items to return.
+ *
+ * @return {Promise} resolved when the listing is complete.
+ * @resolves to an array of recent bookmark-items.
+ * @rejects if an error happens while querying.
+ */
+ getRecent(numberOfItems) {
+ if (numberOfItems === undefined) {
+ throw new Error("numberOfItems argument is required");
+ }
+ if (!typeof numberOfItems === 'number' || (numberOfItems % 1) !== 0) {
+ throw new Error("numberOfItems argument must be an integer");
+ }
+ if (numberOfItems <= 0) {
+ throw new Error("numberOfItems argument must be greater than zero");
+ }
+
+ return Task.spawn(function* () {
+ return yield fetchRecentBookmarks(numberOfItems);
+ });
+ },
+
+ /**
+ * Fetches information about a bookmark-item.
+ *
+ * REMARK: any successful call to this method resolves to a single
+ * bookmark-item (or null), even when multiple bookmarks may exist
+ * (e.g. fetching by url). If you wish to retrieve all of the
+ * bookmarks for a given match, use the callback instead.
+ *
+ * Input can be either a guid or an object with one, and only one, of these
+ * filtering properties set:
+ * - guid
+ * retrieves the item with the specified guid.
+ * - parentGuid and index
+ * retrieves the item by its position.
+ * - url
+ * retrieves the most recent bookmark having the given URL.
+ * To retrieve ALL of the bookmarks for that URL, you must pass in an
+ * onResult callback, that will be invoked once for each found bookmark.
+ *
+ * @param guidOrInfo
+ * The globally unique identifier of the item to fetch, or an
+ * object representing it, as defined above.
+ * @param onResult [optional]
+ * Callback invoked for each found bookmark.
+ *
+ * @return {Promise} resolved when the fetch is complete.
+ * @resolves to an object representing the found item, as described above, or
+ * an array of such objects. if no item is found, the returned
+ * promise is resolved to null.
+ * @rejects if an error happens while fetching.
+ * @throws if the arguments are invalid.
+ *
+ * @note Any unknown property in the info object is ignored. Known properties
+ * may be overwritten.
+ */
+ fetch(guidOrInfo, onResult=null) {
+ if (onResult && typeof onResult != "function")
+ throw new Error("onResult callback must be a valid function");
+ let info = guidOrInfo;
+ if (!info)
+ throw new Error("Input should be a valid object");
+ if (typeof(info) != "object")
+ info = { guid: guidOrInfo };
+
+ // Only one condition at a time can be provided.
+ let conditionsCount = [
+ v => v.hasOwnProperty("guid"),
+ v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"),
+ v => v.hasOwnProperty("url")
+ ].reduce((old, fn) => old + fn(info)|0, 0);
+ if (conditionsCount != 1)
+ throw new Error(`Unexpected number of conditions provided: ${conditionsCount}`);
+
+ // Even if we ignore any other unneeded property, we still validate any
+ // known property to reduce likelihood of hidden bugs.
+ let fetchInfo = validateBookmarkObject(info,
+ { parentGuid: { requiredIf: b => b.hasOwnProperty("index") }
+ , index: { requiredIf: b => b.hasOwnProperty("parentGuid")
+ , validIf: b => typeof(b.index) == "number" &&
+ b.index >= 0 || b.index == this.DEFAULT_INDEX }
+ });
+
+ return Task.spawn(function* () {
+ let results;
+ if (fetchInfo.hasOwnProperty("guid"))
+ results = yield fetchBookmark(fetchInfo);
+ else if (fetchInfo.hasOwnProperty("parentGuid") && fetchInfo.hasOwnProperty("index"))
+ results = yield fetchBookmarkByPosition(fetchInfo);
+ else if (fetchInfo.hasOwnProperty("url"))
+ results = yield fetchBookmarksByURL(fetchInfo);
+
+ if (!results)
+ return null;
+
+ if (!Array.isArray(results))
+ results = [results];
+ // Remove non-enumerable properties.
+ results = results.map(r => Object.assign({}, r));
+
+ // Ideally this should handle an incremental behavior and thus be invoked
+ // while we fetch. Though, the likelihood of 2 or more bookmarks for the
+ // same match is very low, so it's not worth the added code complication.
+ if (onResult) {
+ for (let result of results) {
+ try {
+ onResult(result);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ }
+
+ return results[0];
+ });
+ },
+
+ /**
+ * Retrieves an object representation of a bookmark-item, along with all of
+ * its descendants, if any.
+ *
+ * Each node in the tree is an object that extends the item representation
+ * described above with some additional properties:
+ *
+ * - [deprecated] id (number)
+ * the item's id. Defined only if aOptions.includeItemIds is set.
+ * - annos (array)
+ * the item's annotations. This is not set if there are no annotations
+ * set for the item.
+ *
+ * The root object of the tree also has the following properties set:
+ * - itemsCount (number, not enumerable)
+ * the number of items, including the root item itself, which are
+ * represented in the resolved object.
+ *
+ * Bookmarked URLs may also have the following properties:
+ * - tags (string)
+ * csv string of the bookmark's tags, if any.
+ * - charset (string)
+ * the last known charset of the bookmark, if any.
+ * - iconurl (URL)
+ * the bookmark's favicon URL, if any.
+ *
+ * Folders may also have the following properties:
+ * - children (array)
+ * the folder's children information, each of them having the same set of
+ * properties as above.
+ *
+ * @param [optional] guid
+ * the topmost item to be queried. If it's not passed, the Places
+ * root folder is queried: that is, you get a representation of the
+ * entire bookmarks hierarchy.
+ * @param [optional] options
+ * Options for customizing the query behavior, in the form of an
+ * object with any of the following properties:
+ * - excludeItemsCallback: a function for excluding items, along with
+ * their descendants. Given an item object (that has everything set
+ * apart its potential children data), it should return true if the
+ * item should be excluded. Once an item is excluded, the function
+ * isn't called for any of its descendants. This isn't called for
+ * the root item.
+ * WARNING: since the function may be called for each item, using
+ * this option can slow down the process significantly if the
+ * callback does anything that's not relatively trivial. It is
+ * highly recommended to avoid any synchronous I/O or DB queries.
+ * - includeItemIds: opt-in to include the deprecated id property.
+ * Use it if you must. It'll be removed once the switch to guids is
+ * complete.
+ *
+ * @return {Promise} resolved when the fetch is complete.
+ * @resolves to an object that represents either a single item or a
+ * bookmarks tree. if guid points to a non-existent item, the
+ * returned promise is resolved to null.
+ * @rejects if an error happens while fetching.
+ * @throws if the arguments are invalid.
+ */
+ // TODO must implement these methods yet:
+ // PlacesUtils.promiseBookmarksTree()
+ fetchTree(guid = "", options = {}) {
+ throw new Error("Not yet implemented");
+ },
+
+ /**
+ * Reorders contents of a folder based on a provided array of GUIDs.
+ *
+ * @param parentGuid
+ * The globally unique identifier of the folder whose contents should
+ * be reordered.
+ * @param orderedChildrenGuids
+ * Ordered array of the children's GUIDs. If this list contains
+ * non-existing entries they will be ignored. If the list is
+ * incomplete, missing entries will be appended.
+ * @param {Object} [options={}]
+ * Additional options. Currently supports the following properties:
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when reordering is complete.
+ * @rejects if an error happens while reordering.
+ * @throws if the arguments are invalid.
+ */
+ reorder(parentGuid, orderedChildrenGuids, options={}) {
+ let info = { guid: parentGuid, source: this.SOURCES.DEFAULT };
+ info = validateBookmarkObject(info, { guid: { required: true } });
+
+ if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length)
+ throw new Error("Must provide a sorted array of children GUIDs.");
+ try {
+ orderedChildrenGuids.forEach(PlacesUtils.BOOKMARK_VALIDATORS.guid);
+ } catch (ex) {
+ throw new Error("Invalid GUID found in the sorted children array.");
+ }
+
+ return Task.spawn(function* () {
+ let parent = yield fetchBookmark(info);
+ if (!parent || parent.type != this.TYPE_FOLDER)
+ throw new Error("No folder found for the provided GUID.");
+
+ let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids);
+
+ let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // Note that child.index is the old index.
+ for (let i = 0; i < sortedChildren.length; ++i) {
+ let child = sortedChildren[i];
+ notify(observers, "onItemMoved", [ child._id, child._parentId,
+ child.index, child._parentId,
+ i, child.type,
+ child.guid, child.parentGuid,
+ child.parentGuid,
+ source ]);
+ }
+ }.bind(this));
+ },
+
+ /**
+ * Searches a list of bookmark-items by a search term, url or title.
+ *
+ * IMPORTANT:
+ * This is intended as an interim API for the web-extensions implementation.
+ * It will be removed as soon as we have a new querying API.
+ *
+ * If you just want to search bookmarks by URL, use .fetch() instead.
+ *
+ * @param query
+ * Either a string to use as search term, or an object
+ * containing any of these keys: query, title or url with the
+ * corresponding string to match as value.
+ * The url property can be either a string or an nsIURI.
+ *
+ * @return {Promise} resolved when the search is complete.
+ * @resolves to an array of found bookmark-items.
+ * @rejects if an error happens while searching.
+ * @throws if the arguments are invalid.
+ *
+ * @note Any unknown property in the query object is ignored.
+ * Known properties may be overwritten.
+ */
+ search(query) {
+ if (!query) {
+ throw new Error("Query object is required");
+ }
+ if (typeof query === "string") {
+ query = { query: query };
+ }
+ if (typeof query !== "object") {
+ throw new Error("Query must be an object or a string");
+ }
+ if (query.query && typeof query.query !== "string") {
+ throw new Error("Query option must be a string");
+ }
+ if (query.title && typeof query.title !== "string") {
+ throw new Error("Title option must be a string");
+ }
+
+ if (query.url) {
+ if (typeof query.url === "string" || (query.url instanceof URL)) {
+ query.url = new URL(query.url).href;
+ } else if (query.url instanceof Ci.nsIURI) {
+ query.url = query.url.spec;
+ } else {
+ throw new Error("Url option must be a string or a URL object");
+ }
+ }
+
+ return Task.spawn(function* () {
+ let results = yield queryBookmarks(query);
+
+ return results;
+ });
+ },
+});
+
+// Globals.
+
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ * array of nsINavBookmarkObserver objects.
+ * @param notification
+ * the notification name.
+ * @param args
+ * array of arguments to pass to the notification.
+ * @param information
+ * Information about the notification, so we can filter based
+ * based on the observer's preferences.
+ */
+function notify(observers, notification, args, information = {}) {
+ for (let observer of observers) {
+ if (information.isTagging && observer.skipTags) {
+ continue;
+ }
+
+ if (information.isDescendantRemoval && observer.skipDescendantsOnItemRemoval) {
+ continue;
+ }
+
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+}
+
+// Update implementation.
+
+function updateBookmark(info, item, newParent) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ Task.async(function*(db) {
+
+ let tuples = new Map();
+ if (info.hasOwnProperty("lastModified"))
+ tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
+ if (info.hasOwnProperty("title"))
+ tuples.set("title", { value: info.title });
+
+ yield db.executeTransaction(function* () {
+ if (info.hasOwnProperty("url")) {
+ // Ensure a page exists in moz_places for this URL.
+ yield maybeInsertPlace(db, info.url);
+ // Update tuples for the update query.
+ tuples.set("url", { value: info.url.href
+ , fragment: "fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)" });
+ }
+
+ if (newParent) {
+ // For simplicity, update the index regardless.
+ let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
+ tuples.set("position", { value: newIndex });
+
+ if (newParent.guid == item.parentGuid) {
+ // Moving inside the original container.
+ // When moving "up", add 1 to each index in the interval.
+ // Otherwise when moving down, we subtract 1.
+ let sign = newIndex < item.index ? +1 : -1;
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position BETWEEN :lowIndex AND :highIndex
+ `, { sign: sign, newParentId: newParent._id,
+ lowIndex: Math.min(item.index, newIndex),
+ highIndex: Math.max(item.index, newIndex) });
+ } else {
+ // Moving across different containers.
+ tuples.set("parent", { value: newParent._id} );
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :oldParentId
+ AND position >= :oldIndex
+ `, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position >= :newIndex
+ `, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
+
+ yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
+ }
+ yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
+ }
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks
+ SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")}
+ WHERE guid = :guid
+ `, Object.assign({ guid: info.guid },
+ [...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
+ });
+
+ // If the parent changed, update related non-enumerable properties.
+ let additionalParentInfo = {};
+ if (newParent) {
+ Object.defineProperty(additionalParentInfo, "_parentId",
+ { value: newParent._id, enumerable: false });
+ Object.defineProperty(additionalParentInfo, "_grandParentId",
+ { value: newParent._parentId, enumerable: false });
+ }
+
+ let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo);
+
+ // Don't return an empty title to the caller.
+ if (updatedItem.hasOwnProperty("title") && updatedItem.title === null)
+ delete updatedItem.title;
+
+ return updatedItem;
+ }));
+}
+
+// Insert implementation.
+
+function insertBookmark(item, parent) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
+ Task.async(function*(db) {
+
+ // If a guid was not provided, generate one, so we won't need to fetch the
+ // bookmark just after having created it.
+ if (!item.hasOwnProperty("guid"))
+ item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
+
+ yield db.executeTransaction(function* transaction() {
+ if (item.type == Bookmarks.TYPE_BOOKMARK) {
+ // Ensure a page exists in moz_places for this URL.
+ // The IGNORE conflict can trigger on `guid`.
+ yield maybeInsertPlace(db, item.url);
+ }
+
+ // Adjust indices.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + 1
+ WHERE parent = :parent
+ AND position >= :index
+ `, { parent: parent._id, index: item.index });
+
+ // Insert the bookmark into the database.
+ yield db.executeCached(
+ `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
+ dateAdded, lastModified, guid)
+ VALUES ((SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :type, :parent,
+ :index, :title, :date_added, :last_modified, :guid)
+ `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
+ type: item.type, parent: parent._id, index: item.index,
+ title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded),
+ last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid });
+
+ yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
+ });
+
+ // If not a tag recalculate frecency...
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ // Don't return an empty title to the caller.
+ if (item.hasOwnProperty("title") && item.title === null)
+ delete item.title;
+
+ return item;
+ }));
+}
+
+// Query implementation.
+
+function queryBookmarks(info) {
+ let queryParams = {tags_folder: PlacesUtils.tagsFolderId};
+ // we're searching for bookmarks, so exclude tags
+ let queryString = "WHERE p.parent <> :tags_folder";
+
+ if (info.title) {
+ queryString += " AND b.title = :title";
+ queryParams.title = info.title;
+ }
+
+ if (info.url) {
+ queryString += " AND h.url_hash = hash(:url) AND h.url = :url";
+ queryParams.url = info.url;
+ }
+
+ if (info.query) {
+ queryString += " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior) ";
+ queryParams.query = info.query;
+ queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED;
+ queryParams.searchBehavior = BEHAVIOR_BOOKMARK;
+ }
+
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: queryBookmarks",
+ Task.async(function*(db) {
+
+ // _id, _childCount, _grandParentId and _parentId fields
+ // are required to be in the result by the converting function
+ // hence setting them to NULL
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title,
+ h.url AS url, b.parent, p.parent,
+ NULL AS _id,
+ NULL AS _childCount,
+ NULL AS _grandParentId,
+ NULL AS _parentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ ${queryString}
+ `, queryParams);
+
+ return rowsToItemsArray(rows);
+ }));
+}
+
+
+// Fetch implementation.
+
+function fetchBookmark(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE b.guid = :guid
+ `, { guid: info.guid });
+
+ return rows.length ? rowsToItemsArray(rows)[0] : null;
+ }));
+}
+
+function fetchBookmarkByPosition(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition",
+ Task.async(function*(db) {
+ let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ AND b.position = IFNULL(:index, (SELECT count(*) - 1
+ FROM moz_bookmarks
+ WHERE parent = p.id))
+ `, { parentGuid: info.parentGuid, index });
+
+ return rows.length ? rowsToItemsArray(rows)[0] : null;
+ }));
+}
+
+function fetchBookmarksByURL(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `/* do not warn (bug no): not worth to add an index */
+ SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE h.url_hash = hash(:url) AND h.url = :url
+ AND _grandParentId <> :tags_folder
+ ORDER BY b.lastModified DESC
+ `, { url: info.url.href,
+ tags_folder: PlacesUtils.tagsFolderId });
+
+ return rows.length ? rowsToItemsArray(rows) : null;
+ }));
+}
+
+function fetchRecentBookmarks(numberOfItems) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.parent <> :tags_folder
+ ORDER BY b.dateAdded DESC, b.ROWID DESC
+ LIMIT :numberOfItems
+ `, { tags_folder: PlacesUtils.tagsFolderId, numberOfItems });
+
+ return rows.length ? rowsToItemsArray(rows) : [];
+ }));
+}
+
+function fetchBookmarksByParent(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ ORDER BY b.position ASC
+ `, { parentGuid: info.parentGuid });
+
+ return rowsToItemsArray(rows);
+ }));
+}
+
+// Remove implementation.
+
+function removeBookmark(item, options) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: removeBookmark",
+ Task.async(function*(db) {
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+
+ yield db.executeTransaction(function* transaction() {
+ // If it's a folder, remove its contents first.
+ if (item.type == Bookmarks.TYPE_FOLDER) {
+ if (options.preventRemovalOfNonEmptyFolders && item._childCount > 0) {
+ throw new Error("Cannot remove a non-empty folder.");
+ }
+ yield removeFoldersContents(db, [item.guid], options);
+ }
+
+ // Remove annotations first. If it's a tag, we can avoid paying that cost.
+ if (!isUntagging) {
+ // We don't go through the annotations service for this cause otherwise
+ // we'd get a pointless onItemChanged notification and it would also
+ // set lastModified to an unexpected value.
+ yield removeAnnotationsForItem(db, item._id);
+ }
+
+ // Remove the bookmark from the database.
+ yield db.executeCached(
+ `DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
+
+ // Fix indices in the parent.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position - 1 WHERE
+ parent = :parentId AND position > :index
+ `, { parentId: item._parentId, index: item.index });
+
+ yield setAncestorsLastModified(db, item.parentGuid, new Date());
+ });
+
+ // If not a tag recalculate frecency...
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ return item;
+ }));
+}
+
+// Reorder implementation.
+
+function reorderChildren(parent, orderedChildrenGuids) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ db => db.executeTransaction(function* () {
+ // Select all of the direct children for the given parent.
+ let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
+ if (!children.length)
+ return undefined;
+
+ // Build a map of GUIDs to indices for fast lookups in the comparator
+ // function.
+ let guidIndices = new Map();
+ for (let i = 0; i < orderedChildrenGuids.length; ++i) {
+ let guid = orderedChildrenGuids[i];
+ guidIndices.set(guid, i);
+ }
+
+ // Reorder the children array according to the specified order, provided
+ // GUIDs come first, others are appended in somehow random order.
+ children.sort((a, b) => {
+ // This works provided fetchBookmarksByParent returns sorted children.
+ if (!guidIndices.has(a.guid) && !guidIndices.has(b.guid)) {
+ return 0;
+ }
+ if (!guidIndices.has(a.guid)) {
+ return 1;
+ }
+ if (!guidIndices.has(b.guid)) {
+ return -1;
+ }
+ return guidIndices.get(a.guid) < guidIndices.get(b.guid) ? -1 : 1;
+ });
+
+ // Update the bookmarks position now. If any unknown guid have been
+ // inserted meanwhile, its position will be set to -position, and we'll
+ // handle it later.
+ // To do the update in a single step, we build a VALUES (guid, position)
+ // table. We then use count() in the sorting table to avoid skipping values
+ // when no more existing GUIDs have been provided.
+ let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`)
+ .join();
+ yield db.execute(
+ `WITH sorting(g, p) AS (
+ VALUES ${valuesTable}
+ )
+ UPDATE moz_bookmarks SET position = (
+ SELECT CASE count(*) WHEN 0 THEN -position
+ ELSE count(*) - 1
+ END
+ FROM sorting a
+ JOIN sorting b ON b.p <= a.p
+ WHERE a.g = guid
+ )
+ WHERE parent = :parentId
+ `, { parentId: parent._id});
+
+ // Update position of items that could have been inserted in the meanwhile.
+ // Since this can happen rarely and it's only done for schema coherence
+ // resonds, we won't notify about these changes.
+ yield db.executeCached(
+ `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
+ AFTER UPDATE OF position ON moz_bookmarks
+ WHEN NEW.position = -1
+ BEGIN
+ UPDATE moz_bookmarks
+ SET position = (SELECT MAX(position) FROM moz_bookmarks
+ WHERE parent = NEW.parent) +
+ (SELECT count(*) FROM moz_bookmarks
+ WHERE parent = NEW.parent
+ AND position BETWEEN OLD.position AND -1)
+ WHERE guid = NEW.guid;
+ END
+ `);
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
+
+ yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
+
+ return children;
+ }.bind(this))
+ );
+}
+
+// Helpers.
+
+/**
+ * Merges objects into a new object, included non-enumerable properties.
+ *
+ * @param sources
+ * source objects to merge.
+ * @return a new object including all properties from the source objects.
+ */
+function mergeIntoNewObject(...sources) {
+ let dest = {};
+ for (let src of sources) {
+ for (let prop of Object.getOwnPropertyNames(src)) {
+ Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop));
+ }
+ }
+ return dest;
+}
+
+/**
+ * Remove properties that have the same value across two bookmark objects.
+ *
+ * @param dest
+ * destination bookmark object.
+ * @param src
+ * source bookmark object.
+ * @return a cleaned up bookmark object.
+ * @note "guid" is never removed.
+ */
+function removeSameValueProperties(dest, src) {
+ for (let prop in dest) {
+ let remove = false;
+ switch (prop) {
+ case "lastModified":
+ case "dateAdded":
+ remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime();
+ break;
+ case "url":
+ remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href;
+ break;
+ default:
+ remove = dest[prop] == src[prop];
+ }
+ if (remove && prop != "guid")
+ delete dest[prop];
+ }
+}
+
+/**
+ * Convert an array of mozIStorageRow objects to an array of bookmark objects.
+ *
+ * @param rows
+ * the array of mozIStorageRow objects.
+ * @return an array of bookmark objects.
+ */
+function rowsToItemsArray(rows) {
+ return rows.map(row => {
+ let item = {};
+ for (let prop of ["guid", "index", "type"]) {
+ item[prop] = row.getResultByName(prop);
+ }
+ for (let prop of ["dateAdded", "lastModified"]) {
+ item[prop] = PlacesUtils.toDate(row.getResultByName(prop));
+ }
+ for (let prop of ["title", "parentGuid", "url" ]) {
+ let val = row.getResultByName(prop);
+ if (val)
+ item[prop] = prop === "url" ? new URL(val) : val;
+ }
+ for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
+ let val = row.getResultByName(prop);
+ if (val !== null) {
+ // These properties should not be returned to the API consumer, thus
+ // they are non-enumerable and removed through Object.assign just before
+ // the object is returned.
+ // Configurable is set to support mergeIntoNewObject overwrites.
+ Object.defineProperty(item, prop, { value: val, enumerable: false,
+ configurable: true });
+ }
+ }
+
+ return item;
+ });
+}
+
+function validateBookmarkObject(input, behavior) {
+ return PlacesUtils.validateItemProperties(
+ PlacesUtils.BOOKMARK_VALIDATORS, input, behavior);
+}
+
+/**
+ * Updates frecency for a list of URLs.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param urls
+ * the array of URLs to update.
+ */
+var updateFrecency = Task.async(function* (db, urls) {
+ // We just use the hashes, since updating a few additional urls won't hurt.
+ yield db.execute(
+ `UPDATE moz_places
+ SET frecency = NOTIFY_FRECENCY(
+ CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
+ ) WHERE url_hash IN ( ${urls.map(url => `hash("${url.href}")`).join(", ")} )
+ `);
+
+ yield db.execute(
+ `UPDATE moz_places
+ SET hidden = 0
+ WHERE url_hash IN ( ${urls.map(url => `hash(${JSON.stringify(url.href)})`).join(", ")} )
+ AND frecency <> 0
+ `);
+});
+
+/**
+ * Removes any orphan annotation entries.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ */
+var removeOrphanAnnotations = Task.async(function* (db) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE id IN (SELECT a.id from moz_items_annos a
+ LEFT JOIN moz_bookmarks b ON a.item_id = b.id
+ WHERE b.id ISNULL)
+ `);
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Removes annotations for a given item.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param itemId
+ * internal id of the item for which to remove annotations.
+ */
+var removeAnnotationsForItem = Task.async(function* (db, itemId) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE item_id = :id
+ `, { id: itemId });
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Updates lastModified for all the ancestors of a given folder GUID.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuid
+ * the GUID of the folder whose ancestors should be updated.
+ * @param time
+ * a Date object to use for the update.
+ *
+ * @note the folder itself is also updated.
+ */
+var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
+ yield db.executeCached(
+ `WITH RECURSIVE
+ ancestors(aid) AS (
+ SELECT id FROM moz_bookmarks WHERE guid = :guid
+ UNION ALL
+ SELECT parent FROM moz_bookmarks
+ JOIN ancestors ON id = aid
+ WHERE type = :type
+ )
+ UPDATE moz_bookmarks SET lastModified = :time
+ WHERE id IN ancestors
+ `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
+ time: PlacesUtils.toPRTime(time) });
+});
+
+/**
+ * Remove all descendants of one or more bookmark folders.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuids
+ * array of folder guids.
+ */
+var removeFoldersContents =
+Task.async(function* (db, folderGuids, options) {
+ let itemsRemoved = [];
+ for (let folderGuid of folderGuids) {
+ let rows = yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
+ b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
+ b.lastModified, b.title, p.parent AS _grandParentId,
+ NULL AS _childCount
+ FROM descendants
+ JOIN moz_bookmarks b ON did = b.id
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON b.fk = h.id`, { folderGuid });
+
+ itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows));
+
+ yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
+ }
+
+ // Cleanup orphans.
+ yield removeOrphanAnnotations(db);
+
+ // TODO (Bug 1087576): this may leave orphan tags behind.
+
+ let urls = itemsRemoved.filter(item => "url" in item).map(item => item.url);
+ updateFrecency(db, urls).then(null, Cu.reportError);
+
+ // Send onItemRemoved notifications to listeners.
+ // TODO (Bug 1087580): for the case of eraseEverything, this should send a
+ // single clear bookmarks notification rather than notifying for each
+ // bookmark.
+
+ // Notify listeners in reverse order to serve children before parents.
+ let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ for (let item of itemsRemoved.reverse()) {
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ notify(observers, "onItemRemoved", [ item._id, item._parentId,
+ item.index, item.type, uri,
+ item.guid, item.parentGuid,
+ source ],
+ // Notify observers that this item is being
+ // removed as a descendent.
+ { isDescendantRemoval: true });
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ if (isUntagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", source ]);
+ }
+ }
+ }
+});
+
+/**
+ * Tries to insert a new place if it doesn't exist yet.
+ * @param url
+ * A valid URL object.
+ * @return {Promise} resolved when the operation is complete.
+ */
+function maybeInsertPlace(db, url) {
+ // The IGNORE conflict can trigger on `guid`.
+ return db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
+ VALUES (:url, hash(:url), :rev_host, 0, :frecency,
+ IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ GENERATE_GUID()))
+ `, { url: url.href,
+ rev_host: PlacesUtils.getReversedHost(url),
+ frecency: url.protocol == "place:" ? 0 : -1 });
+}
diff --git a/toolkit/components/places/ClusterLib.js b/toolkit/components/places/ClusterLib.js
new file mode 100644
index 000000000..ae5debff9
--- /dev/null
+++ b/toolkit/components/places/ClusterLib.js
@@ -0,0 +1,248 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Class that can run the hierarchical clustering algorithm with the given
+ * parameters.
+ *
+ * @param distance
+ * Function that should return the distance between two items.
+ * Defaults to clusterlib.euclidean_distance.
+ * @param merge
+ * Function that should take in two items and return a merged one.
+ * Defaults to clusterlib.average_linkage.
+ * @param threshold
+ * The maximum distance between two items for which their clusters
+ * can be merged.
+ */
+function HierarchicalClustering(distance, merge, threshold) {
+ this.distance = distance || clusterlib.euclidean_distance;
+ this.merge = merge || clusterlib.average_linkage;
+ this.threshold = threshold == undefined ? Infinity : threshold;
+}
+
+HierarchicalClustering.prototype = {
+ /**
+ * Run the hierarchical clustering algorithm on the given items to produce
+ * a final set of clusters. Uses the parameters set in the constructor.
+ *
+ * @param items
+ * An array of "things" to cluster - this is the domain-specific
+ * collection you're trying to cluster (colors, points, etc.)
+ * @param snapshotGap
+ * How many iterations of the clustering algorithm to wait between
+ * calling the snapshotCallback
+ * @param snapshotCallback
+ * If provided, will be called as clusters are merged to let you view
+ * the progress of the algorithm. Passed the current array of
+ * clusters, cached distances, and cached closest clusters.
+ *
+ * @return An array of merged clusters. The represented item can be
+ * found in the "item" property of the cluster.
+ */
+ cluster: function HC_cluster(items, snapshotGap, snapshotCallback) {
+ // array of all remaining clusters
+ let clusters = [];
+ // 2D matrix of distances between each pair of clusters, indexed by key
+ let distances = [];
+ // closest cluster key for each cluster, indexed by key
+ let neighbors = [];
+ // an array of all clusters, but indexed by key
+ let clustersByKey = [];
+
+ // set up clusters from the initial items array
+ for (let index = 0; index < items.length; index++) {
+ let cluster = {
+ // the item this cluster represents
+ item: items[index],
+ // a unique key for this cluster, stays constant unless merged itself
+ key: index,
+ // index of cluster in clusters array, can change during any merge
+ index: index,
+ // how many clusters have been merged into this one
+ size: 1
+ };
+ clusters[index] = cluster;
+ clustersByKey[index] = cluster;
+ distances[index] = [];
+ neighbors[index] = 0;
+ }
+
+ // initialize distance matrix and cached neighbors
+ for (let i = 0; i < clusters.length; i++) {
+ for (let j = 0; j <= i; j++) {
+ var dist = (i == j) ? Infinity :
+ this.distance(clusters[i].item, clusters[j].item);
+ distances[i][j] = dist;
+ distances[j][i] = dist;
+
+ if (dist < distances[i][neighbors[i]]) {
+ neighbors[i] = j;
+ }
+ }
+ }
+
+ // merge the next two closest clusters until none of them are close enough
+ let next = null, i = 0;
+ for (; next = this.closestClusters(clusters, distances, neighbors); i++) {
+ if (snapshotCallback && (i % snapshotGap) == 0) {
+ snapshotCallback(clusters);
+ }
+ this.mergeClusters(clusters, distances, neighbors, clustersByKey,
+ clustersByKey[next[0]], clustersByKey[next[1]]);
+ }
+ return clusters;
+ },
+
+ /**
+ * Once we decide to merge two clusters in the cluster method, actually
+ * merge them. Alters the given state of the algorithm.
+ *
+ * @param clusters
+ * The array of all remaining clusters
+ * @param distances
+ * Cached distances between pairs of clusters
+ * @param neighbors
+ * Cached closest clusters
+ * @param clustersByKey
+ * Array of all clusters, indexed by key
+ * @param cluster1
+ * First cluster to merge
+ * @param cluster2
+ * Second cluster to merge
+ */
+ mergeClusters: function HC_mergeClus(clusters, distances, neighbors,
+ clustersByKey, cluster1, cluster2) {
+ let merged = { item: this.merge(cluster1.item, cluster2.item),
+ left: cluster1,
+ right: cluster2,
+ key: cluster1.key,
+ size: cluster1.size + cluster2.size };
+
+ clusters[cluster1.index] = merged;
+ clusters.splice(cluster2.index, 1);
+ clustersByKey[cluster1.key] = merged;
+
+ // update distances with new merged cluster
+ for (let i = 0; i < clusters.length; i++) {
+ var ci = clusters[i];
+ var dist;
+ if (cluster1.key == ci.key) {
+ dist = Infinity;
+ } else if (this.merge == clusterlib.single_linkage) {
+ dist = distances[cluster1.key][ci.key];
+ if (distances[cluster1.key][ci.key] >
+ distances[cluster2.key][ci.key]) {
+ dist = distances[cluster2.key][ci.key];
+ }
+ } else if (this.merge == clusterlib.complete_linkage) {
+ dist = distances[cluster1.key][ci.key];
+ if (distances[cluster1.key][ci.key] <
+ distances[cluster2.key][ci.key]) {
+ dist = distances[cluster2.key][ci.key];
+ }
+ } else if (this.merge == clusterlib.average_linkage) {
+ dist = (distances[cluster1.key][ci.key] * cluster1.size
+ + distances[cluster2.key][ci.key] * cluster2.size)
+ / (cluster1.size + cluster2.size);
+ } else {
+ dist = this.distance(ci.item, cluster1.item);
+ }
+
+ distances[cluster1.key][ci.key] = distances[ci.key][cluster1.key]
+ = dist;
+ }
+
+ // update cached neighbors
+ for (let i = 0; i < clusters.length; i++) {
+ var key1 = clusters[i].key;
+ if (neighbors[key1] == cluster1.key ||
+ neighbors[key1] == cluster2.key) {
+ let minKey = key1;
+ for (let j = 0; j < clusters.length; j++) {
+ var key2 = clusters[j].key;
+ if (distances[key1][key2] < distances[key1][minKey]) {
+ minKey = key2;
+ }
+ }
+ neighbors[key1] = minKey;
+ }
+ clusters[i].index = i;
+ }
+ },
+
+ /**
+ * Given the current state of the algorithm, return the keys of the two
+ * clusters that are closest to each other so we know which ones to merge
+ * next.
+ *
+ * @param clusters
+ * The array of all remaining clusters
+ * @param distances
+ * Cached distances between pairs of clusters
+ * @param neighbors
+ * Cached closest clusters
+ *
+ * @return An array of two keys of clusters to merge, or null if there are
+ * no more clusters close enough to merge
+ */
+ closestClusters: function HC_closestClus(clusters, distances, neighbors) {
+ let minKey = 0, minDist = Infinity;
+ for (let i = 0; i < clusters.length; i++) {
+ var key = clusters[i].key;
+ if (distances[key][neighbors[key]] < minDist) {
+ minKey = key;
+ minDist = distances[key][neighbors[key]];
+ }
+ }
+ if (minDist < this.threshold) {
+ return [minKey, neighbors[minKey]];
+ }
+ return null;
+ }
+};
+
+var clusterlib = {
+ hcluster: function hcluster(items, distance, merge, threshold, snapshotGap,
+ snapshotCallback) {
+ return (new HierarchicalClustering(distance, merge, threshold))
+ .cluster(items, snapshotGap, snapshotCallback);
+ },
+
+ single_linkage: function single_linkage(cluster1, cluster2) {
+ return cluster1;
+ },
+
+ complete_linkage: function complete_linkage(cluster1, cluster2) {
+ return cluster1;
+ },
+
+ average_linkage: function average_linkage(cluster1, cluster2) {
+ return cluster1;
+ },
+
+ euclidean_distance: function euclidean_distance(v1, v2) {
+ let total = 0;
+ for (let i = 0; i < v1.length; i++) {
+ total += Math.pow(v2[i] - v1[i], 2);
+ }
+ return Math.sqrt(total);
+ },
+
+ manhattan_distance: function manhattan_distance(v1, v2) {
+ let total = 0;
+ for (let i = 0; i < v1.length; i++) {
+ total += Math.abs(v2[i] - v1[i]);
+ }
+ return total;
+ },
+
+ max_distance: function max_distance(v1, v2) {
+ let max = 0;
+ for (let i = 0; i < v1.length; i++) {
+ max = Math.max(max, Math.abs(v2[i] - v1[i]));
+ }
+ return max;
+ }
+};
diff --git a/toolkit/components/places/ColorAnalyzer.js b/toolkit/components/places/ColorAnalyzer.js
new file mode 100644
index 000000000..861ce7107
--- /dev/null
+++ b/toolkit/components/places/ColorAnalyzer.js
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Ci = Components.interfaces;
+const Cc = Components.classes;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const MAXIMUM_PIXELS = Math.pow(144, 2);
+
+function ColorAnalyzer() {
+ // a queue of callbacks for each job we give to the worker
+ this.callbacks = [];
+
+ this.hiddenWindowDoc = Cc["@mozilla.org/appshell/appShellService;1"].
+ getService(Ci.nsIAppShellService).
+ hiddenDOMWindow.document;
+
+ this.worker = new ChromeWorker("resource://gre/modules/ColorAnalyzer_worker.js");
+ this.worker.onmessage = this.onWorkerMessage.bind(this);
+ this.worker.onerror = this.onWorkerError.bind(this);
+}
+
+ColorAnalyzer.prototype = {
+ findRepresentativeColor: function ColorAnalyzer_frc(imageURI, callback) {
+ function cleanup() {
+ image.removeEventListener("load", loadListener);
+ image.removeEventListener("error", errorListener);
+ }
+ let image = this.hiddenWindowDoc.createElementNS(XHTML_NS, "img");
+ let loadListener = this.onImageLoad.bind(this, image, callback, cleanup);
+ let errorListener = this.onImageError.bind(this, image, callback, cleanup);
+ image.addEventListener("load", loadListener);
+ image.addEventListener("error", errorListener);
+ image.src = imageURI.spec;
+ },
+
+ onImageLoad: function ColorAnalyzer_onImageLoad(image, callback, cleanup) {
+ if (image.naturalWidth * image.naturalHeight > MAXIMUM_PIXELS) {
+ // this will probably take too long to process - fail
+ callback.onComplete(false);
+ } else {
+ let canvas = this.hiddenWindowDoc.createElementNS(XHTML_NS, "canvas");
+ canvas.width = image.naturalWidth;
+ canvas.height = image.naturalHeight;
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(image, 0, 0);
+ this.startJob(ctx.getImageData(0, 0, canvas.width, canvas.height),
+ callback);
+ }
+ cleanup();
+ },
+
+ onImageError: function ColorAnalyzer_onImageError(image, callback, cleanup) {
+ Cu.reportError("ColorAnalyzer: image at " + image.src + " didn't load");
+ callback.onComplete(false);
+ cleanup();
+ },
+
+ startJob: function ColorAnalyzer_startJob(imageData, callback) {
+ this.callbacks.push(callback);
+ this.worker.postMessage({ imageData: imageData, maxColors: 1 });
+ },
+
+ onWorkerMessage: function ColorAnalyzer_onWorkerMessage(event) {
+ // colors can be empty on failure
+ if (event.data.colors.length < 1) {
+ this.callbacks.shift().onComplete(false);
+ } else {
+ this.callbacks.shift().onComplete(true, event.data.colors[0]);
+ }
+ },
+
+ onWorkerError: function ColorAnalyzer_onWorkerError(error) {
+ // this shouldn't happen, but just in case
+ error.preventDefault();
+ Cu.reportError("ColorAnalyzer worker: " + error.message);
+ this.callbacks.shift().onComplete(false);
+ },
+
+ classID: Components.ID("{d056186c-28a0-494e-aacc-9e433772b143}"),
+ QueryInterface: XPCOMUtils.generateQI([Ci.mozIColorAnalyzer])
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ColorAnalyzer]);
diff --git a/toolkit/components/places/ColorAnalyzer_worker.js b/toolkit/components/places/ColorAnalyzer_worker.js
new file mode 100644
index 000000000..01fce0637
--- /dev/null
+++ b/toolkit/components/places/ColorAnalyzer_worker.js
@@ -0,0 +1,392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+importScripts("ClusterLib.js", "ColorConversion.js");
+
+// Offsets in the ImageData pixel array to reach pixel colors
+const PIXEL_RED = 0;
+const PIXEL_GREEN = 1;
+const PIXEL_BLUE = 2;
+const PIXEL_ALPHA = 3;
+
+// Number of components in one ImageData pixel (RGBA)
+const NUM_COMPONENTS = 4;
+
+// Shift a color represented as a 24 bit integer by N bits to get a component
+const RED_SHIFT = 16;
+const GREEN_SHIFT = 8;
+
+// Only run the N most frequent unique colors through the clustering algorithm
+// Images with more than this many unique colors will have reduced accuracy.
+const MAX_COLORS_TO_MERGE = 500;
+
+// Each cluster of colors has a mean color in the Lab color space.
+// If the euclidean distance between the means of two clusters is greater
+// than or equal to this threshold, they won't be merged.
+const MERGE_THRESHOLD = 12;
+
+// The highest the distance handicap can be for large clusters
+const MAX_SIZE_HANDICAP = 5;
+// If the handicap is below this number, it is cut off to zero
+const SIZE_HANDICAP_CUTOFF = 2;
+
+// If potential background colors deviate from the mean background color by
+// this threshold or greater, finding a background color will fail
+const BACKGROUND_THRESHOLD = 10;
+
+// Alpha component of colors must be larger than this in order to make it into
+// the clustering algorithm or be considered a background color (0 - 255).
+const MIN_ALPHA = 25;
+
+// The euclidean distance in the Lab color space under which merged colors
+// are weighted lower for being similar to the background color
+const BACKGROUND_WEIGHT_THRESHOLD = 15;
+
+// The range in which color chroma differences will affect desirability.
+// Colors with chroma outside of the range take on the desirability of
+// their nearest extremes. Should be roughly 0 - 150.
+const CHROMA_WEIGHT_UPPER = 90;
+const CHROMA_WEIGHT_LOWER = 1;
+const CHROMA_WEIGHT_MIDDLE = (CHROMA_WEIGHT_UPPER + CHROMA_WEIGHT_LOWER) / 2;
+
+/**
+ * When we receive a message from the outside world, find the representative
+ * colors of the given image. The colors will be posted back to the caller
+ * through the "colors" property on the event data object as an array of
+ * integers. Colors of lower indices are more representative.
+ * This array can be empty if this worker can't find a color.
+ *
+ * @param event
+ * A MessageEvent whose data should have the following properties:
+ * imageData - A DOM ImageData instance to analyze
+ * maxColors - The maximum number of representative colors to find,
+ * defaults to 1 if not provided
+ */
+onmessage = function(event) {
+ let imageData = event.data.imageData;
+ let pixels = imageData.data;
+ let width = imageData.width;
+ let height = imageData.height;
+ let maxColors = event.data.maxColors;
+ if (typeof(maxColors) != "number") {
+ maxColors = 1;
+ }
+
+ let allColors = getColors(pixels, width, height);
+
+ // Only merge top colors by frequency for speed.
+ let mergedColors = mergeColors(allColors.slice(0, MAX_COLORS_TO_MERGE),
+ width * height, MERGE_THRESHOLD);
+
+ let backgroundColor = getBackgroundColor(pixels, width, height);
+
+ mergedColors = mergedColors.map(function(cluster) {
+ // metadata holds a bunch of information about the color represented by
+ // this cluster
+ let metadata = cluster.item;
+
+ // the basis of color desirability is how much of the image the color is
+ // responsible for, but we'll need to weigh this number differently
+ // depending on other factors
+ metadata.desirability = metadata.ratio;
+ let weight = 1;
+
+ // if the color is close to the background color, we don't want it
+ if (backgroundColor != null) {
+ let backgroundDistance = labEuclidean(metadata.mean, backgroundColor);
+ if (backgroundDistance < BACKGROUND_WEIGHT_THRESHOLD) {
+ weight = backgroundDistance / BACKGROUND_WEIGHT_THRESHOLD;
+ }
+ }
+
+ // prefer more interesting colors, but don't knock low chroma colors
+ // completely out of the running (lower bound), and we don't really care
+ // if a color is slightly more intense than another on the higher end
+ let chroma = labChroma(metadata.mean);
+ if (chroma < CHROMA_WEIGHT_LOWER) {
+ chroma = CHROMA_WEIGHT_LOWER;
+ } else if (chroma > CHROMA_WEIGHT_UPPER) {
+ chroma = CHROMA_WEIGHT_UPPER;
+ }
+ weight *= chroma / CHROMA_WEIGHT_MIDDLE;
+
+ metadata.desirability *= weight;
+ return metadata;
+ });
+
+ // only send back the most desirable colors
+ mergedColors.sort(function(a, b) {
+ return b.desirability != a.desirability ? b.desirability - a.desirability : b.color - a.color;
+ });
+ mergedColors = mergedColors.map(function(metadata) {
+ return metadata.color;
+ }).slice(0, maxColors);
+ postMessage({ colors: mergedColors });
+};
+
+/**
+ * Given the pixel data and dimensions of an image, return an array of objects
+ * associating each unique color and its frequency in the image, sorted
+ * descending by frequency. Sufficiently transparent colors are ignored.
+ *
+ * @param pixels
+ * Pixel data array for the image to get colors from (ImageData.data).
+ * @param width
+ * Width of the image, in # of pixels.
+ * @param height
+ * Height of the image, in # of pixels.
+ *
+ * @return An array of objects with color and freq properties, sorted
+ * descending by freq
+ */
+function getColors(pixels, width, height) {
+ let colorFrequency = {};
+ for (let x = 0; x < width; x++) {
+ for (let y = 0; y < height; y++) {
+ let offset = (x * NUM_COMPONENTS) + (y * NUM_COMPONENTS * width);
+
+ if (pixels[offset + PIXEL_ALPHA] < MIN_ALPHA) {
+ continue;
+ }
+
+ let color = pixels[offset + PIXEL_RED] << RED_SHIFT
+ | pixels[offset + PIXEL_GREEN] << GREEN_SHIFT
+ | pixels[offset + PIXEL_BLUE];
+
+ if (color in colorFrequency) {
+ colorFrequency[color]++;
+ } else {
+ colorFrequency[color] = 1;
+ }
+ }
+ }
+
+ let colors = [];
+ for (var color in colorFrequency) {
+ colors.push({ color: +color, freq: colorFrequency[+color] });
+ }
+ colors.sort(descendingFreqSort);
+ return colors;
+}
+
+/**
+ * Given an array of objects from getColors, the number of pixels in the
+ * image, and a merge threshold, run the clustering algorithm on the colors
+ * and return the set of merged clusters.
+ *
+ * @param colorFrequencies
+ * An array of objects from getColors to cluster
+ * @param numPixels
+ * The number of pixels in the image
+ * @param threshold
+ * The maximum distance between two clusters for which those clusters
+ * can be merged.
+ *
+ * @return An array of merged clusters
+ *
+ * @see clusterlib.hcluster
+ * @see getColors
+ */
+function mergeColors(colorFrequencies, numPixels, threshold) {
+ let items = colorFrequencies.map(function(colorFrequency) {
+ let color = colorFrequency.color;
+ let freq = colorFrequency.freq;
+ return {
+ mean: rgb2lab(color >> RED_SHIFT, color >> GREEN_SHIFT & 0xff,
+ color & 0xff),
+ // the canonical color of the cluster
+ // (one w/ highest freq or closest to mean)
+ color: color,
+ colors: [color],
+ highFreq: freq,
+ highRatio: freq / numPixels,
+ // the individual color w/ the highest frequency in this cluster
+ highColor: color,
+ // ratio of image taken up by colors in this cluster
+ ratio: freq / numPixels,
+ freq: freq,
+ };
+ });
+
+ let merged = clusterlib.hcluster(items, distance, merge, threshold);
+ return merged;
+}
+
+function descendingFreqSort(a, b) {
+ return b.freq != a.freq ? b.freq - a.freq : b.color - a.color;
+}
+
+/**
+ * Given two items for a pair of clusters (as created in mergeColors above),
+ * determine the distance between them so we know if we should merge or not.
+ * Uses the euclidean distance between their mean colors in the lab color
+ * space, weighted so larger items are harder to merge.
+ *
+ * @param item1
+ * The first item to compare
+ * @param item2
+ * The second item to compare
+ *
+ * @return The distance between the two items
+ */
+function distance(item1, item2) {
+ // don't cluster large blocks of color unless they're really similar
+ let minRatio = Math.min(item1.ratio, item2.ratio);
+ let dist = labEuclidean(item1.mean, item2.mean);
+ let handicap = Math.min(MAX_SIZE_HANDICAP, dist * minRatio);
+ if (handicap <= SIZE_HANDICAP_CUTOFF) {
+ handicap = 0;
+ }
+ return dist + handicap;
+}
+
+/**
+ * Find the euclidean distance between two colors in the Lab color space.
+ *
+ * @param color1
+ * The first color to compare
+ * @param color2
+ * The second color to compare
+ *
+ * @return The euclidean distance between the two colors
+ */
+function labEuclidean(color1, color2) {
+ return Math.sqrt(
+ Math.pow(color2.lightness - color1.lightness, 2)
+ + Math.pow(color2.a - color1.a, 2)
+ + Math.pow(color2.b - color1.b, 2));
+}
+
+/**
+ * Given items from two clusters we know are appropriate for merging,
+ * merge them together into a third item such that its metadata describes both
+ * input items. The "color" property is set to the color in the new item that
+ * is closest to its mean color.
+ *
+ * @param item1
+ * The first item to merge
+ * @param item2
+ * The second item to merge
+ *
+ * @return An item that represents the merging of the given items
+ */
+function merge(item1, item2) {
+ let lab1 = item1.mean;
+ let lab2 = item2.mean;
+
+ /* algorithm tweak point - weighting the mean of the cluster */
+ let num1 = item1.freq;
+ let num2 = item2.freq;
+
+ let total = num1 + num2;
+
+ let mean = {
+ lightness: (lab1.lightness * num1 + lab2.lightness * num2) / total,
+ a: (lab1.a * num1 + lab2.a * num2) / total,
+ b: (lab1.b * num1 + lab2.b * num2) / total
+ };
+
+ let colors = item1.colors.concat(item2.colors);
+
+ // get the canonical color of the new cluster
+ let color;
+ let avgFreq = colors.length / (item1.freq + item2.freq);
+ if ((item1.highFreq > item2.highFreq) && (item1.highFreq > avgFreq * 2)) {
+ color = item1.highColor;
+ } else if (item2.highFreq > avgFreq * 2) {
+ color = item2.highColor;
+ } else {
+ // if there's no stand-out color
+ let minDist = Infinity, closest = 0;
+ for (let i = 0; i < colors.length; i++) {
+ let color = colors[i];
+ let lab = rgb2lab(color >> RED_SHIFT, color >> GREEN_SHIFT & 0xff,
+ color & 0xff);
+ let dist = labEuclidean(lab, mean);
+ if (dist < minDist) {
+ minDist = dist;
+ closest = i;
+ }
+ }
+ color = colors[closest];
+ }
+
+ const higherItem = item1.highFreq > item2.highFreq ? item1 : item2;
+
+ return {
+ mean: mean,
+ color: color,
+ highFreq: higherItem.highFreq,
+ highColor: higherItem.highColor,
+ highRatio: higherItem.highRatio,
+ ratio: item1.ratio + item2.ratio,
+ freq: item1.freq + item2.freq,
+ colors: colors,
+ };
+}
+
+/**
+ * Find the background color of the given image.
+ *
+ * @param pixels
+ * The pixel data for the image (an array of component integers)
+ * @param width
+ * The width of the image
+ * @param height
+ * The height of the image
+ *
+ * @return The background color of the image as a Lab object, or null if we
+ * can't determine the background color
+ */
+function getBackgroundColor(pixels, width, height) {
+ // we'll assume that if the four corners are roughly the same color,
+ // then that's the background color
+ let coordinates = [[0, 0], [width - 1, 0], [width - 1, height - 1],
+ [0, height - 1]];
+
+ // find the corner colors in LAB
+ let cornerColors = [];
+ for (let i = 0; i < coordinates.length; i++) {
+ let offset = (coordinates[i][0] * NUM_COMPONENTS)
+ + (coordinates[i][1] * NUM_COMPONENTS * width);
+ if (pixels[offset + PIXEL_ALPHA] < MIN_ALPHA) {
+ // we can't make very accurate judgements below this opacity
+ continue;
+ }
+ cornerColors.push(rgb2lab(pixels[offset + PIXEL_RED],
+ pixels[offset + PIXEL_GREEN],
+ pixels[offset + PIXEL_BLUE]));
+ }
+
+ // we want at least two points at acceptable alpha levels
+ if (cornerColors.length <= 1) {
+ return null;
+ }
+
+ // find the average color among the corners
+ let averageColor = { lightness: 0, a: 0, b: 0 };
+ cornerColors.forEach(function(color) {
+ for (let i in color) {
+ averageColor[i] += color[i];
+ }
+ });
+ for (let i in averageColor) {
+ averageColor[i] /= cornerColors.length;
+ }
+
+ // if we have fewer points due to low alpha, they need to be closer together
+ let threshold = BACKGROUND_THRESHOLD
+ * (cornerColors.length / coordinates.length);
+
+ // if any of the corner colors deviate enough from the average, they aren't
+ // similar enough to be considered the background color
+ for (let cornerColor of cornerColors) {
+ if (labEuclidean(cornerColor, averageColor) > threshold) {
+ return null;
+ }
+ }
+ return averageColor;
+}
diff --git a/toolkit/components/places/ColorConversion.js b/toolkit/components/places/ColorConversion.js
new file mode 100644
index 000000000..b8a2c860d
--- /dev/null
+++ b/toolkit/components/places/ColorConversion.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Given a color in the Lab space, return its chroma (colorfulness,
+ * saturation).
+ *
+ * @param lab
+ * The lab color to get the chroma from
+ *
+ * @return A number greater than zero that measures chroma in the image
+ */
+function labChroma(lab) {
+ return Math.sqrt(Math.pow(lab.a, 2) + Math.pow(lab.b, 2));
+}
+
+/**
+ * Given the RGB components of a color as integers from 0-255, return the
+ * color in the XYZ color space.
+ *
+ * @return An object with x, y, z properties holding those components of the
+ * color in the XYZ color space.
+ */
+function rgb2xyz(r, g, b) {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ // assume sRGB
+ r = r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : (r / 12.92);
+ g = g > 0.04045 ? Math.pow(((g + 0.055) / 1.055), 2.4) : (g / 12.92);
+ b = b > 0.04045 ? Math.pow(((b + 0.055) / 1.055), 2.4) : (b / 12.92);
+
+ return {
+ x: ((r * 0.4124) + (g * 0.3576) + (b * 0.1805)) * 100,
+ y: ((r * 0.2126) + (g * 0.7152) + (b * 0.0722)) * 100,
+ z: ((r * 0.0193) + (g * 0.1192) + (b * 0.9505)) * 100
+ };
+}
+
+/**
+ * Given the RGB components of a color as integers from 0-255, return the
+ * color in the Lab color space.
+ *
+ * @return An object with lightness, a, b properties holding those components
+ * of the color in the Lab color space.
+ */
+function rgb2lab(r, g, b) {
+ let xyz = rgb2xyz(r, g, b),
+ x = xyz.x / 95.047,
+ y = xyz.y / 100,
+ z = xyz.z / 108.883;
+
+ x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116);
+ y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116);
+ z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116);
+
+ return {
+ lightness: (116 * y) - 16,
+ a: 500 * (x - y),
+ b: 200 * (y - z)
+ };
+}
diff --git a/toolkit/components/places/Database.cpp b/toolkit/components/places/Database.cpp
new file mode 100644
index 000000000..37502e2a1
--- /dev/null
+++ b/toolkit/components/places/Database.cpp
@@ -0,0 +1,2333 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/DebugOnly.h"
+#include "mozilla/ScopeExit.h"
+
+#include "Database.h"
+
+#include "nsIAnnotationService.h"
+#include "nsINavBookmarksService.h"
+#include "nsIInterfaceRequestorUtils.h"
+#include "nsIFile.h"
+#include "nsIWritablePropertyBag2.h"
+
+#include "nsNavHistory.h"
+#include "nsPlacesTables.h"
+#include "nsPlacesIndexes.h"
+#include "nsPlacesTriggers.h"
+#include "nsPlacesMacros.h"
+#include "nsVariant.h"
+#include "SQLFunctions.h"
+#include "Helpers.h"
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsDirectoryServiceUtils.h"
+#include "prenv.h"
+#include "prsystem.h"
+#include "nsPrintfCString.h"
+#include "mozilla/Preferences.h"
+#include "mozilla/Services.h"
+#include "mozilla/Unused.h"
+#include "prtime.h"
+
+#include "nsXULAppAPI.h"
+
+// Time between corrupt database backups.
+#define RECENT_BACKUP_TIME_MICROSEC (int64_t)86400 * PR_USEC_PER_SEC // 24H
+
+// Filename of the database.
+#define DATABASE_FILENAME NS_LITERAL_STRING("places.sqlite")
+// Filename used to backup corrupt databases.
+#define DATABASE_CORRUPT_FILENAME NS_LITERAL_STRING("places.sqlite.corrupt")
+
+// Set when the database file was found corrupt by a previous maintenance.
+#define PREF_FORCE_DATABASE_REPLACEMENT "places.database.replaceOnStartup"
+
+// Set to specify the size of the places database growth increments in kibibytes
+#define PREF_GROWTH_INCREMENT_KIB "places.database.growthIncrementKiB"
+
+// Set to disable the default robust storage and use volatile, in-memory
+// storage without robust transaction flushing guarantees. This makes
+// SQLite use much less I/O at the cost of losing data when things crash.
+// The pref is only honored if an environment variable is set. The env
+// variable is intentionally named something scary to help prevent someone
+// from thinking it is a useful performance optimization they should enable.
+#define PREF_DISABLE_DURABILITY "places.database.disableDurability"
+#define ENV_ALLOW_CORRUPTION "ALLOW_PLACES_DATABASE_TO_LOSE_DATA_AND_BECOME_CORRUPT"
+
+// The maximum url length we can store in history.
+// We do not add to history URLs longer than this value.
+#define PREF_HISTORY_MAXURLLEN "places.history.maxUrlLength"
+// This number is mostly a guess based on various facts:
+// * IE didn't support urls longer than 2083 chars
+// * Sitemaps protocol used to support a maximum of 2048 chars
+// * Various SEO guides suggest to not go over 2000 chars
+// * Various apps/services are known to have issues over 2000 chars
+// * RFC 2616 - HTTP/1.1 suggests being cautious about depending
+// on URI lengths above 255 bytes
+#define PREF_HISTORY_MAXURLLEN_DEFAULT 2000
+
+// Maximum size for the WAL file. It should be small enough since in case of
+// crashes we could lose all the transactions in the file. But a too small
+// file could hurt performance.
+#define DATABASE_MAX_WAL_SIZE_IN_KIBIBYTES 512
+
+#define BYTES_PER_KIBIBYTE 1024
+
+// How much time Sqlite can wait before returning a SQLITE_BUSY error.
+#define DATABASE_BUSY_TIMEOUT_MS 100
+
+// Old Sync GUID annotation.
+#define SYNCGUID_ANNO NS_LITERAL_CSTRING("sync/guid")
+
+// Places string bundle, contains internationalized bookmark root names.
+#define PLACES_BUNDLE "chrome://places/locale/places.properties"
+
+// Livemarks annotations.
+#define LMANNO_FEEDURI "livemark/feedURI"
+#define LMANNO_SITEURI "livemark/siteURI"
+
+#define MOBILE_ROOT_GUID "mobile______"
+#define MOBILE_ROOT_ANNO "mobile/bookmarksRoot"
+
+// We use a fixed title for the mobile root to avoid marking the database as
+// corrupt if we can't look up the localized title in the string bundle. Sync
+// sets the title to the localized version when it creates the left pane query.
+#define MOBILE_ROOT_TITLE "mobile"
+
+using namespace mozilla;
+
+namespace mozilla {
+namespace places {
+
+namespace {
+
+////////////////////////////////////////////////////////////////////////////////
+//// Helpers
+
+/**
+ * Checks whether exists a database backup created not longer than
+ * RECENT_BACKUP_TIME_MICROSEC ago.
+ */
+bool
+hasRecentCorruptDB()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<nsIFile> profDir;
+ NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, getter_AddRefs(profDir));
+ NS_ENSURE_TRUE(profDir, false);
+ nsCOMPtr<nsISimpleEnumerator> entries;
+ profDir->GetDirectoryEntries(getter_AddRefs(entries));
+ NS_ENSURE_TRUE(entries, false);
+ bool hasMore;
+ while (NS_SUCCEEDED(entries->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsISupports> next;
+ entries->GetNext(getter_AddRefs(next));
+ NS_ENSURE_TRUE(next, false);
+ nsCOMPtr<nsIFile> currFile = do_QueryInterface(next);
+ NS_ENSURE_TRUE(currFile, false);
+
+ nsAutoString leafName;
+ if (NS_SUCCEEDED(currFile->GetLeafName(leafName)) &&
+ leafName.Length() >= DATABASE_CORRUPT_FILENAME.Length() &&
+ leafName.Find(".corrupt", DATABASE_FILENAME.Length()) != -1) {
+ PRTime lastMod = 0;
+ currFile->GetLastModifiedTime(&lastMod);
+ NS_ENSURE_TRUE(lastMod > 0, false);
+ return (PR_Now() - lastMod) > RECENT_BACKUP_TIME_MICROSEC;
+ }
+ }
+ return false;
+}
+
+/**
+ * Updates sqlite_stat1 table through ANALYZE.
+ * Since also nsPlacesExpiration.js executes ANALYZE, the analyzed tables
+ * must be the same in both components. So ensure they are in sync.
+ *
+ * @param aDBConn
+ * The database connection.
+ */
+nsresult
+updateSQLiteStatistics(mozIStorageConnection* aDBConn)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ nsCOMPtr<mozIStorageAsyncStatement> analyzePlacesStmt;
+ aDBConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "ANALYZE moz_places"
+ ), getter_AddRefs(analyzePlacesStmt));
+ NS_ENSURE_STATE(analyzePlacesStmt);
+ nsCOMPtr<mozIStorageAsyncStatement> analyzeBookmarksStmt;
+ aDBConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "ANALYZE moz_bookmarks"
+ ), getter_AddRefs(analyzeBookmarksStmt));
+ NS_ENSURE_STATE(analyzeBookmarksStmt);
+ nsCOMPtr<mozIStorageAsyncStatement> analyzeVisitsStmt;
+ aDBConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "ANALYZE moz_historyvisits"
+ ), getter_AddRefs(analyzeVisitsStmt));
+ NS_ENSURE_STATE(analyzeVisitsStmt);
+ nsCOMPtr<mozIStorageAsyncStatement> analyzeInputStmt;
+ aDBConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "ANALYZE moz_inputhistory"
+ ), getter_AddRefs(analyzeInputStmt));
+ NS_ENSURE_STATE(analyzeInputStmt);
+
+ mozIStorageBaseStatement *stmts[] = {
+ analyzePlacesStmt,
+ analyzeBookmarksStmt,
+ analyzeVisitsStmt,
+ analyzeInputStmt
+ };
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ (void)aDBConn->ExecuteAsync(stmts, ArrayLength(stmts), nullptr,
+ getter_AddRefs(ps));
+ return NS_OK;
+}
+
+/**
+ * Sets the connection journal mode to one of the JOURNAL_* types.
+ *
+ * @param aDBConn
+ * The database connection.
+ * @param aJournalMode
+ * One of the JOURNAL_* types.
+ * @returns the current journal mode.
+ * @note this may return a different journal mode than the required one, since
+ * setting it may fail.
+ */
+enum JournalMode
+SetJournalMode(nsCOMPtr<mozIStorageConnection>& aDBConn,
+ enum JournalMode aJournalMode)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ nsAutoCString journalMode;
+ switch (aJournalMode) {
+ default:
+ MOZ_FALLTHROUGH_ASSERT("Trying to set an unknown journal mode.");
+ // Fall through to the default DELETE journal.
+ case JOURNAL_DELETE:
+ journalMode.AssignLiteral("delete");
+ break;
+ case JOURNAL_TRUNCATE:
+ journalMode.AssignLiteral("truncate");
+ break;
+ case JOURNAL_MEMORY:
+ journalMode.AssignLiteral("memory");
+ break;
+ case JOURNAL_WAL:
+ journalMode.AssignLiteral("wal");
+ break;
+ }
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsAutoCString query(MOZ_STORAGE_UNIQUIFY_QUERY_STR
+ "PRAGMA journal_mode = ");
+ query.Append(journalMode);
+ aDBConn->CreateStatement(query, getter_AddRefs(statement));
+ NS_ENSURE_TRUE(statement, JOURNAL_DELETE);
+
+ bool hasResult = false;
+ if (NS_SUCCEEDED(statement->ExecuteStep(&hasResult)) && hasResult &&
+ NS_SUCCEEDED(statement->GetUTF8String(0, journalMode))) {
+ if (journalMode.EqualsLiteral("delete")) {
+ return JOURNAL_DELETE;
+ }
+ if (journalMode.EqualsLiteral("truncate")) {
+ return JOURNAL_TRUNCATE;
+ }
+ if (journalMode.EqualsLiteral("memory")) {
+ return JOURNAL_MEMORY;
+ }
+ if (journalMode.EqualsLiteral("wal")) {
+ return JOURNAL_WAL;
+ }
+ // This is an unknown journal.
+ MOZ_ASSERT(true);
+ }
+
+ return JOURNAL_DELETE;
+}
+
+nsresult
+CreateRoot(nsCOMPtr<mozIStorageConnection>& aDBConn,
+ const nsCString& aRootName, const nsCString& aGuid,
+ const nsXPIDLString& titleString)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // The position of the new item in its folder.
+ static int32_t itemPosition = 0;
+
+ // A single creation timestamp for all roots so that the root folder's
+ // last modification time isn't earlier than its childrens' creation time.
+ static PRTime timestamp = 0;
+ if (!timestamp)
+ timestamp = RoundedPRNow();
+
+ // Create a new bookmark folder for the root.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = aDBConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT INTO moz_bookmarks "
+ "(type, position, title, dateAdded, lastModified, guid, parent) "
+ "VALUES (:item_type, :item_position, :item_title,"
+ ":date_added, :last_modified, :guid,"
+ "IFNULL((SELECT id FROM moz_bookmarks WHERE parent = 0), 0))"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) return rv;
+
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_type"),
+ nsINavBookmarksService::TYPE_FOLDER);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_position"), itemPosition);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("item_title"),
+ NS_ConvertUTF16toUTF8(titleString));
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("date_added"), timestamp);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("last_modified"), timestamp);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"), aGuid);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->Execute();
+ if (NS_FAILED(rv)) return rv;
+
+ // The 'places' root is a folder containing the other roots.
+ // The first bookmark in a folder has position 0.
+ if (!aRootName.EqualsLiteral("places"))
+ ++itemPosition;
+
+ return NS_OK;
+}
+
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+//// Database
+
+PLACES_FACTORY_SINGLETON_IMPLEMENTATION(Database, gDatabase)
+
+NS_IMPL_ISUPPORTS(Database
+, nsIObserver
+, nsISupportsWeakReference
+)
+
+Database::Database()
+ : mMainThreadStatements(mMainConn)
+ , mMainThreadAsyncStatements(mMainConn)
+ , mAsyncThreadStatements(mMainConn)
+ , mDBPageSize(0)
+ , mDatabaseStatus(nsINavHistoryService::DATABASE_STATUS_OK)
+ , mClosed(false)
+ , mClientsShutdown(new ClientsShutdownBlocker())
+ , mConnectionShutdown(new ConnectionShutdownBlocker(this))
+ , mMaxUrlLength(0)
+{
+ MOZ_ASSERT(!XRE_IsContentProcess(),
+ "Cannot instantiate Places in the content process");
+ // Attempting to create two instances of the service?
+ MOZ_ASSERT(!gDatabase);
+ gDatabase = this;
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+Database::GetProfileChangeTeardownPhase()
+{
+ nsCOMPtr<nsIAsyncShutdownService> asyncShutdownSvc = services::GetAsyncShutdown();
+ MOZ_ASSERT(asyncShutdownSvc);
+ if (NS_WARN_IF(!asyncShutdownSvc)) {
+ return nullptr;
+ }
+
+ // Consumers of Places should shutdown before us, at profile-change-teardown.
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase;
+ DebugOnly<nsresult> rv = asyncShutdownSvc->
+ GetProfileChangeTeardown(getter_AddRefs(shutdownPhase));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ return shutdownPhase.forget();
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+Database::GetProfileBeforeChangePhase()
+{
+ nsCOMPtr<nsIAsyncShutdownService> asyncShutdownSvc = services::GetAsyncShutdown();
+ MOZ_ASSERT(asyncShutdownSvc);
+ if (NS_WARN_IF(!asyncShutdownSvc)) {
+ return nullptr;
+ }
+
+ // Consumers of Places should shutdown before us, at profile-change-teardown.
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase;
+ DebugOnly<nsresult> rv = asyncShutdownSvc->
+ GetProfileBeforeChange(getter_AddRefs(shutdownPhase));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ return shutdownPhase.forget();
+}
+
+Database::~Database()
+{
+}
+
+bool
+Database::IsShutdownStarted() const
+{
+ if (!mConnectionShutdown) {
+ // We have already broken the cycle between `this` and `mConnectionShutdown`.
+ return true;
+ }
+ return mConnectionShutdown->IsStarted();
+}
+
+already_AddRefed<mozIStorageAsyncStatement>
+Database::GetAsyncStatement(const nsACString& aQuery) const
+{
+ if (IsShutdownStarted()) {
+ return nullptr;
+ }
+ MOZ_ASSERT(NS_IsMainThread());
+ return mMainThreadAsyncStatements.GetCachedStatement(aQuery);
+}
+
+already_AddRefed<mozIStorageStatement>
+Database::GetStatement(const nsACString& aQuery) const
+{
+ if (IsShutdownStarted()) {
+ return nullptr;
+ }
+ if (NS_IsMainThread()) {
+ return mMainThreadStatements.GetCachedStatement(aQuery);
+ }
+ return mAsyncThreadStatements.GetCachedStatement(aQuery);
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+Database::GetClientsShutdown()
+{
+ MOZ_ASSERT(mClientsShutdown);
+ return mClientsShutdown->GetClient();
+}
+
+// static
+already_AddRefed<Database>
+Database::GetDatabase()
+{
+ if (PlacesShutdownBlocker::IsStarted()) {
+ return nullptr;
+ }
+ return GetSingleton();
+}
+
+nsresult
+Database::Init()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<mozIStorageService> storage =
+ do_GetService(MOZ_STORAGE_SERVICE_CONTRACTID);
+ NS_ENSURE_STATE(storage);
+
+ // Init the database file and connect to it.
+ bool databaseCreated = false;
+ nsresult rv = InitDatabaseFile(storage, &databaseCreated);
+ if (NS_SUCCEEDED(rv) && databaseCreated) {
+ mDatabaseStatus = nsINavHistoryService::DATABASE_STATUS_CREATE;
+ }
+ else if (rv == NS_ERROR_FILE_CORRUPTED) {
+ // The database is corrupt, backup and replace it with a new one.
+ mDatabaseStatus = nsINavHistoryService::DATABASE_STATUS_CORRUPT;
+ rv = BackupAndReplaceDatabaseFile(storage);
+ // Fallback to catch-all handler, that notifies a database locked failure.
+ }
+
+ // If the database connection still cannot be opened, it may just be locked
+ // by third parties. Send out a notification and interrupt initialization.
+ if (NS_FAILED(rv)) {
+ RefPtr<PlacesEvent> lockedEvent = new PlacesEvent(TOPIC_DATABASE_LOCKED);
+ (void)NS_DispatchToMainThread(lockedEvent);
+ return rv;
+ }
+
+ // Initialize the database schema. In case of failure the existing schema is
+ // is corrupt or incoherent, thus the database should be replaced.
+ bool databaseMigrated = false;
+ rv = InitSchema(&databaseMigrated);
+ if (NS_FAILED(rv)) {
+ mDatabaseStatus = nsINavHistoryService::DATABASE_STATUS_CORRUPT;
+ rv = BackupAndReplaceDatabaseFile(storage);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // Try to initialize the schema again on the new database.
+ rv = InitSchema(&databaseMigrated);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (databaseMigrated) {
+ mDatabaseStatus = nsINavHistoryService::DATABASE_STATUS_UPGRADED;
+ }
+
+ if (mDatabaseStatus != nsINavHistoryService::DATABASE_STATUS_OK) {
+ rv = updateSQLiteStatistics(MainConn());
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Initialize here all the items that are not part of the on-disk database,
+ // like views, temp triggers or temp tables. The database should not be
+ // considered corrupt if any of the following fails.
+
+ rv = InitTempEntities();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Notify we have finished database initialization.
+ // Enqueue the notification, so if we init another service that requires
+ // nsNavHistoryService we don't recursive try to get it.
+ RefPtr<PlacesEvent> completeEvent =
+ new PlacesEvent(TOPIC_PLACES_INIT_COMPLETE);
+ rv = NS_DispatchToMainThread(completeEvent);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // At this point we know the Database object points to a valid connection
+ // and we need to setup async shutdown.
+ {
+ // First of all Places clients should block profile-change-teardown.
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileChangeTeardownPhase();
+ MOZ_ASSERT(shutdownPhase);
+ if (shutdownPhase) {
+ DebugOnly<nsresult> rv = shutdownPhase->AddBlocker(
+ static_cast<nsIAsyncShutdownBlocker*>(mClientsShutdown.get()),
+ NS_LITERAL_STRING(__FILE__),
+ __LINE__,
+ NS_LITERAL_STRING(""));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ }
+
+ {
+ // Then connection closing should block profile-before-change.
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileBeforeChangePhase();
+ MOZ_ASSERT(shutdownPhase);
+ if (shutdownPhase) {
+ DebugOnly<nsresult> rv = shutdownPhase->AddBlocker(
+ static_cast<nsIAsyncShutdownBlocker*>(mConnectionShutdown.get()),
+ NS_LITERAL_STRING(__FILE__),
+ __LINE__,
+ NS_LITERAL_STRING(""));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ }
+
+ // Finally observe profile shutdown notifications.
+ nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
+ if (os) {
+ (void)os->AddObserver(this, TOPIC_PROFILE_CHANGE_TEARDOWN, true);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::InitDatabaseFile(nsCOMPtr<mozIStorageService>& aStorage,
+ bool* aNewDatabaseCreated)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ *aNewDatabaseCreated = false;
+
+ nsCOMPtr<nsIFile> databaseFile;
+ nsresult rv = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR,
+ getter_AddRefs(databaseFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = databaseFile->Append(DATABASE_FILENAME);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool databaseFileExists = false;
+ rv = databaseFile->Exists(&databaseFileExists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (databaseFileExists &&
+ Preferences::GetBool(PREF_FORCE_DATABASE_REPLACEMENT, false)) {
+ // If this pref is set, Maintenance required a database replacement, due to
+ // integrity corruption.
+ // Be sure to clear the pref to avoid handling it more than once.
+ (void)Preferences::ClearUser(PREF_FORCE_DATABASE_REPLACEMENT);
+
+ return NS_ERROR_FILE_CORRUPTED;
+ }
+
+ // Open the database file. If it does not exist a new one will be created.
+ // Use an unshared connection, it will consume more memory but avoid shared
+ // cache contentions across threads.
+ rv = aStorage->OpenUnsharedDatabase(databaseFile, getter_AddRefs(mMainConn));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *aNewDatabaseCreated = !databaseFileExists;
+ return NS_OK;
+}
+
+nsresult
+Database::BackupAndReplaceDatabaseFile(nsCOMPtr<mozIStorageService>& aStorage)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ nsCOMPtr<nsIFile> profDir;
+ nsresult rv = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR,
+ getter_AddRefs(profDir));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIFile> databaseFile;
+ rv = profDir->Clone(getter_AddRefs(databaseFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = databaseFile->Append(DATABASE_FILENAME);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If we have
+ // already failed in the last 24 hours avoid to create another corrupt file,
+ // since doing so, in some situation, could cause us to create a new corrupt
+ // file at every try to access any Places service. That is bad because it
+ // would quickly fill the user's disk space without any notice.
+ if (!hasRecentCorruptDB()) {
+ nsCOMPtr<nsIFile> backup;
+ (void)aStorage->BackupDatabaseFile(databaseFile, DATABASE_CORRUPT_FILENAME,
+ profDir, getter_AddRefs(backup));
+ }
+
+ // If anything fails from this point on, we have a stale connection or
+ // database file, and there's not much more we can do.
+ // The only thing we can try to do is to replace the database on the next
+ // startup, and report the problem through telemetry.
+ {
+ enum eCorruptDBReplaceStage : int8_t {
+ stage_closing = 0,
+ stage_removing,
+ stage_reopening,
+ stage_replaced
+ };
+ eCorruptDBReplaceStage stage = stage_closing;
+ auto guard = MakeScopeExit([&]() {
+ if (stage != stage_replaced) {
+ // Reaching this point means the database is corrupt and we failed to
+ // replace it. For this session part of the application related to
+ // bookmarks and history will misbehave. The frontend may show a
+ // "locked" notification to the user though.
+ // Set up a pref to try replacing the database at the next startup.
+ Preferences::SetBool(PREF_FORCE_DATABASE_REPLACEMENT, true);
+ }
+ // Report the corruption through telemetry.
+ Telemetry::Accumulate(Telemetry::PLACES_DATABASE_CORRUPTION_HANDLING_STAGE,
+ static_cast<int8_t>(stage));
+ });
+
+ // Close database connection if open.
+ if (mMainConn) {
+ rv = mMainConn->Close();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Remove the broken database.
+ stage = stage_removing;
+ rv = databaseFile->Remove(false);
+ if (NS_FAILED(rv) && rv != NS_ERROR_FILE_TARGET_DOES_NOT_EXIST) {
+ return rv;
+ }
+
+ // Create a new database file.
+ // Use an unshared connection, it will consume more memory but avoid shared
+ // cache contentions across threads.
+ stage = stage_reopening;
+ rv = aStorage->OpenUnsharedDatabase(databaseFile, getter_AddRefs(mMainConn));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ stage = stage_replaced;
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::InitSchema(bool* aDatabaseMigrated)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ *aDatabaseMigrated = false;
+
+ // WARNING: any statement executed before setting the journal mode must be
+ // finalized, since SQLite doesn't allow changing the journal mode if there
+ // is any outstanding statement.
+
+ {
+ // Get the page size. This may be different than the default if the
+ // database file already existed with a different page size.
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ MOZ_STORAGE_UNIQUIFY_QUERY_STR "PRAGMA page_size"
+ ), getter_AddRefs(statement));
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool hasResult = false;
+ rv = statement->ExecuteStep(&hasResult);
+ NS_ENSURE_TRUE(NS_SUCCEEDED(rv) && hasResult, NS_ERROR_FAILURE);
+ rv = statement->GetInt32(0, &mDBPageSize);
+ NS_ENSURE_TRUE(NS_SUCCEEDED(rv) && mDBPageSize > 0, NS_ERROR_UNEXPECTED);
+ }
+
+ // Ensure that temp tables are held in memory, not on disk.
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ MOZ_STORAGE_UNIQUIFY_QUERY_STR "PRAGMA temp_store = MEMORY"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (PR_GetEnv(ENV_ALLOW_CORRUPTION) && Preferences::GetBool(PREF_DISABLE_DURABILITY, false)) {
+ // Volatile storage was requested. Use the in-memory journal (no
+ // filesystem I/O) and don't sync the filesystem after writing.
+ SetJournalMode(mMainConn, JOURNAL_MEMORY);
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "PRAGMA synchronous = OFF"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ // Be sure to set journal mode after page_size. WAL would prevent the change
+ // otherwise.
+ if (JOURNAL_WAL == SetJournalMode(mMainConn, JOURNAL_WAL)) {
+ // Set the WAL journal size limit. We want it to be small, since in
+ // synchronous = NORMAL mode a crash could cause loss of all the
+ // transactions in the journal. For added safety we will also force
+ // checkpointing at strategic moments.
+ int32_t checkpointPages =
+ static_cast<int32_t>(DATABASE_MAX_WAL_SIZE_IN_KIBIBYTES * 1024 / mDBPageSize);
+ nsAutoCString checkpointPragma("PRAGMA wal_autocheckpoint = ");
+ checkpointPragma.AppendInt(checkpointPages);
+ rv = mMainConn->ExecuteSimpleSQL(checkpointPragma);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ // Ignore errors, if we fail here the database could be considered corrupt
+ // and we won't be able to go on, even if it's just matter of a bogus file
+ // system. The default mode (DELETE) will be fine in such a case.
+ (void)SetJournalMode(mMainConn, JOURNAL_TRUNCATE);
+
+ // Set synchronous to FULL to ensure maximum data integrity, even in
+ // case of crashes or unclean shutdowns.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "PRAGMA synchronous = FULL"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ // The journal is usually free to grow for performance reasons, but it never
+ // shrinks back. Since the space taken may be problematic, especially on
+ // mobile devices, limit its size.
+ // Since exceeding the limit will cause a truncate, allow a slightly
+ // larger limit than DATABASE_MAX_WAL_SIZE_IN_KIBIBYTES to reduce the number
+ // of times it is needed.
+ nsAutoCString journalSizePragma("PRAGMA journal_size_limit = ");
+ journalSizePragma.AppendInt(DATABASE_MAX_WAL_SIZE_IN_KIBIBYTES * 3);
+ (void)mMainConn->ExecuteSimpleSQL(journalSizePragma);
+
+ // Grow places in |growthIncrementKiB| increments to limit fragmentation on disk.
+ // By default, it's 10 MB.
+ int32_t growthIncrementKiB =
+ Preferences::GetInt(PREF_GROWTH_INCREMENT_KIB, 10 * BYTES_PER_KIBIBYTE);
+ if (growthIncrementKiB > 0) {
+ (void)mMainConn->SetGrowthIncrement(growthIncrementKiB * BYTES_PER_KIBIBYTE, EmptyCString());
+ }
+
+ nsAutoCString busyTimeoutPragma("PRAGMA busy_timeout = ");
+ busyTimeoutPragma.AppendInt(DATABASE_BUSY_TIMEOUT_MS);
+ (void)mMainConn->ExecuteSimpleSQL(busyTimeoutPragma);
+
+ // We use our functions during migration, so initialize them now.
+ rv = InitFunctions();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Get the database schema version.
+ int32_t currentSchemaVersion;
+ rv = mMainConn->GetSchemaVersion(&currentSchemaVersion);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool databaseInitialized = currentSchemaVersion > 0;
+
+ if (databaseInitialized && currentSchemaVersion == DATABASE_SCHEMA_VERSION) {
+ // The database is up to date and ready to go.
+ return NS_OK;
+ }
+
+ // We are going to update the database, so everything from now on should be in
+ // a transaction for performances.
+ mozStorageTransaction transaction(mMainConn, false);
+
+ if (databaseInitialized) {
+ // Migration How-to:
+ //
+ // 1. increment PLACES_SCHEMA_VERSION.
+ // 2. implement a method that performs upgrade to your version from the
+ // previous one.
+ //
+ // NOTE: The downgrade process is pretty much complicated by the fact old
+ // versions cannot know what a new version is going to implement.
+ // The only thing we will do for downgrades is setting back the schema
+ // version, so that next upgrades will run again the migration step.
+
+ if (currentSchemaVersion > 36) {
+ // These versions are not downgradable.
+ return NS_ERROR_FILE_CORRUPTED;
+ }
+
+ if (currentSchemaVersion < DATABASE_SCHEMA_VERSION) {
+ *aDatabaseMigrated = true;
+
+ if (currentSchemaVersion < 11) {
+ // These are versions older than Firefox 4 that are not supported
+ // anymore. In this case it's safer to just replace the database.
+ return NS_ERROR_FILE_CORRUPTED;
+ }
+
+ // Firefox 4 uses schema version 11.
+
+ // Firefox 8 uses schema version 12.
+
+ if (currentSchemaVersion < 13) {
+ rv = MigrateV13Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (currentSchemaVersion < 15) {
+ rv = MigrateV15Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (currentSchemaVersion < 17) {
+ rv = MigrateV17Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 12 uses schema version 17.
+
+ if (currentSchemaVersion < 18) {
+ rv = MigrateV18Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (currentSchemaVersion < 19) {
+ rv = MigrateV19Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 13 uses schema version 19.
+
+ if (currentSchemaVersion < 20) {
+ rv = MigrateV20Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (currentSchemaVersion < 21) {
+ rv = MigrateV21Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 14 uses schema version 21.
+
+ if (currentSchemaVersion < 22) {
+ rv = MigrateV22Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 22 uses schema version 22.
+
+ if (currentSchemaVersion < 23) {
+ rv = MigrateV23Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 24 uses schema version 23.
+
+ if (currentSchemaVersion < 24) {
+ rv = MigrateV24Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 34 uses schema version 24.
+
+ if (currentSchemaVersion < 25) {
+ rv = MigrateV25Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 36 uses schema version 25.
+
+ if (currentSchemaVersion < 26) {
+ rv = MigrateV26Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 37 uses schema version 26.
+
+ if (currentSchemaVersion < 27) {
+ rv = MigrateV27Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (currentSchemaVersion < 28) {
+ rv = MigrateV28Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 39 uses schema version 28.
+
+ if (currentSchemaVersion < 30) {
+ rv = MigrateV30Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 41 uses schema version 30.
+
+ if (currentSchemaVersion < 31) {
+ rv = MigrateV31Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 48 uses schema version 31.
+
+ if (currentSchemaVersion < 32) {
+ rv = MigrateV32Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 49 uses schema version 32.
+
+ if (currentSchemaVersion < 33) {
+ rv = MigrateV33Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 50 uses schema version 33.
+
+ if (currentSchemaVersion < 34) {
+ rv = MigrateV34Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 51 uses schema version 34.
+
+ if (currentSchemaVersion < 35) {
+ rv = MigrateV35Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 52 uses schema version 35.
+
+ // Schema Upgrades must add migration code here.
+
+ rv = UpdateBookmarkRootTitles();
+ // We don't want a broken localization to cause us to think
+ // the database is corrupt and needs to be replaced.
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ }
+ else {
+ // This is a new database, so we have to create all the tables and indices.
+
+ // moz_places.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_PLACES);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_URL_HASH);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_FAVICON);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_REVHOST);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_VISITCOUNT);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_FRECENCY);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_LASTVISITDATE);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_historyvisits.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_HISTORYVISITS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_HISTORYVISITS_PLACEDATE);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_HISTORYVISITS_FROMVISIT);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_HISTORYVISITS_VISITDATE);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_inputhistory.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_INPUTHISTORY);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_hosts.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_HOSTS);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_bookmarks.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_BOOKMARKS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_BOOKMARKS_PLACETYPE);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_BOOKMARKS_PARENTPOSITION);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_BOOKMARKS_PLACELASTMODIFIED);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_BOOKMARKS_GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_keywords.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_KEYWORDS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_KEYWORDS_PLACEPOSTDATA);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_favicons.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_FAVICONS);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_anno_attributes.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_ANNO_ATTRIBUTES);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_annos.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_ANNOS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_ANNOS_PLACEATTRIBUTE);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // moz_items_annos.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_ITEMS_ANNOS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_ITEMSANNOS_PLACEATTRIBUTE);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Initialize the bookmark roots in the new DB.
+ rv = CreateBookmarkRoots();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Set the schema version to the current one.
+ rv = mMainConn->SetSchemaVersion(DATABASE_SCHEMA_VERSION);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ ForceWALCheckpoint();
+
+ // ANY FAILURE IN THIS METHOD WILL CAUSE US TO MARK THE DATABASE AS CORRUPT
+ // AND TRY TO REPLACE IT.
+ // DO NOT PUT HERE ANYTHING THAT IS NOT RELATED TO INITIALIZATION OR MODIFYING
+ // THE DISK DATABASE.
+
+ return NS_OK;
+}
+
+nsresult
+Database::CreateBookmarkRoots()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ services::GetStringBundleService();
+ NS_ENSURE_STATE(bundleService);
+ nsCOMPtr<nsIStringBundle> bundle;
+ nsresult rv = bundleService->CreateBundle(PLACES_BUNDLE, getter_AddRefs(bundle));
+ if (NS_FAILED(rv)) return rv;
+
+ nsXPIDLString rootTitle;
+ // The first root's title is an empty string.
+ rv = CreateRoot(mMainConn, NS_LITERAL_CSTRING("places"),
+ NS_LITERAL_CSTRING("root________"), rootTitle);
+ if (NS_FAILED(rv)) return rv;
+
+ // Fetch the internationalized folder name from the string bundle.
+ rv = bundle->GetStringFromName(u"BookmarksMenuFolderTitle",
+ getter_Copies(rootTitle));
+ if (NS_FAILED(rv)) return rv;
+ rv = CreateRoot(mMainConn, NS_LITERAL_CSTRING("menu"),
+ NS_LITERAL_CSTRING("menu________"), rootTitle);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = bundle->GetStringFromName(u"BookmarksToolbarFolderTitle",
+ getter_Copies(rootTitle));
+ if (NS_FAILED(rv)) return rv;
+ rv = CreateRoot(mMainConn, NS_LITERAL_CSTRING("toolbar"),
+ NS_LITERAL_CSTRING("toolbar_____"), rootTitle);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = bundle->GetStringFromName(u"TagsFolderTitle",
+ getter_Copies(rootTitle));
+ if (NS_FAILED(rv)) return rv;
+ rv = CreateRoot(mMainConn, NS_LITERAL_CSTRING("tags"),
+ NS_LITERAL_CSTRING("tags________"), rootTitle);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = bundle->GetStringFromName(u"OtherBookmarksFolderTitle",
+ getter_Copies(rootTitle));
+ if (NS_FAILED(rv)) return rv;
+ rv = CreateRoot(mMainConn, NS_LITERAL_CSTRING("unfiled"),
+ NS_LITERAL_CSTRING("unfiled_____"), rootTitle);
+ if (NS_FAILED(rv)) return rv;
+
+ int64_t mobileRootId = CreateMobileRoot();
+ if (mobileRootId <= 0) return NS_ERROR_FAILURE;
+
+#if DEBUG
+ nsCOMPtr<mozIStorageStatement> stmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT count(*), sum(position) FROM moz_bookmarks"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) return rv;
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ if (NS_FAILED(rv)) return rv;
+ MOZ_ASSERT(hasResult);
+ int32_t bookmarkCount = stmt->AsInt32(0);
+ int32_t positionSum = stmt->AsInt32(1);
+ MOZ_ASSERT(bookmarkCount == 6 && positionSum == 10);
+#endif
+
+ return NS_OK;
+}
+
+nsresult
+Database::InitFunctions()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = GetUnreversedHostFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = MatchAutoCompleteFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = CalculateFrecencyFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = GenerateGUIDFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = FixupURLFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = FrecencyNotificationFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = StoreLastInsertedIdFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = HashFunction::create(mMainConn);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::InitTempEntities()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = mMainConn->ExecuteSimpleSQL(CREATE_HISTORYVISITS_AFTERINSERT_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_HISTORYVISITS_AFTERDELETE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Add the triggers that update the moz_hosts table as necessary.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_PLACES_AFTERINSERT_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_UPDATEHOSTS_TEMP);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_UPDATEHOSTS_AFTERDELETE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_PLACES_AFTERDELETE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_PLACES_AFTERUPDATE_FRECENCY_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_PLACES_AFTERUPDATE_TYPED_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERDELETE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERINSERT_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERUPDATE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_KEYWORDS_FOREIGNCOUNT_AFTERDELETE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_KEYWORDS_FOREIGNCOUNT_AFTERINSERT_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_KEYWORDS_FOREIGNCOUNT_AFTERUPDATE_TRIGGER);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::UpdateBookmarkRootTitles()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ services::GetStringBundleService();
+ NS_ENSURE_STATE(bundleService);
+
+ nsCOMPtr<nsIStringBundle> bundle;
+ nsresult rv = bundleService->CreateBundle(PLACES_BUNDLE, getter_AddRefs(bundle));
+ if (NS_FAILED(rv)) return rv;
+
+ nsCOMPtr<mozIStorageAsyncStatement> stmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_bookmarks SET title = :new_title WHERE guid = :guid"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) return rv;
+
+ nsCOMPtr<mozIStorageBindingParamsArray> paramsArray;
+ rv = stmt->NewBindingParamsArray(getter_AddRefs(paramsArray));
+ if (NS_FAILED(rv)) return rv;
+
+ const char *rootGuids[] = { "menu________"
+ , "toolbar_____"
+ , "tags________"
+ , "unfiled_____"
+ , "mobile______"
+ };
+ const char *titleStringIDs[] = { "BookmarksMenuFolderTitle"
+ , "BookmarksToolbarFolderTitle"
+ , "TagsFolderTitle"
+ , "OtherBookmarksFolderTitle"
+ , "MobileBookmarksFolderTitle"
+ };
+
+ for (uint32_t i = 0; i < ArrayLength(rootGuids); ++i) {
+ nsXPIDLString title;
+ rv = bundle->GetStringFromName(NS_ConvertASCIItoUTF16(titleStringIDs[i]).get(),
+ getter_Copies(title));
+ if (NS_FAILED(rv)) return rv;
+
+ nsCOMPtr<mozIStorageBindingParams> params;
+ rv = paramsArray->NewBindingParams(getter_AddRefs(params));
+ if (NS_FAILED(rv)) return rv;
+ rv = params->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"),
+ nsDependentCString(rootGuids[i]));
+ if (NS_FAILED(rv)) return rv;
+ rv = params->BindUTF8StringByName(NS_LITERAL_CSTRING("new_title"),
+ NS_ConvertUTF16toUTF8(title));
+ if (NS_FAILED(rv)) return rv;
+ rv = paramsArray->AddParams(params);
+ if (NS_FAILED(rv)) return rv;
+ }
+
+ rv = stmt->BindParameters(paramsArray);
+ if (NS_FAILED(rv)) return rv;
+ nsCOMPtr<mozIStoragePendingStatement> pendingStmt;
+ rv = stmt->ExecuteAsync(nullptr, getter_AddRefs(pendingStmt));
+ if (NS_FAILED(rv)) return rv;
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV13Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Dynamic containers are no longer supported.
+ nsCOMPtr<mozIStorageAsyncStatement> deleteDynContainersStmt;
+ nsresult rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_bookmarks WHERE type = :item_type"),
+ getter_AddRefs(deleteDynContainersStmt));
+ rv = deleteDynContainersStmt->BindInt32ByName(
+ NS_LITERAL_CSTRING("item_type"),
+ nsINavBookmarksService::TYPE_DYNAMIC_CONTAINER
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = deleteDynContainersStmt->ExecuteAsync(nullptr, getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV15Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Drop moz_bookmarks_beforedelete_v1_trigger, since it's more expensive than
+ // useful.
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP TRIGGER IF EXISTS moz_bookmarks_beforedelete_v1_trigger"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Remove any orphan keywords.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_keywords "
+ "WHERE NOT EXISTS ( "
+ "SELECT id "
+ "FROM moz_bookmarks "
+ "WHERE keyword_id = moz_keywords.id "
+ ")"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV17Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ bool tableExists = false;
+
+ nsresult rv = mMainConn->TableExists(NS_LITERAL_CSTRING("moz_hosts"), &tableExists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!tableExists) {
+ // For anyone who used in-development versions of this autocomplete,
+ // drop the old tables and its indexes.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP INDEX IF EXISTS moz_hostnames_frecencyindex"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP TABLE IF EXISTS moz_hostnames"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Add the moz_hosts table so we can get hostnames for URL autocomplete.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_MOZ_HOSTS);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Fill the moz_hosts table with all the domains in moz_places.
+ nsCOMPtr<mozIStorageAsyncStatement> fillHostsStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "INSERT OR IGNORE INTO moz_hosts (host, frecency) "
+ "SELECT fixup_url(get_unreversed_host(h.rev_host)) AS host, "
+ "(SELECT MAX(frecency) FROM moz_places "
+ "WHERE rev_host = h.rev_host "
+ "OR rev_host = h.rev_host || 'www.' "
+ ") AS frecency "
+ "FROM moz_places h "
+ "WHERE LENGTH(h.rev_host) > 1 "
+ "GROUP BY h.rev_host"
+ ), getter_AddRefs(fillHostsStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = fillHostsStmt->ExecuteAsync(nullptr, getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV18Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // moz_hosts should distinguish on typed entries.
+
+ // Check if the profile already has a typed column.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT typed FROM moz_hosts"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_hosts ADD COLUMN typed NOT NULL DEFAULT 0"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // With the addition of the typed column the covering index loses its
+ // advantages. On the other side querying on host and (optionally) typed
+ // largely restricts the number of results, making scans decently fast.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP INDEX IF EXISTS moz_hosts_frecencyhostindex"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Update typed data.
+ nsCOMPtr<mozIStorageAsyncStatement> updateTypedStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_hosts SET typed = 1 WHERE host IN ( "
+ "SELECT fixup_url(get_unreversed_host(rev_host)) "
+ "FROM moz_places WHERE typed = 1 "
+ ") "
+ ), getter_AddRefs(updateTypedStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = updateTypedStmt->ExecuteAsync(nullptr, getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV19Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Livemarks children are no longer bookmarks.
+
+ // Remove all children of folders annotated as livemarks.
+ nsCOMPtr<mozIStorageStatement> deleteLivemarksChildrenStmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_bookmarks WHERE parent IN("
+ "SELECT b.id FROM moz_bookmarks b "
+ "JOIN moz_items_annos a ON a.item_id = b.id "
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+ "WHERE b.type = :item_type AND n.name = :anno_name "
+ ")"
+ ), getter_AddRefs(deleteLivemarksChildrenStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksChildrenStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_name"), NS_LITERAL_CSTRING(LMANNO_FEEDURI)
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksChildrenStmt->BindInt32ByName(
+ NS_LITERAL_CSTRING("item_type"), nsINavBookmarksService::TYPE_FOLDER
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksChildrenStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clear obsolete livemark prefs.
+ (void)Preferences::ClearUser("browser.bookmarks.livemark_refresh_seconds");
+ (void)Preferences::ClearUser("browser.bookmarks.livemark_refresh_limit_count");
+ (void)Preferences::ClearUser("browser.bookmarks.livemark_refresh_delay_time");
+
+ // Remove the old status annotations.
+ nsCOMPtr<mozIStorageStatement> deleteLivemarksAnnosStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_items_annos WHERE anno_attribute_id IN("
+ "SELECT id FROM moz_anno_attributes "
+ "WHERE name IN (:anno_loading, :anno_loadfailed, :anno_expiration) "
+ ")"
+ ), getter_AddRefs(deleteLivemarksAnnosStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_loading"), NS_LITERAL_CSTRING("livemark/loading")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_loadfailed"), NS_LITERAL_CSTRING("livemark/loadfailed")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_expiration"), NS_LITERAL_CSTRING("livemark/expiration")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Remove orphan annotation names.
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_anno_attributes "
+ "WHERE name IN (:anno_loading, :anno_loadfailed, :anno_expiration) "
+ ), getter_AddRefs(deleteLivemarksAnnosStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_loading"), NS_LITERAL_CSTRING("livemark/loading")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_loadfailed"), NS_LITERAL_CSTRING("livemark/loadfailed")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_expiration"), NS_LITERAL_CSTRING("livemark/expiration")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteLivemarksAnnosStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV20Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Remove obsolete bookmark GUID annotations.
+ nsCOMPtr<mozIStorageStatement> deleteOldBookmarkGUIDAnnosStmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_items_annos WHERE anno_attribute_id = ("
+ "SELECT id FROM moz_anno_attributes "
+ "WHERE name = :anno_guid"
+ ")"
+ ), getter_AddRefs(deleteOldBookmarkGUIDAnnosStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteOldBookmarkGUIDAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_guid"), NS_LITERAL_CSTRING("placesInternal/GUID")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteOldBookmarkGUIDAnnosStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Remove the orphan annotation name.
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_anno_attributes "
+ "WHERE name = :anno_guid"
+ ), getter_AddRefs(deleteOldBookmarkGUIDAnnosStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteOldBookmarkGUIDAnnosStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_guid"), NS_LITERAL_CSTRING("placesInternal/GUID")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteOldBookmarkGUIDAnnosStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV21Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Add a prefix column to moz_hosts.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT prefix FROM moz_hosts"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_hosts ADD COLUMN prefix"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV22Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Reset all session IDs to 0 since we don't support them anymore.
+ // We don't set them to NULL to avoid breaking downgrades.
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_historyvisits SET session = 0"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+nsresult
+Database::MigrateV23Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Recalculate hosts prefixes.
+ nsCOMPtr<mozIStorageAsyncStatement> updatePrefixesStmt;
+ nsresult rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_hosts SET prefix = ( " HOSTS_PREFIX_PRIORITY_FRAGMENT ") "
+ ), getter_AddRefs(updatePrefixesStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = updatePrefixesStmt->ExecuteAsync(nullptr, getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV24Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Add a foreign_count column to moz_places
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT foreign_count FROM moz_places"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_places ADD COLUMN foreign_count INTEGER DEFAULT 0 NOT NULL"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Adjust counts for all the rows
+ nsCOMPtr<mozIStorageStatement> updateStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_places SET foreign_count = "
+ "(SELECT count(*) FROM moz_bookmarks WHERE fk = moz_places.id) "
+ ), getter_AddRefs(updateStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper updateScoper(updateStmt);
+ rv = updateStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV25Up()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Change bookmark roots GUIDs to constant values.
+
+ // If moz_bookmarks_roots doesn't exist anymore, it's because we finally have
+ // been able to remove it. In such a case, we already assigned constant GUIDs
+ // to the roots and we can skip this migration.
+ {
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT root_name FROM moz_bookmarks_roots"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ return NS_OK;
+ }
+ }
+
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_bookmarks SET guid = :guid "
+ "WHERE id = (SELECT folder_id FROM moz_bookmarks_roots WHERE root_name = :name) "
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ const char *rootNames[] = { "places", "menu", "toolbar", "tags", "unfiled" };
+ const char *rootGuids[] = { "root________"
+ , "menu________"
+ , "toolbar_____"
+ , "tags________"
+ , "unfiled_____"
+ };
+
+ for (uint32_t i = 0; i < ArrayLength(rootNames); ++i) {
+ // Since this is using the synchronous API, we cannot use
+ // a BindingParamsArray.
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("name"),
+ nsDependentCString(rootNames[i]));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"),
+ nsDependentCString(rootGuids[i]));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV26Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Round down dateAdded and lastModified values to milliseconds precision.
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_bookmarks SET dateAdded = dateAdded - dateAdded % 1000, "
+ " lastModified = lastModified - lastModified % 1000"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV27Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Change keywords store, moving their relation from bookmarks to urls.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT place_id FROM moz_keywords"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ // Even if these 2 columns have a unique constraint, we allow NULL values
+ // for backwards compatibility. NULL never breaks a unique constraint.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_keywords ADD COLUMN place_id INTEGER"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_keywords ADD COLUMN post_data TEXT"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_KEYWORDS_PLACEPOSTDATA);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Associate keywords with uris. A keyword could be associated to multiple
+ // bookmarks uris, or multiple keywords could be associated to the same uri.
+ // The new system only allows multiple uris per keyword, provided they have
+ // a different post_data value.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "INSERT OR REPLACE INTO moz_keywords (id, keyword, place_id, post_data) "
+ "SELECT k.id, k.keyword, h.id, MAX(a.content) "
+ "FROM moz_places h "
+ "JOIN moz_bookmarks b ON b.fk = h.id "
+ "JOIN moz_keywords k ON k.id = b.keyword_id "
+ "LEFT JOIN moz_items_annos a ON a.item_id = b.id "
+ "AND a.anno_attribute_id = (SELECT id FROM moz_anno_attributes "
+ "WHERE name = 'bookmarkProperties/POSTData') "
+ "WHERE k.place_id ISNULL "
+ "GROUP BY keyword"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Remove any keyword that points to a non-existing place id.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_keywords "
+ "WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = moz_keywords.place_id)"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_bookmarks SET keyword_id = NULL "
+ "WHERE NOT EXISTS (SELECT 1 FROM moz_keywords WHERE id = moz_bookmarks.keyword_id)"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Adjust foreign_count for all the rows.
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_places SET foreign_count = "
+ "(SELECT count(*) FROM moz_bookmarks WHERE fk = moz_places.id) + "
+ "(SELECT count(*) FROM moz_keywords WHERE place_id = moz_places.id) "
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV28Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // v27 migration was bogus and set some unrelated annotations as post_data for
+ // keywords having an annotated bookmark.
+ // The current v27 migration function is fixed, but we still need to handle
+ // users that hit the bogus version. Since we can't distinguish, we'll just
+ // set again all of the post data.
+ DebugOnly<nsresult> rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_keywords "
+ "SET post_data = ( "
+ "SELECT content FROM moz_items_annos a "
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+ "JOIN moz_bookmarks b on b.id = a.item_id "
+ "WHERE n.name = 'bookmarkProperties/POSTData' "
+ "AND b.keyword_id = moz_keywords.id "
+ "ORDER BY b.lastModified DESC "
+ "LIMIT 1 "
+ ") "
+ "WHERE EXISTS(SELECT 1 FROM moz_bookmarks WHERE keyword_id = moz_keywords.id) "
+ ));
+ // In case the update fails a constraint, we don't want to throw away the
+ // whole database for just a few keywords. In rare cases the user might have
+ // to recreate them. Though, at this point, there shouldn't be 2 keywords
+ // pointing to the same url and post data, cause the previous migration step
+ // removed them.
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV30Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP INDEX IF EXISTS moz_favicons_guid_uniqueindex"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV31Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP TABLE IF EXISTS moz_bookmarks_roots"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV32Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Remove some old and no more used Places preferences that may be confusing
+ // for the user.
+ mozilla::Unused << Preferences::ClearUser("places.history.expiration.transient_optimal_database_size");
+ mozilla::Unused << Preferences::ClearUser("places.last_vacuum");
+ mozilla::Unused << Preferences::ClearUser("browser.history_expire_sites");
+ mozilla::Unused << Preferences::ClearUser("browser.history_expire_days.mirror");
+ mozilla::Unused << Preferences::ClearUser("browser.history_expire_days_min");
+
+ // For performance reasons we want to remove too long urls from history.
+ // We cannot use the moz_places triggers here, cause they are defined only
+ // after the schema migration. Thus we need to collect the hosts that need to
+ // be updated first.
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "CREATE TEMP TABLE moz_migrate_v32_temp ("
+ "host TEXT PRIMARY KEY "
+ ") WITHOUT ROWID "
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+ {
+ nsCOMPtr<mozIStorageStatement> stmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT OR IGNORE INTO moz_migrate_v32_temp (host) "
+ "SELECT fixup_url(get_unreversed_host(rev_host)) "
+ "FROM moz_places WHERE LENGTH(url) > :maxlen AND foreign_count = 0"
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("maxlen"), MaxUrlLength());
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ // Now remove the pages with a long url.
+ {
+ nsCOMPtr<mozIStorageStatement> stmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_places WHERE LENGTH(url) > :maxlen AND foreign_count = 0"
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("maxlen"), MaxUrlLength());
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Expire orphan visits and update moz_hosts.
+ // These may be a bit more expensive and are not critical for the DB
+ // functionality, so we execute them asynchronously.
+ nsCOMPtr<mozIStorageAsyncStatement> expireOrphansStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_historyvisits "
+ "WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = place_id)"
+ ), getter_AddRefs(expireOrphansStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<mozIStorageAsyncStatement> deleteHostsStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_hosts "
+ "WHERE host IN (SELECT host FROM moz_migrate_v32_temp) "
+ "AND NOT EXISTS("
+ "SELECT 1 FROM moz_places "
+ "WHERE rev_host = get_unreversed_host(host || '.') || '.' "
+ "OR rev_host = get_unreversed_host(host || '.') || '.www.' "
+ "); "
+ ), getter_AddRefs(deleteHostsStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<mozIStorageAsyncStatement> updateHostsStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_hosts "
+ "SET prefix = (" HOSTS_PREFIX_PRIORITY_FRAGMENT ") "
+ "WHERE host IN (SELECT host FROM moz_migrate_v32_temp) "
+ ), getter_AddRefs(updateHostsStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<mozIStorageAsyncStatement> dropTableStmt;
+ rv = mMainConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "DROP TABLE IF EXISTS moz_migrate_v32_temp"
+ ), getter_AddRefs(dropTableStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozIStorageBaseStatement *stmts[] = {
+ expireOrphansStmt,
+ deleteHostsStmt,
+ updateHostsStmt,
+ dropTableStmt
+ };
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = mMainConn->ExecuteAsync(stmts, ArrayLength(stmts), nullptr,
+ getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV33Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DROP INDEX IF EXISTS moz_places_url_uniqueindex"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Add an url_hash column to moz_places.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT url_hash FROM moz_places"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "ALTER TABLE moz_places ADD COLUMN url_hash INTEGER DEFAULT 0 NOT NULL"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "UPDATE moz_places SET url_hash = hash(url) WHERE url_hash = 0"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Create an index on url_hash.
+ rv = mMainConn->ExecuteSimpleSQL(CREATE_IDX_MOZ_PLACES_URL_HASH);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV34Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsresult rv = mMainConn->ExecuteSimpleSQL(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_keywords WHERE id IN ( "
+ "SELECT id FROM moz_keywords k "
+ "WHERE NOT EXISTS (SELECT 1 FROM moz_places h WHERE k.place_id = h.id) "
+ ")"
+ ));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+Database::MigrateV35Up() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ int64_t mobileRootId = CreateMobileRoot();
+ if (mobileRootId <= 0) {
+ // Either the schema is broken or there isn't any root. The latter can
+ // happen if a consumer, for example Thunderbird, never used bookmarks.
+ // If there are no roots, this migration should not run.
+ nsCOMPtr<mozIStorageStatement> checkRootsStmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT id FROM moz_bookmarks WHERE parent = 0"
+ ), getter_AddRefs(checkRootsStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(checkRootsStmt);
+ bool hasResult = false;
+ rv = checkRootsStmt->ExecuteStep(&hasResult);
+ if (NS_SUCCEEDED(rv) && !hasResult) {
+ return NS_OK;
+ }
+ return NS_ERROR_FAILURE;
+ }
+
+ // At this point, we should have no more than two folders with the mobile
+ // bookmarks anno: the new root, and the old folder if one exists. If, for
+ // some reason, we have multiple folders with the anno, we append their
+ // children to the new root.
+ nsTArray<int64_t> folderIds;
+ nsresult rv = GetItemsWithAnno(NS_LITERAL_CSTRING(MOBILE_ROOT_ANNO),
+ nsINavBookmarksService::TYPE_FOLDER,
+ folderIds);
+ if (NS_FAILED(rv)) return rv;
+
+ for (uint32_t i = 0; i < folderIds.Length(); ++i) {
+ if (folderIds[i] == mobileRootId) {
+ // Ignore the new mobile root. We'll remove this anno from the root in
+ // bug 1306445.
+ continue;
+ }
+
+ // Append the folder's children to the new root.
+ nsCOMPtr<mozIStorageStatement> moveStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "UPDATE moz_bookmarks "
+ "SET parent = :root_id, "
+ "position = position + IFNULL("
+ "(SELECT MAX(position) + 1 FROM moz_bookmarks "
+ "WHERE parent = :root_id), 0)"
+ "WHERE parent = :folder_id"
+ ), getter_AddRefs(moveStmt));
+ if (NS_FAILED(rv)) return rv;
+ mozStorageStatementScoper moveScoper(moveStmt);
+
+ rv = moveStmt->BindInt64ByName(NS_LITERAL_CSTRING("root_id"),
+ mobileRootId);
+ if (NS_FAILED(rv)) return rv;
+ rv = moveStmt->BindInt64ByName(NS_LITERAL_CSTRING("folder_id"),
+ folderIds[i]);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = moveStmt->Execute();
+ if (NS_FAILED(rv)) return rv;
+
+ // Delete the old folder.
+ rv = DeleteBookmarkItem(folderIds[i]);
+ if (NS_FAILED(rv)) return rv;
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::GetItemsWithAnno(const nsACString& aAnnoName, int32_t aItemType,
+ nsTArray<int64_t>& aItemIds)
+{
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT b.id FROM moz_items_annos a "
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+ "JOIN moz_bookmarks b ON b.id = a.item_id "
+ "WHERE n.name = :anno_name AND "
+ "b.type = :item_type"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) return rv;
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aAnnoName);
+ if (NS_FAILED(rv)) return rv;
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_type"), aItemType);
+ if (NS_FAILED(rv)) return rv;
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ int64_t itemId;
+ rv = stmt->GetInt64(0, &itemId);
+ if (NS_FAILED(rv)) return rv;
+ aItemIds.AppendElement(itemId);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+Database::DeleteBookmarkItem(int32_t aItemId)
+{
+ // Delete the old bookmark.
+ nsCOMPtr<mozIStorageStatement> deleteStmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_bookmarks WHERE id = :item_id"
+ ), getter_AddRefs(deleteStmt));
+ if (NS_FAILED(rv)) return rv;
+ mozStorageStatementScoper deleteScoper(deleteStmt);
+
+ rv = deleteStmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"),
+ aItemId);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = deleteStmt->Execute();
+ if (NS_FAILED(rv)) return rv;
+
+ // Clean up orphan annotations.
+ nsCOMPtr<mozIStorageStatement> removeAnnosStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_items_annos WHERE item_id = :item_id"
+ ), getter_AddRefs(removeAnnosStmt));
+ if (NS_FAILED(rv)) return rv;
+ mozStorageStatementScoper removeAnnosScoper(removeAnnosStmt);
+
+ rv = removeAnnosStmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"),
+ aItemId);
+ if (NS_FAILED(rv)) return rv;
+
+ rv = removeAnnosStmt->Execute();
+ if (NS_FAILED(rv)) return rv;
+
+ return NS_OK;
+}
+
+int64_t
+Database::CreateMobileRoot()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Create the mobile root, ignoring conflicts if one already exists (for
+ // example, if the user downgraded to an earlier release channel).
+ nsCOMPtr<mozIStorageStatement> createStmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT OR IGNORE INTO moz_bookmarks "
+ "(type, title, dateAdded, lastModified, guid, position, parent) "
+ "SELECT :item_type, :item_title, :timestamp, :timestamp, :guid, "
+ "(SELECT COUNT(*) FROM moz_bookmarks p WHERE p.parent = b.id), b.id "
+ "FROM moz_bookmarks b WHERE b.parent = 0"
+ ), getter_AddRefs(createStmt));
+ if (NS_FAILED(rv)) return -1;
+ mozStorageStatementScoper createScoper(createStmt);
+
+ rv = createStmt->BindInt32ByName(NS_LITERAL_CSTRING("item_type"),
+ nsINavBookmarksService::TYPE_FOLDER);
+ if (NS_FAILED(rv)) return -1;
+ rv = createStmt->BindUTF8StringByName(NS_LITERAL_CSTRING("item_title"),
+ NS_LITERAL_CSTRING(MOBILE_ROOT_TITLE));
+ if (NS_FAILED(rv)) return -1;
+ rv = createStmt->BindInt64ByName(NS_LITERAL_CSTRING("timestamp"),
+ RoundedPRNow());
+ if (NS_FAILED(rv)) return -1;
+ rv = createStmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"),
+ NS_LITERAL_CSTRING(MOBILE_ROOT_GUID));
+ if (NS_FAILED(rv)) return -1;
+
+ rv = createStmt->Execute();
+ if (NS_FAILED(rv)) return -1;
+
+ // Find the mobile root ID. We can't use the last inserted ID because the
+ // root might already exist, and we ignore on conflict.
+ nsCOMPtr<mozIStorageStatement> findIdStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT id FROM moz_bookmarks WHERE guid = :guid"
+ ), getter_AddRefs(findIdStmt));
+ if (NS_FAILED(rv)) return -1;
+ mozStorageStatementScoper findIdScoper(findIdStmt);
+
+ rv = findIdStmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"),
+ NS_LITERAL_CSTRING(MOBILE_ROOT_GUID));
+ if (NS_FAILED(rv)) return -1;
+
+ bool hasResult = false;
+ rv = findIdStmt->ExecuteStep(&hasResult);
+ if (NS_FAILED(rv) || !hasResult) return -1;
+
+ int64_t rootId;
+ rv = findIdStmt->GetInt64(0, &rootId);
+ if (NS_FAILED(rv)) return -1;
+
+ // Set the mobile bookmarks anno on the new root, so that Sync code on an
+ // older channel can still find it in case of a downgrade. This can be
+ // removed in bug 1306445.
+ nsCOMPtr<mozIStorageStatement> addAnnoNameStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT OR IGNORE INTO moz_anno_attributes (name) VALUES (:anno_name)"
+ ), getter_AddRefs(addAnnoNameStmt));
+ if (NS_FAILED(rv)) return -1;
+ mozStorageStatementScoper addAnnoNameScoper(addAnnoNameStmt);
+
+ rv = addAnnoNameStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_name"), NS_LITERAL_CSTRING(MOBILE_ROOT_ANNO));
+ if (NS_FAILED(rv)) return -1;
+ rv = addAnnoNameStmt->Execute();
+ if (NS_FAILED(rv)) return -1;
+
+ nsCOMPtr<mozIStorageStatement> addAnnoStmt;
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT OR IGNORE INTO moz_items_annos "
+ "(id, item_id, anno_attribute_id, content, flags, "
+ "expiration, type, dateAdded, lastModified) "
+ "SELECT "
+ "(SELECT a.id FROM moz_items_annos a "
+ "WHERE a.anno_attribute_id = n.id AND "
+ "a.item_id = :root_id), "
+ ":root_id, n.id, 1, 0, :expiration, :type, :timestamp, :timestamp "
+ "FROM moz_anno_attributes n WHERE name = :anno_name"
+ ), getter_AddRefs(addAnnoStmt));
+ if (NS_FAILED(rv)) return -1;
+ mozStorageStatementScoper addAnnoScoper(addAnnoStmt);
+
+ rv = addAnnoStmt->BindInt64ByName(NS_LITERAL_CSTRING("root_id"), rootId);
+ if (NS_FAILED(rv)) return -1;
+ rv = addAnnoStmt->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno_name"), NS_LITERAL_CSTRING(MOBILE_ROOT_ANNO));
+ if (NS_FAILED(rv)) return -1;
+ rv = addAnnoStmt->BindInt32ByName(NS_LITERAL_CSTRING("expiration"),
+ nsIAnnotationService::EXPIRE_NEVER);
+ if (NS_FAILED(rv)) return -1;
+ rv = addAnnoStmt->BindInt32ByName(NS_LITERAL_CSTRING("type"),
+ nsIAnnotationService::TYPE_INT32);
+ if (NS_FAILED(rv)) return -1;
+ rv = addAnnoStmt->BindInt32ByName(NS_LITERAL_CSTRING("timestamp"),
+ RoundedPRNow());
+ if (NS_FAILED(rv)) return -1;
+
+ rv = addAnnoStmt->Execute();
+ if (NS_FAILED(rv)) return -1;
+
+ return rootId;
+}
+
+void
+Database::Shutdown()
+{
+ // As the last step in the shutdown path, finalize the database handle.
+ MOZ_ASSERT(NS_IsMainThread());
+ MOZ_ASSERT(!mClosed);
+
+ // Break cycles with the shutdown blockers.
+ mClientsShutdown = nullptr;
+ nsCOMPtr<mozIStorageCompletionCallback> connectionShutdown = mConnectionShutdown.forget();
+
+ if (!mMainConn) {
+ // The connection has never been initialized. Just mark it as closed.
+ mClosed = true;
+ (void)connectionShutdown->Complete(NS_OK, nullptr);
+ return;
+ }
+
+#ifdef DEBUG
+ { // Sanity check for missing guids.
+ bool haveNullGuids = false;
+ nsCOMPtr<mozIStorageStatement> stmt;
+
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 "
+ "FROM moz_places "
+ "WHERE guid IS NULL "
+ ), getter_AddRefs(stmt));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ rv = stmt->ExecuteStep(&haveNullGuids);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ MOZ_ASSERT(!haveNullGuids, "Found a page without a GUID!");
+
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 "
+ "FROM moz_bookmarks "
+ "WHERE guid IS NULL "
+ ), getter_AddRefs(stmt));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ rv = stmt->ExecuteStep(&haveNullGuids);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ MOZ_ASSERT(!haveNullGuids, "Found a bookmark without a GUID!");
+ }
+
+ { // Sanity check for unrounded dateAdded and lastModified values (bug
+ // 1107308).
+ bool hasUnroundedDates = false;
+ nsCOMPtr<mozIStorageStatement> stmt;
+
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 "
+ "FROM moz_bookmarks "
+ "WHERE dateAdded % 1000 > 0 OR lastModified % 1000 > 0 LIMIT 1"
+ ), getter_AddRefs(stmt));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ rv = stmt->ExecuteStep(&hasUnroundedDates);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ MOZ_ASSERT(!hasUnroundedDates, "Found unrounded dates!");
+ }
+
+ { // Sanity check url_hash
+ bool hasNullHash = false;
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 FROM moz_places WHERE url_hash = 0"
+ ), getter_AddRefs(stmt));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ rv = stmt->ExecuteStep(&hasNullHash);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ MOZ_ASSERT(!hasNullHash, "Found a place without a hash!");
+ }
+
+ { // Sanity check unique urls
+ bool hasDupeUrls = false;
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 FROM moz_places GROUP BY url HAVING count(*) > 1 "
+ ), getter_AddRefs(stmt));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ rv = stmt->ExecuteStep(&hasDupeUrls);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ MOZ_ASSERT(!hasDupeUrls, "Found a duplicate url!");
+ }
+#endif
+
+ mMainThreadStatements.FinalizeStatements();
+ mMainThreadAsyncStatements.FinalizeStatements();
+
+ RefPtr< FinalizeStatementCacheProxy<mozIStorageStatement> > event =
+ new FinalizeStatementCacheProxy<mozIStorageStatement>(
+ mAsyncThreadStatements,
+ NS_ISUPPORTS_CAST(nsIObserver*, this)
+ );
+ DispatchToAsyncThread(event);
+
+ mClosed = true;
+
+ (void)mMainConn->AsyncClose(connectionShutdown);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+Database::Observe(nsISupports *aSubject,
+ const char *aTopic,
+ const char16_t *aData)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ if (strcmp(aTopic, TOPIC_PROFILE_CHANGE_TEARDOWN) == 0) {
+ // Tests simulating shutdown may cause multiple notifications.
+ if (IsShutdownStarted()) {
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIObserverService> os = services::GetObserverService();
+ NS_ENSURE_STATE(os);
+
+ // If shutdown happens in the same mainthread loop as init, observers could
+ // handle the places-init-complete notification after xpcom-shutdown, when
+ // the connection does not exist anymore. Removing those observers would
+ // be less expensive but may cause their RemoveObserver calls to throw.
+ // Thus notify the topic now, so they stop listening for it.
+ nsCOMPtr<nsISimpleEnumerator> e;
+ if (NS_SUCCEEDED(os->EnumerateObservers(TOPIC_PLACES_INIT_COMPLETE,
+ getter_AddRefs(e))) && e) {
+ bool hasMore = false;
+ while (NS_SUCCEEDED(e->HasMoreElements(&hasMore)) && hasMore) {
+ nsCOMPtr<nsISupports> supports;
+ if (NS_SUCCEEDED(e->GetNext(getter_AddRefs(supports)))) {
+ nsCOMPtr<nsIObserver> observer = do_QueryInterface(supports);
+ (void)observer->Observe(observer, TOPIC_PLACES_INIT_COMPLETE, nullptr);
+ }
+ }
+ }
+
+ // Notify all Places users that we are about to shutdown.
+ (void)os->NotifyObservers(nullptr, TOPIC_PLACES_SHUTDOWN, nullptr);
+ } else if (strcmp(aTopic, TOPIC_SIMULATE_PLACES_SHUTDOWN) == 0) {
+ // This notification is (and must be) only used by tests that are trying
+ // to simulate Places shutdown out of the normal shutdown path.
+
+ // Tests simulating shutdown may cause re-entrance.
+ if (IsShutdownStarted()) {
+ return NS_OK;
+ }
+
+ // We are simulating a shutdown, so invoke the shutdown blockers,
+ // wait for them, then proceed with connection shutdown.
+ // Since we are already going through shutdown, but it's not the real one,
+ // we won't need to block the real one anymore, so we can unblock it.
+ {
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileChangeTeardownPhase();
+ if (shutdownPhase) {
+ shutdownPhase->RemoveBlocker(mClientsShutdown.get());
+ }
+ (void)mClientsShutdown->BlockShutdown(nullptr);
+ }
+
+ // Spin the events loop until the clients are done.
+ // Note, this is just for tests, specifically test_clearHistory_shutdown.js
+ while (mClientsShutdown->State() != PlacesShutdownBlocker::States::RECEIVED_DONE) {
+ (void)NS_ProcessNextEvent();
+ }
+
+ {
+ nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileBeforeChangePhase();
+ if (shutdownPhase) {
+ shutdownPhase->RemoveBlocker(mConnectionShutdown.get());
+ }
+ (void)mConnectionShutdown->BlockShutdown(nullptr);
+ }
+ }
+ return NS_OK;
+}
+
+uint32_t
+Database::MaxUrlLength() {
+ MOZ_ASSERT(NS_IsMainThread());
+ if (!mMaxUrlLength) {
+ mMaxUrlLength = Preferences::GetInt(PREF_HISTORY_MAXURLLEN,
+ PREF_HISTORY_MAXURLLEN_DEFAULT);
+ if (mMaxUrlLength < 255 || mMaxUrlLength > INT32_MAX) {
+ mMaxUrlLength = PREF_HISTORY_MAXURLLEN_DEFAULT;
+ }
+ }
+ return mMaxUrlLength;
+}
+
+
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/Database.h b/toolkit/components/places/Database.h
new file mode 100644
index 000000000..22488fddb
--- /dev/null
+++ b/toolkit/components/places/Database.h
@@ -0,0 +1,331 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_Database_h_
+#define mozilla_places_Database_h_
+
+#include "MainThreadUtils.h"
+#include "nsWeakReference.h"
+#include "nsIInterfaceRequestorUtils.h"
+#include "nsIObserver.h"
+#include "nsIAsyncShutdown.h"
+#include "mozilla/storage.h"
+#include "mozilla/storage/StatementCache.h"
+#include "mozilla/Attributes.h"
+#include "nsIEventTarget.h"
+#include "Shutdown.h"
+
+// This is the schema version. Update it at any schema change and add a
+// corresponding migrateVxx method below.
+#define DATABASE_SCHEMA_VERSION 35
+
+// Fired after Places inited.
+#define TOPIC_PLACES_INIT_COMPLETE "places-init-complete"
+// Fired when initialization fails due to a locked database.
+#define TOPIC_DATABASE_LOCKED "places-database-locked"
+// This topic is received when the profile is about to be lost. Places does
+// initial shutdown work and notifies TOPIC_PLACES_SHUTDOWN to all listeners.
+// Any shutdown work that requires the Places APIs should happen here.
+#define TOPIC_PROFILE_CHANGE_TEARDOWN "profile-change-teardown"
+// Fired when Places is shutting down. Any code should stop accessing Places
+// APIs after this notification. If you need to listen for Places shutdown
+// you should only use this notification, next ones are intended only for
+// internal Places use.
+#define TOPIC_PLACES_SHUTDOWN "places-shutdown"
+// For Internal use only. Fired when connection is about to be closed, only
+// cleanup tasks should run at this stage, nothing should be added to the
+// database, nor APIs should be called.
+#define TOPIC_PLACES_WILL_CLOSE_CONNECTION "places-will-close-connection"
+// Fired when the connection has gone, nothing will work from now on.
+#define TOPIC_PLACES_CONNECTION_CLOSED "places-connection-closed"
+
+// Simulate profile-before-change. This topic may only be used by
+// calling `observe` directly on the database. Used for testing only.
+#define TOPIC_SIMULATE_PLACES_SHUTDOWN "test-simulate-places-shutdown"
+
+class nsIRunnable;
+
+namespace mozilla {
+namespace places {
+
+enum JournalMode {
+ // Default SQLite journal mode.
+ JOURNAL_DELETE = 0
+ // Can reduce fsyncs on Linux when journal is deleted (See bug 460315).
+ // We fallback to this mode when WAL is unavailable.
+, JOURNAL_TRUNCATE
+ // Unsafe in case of crashes on database swap or low memory.
+, JOURNAL_MEMORY
+ // Can reduce number of fsyncs. We try to use this mode by default.
+, JOURNAL_WAL
+};
+
+class ClientsShutdownBlocker;
+class ConnectionShutdownBlocker;
+
+class Database final : public nsIObserver
+ , public nsSupportsWeakReference
+{
+ typedef mozilla::storage::StatementCache<mozIStorageStatement> StatementCache;
+ typedef mozilla::storage::StatementCache<mozIStorageAsyncStatement> AsyncStatementCache;
+
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+
+ Database();
+
+ /**
+ * Initializes the database connection and the schema.
+ * In case of corruption the database is copied to a backup file and replaced.
+ */
+ nsresult Init();
+
+ /**
+ * The AsyncShutdown client used by clients of this API to be informed of shutdown.
+ */
+ already_AddRefed<nsIAsyncShutdownClient> GetClientsShutdown();
+
+ /**
+ * Getter to use when instantiating the class.
+ *
+ * @return Singleton instance of this class.
+ */
+ static already_AddRefed<Database> GetDatabase();
+
+ /**
+ * Returns last known database status.
+ *
+ * @return one of the nsINavHistoryService::DATABASE_STATUS_* constants.
+ */
+ uint16_t GetDatabaseStatus() const
+ {
+ return mDatabaseStatus;
+ }
+
+ /**
+ * Returns a pointer to the storage connection.
+ *
+ * @return The connection handle.
+ */
+ mozIStorageConnection* MainConn() const
+ {
+ return mMainConn;
+ }
+
+ /**
+ * Dispatches a runnable to the connection async thread, to be serialized
+ * with async statements.
+ *
+ * @param aEvent
+ * The runnable to be dispatched.
+ */
+ void DispatchToAsyncThread(nsIRunnable* aEvent) const
+ {
+ if (mClosed) {
+ return;
+ }
+ nsCOMPtr<nsIEventTarget> target = do_GetInterface(mMainConn);
+ if (target) {
+ (void)target->Dispatch(aEvent, NS_DISPATCH_NORMAL);
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////
+ //// Statements Getters.
+
+ /**
+ * Gets a cached synchronous statement.
+ *
+ * @param aQuery
+ * SQL query literal.
+ * @return The cached statement.
+ * @note Always null check the result.
+ * @note Always use a scoper to reset the statement.
+ */
+ template<int N>
+ already_AddRefed<mozIStorageStatement>
+ GetStatement(const char (&aQuery)[N]) const
+ {
+ nsDependentCString query(aQuery, N - 1);
+ return GetStatement(query);
+ }
+
+ /**
+ * Gets a cached synchronous statement.
+ *
+ * @param aQuery
+ * nsCString of SQL query.
+ * @return The cached statement.
+ * @note Always null check the result.
+ * @note Always use a scoper to reset the statement.
+ */
+ already_AddRefed<mozIStorageStatement> GetStatement(const nsACString& aQuery) const;
+
+ /**
+ * Gets a cached asynchronous statement.
+ *
+ * @param aQuery
+ * SQL query literal.
+ * @return The cached statement.
+ * @note Always null check the result.
+ * @note AsyncStatements are automatically reset on execution.
+ */
+ template<int N>
+ already_AddRefed<mozIStorageAsyncStatement>
+ GetAsyncStatement(const char (&aQuery)[N]) const
+ {
+ nsDependentCString query(aQuery, N - 1);
+ return GetAsyncStatement(query);
+ }
+
+ /**
+ * Gets a cached asynchronous statement.
+ *
+ * @param aQuery
+ * nsCString of SQL query.
+ * @return The cached statement.
+ * @note Always null check the result.
+ * @note AsyncStatements are automatically reset on execution.
+ */
+ already_AddRefed<mozIStorageAsyncStatement> GetAsyncStatement(const nsACString& aQuery) const;
+
+ uint32_t MaxUrlLength();
+
+protected:
+ /**
+ * Finalizes the cached statements and closes the database connection.
+ * A TOPIC_PLACES_CONNECTION_CLOSED notification is fired when done.
+ */
+ void Shutdown();
+
+ bool IsShutdownStarted() const;
+
+ /**
+ * Initializes the database file. If the database does not exist or is
+ * corrupt, a new one is created. In case of corruption it also creates a
+ * backup copy of the database.
+ *
+ * @param aStorage
+ * mozStorage service instance.
+ * @param aNewDatabaseCreated
+ * whether a new database file has been created.
+ */
+ nsresult InitDatabaseFile(nsCOMPtr<mozIStorageService>& aStorage,
+ bool* aNewDatabaseCreated);
+
+ /**
+ * Creates a database backup and replaces the original file with a new
+ * one.
+ *
+ * @param aStorage
+ * mozStorage service instance.
+ */
+ nsresult BackupAndReplaceDatabaseFile(nsCOMPtr<mozIStorageService>& aStorage);
+
+ /**
+ * Initializes the database. This performs any necessary migrations for the
+ * database. All migration is done inside a transaction that is rolled back
+ * if any error occurs.
+ * @param aDatabaseMigrated
+ * Whether a schema upgrade happened.
+ */
+ nsresult InitSchema(bool* aDatabaseMigrated);
+
+ /**
+ * Creates bookmark roots in a new DB.
+ */
+ nsresult CreateBookmarkRoots();
+
+ /**
+ * Initializes additionale SQLite functions, defined in SQLFunctions.h
+ */
+ nsresult InitFunctions();
+
+ /**
+ * Initializes temp entities, like triggers, tables, views...
+ */
+ nsresult InitTempEntities();
+
+ /**
+ * Helpers used by schema upgrades.
+ */
+ nsresult MigrateV13Up();
+ nsresult MigrateV15Up();
+ nsresult MigrateV17Up();
+ nsresult MigrateV18Up();
+ nsresult MigrateV19Up();
+ nsresult MigrateV20Up();
+ nsresult MigrateV21Up();
+ nsresult MigrateV22Up();
+ nsresult MigrateV23Up();
+ nsresult MigrateV24Up();
+ nsresult MigrateV25Up();
+ nsresult MigrateV26Up();
+ nsresult MigrateV27Up();
+ nsresult MigrateV28Up();
+ nsresult MigrateV30Up();
+ nsresult MigrateV31Up();
+ nsresult MigrateV32Up();
+ nsresult MigrateV33Up();
+ nsresult MigrateV34Up();
+ nsresult MigrateV35Up();
+
+ nsresult UpdateBookmarkRootTitles();
+
+ friend class ConnectionShutdownBlocker;
+
+ int64_t CreateMobileRoot();
+ nsresult GetItemsWithAnno(const nsACString& aAnnoName, int32_t aItemType,
+ nsTArray<int64_t>& aItemIds);
+ nsresult DeleteBookmarkItem(int32_t aItemId);
+
+private:
+ ~Database();
+
+ /**
+ * Singleton getter, invoked by class instantiation.
+ */
+ static already_AddRefed<Database> GetSingleton();
+
+ static Database* gDatabase;
+
+ nsCOMPtr<mozIStorageConnection> mMainConn;
+
+ mutable StatementCache mMainThreadStatements;
+ mutable AsyncStatementCache mMainThreadAsyncStatements;
+ mutable StatementCache mAsyncThreadStatements;
+
+ int32_t mDBPageSize;
+ uint16_t mDatabaseStatus;
+ bool mClosed;
+
+ /**
+ * Phases for shutting down the Database.
+ * See Shutdown.h for further details about the shutdown procedure.
+ */
+ already_AddRefed<nsIAsyncShutdownClient> GetProfileChangeTeardownPhase();
+ already_AddRefed<nsIAsyncShutdownClient> GetProfileBeforeChangePhase();
+
+ /**
+ * Blockers in charge of waiting for the Places clients and then shutting
+ * down the mozStorage connection.
+ * See Shutdown.h for further details about the shutdown procedure.
+ *
+ * Cycles with these are broken in `Shutdown()`.
+ */
+ RefPtr<ClientsShutdownBlocker> mClientsShutdown;
+ RefPtr<ConnectionShutdownBlocker> mConnectionShutdown;
+
+ // Maximum length of a stored url.
+ // For performance reasons we don't store very long urls in history, since
+ // they are slower to search through and cause abnormal database growth,
+ // affecting the awesomebar fetch time.
+ uint32_t mMaxUrlLength;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_Database_h_
diff --git a/toolkit/components/places/ExtensionSearchHandler.jsm b/toolkit/components/places/ExtensionSearchHandler.jsm
new file mode 100644
index 000000000..3eb699ca1
--- /dev/null
+++ b/toolkit/components/places/ExtensionSearchHandler.jsm
@@ -0,0 +1,292 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [ "ExtensionSearchHandler" ];
+
+// Used to keep track of all of the registered keywords, where each keyword is
+// mapped to a KeywordInfo instance.
+let gKeywordMap = new Map();
+
+// Used to keep track of the active input session.
+let gActiveInputSession = null;
+
+// Used to keep track of who has control over the active suggestion callback
+// so older callbacks can be ignored. The callback ID should increment whenever
+// the input changes or the input session ends.
+let gCurrentCallbackID = 0;
+
+// Handles keeping track of information associated to the registered keyword.
+class KeywordInfo {
+ constructor(extension, description) {
+ this._extension = extension;
+ this._description = description;
+ }
+
+ get description() {
+ return this._description;
+ }
+
+ set description(desc) {
+ this._description = desc;
+ }
+
+ get extension() {
+ return this._extension;
+ }
+}
+
+// Responsible for handling communication between the extension and the urlbar.
+class InputSession {
+ constructor(keyword, extension) {
+ this._keyword = keyword;
+ this._extension = extension;
+ this._suggestionsCallback = null;
+ this._searchFinishedCallback = null;
+ }
+
+ get keyword() {
+ return this._keyword;
+ }
+
+ addSuggestions(suggestions) {
+ this._suggestionsCallback(suggestions);
+ }
+
+ start(eventName) {
+ this._extension.emit(eventName);
+ }
+
+ update(eventName, text, suggestionsCallback, searchFinishedCallback) {
+ if (this._searchFinishedCallback) {
+ this._searchFinishedCallback();
+ }
+ this._searchFinishedCallback = searchFinishedCallback;
+ this._suggestionsCallback = suggestionsCallback;
+ this._extension.emit(eventName, text, ++gCurrentCallbackID);
+ }
+
+ cancel(eventName) {
+ this._searchFinishedCallback();
+ this._extension.emit(eventName);
+ }
+
+ end(eventName, text, disposition) {
+ this._searchFinishedCallback();
+ this._extension.emit(eventName, text, disposition);
+ }
+}
+
+var ExtensionSearchHandler = Object.freeze({
+ MSG_INPUT_STARTED: "webext-omnibox-input-started",
+ MSG_INPUT_CHANGED: "webext-omnibox-input-changed",
+ MSG_INPUT_ENTERED: "webext-omnibox-input-entered",
+ MSG_INPUT_CANCELLED: "webext-omnibox-input-cancelled",
+
+ /**
+ * Registers a keyword.
+ *
+ * @param {string} keyword The keyword to register.
+ * @param {Extension} extension The extension registering the keyword.
+ */
+ registerKeyword(keyword, extension) {
+ if (gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is already registered: "${keyword}"`);
+ }
+ gKeywordMap.set(keyword, new KeywordInfo(extension, extension.name));
+ },
+
+ /**
+ * Unregisters a keyword.
+ *
+ * @param {string} keyword The keyword to unregister.
+ */
+ unregisterKeyword(keyword) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ gActiveInputSession = null;
+ gKeywordMap.delete(keyword);
+ },
+
+ /**
+ * Checks if a keyword is registered.
+ *
+ * @param {string} keyword The word to check.
+ * @return {boolean} true if the word is a registered keyword.
+ */
+ isKeywordRegistered(keyword) {
+ return gKeywordMap.has(keyword);
+ },
+
+ /**
+ * @return {boolean} true if there is an active input session.
+ */
+ hasActiveInputSession() {
+ return gActiveInputSession != null;
+ },
+
+ /**
+ * @param {string} keyword The keyword to look up.
+ * @return {string} the description to use for the heuristic result.
+ */
+ getDescription(keyword) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ return gKeywordMap.get(keyword).description;
+ },
+
+ /**
+ * Sets the default suggestion for the registered keyword. The suggestion's
+ * description will be used for the comment in the heuristic result.
+ *
+ * @param {string} keyword The keyword.
+ * @param {string} description The description to use for the heuristic result.
+ */
+ setDefaultSuggestion(keyword, {description}) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+ gKeywordMap.get(keyword).description = description;
+ },
+
+ /**
+ * Adds suggestions for the registered keyword. This function will throw if
+ * the keyword provided is not registered or active, or if the callback ID
+ * provided is no longer equal to the active callback ID.
+ *
+ * @param {string} keyword The keyword.
+ * @param {integer} id The ID of the suggestion callback.
+ * @param {Array<Object>} suggestions An array of suggestions to provide to the urlbar.
+ */
+ addSuggestions(keyword, id, suggestions) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ if (!gActiveInputSession || gActiveInputSession.keyword != keyword) {
+ throw new Error(`The keyword provided is not apart of an active input session: "${keyword}"`);
+ }
+
+ if (id != gCurrentCallbackID) {
+ throw new Error(`The callback is no longer active for the keyword provided: "${keyword}"`);
+ }
+
+ gActiveInputSession.addSuggestions(suggestions);
+ },
+
+ /**
+ * Called when the input in the urlbar begins with `<keyword><space>`.
+ *
+ * If the keyword is inactive, MSG_INPUT_STARTED is emitted and the
+ * keyword is marked as active. If the keyword is followed by any text,
+ * MSG_INPUT_CHANGED is fired with the current callback ID that can be
+ * used to provide suggestions to the urlbar while the callback ID is active.
+ * The callback is invalidated when either the input changes or the urlbar blurs.
+ *
+ * @param {string} keyword The keyword to handle.
+ * @param {string} text The search text in the urlbar.
+ * @param {Function} callback The callback used to provide search suggestions.
+ * @return {Promise} promise that resolves when the current search is complete.
+ */
+ handleSearch(keyword, text, callback) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
+ throw new Error("A different input session is already ongoing");
+ }
+
+ if (!text || !text.startsWith(`${keyword} `)) {
+ throw new Error(`The text provided must start with: "${keyword} "`);
+ }
+
+ if (!callback) {
+ throw new Error("A callback must be provided");
+ }
+
+ // The search text in the urlbar currently starts with <keyword><space>, and
+ // we only want the text that follows.
+ text = text.substring(keyword.length + 1);
+
+ // We fire MSG_INPUT_STARTED once we have <keyword><space>, and only fire
+ // MSG_INPUT_CHANGED when we have text to process. This is different from Chrome's
+ // behavior, which always fires MSG_INPUT_STARTED right before MSG_INPUT_CHANGED
+ // first fires, but this is a bug in Chrome according to https://crbug.com/258911.
+ if (!gActiveInputSession) {
+ gActiveInputSession = new InputSession(keyword, gKeywordMap.get(keyword).extension);
+ gActiveInputSession.start(this.MSG_INPUT_STARTED);
+
+ // Resolve early if there is no text to process. There can be text to process when
+ // the input starts if the user copy/pastes the text into the urlbar.
+ if (!text.length) {
+ return Promise.resolve();
+ }
+ }
+
+ return new Promise(resolve => {
+ gActiveInputSession.update(this.MSG_INPUT_CHANGED, text, callback, resolve);
+ });
+ },
+
+ /**
+ * Called when the user clicks on a suggestion that was added by
+ * an extension. MSG_INPUT_ENTERED is emitted to the extension with
+ * the keyword, the current search string, and info about how the
+ * the search should be handled. This ends the active input session.
+ *
+ * @param {string} keyword The keyword associated to the suggestion.
+ * @param {string} text The search text in the urlbar.
+ * @param {string} where How the page should be opened. Accepted values are:
+ * "current": open the page in the same tab.
+ * "tab": open the page in a new foreground tab.
+ * "tabshifted": open the page in a new background tab.
+ */
+ handleInputEntered(keyword, text, where) {
+ if (!gKeywordMap.has(keyword)) {
+ throw new Error(`The keyword provided is not registered: "${keyword}"`);
+ }
+
+ if (gActiveInputSession && gActiveInputSession.keyword != keyword) {
+ throw new Error("A different input session is already ongoing");
+ }
+
+ if (!text || !text.startsWith(`${keyword} `)) {
+ throw new Error(`The text provided must start with: "${keyword} "`);
+ }
+
+ let dispositionMap = {
+ current: "currentTab",
+ tab: "newForegroundTab",
+ tabshifted: "newBackgroundTab",
+ }
+ let disposition = dispositionMap[where];
+
+ if (!disposition) {
+ throw new Error(`Invalid "where" argument: ${where}`);
+ }
+
+ // The search text in the urlbar currently starts with <keyword><space>, and
+ // we only want to send the text that follows.
+ text = text.substring(keyword.length + 1);
+
+ gActiveInputSession.end(this.MSG_INPUT_ENTERED, text, disposition)
+ gActiveInputSession = null;
+ },
+
+ /**
+ * If the user has ended the keyword input session without accepting the input,
+ * MSG_INPUT_CANCELLED is emitted and the input session is ended.
+ */
+ handleInputCancelled() {
+ if (!gActiveInputSession) {
+ throw new Error("There is no active input session");
+ }
+ gActiveInputSession.cancel(this.MSG_INPUT_CANCELLED);
+ gActiveInputSession = null;
+ }
+});
diff --git a/toolkit/components/places/FaviconHelpers.cpp b/toolkit/components/places/FaviconHelpers.cpp
new file mode 100644
index 000000000..69c202338
--- /dev/null
+++ b/toolkit/components/places/FaviconHelpers.cpp
@@ -0,0 +1,934 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "FaviconHelpers.h"
+
+#include "nsICacheEntry.h"
+#include "nsICachingChannel.h"
+#include "nsIAsyncVerifyRedirectCallback.h"
+#include "nsIPrincipal.h"
+
+#include "nsNavHistory.h"
+#include "nsFaviconService.h"
+#include "mozilla/storage.h"
+#include "mozilla/Telemetry.h"
+#include "nsNetUtil.h"
+#include "nsPrintfCString.h"
+#include "nsStreamUtils.h"
+#include "nsIPrivateBrowsingChannel.h"
+#include "nsISupportsPriority.h"
+#include "nsContentUtils.h"
+#include <algorithm>
+
+using namespace mozilla::places;
+using namespace mozilla::storage;
+
+namespace mozilla {
+namespace places {
+
+namespace {
+
+/**
+ * Fetches information on a page from the Places database.
+ *
+ * @param aDBConn
+ * Database connection to history tables.
+ * @param _page
+ * Page that should be fetched.
+ */
+nsresult
+FetchPageInfo(const RefPtr<Database>& aDB,
+ PageData& _page)
+{
+ MOZ_ASSERT(_page.spec.Length(), "Must have a non-empty spec!");
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ // This query finds the bookmarked uri we want to set the icon for,
+ // walking up to two redirect levels.
+ nsCString query = nsPrintfCString(
+ "SELECT h.id, h.favicon_id, h.guid, ( "
+ "SELECT h.url FROM moz_bookmarks b WHERE b.fk = h.id "
+ "UNION ALL " // Union not directly bookmarked pages.
+ "SELECT url FROM moz_places WHERE id = ( "
+ "SELECT COALESCE(grandparent.place_id, parent.place_id) as r_place_id "
+ "FROM moz_historyvisits dest "
+ "LEFT JOIN moz_historyvisits parent ON parent.id = dest.from_visit "
+ "AND dest.visit_type IN (%d, %d) "
+ "LEFT JOIN moz_historyvisits grandparent ON parent.from_visit = grandparent.id "
+ "AND parent.visit_type IN (%d, %d) "
+ "WHERE dest.place_id = h.id "
+ "AND EXISTS(SELECT 1 FROM moz_bookmarks b WHERE b.fk = r_place_id) "
+ "LIMIT 1 "
+ ") "
+ ") FROM moz_places h WHERE h.url_hash = hash(:page_url) AND h.url = :page_url",
+ nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT,
+ nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY,
+ nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT,
+ nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY
+ );
+
+ nsCOMPtr<mozIStorageStatement> stmt = aDB->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"),
+ _page.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasResult) {
+ // The page does not exist.
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ rv = stmt->GetInt64(0, &_page.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool isNull;
+ rv = stmt->GetIsNull(1, &isNull);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // favicon_id can be nullptr.
+ if (!isNull) {
+ rv = stmt->GetInt64(1, &_page.iconId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ rv = stmt->GetUTF8String(2, _page.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetIsNull(3, &isNull);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // The page could not be bookmarked.
+ if (!isNull) {
+ rv = stmt->GetUTF8String(3, _page.bookmarkedSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (!_page.canAddToHistory) {
+ // Either history is disabled or the scheme is not supported. In such a
+ // case we want to update the icon only if the page is bookmarked.
+
+ if (_page.bookmarkedSpec.IsEmpty()) {
+ // The page is not bookmarked. Since updating the icon with a disabled
+ // history would be a privacy leak, bail out as if the page did not exist.
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+ else {
+ // The page, or a redirect to it, is bookmarked. If the bookmarked spec
+ // is different from the requested one, use it.
+ if (!_page.bookmarkedSpec.Equals(_page.spec)) {
+ _page.spec = _page.bookmarkedSpec;
+ rv = FetchPageInfo(aDB, _page);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Stores information on a icon in the database.
+ *
+ * @param aDBConn
+ * Database connection to history tables.
+ * @param aIcon
+ * Icon that should be stored.
+ */
+nsresult
+SetIconInfo(const RefPtr<Database>& aDB,
+ const IconData& aIcon)
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ nsCOMPtr<mozIStorageStatement> stmt = aDB->GetStatement(
+ "INSERT OR REPLACE INTO moz_favicons "
+ "(id, url, data, mime_type, expiration) "
+ "VALUES ((SELECT id FROM moz_favicons WHERE url = :icon_url), "
+ ":icon_url, :data, :mime_type, :expiration) "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("icon_url"), aIcon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindBlobByName(NS_LITERAL_CSTRING("data"),
+ TO_INTBUFFER(aIcon.data), aIcon.data.Length());
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("mime_type"), aIcon.mimeType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("expiration"), aIcon.expiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+/**
+ * Fetches information on a icon from the Places database.
+ *
+ * @param aDBConn
+ * Database connection to history tables.
+ * @param _icon
+ * Icon that should be fetched.
+ */
+nsresult
+FetchIconInfo(const RefPtr<Database>& aDB,
+ IconData& _icon)
+{
+ MOZ_ASSERT(_icon.spec.Length(), "Must have a non-empty spec!");
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ if (_icon.status & ICON_STATUS_CACHED) {
+ return NS_OK;
+ }
+
+ nsCOMPtr<mozIStorageStatement> stmt = aDB->GetStatement(
+ "SELECT id, expiration, data, mime_type "
+ "FROM moz_favicons WHERE url = :icon_url"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ DebugOnly<nsresult> rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("icon_url"),
+ _icon.spec);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ if (!hasResult) {
+ // The icon does not exist yet, bail out.
+ return NS_OK;
+ }
+
+ rv = stmt->GetInt64(0, &_icon.id);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+
+ // Expiration can be nullptr.
+ bool isNull;
+ rv = stmt->GetIsNull(1, &isNull);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ if (!isNull) {
+ rv = stmt->GetInt64(1, reinterpret_cast<int64_t*>(&_icon.expiration));
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+
+ // Data can be nullptr.
+ rv = stmt->GetIsNull(2, &isNull);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ if (!isNull) {
+ uint8_t* data;
+ uint32_t dataLen = 0;
+ rv = stmt->GetBlob(2, &dataLen, &data);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ _icon.data.Adopt(TO_CHARBUFFER(data), dataLen);
+ // Read mime only if we have data.
+ rv = stmt->GetUTF8String(3, _icon.mimeType);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+
+ return NS_OK;
+}
+
+nsresult
+FetchIconURL(const RefPtr<Database>& aDB,
+ const nsACString& aPageSpec,
+ nsACString& aIconSpec)
+{
+ MOZ_ASSERT(!aPageSpec.IsEmpty(), "Page spec must not be empty.");
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ aIconSpec.Truncate();
+
+ nsCOMPtr<mozIStorageStatement> stmt = aDB->GetStatement(
+ "SELECT f.url "
+ "FROM moz_places h "
+ "JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"),
+ aPageSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && hasResult) {
+ rv = stmt->GetUTF8String(0, aIconSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Tries to compute the expiration time for a icon from the channel.
+ *
+ * @param aChannel
+ * The network channel used to fetch the icon.
+ * @return a valid expiration value for the fetched icon.
+ */
+PRTime
+GetExpirationTimeFromChannel(nsIChannel* aChannel)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Attempt to get an expiration time from the cache. If this fails, we'll
+ // make one up.
+ PRTime expiration = -1;
+ nsCOMPtr<nsICachingChannel> cachingChannel = do_QueryInterface(aChannel);
+ if (cachingChannel) {
+ nsCOMPtr<nsISupports> cacheToken;
+ nsresult rv = cachingChannel->GetCacheToken(getter_AddRefs(cacheToken));
+ if (NS_SUCCEEDED(rv)) {
+ nsCOMPtr<nsICacheEntry> cacheEntry = do_QueryInterface(cacheToken);
+ uint32_t seconds;
+ rv = cacheEntry->GetExpirationTime(&seconds);
+ if (NS_SUCCEEDED(rv)) {
+ // Set the expiration, but make sure we honor our cap.
+ expiration = PR_Now() + std::min((PRTime)seconds * PR_USEC_PER_SEC,
+ MAX_FAVICON_EXPIRATION);
+ }
+ }
+ }
+ // If we did not obtain a time from the cache, use the cap value.
+ return expiration < 0 ? PR_Now() + MAX_FAVICON_EXPIRATION
+ : expiration;
+}
+
+/**
+ * Checks the icon and evaluates if it needs to be optimized. In such a case it
+ * will try to reduce its size through OptimizeFaviconImage method of the
+ * favicons service.
+ *
+ * @param aIcon
+ * The icon to be evaluated.
+ * @param aFaviconSvc
+ * Pointer to the favicons service.
+ */
+nsresult
+OptimizeIconSize(IconData& aIcon,
+ nsFaviconService* aFaviconSvc)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Even if the page provides a large image for the favicon (eg, a highres
+ // image or a multiresolution .ico file), don't try to store more data than
+ // needed.
+ nsAutoCString newData, newMimeType;
+ if (aIcon.data.Length() > MAX_FAVICON_FILESIZE) {
+ nsresult rv = aFaviconSvc->OptimizeFaviconImage(TO_INTBUFFER(aIcon.data),
+ aIcon.data.Length(),
+ aIcon.mimeType,
+ newData,
+ newMimeType);
+ if (NS_SUCCEEDED(rv) && newData.Length() < aIcon.data.Length()) {
+ aIcon.data = newData;
+ aIcon.mimeType = newMimeType;
+ }
+ }
+ return NS_OK;
+}
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncFetchAndSetIconForPage
+
+NS_IMPL_ISUPPORTS_INHERITED(
+ AsyncFetchAndSetIconForPage
+, Runnable
+, nsIStreamListener
+, nsIInterfaceRequestor
+, nsIChannelEventSink
+, mozIPlacesPendingOperation
+)
+
+AsyncFetchAndSetIconForPage::AsyncFetchAndSetIconForPage(
+ IconData& aIcon
+, PageData& aPage
+, bool aFaviconLoadPrivate
+, nsIFaviconDataCallback* aCallback
+, nsIPrincipal* aLoadingPrincipal
+) : mCallback(new nsMainThreadPtrHolder<nsIFaviconDataCallback>(aCallback))
+ , mIcon(aIcon)
+ , mPage(aPage)
+ , mFaviconLoadPrivate(aFaviconLoadPrivate)
+ , mLoadingPrincipal(new nsMainThreadPtrHolder<nsIPrincipal>(aLoadingPrincipal))
+ , mCanceled(false)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+}
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::Run()
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ // Try to fetch the icon from the database.
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ nsresult rv = FetchIconInfo(DB, mIcon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool isInvalidIcon = mIcon.data.IsEmpty() ||
+ (mIcon.expiration && PR_Now() > mIcon.expiration);
+ bool fetchIconFromNetwork = mIcon.fetchMode == FETCH_ALWAYS ||
+ (mIcon.fetchMode == FETCH_IF_MISSING && isInvalidIcon);
+
+ if (!fetchIconFromNetwork) {
+ // There is already a valid icon or we don't want to fetch a new one,
+ // directly proceed with association.
+ RefPtr<AsyncAssociateIconToPage> event =
+ new AsyncAssociateIconToPage(mIcon, mPage, mCallback);
+ DB->DispatchToAsyncThread(event);
+
+ return NS_OK;
+ }
+
+ // Fetch the icon from the network, the request starts from the main-thread.
+ // When done this will associate the icon to the page and notify.
+ nsCOMPtr<nsIRunnable> event =
+ NewRunnableMethod(this, &AsyncFetchAndSetIconForPage::FetchFromNetwork);
+ return NS_DispatchToMainThread(event);
+}
+
+nsresult
+AsyncFetchAndSetIconForPage::FetchFromNetwork() {
+ MOZ_ASSERT(NS_IsMainThread());
+
+ if (mCanceled) {
+ return NS_OK;
+ }
+
+ // Ensure data is cleared, since it's going to be overwritten.
+ if (mIcon.data.Length() > 0) {
+ mIcon.data.Truncate(0);
+ mIcon.mimeType.Truncate(0);
+ }
+
+ nsCOMPtr<nsIURI> iconURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(iconURI), mIcon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIChannel> channel;
+ rv = NS_NewChannel(getter_AddRefs(channel),
+ iconURI,
+ mLoadingPrincipal,
+ nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_DATA_INHERITS |
+ nsILoadInfo::SEC_ALLOW_CHROME |
+ nsILoadInfo::SEC_DISALLOW_SCRIPT,
+ nsIContentPolicy::TYPE_INTERNAL_IMAGE_FAVICON);
+
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIInterfaceRequestor> listenerRequestor =
+ do_QueryInterface(reinterpret_cast<nsISupports*>(this));
+ NS_ENSURE_STATE(listenerRequestor);
+ rv = channel->SetNotificationCallbacks(listenerRequestor);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIPrivateBrowsingChannel> pbChannel = do_QueryInterface(channel);
+ if (pbChannel) {
+ rv = pbChannel->SetPrivate(mFaviconLoadPrivate);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsCOMPtr<nsISupportsPriority> priorityChannel = do_QueryInterface(channel);
+ if (priorityChannel) {
+ priorityChannel->AdjustPriority(nsISupportsPriority::PRIORITY_LOWEST);
+ }
+
+ rv = channel->AsyncOpen2(this);
+ if (NS_SUCCEEDED(rv)) {
+ mRequest = channel;
+ }
+ return rv;
+}
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::Cancel()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ if (mCanceled) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ mCanceled = true;
+ if (mRequest) {
+ mRequest->Cancel(NS_BINDING_ABORTED);
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::OnStartRequest(nsIRequest* aRequest,
+ nsISupports* aContext)
+{
+ // mRequest should already be set from ::FetchFromNetwork, but in the case of
+ // a redirect we might get a new request, and we should make sure we keep a
+ // reference to the most current request.
+ mRequest = aRequest;
+ if (mCanceled) {
+ mRequest->Cancel(NS_BINDING_ABORTED);
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::OnDataAvailable(nsIRequest* aRequest,
+ nsISupports* aContext,
+ nsIInputStream* aInputStream,
+ uint64_t aOffset,
+ uint32_t aCount)
+{
+ const size_t kMaxFaviconDownloadSize = 1 * 1024 * 1024;
+ if (mIcon.data.Length() + aCount > kMaxFaviconDownloadSize) {
+ mIcon.data.Truncate();
+ return NS_ERROR_FILE_TOO_BIG;
+ }
+
+ nsAutoCString buffer;
+ nsresult rv = NS_ConsumeStream(aInputStream, aCount, buffer);
+ if (rv != NS_BASE_STREAM_WOULD_BLOCK && NS_FAILED(rv)) {
+ return rv;
+ }
+
+ if (!mIcon.data.Append(buffer, fallible)) {
+ mIcon.data.Truncate();
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::GetInterface(const nsIID& uuid,
+ void** aResult)
+{
+ return QueryInterface(uuid, aResult);
+}
+
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::AsyncOnChannelRedirect(
+ nsIChannel* oldChannel
+, nsIChannel* newChannel
+, uint32_t flags
+, nsIAsyncVerifyRedirectCallback *cb
+)
+{
+ // If we've been canceled, stop the redirect with NS_BINDING_ABORTED, and
+ // handle the cancel on the original channel.
+ (void)cb->OnRedirectVerifyCallback(mCanceled ? NS_BINDING_ABORTED : NS_OK);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AsyncFetchAndSetIconForPage::OnStopRequest(nsIRequest* aRequest,
+ nsISupports* aContext,
+ nsresult aStatusCode)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Don't need to track this anymore.
+ mRequest = nullptr;
+ if (mCanceled) {
+ return NS_OK;
+ }
+
+ nsFaviconService* favicons = nsFaviconService::GetFaviconService();
+ NS_ENSURE_STATE(favicons);
+
+ nsresult rv;
+
+ // If fetching the icon failed, add it to the failed cache.
+ if (NS_FAILED(aStatusCode) || mIcon.data.Length() == 0) {
+ nsCOMPtr<nsIURI> iconURI;
+ rv = NS_NewURI(getter_AddRefs(iconURI), mIcon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = favicons->AddFailedFavicon(iconURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest);
+ // aRequest should always QI to nsIChannel.
+ MOZ_ASSERT(channel);
+
+ nsAutoCString contentType;
+ channel->GetContentType(contentType);
+ // Bug 366324 - can't sniff SVG yet, so rely on server-specified type
+ if (contentType.EqualsLiteral("image/svg+xml")) {
+ mIcon.mimeType.AssignLiteral("image/svg+xml");
+ } else {
+ NS_SniffContent(NS_DATA_SNIFFER_CATEGORY, aRequest,
+ TO_INTBUFFER(mIcon.data), mIcon.data.Length(),
+ mIcon.mimeType);
+ }
+
+ // If the icon does not have a valid MIME type, add it to the failed cache.
+ if (mIcon.mimeType.IsEmpty()) {
+ nsCOMPtr<nsIURI> iconURI;
+ rv = NS_NewURI(getter_AddRefs(iconURI), mIcon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = favicons->AddFailedFavicon(iconURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ mIcon.expiration = GetExpirationTimeFromChannel(channel);
+
+ // Telemetry probes to measure the favicon file sizes for each different file type.
+ // This allow us to measure common file sizes while also observing each type popularity.
+ if (mIcon.mimeType.EqualsLiteral("image/png")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_PNG_SIZES, mIcon.data.Length());
+ }
+ else if (mIcon.mimeType.EqualsLiteral("image/x-icon") ||
+ mIcon.mimeType.EqualsLiteral("image/vnd.microsoft.icon")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_ICO_SIZES, mIcon.data.Length());
+ }
+ else if (mIcon.mimeType.EqualsLiteral("image/jpeg") ||
+ mIcon.mimeType.EqualsLiteral("image/pjpeg")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_JPEG_SIZES, mIcon.data.Length());
+ }
+ else if (mIcon.mimeType.EqualsLiteral("image/gif")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_GIF_SIZES, mIcon.data.Length());
+ }
+ else if (mIcon.mimeType.EqualsLiteral("image/bmp") ||
+ mIcon.mimeType.EqualsLiteral("image/x-windows-bmp")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_BMP_SIZES, mIcon.data.Length());
+ }
+ else if (mIcon.mimeType.EqualsLiteral("image/svg+xml")) {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_SVG_SIZES, mIcon.data.Length());
+ }
+ else {
+ mozilla::Telemetry::Accumulate(mozilla::Telemetry::PLACES_FAVICON_OTHER_SIZES, mIcon.data.Length());
+ }
+
+ rv = OptimizeIconSize(mIcon, favicons);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If over the maximum size allowed, don't save data to the database to
+ // avoid bloating it.
+ if (mIcon.data.Length() > nsIFaviconService::MAX_FAVICON_BUFFER_SIZE) {
+ return NS_OK;
+ }
+
+ mIcon.status = ICON_STATUS_CHANGED;
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ RefPtr<AsyncAssociateIconToPage> event =
+ new AsyncAssociateIconToPage(mIcon, mPage, mCallback);
+ DB->DispatchToAsyncThread(event);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncAssociateIconToPage
+
+AsyncAssociateIconToPage::AsyncAssociateIconToPage(
+ const IconData& aIcon
+, const PageData& aPage
+, const nsMainThreadPtrHandle<nsIFaviconDataCallback>& aCallback
+) : mCallback(aCallback)
+ , mIcon(aIcon)
+ , mPage(aPage)
+{
+}
+
+NS_IMETHODIMP
+AsyncAssociateIconToPage::Run()
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ nsresult rv = FetchPageInfo(DB, mPage);
+ if (rv == NS_ERROR_NOT_AVAILABLE){
+ // We have never seen this page. If we can add the page to history,
+ // we will try to do it later, otherwise just bail out.
+ if (!mPage.canAddToHistory) {
+ return NS_OK;
+ }
+ }
+ else {
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ mozStorageTransaction transaction(DB->MainConn(), false,
+ mozIStorageConnection::TRANSACTION_IMMEDIATE);
+
+ // If there is no entry for this icon, or the entry is obsolete, replace it.
+ if (mIcon.id == 0 || (mIcon.status & ICON_STATUS_CHANGED)) {
+ rv = SetIconInfo(DB, mIcon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Get the new icon id. Do this regardless mIcon.id, since other code
+ // could have added a entry before us. Indeed we interrupted the thread
+ // after the previous call to FetchIconInfo.
+ mIcon.status = (mIcon.status & ~(ICON_STATUS_CACHED)) | ICON_STATUS_SAVED;
+ rv = FetchIconInfo(DB, mIcon);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // If the page does not have an id, don't try to insert a new one, cause we
+ // don't know where the page comes from. Not doing so we may end adding
+ // a page that otherwise we'd explicitly ignore, like a POST or an error page.
+ if (mPage.id == 0) {
+ return NS_OK;
+ }
+
+ // Otherwise just associate the icon to the page, if needed.
+ if (mPage.iconId != mIcon.id) {
+ nsCOMPtr<mozIStorageStatement> stmt;
+ if (mPage.id) {
+ stmt = DB->GetStatement(
+ "UPDATE moz_places SET favicon_id = :icon_id WHERE id = :page_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), mPage.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ stmt = DB->GetStatement(
+ "UPDATE moz_places SET favicon_id = :icon_id "
+ "WHERE url_hash = hash(:page_url) AND url = :page_url"
+ );
+ NS_ENSURE_STATE(stmt);
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), mPage.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("icon_id"), mIcon.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mIcon.status |= ICON_STATUS_ASSOCIATED;
+ }
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Finally, dispatch an event to the main thread to notify observers.
+ nsCOMPtr<nsIRunnable> event = new NotifyIconObservers(mIcon, mPage, mCallback);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncGetFaviconURLForPage
+
+AsyncGetFaviconURLForPage::AsyncGetFaviconURLForPage(
+ const nsACString& aPageSpec
+, nsIFaviconDataCallback* aCallback
+) : mCallback(new nsMainThreadPtrHolder<nsIFaviconDataCallback>(aCallback))
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ mPageSpec.Assign(aPageSpec);
+}
+
+NS_IMETHODIMP
+AsyncGetFaviconURLForPage::Run()
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ nsAutoCString iconSpec;
+ nsresult rv = FetchIconURL(DB, mPageSpec, iconSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Now notify our callback of the icon spec we retrieved, even if empty.
+ IconData iconData;
+ iconData.spec.Assign(iconSpec);
+
+ PageData pageData;
+ pageData.spec.Assign(mPageSpec);
+
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyIconObservers(iconData, pageData, mCallback);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncGetFaviconDataForPage
+
+AsyncGetFaviconDataForPage::AsyncGetFaviconDataForPage(
+ const nsACString& aPageSpec
+, nsIFaviconDataCallback* aCallback
+) : mCallback(new nsMainThreadPtrHolder<nsIFaviconDataCallback>(aCallback))
+ {
+ MOZ_ASSERT(NS_IsMainThread());
+ mPageSpec.Assign(aPageSpec);
+}
+
+NS_IMETHODIMP
+AsyncGetFaviconDataForPage::Run()
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ nsAutoCString iconSpec;
+ nsresult rv = FetchIconURL(DB, mPageSpec, iconSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ IconData iconData;
+ iconData.spec.Assign(iconSpec);
+
+ PageData pageData;
+ pageData.spec.Assign(mPageSpec);
+
+ if (!iconSpec.IsEmpty()) {
+ rv = FetchIconInfo(DB, iconData);
+ if (NS_FAILED(rv)) {
+ iconData.spec.Truncate();
+ }
+ }
+
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyIconObservers(iconData, pageData, mCallback);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncReplaceFaviconData
+
+AsyncReplaceFaviconData::AsyncReplaceFaviconData(const IconData &aIcon)
+ : mIcon(aIcon)
+{
+}
+
+NS_IMETHODIMP
+AsyncReplaceFaviconData::Run()
+{
+ MOZ_ASSERT(!NS_IsMainThread());
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ IconData dbIcon;
+ dbIcon.spec.Assign(mIcon.spec);
+ nsresult rv = FetchIconInfo(DB, dbIcon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!dbIcon.id) {
+ return NS_OK;
+ }
+
+ rv = SetIconInfo(DB, mIcon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We can invalidate the cache version since we now persist the icon.
+ nsCOMPtr<nsIRunnable> event =
+ NewRunnableMethod(this, &AsyncReplaceFaviconData::RemoveIconDataCacheEntry);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+AsyncReplaceFaviconData::RemoveIconDataCacheEntry()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<nsIURI> iconURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(iconURI), mIcon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsFaviconService* favicons = nsFaviconService::GetFaviconService();
+ NS_ENSURE_STATE(favicons);
+ favicons->mUnassociatedIcons.RemoveEntry(iconURI);
+
+ return NS_OK;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// NotifyIconObservers
+
+NotifyIconObservers::NotifyIconObservers(
+ const IconData& aIcon
+, const PageData& aPage
+, const nsMainThreadPtrHandle<nsIFaviconDataCallback>& aCallback
+)
+: mCallback(aCallback)
+, mIcon(aIcon)
+, mPage(aPage)
+{
+}
+
+NS_IMETHODIMP
+NotifyIconObservers::Run()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ nsCOMPtr<nsIURI> iconURI;
+ if (!mIcon.spec.IsEmpty()) {
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(iconURI), mIcon.spec));
+ if (iconURI)
+ {
+ // Notify observers only if something changed.
+ if (mIcon.status & ICON_STATUS_SAVED ||
+ mIcon.status & ICON_STATUS_ASSOCIATED) {
+ SendGlobalNotifications(iconURI);
+ }
+ }
+ }
+
+ if (mCallback) {
+ (void)mCallback->OnComplete(iconURI, mIcon.data.Length(),
+ TO_INTBUFFER(mIcon.data), mIcon.mimeType);
+ }
+
+ return NS_OK;
+}
+
+void
+NotifyIconObservers::SendGlobalNotifications(nsIURI* aIconURI)
+{
+ nsCOMPtr<nsIURI> pageURI;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(pageURI), mPage.spec));
+ if (pageURI) {
+ nsFaviconService* favicons = nsFaviconService::GetFaviconService();
+ MOZ_ASSERT(favicons);
+ if (favicons) {
+ (void)favicons->SendFaviconNotifications(pageURI, aIconURI, mPage.guid);
+ }
+ }
+
+ // If the page is bookmarked and the bookmarked url is different from the
+ // updated one, start a new task to update its icon as well.
+ if (!mPage.bookmarkedSpec.IsEmpty() &&
+ !mPage.bookmarkedSpec.Equals(mPage.spec)) {
+ // Create a new page struct to avoid polluting it with old data.
+ PageData bookmarkedPage;
+ bookmarkedPage.spec = mPage.bookmarkedSpec;
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ if (!DB)
+ return;
+ // This will be silent, so be sure to not pass in the current callback.
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> nullCallback;
+ RefPtr<AsyncAssociateIconToPage> event =
+ new AsyncAssociateIconToPage(mIcon, bookmarkedPage, nullCallback);
+ DB->DispatchToAsyncThread(event);
+ }
+}
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/FaviconHelpers.h b/toolkit/components/places/FaviconHelpers.h
new file mode 100644
index 000000000..1c6d5b2bf
--- /dev/null
+++ b/toolkit/components/places/FaviconHelpers.h
@@ -0,0 +1,273 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#pragma once
+
+#include "nsIFaviconService.h"
+#include "nsIChannelEventSink.h"
+#include "nsIInterfaceRequestor.h"
+#include "nsIStreamListener.h"
+#include "mozIPlacesPendingOperation.h"
+#include "nsThreadUtils.h"
+#include "nsProxyRelease.h"
+
+class nsIPrincipal;
+
+#include "Database.h"
+#include "mozilla/storage.h"
+
+#define ICON_STATUS_UNKNOWN 0
+#define ICON_STATUS_CHANGED 1 << 0
+#define ICON_STATUS_SAVED 1 << 1
+#define ICON_STATUS_ASSOCIATED 1 << 2
+#define ICON_STATUS_CACHED 1 << 3
+
+#define TO_CHARBUFFER(_buffer) \
+ reinterpret_cast<char*>(const_cast<uint8_t*>(_buffer))
+#define TO_INTBUFFER(_string) \
+ reinterpret_cast<uint8_t*>(const_cast<char*>(_string.get()))
+
+/**
+ * The maximum time we will keep a favicon around. We always ask the cache, if
+ * we can, but default to this value if we do not get a time back, or the time
+ * is more in the future than this.
+ * Currently set to one week from now.
+ */
+#define MAX_FAVICON_EXPIRATION ((PRTime)7 * 24 * 60 * 60 * PR_USEC_PER_SEC)
+
+namespace mozilla {
+namespace places {
+
+/**
+ * Indicates when a icon should be fetched from network.
+ */
+enum AsyncFaviconFetchMode {
+ FETCH_NEVER = 0
+, FETCH_IF_MISSING
+, FETCH_ALWAYS
+};
+
+/**
+ * Data cache for a icon entry.
+ */
+struct IconData
+{
+ IconData()
+ : id(0)
+ , expiration(0)
+ , fetchMode(FETCH_NEVER)
+ , status(ICON_STATUS_UNKNOWN)
+ {
+ }
+
+ int64_t id;
+ nsCString spec;
+ nsCString data;
+ nsCString mimeType;
+ PRTime expiration;
+ enum AsyncFaviconFetchMode fetchMode;
+ uint16_t status; // This is a bitset, see ICON_STATUS_* defines above.
+};
+
+/**
+ * Data cache for a page entry.
+ */
+struct PageData
+{
+ PageData()
+ : id(0)
+ , canAddToHistory(true)
+ , iconId(0)
+ {
+ guid.SetIsVoid(true);
+ }
+
+ int64_t id;
+ nsCString spec;
+ nsCString bookmarkedSpec;
+ nsString revHost;
+ bool canAddToHistory; // False for disabled history and unsupported schemas.
+ int64_t iconId;
+ nsCString guid;
+};
+
+/**
+ * Async fetches icon from database or network, associates it with the required
+ * page and finally notifies the change.
+ */
+class AsyncFetchAndSetIconForPage final : public Runnable
+ , public nsIStreamListener
+ , public nsIInterfaceRequestor
+ , public nsIChannelEventSink
+ , public mozIPlacesPendingOperation
+ {
+ public:
+ NS_DECL_NSIRUNNABLE
+ NS_DECL_NSISTREAMLISTENER
+ NS_DECL_NSIINTERFACEREQUESTOR
+ NS_DECL_NSICHANNELEVENTSINK
+ NS_DECL_NSIREQUESTOBSERVER
+ NS_DECL_MOZIPLACESPENDINGOPERATION
+ NS_DECL_ISUPPORTS_INHERITED
+
+ /**
+ * Constructor.
+ *
+ * @param aIcon
+ * Icon to be fetched and associated.
+ * @param aPage
+ * Page to which associate the icon.
+ * @param aFaviconLoadPrivate
+ * Whether this favicon load is in private browsing.
+ * @param aCallback
+ * Function to be called when the fetch-and-associate process finishes.
+ * @param aLoadingPrincipal
+ * LoadingPrincipal of the icon to be fetched.
+ */
+ AsyncFetchAndSetIconForPage(IconData& aIcon,
+ PageData& aPage,
+ bool aFaviconLoadPrivate,
+ nsIFaviconDataCallback* aCallback,
+ nsIPrincipal* aLoadingPrincipal);
+
+private:
+ nsresult FetchFromNetwork();
+ virtual ~AsyncFetchAndSetIconForPage() {}
+
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> mCallback;
+ IconData mIcon;
+ PageData mPage;
+ const bool mFaviconLoadPrivate;
+ nsMainThreadPtrHandle<nsIPrincipal> mLoadingPrincipal;
+ bool mCanceled;
+ nsCOMPtr<nsIRequest> mRequest;
+};
+
+/**
+ * Associates the icon to the required page, finally dispatches an event to the
+ * main thread to notify the change to observers.
+ */
+class AsyncAssociateIconToPage final : public Runnable
+{
+public:
+ NS_DECL_NSIRUNNABLE
+
+ /**
+ * Constructor.
+ *
+ * @param aIcon
+ * Icon to be associated.
+ * @param aPage
+ * Page to which associate the icon.
+ * @param aCallback
+ * Function to be called when the associate process finishes.
+ */
+ AsyncAssociateIconToPage(const IconData& aIcon,
+ const PageData& aPage,
+ const nsMainThreadPtrHandle<nsIFaviconDataCallback>& aCallback);
+
+private:
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> mCallback;
+ IconData mIcon;
+ PageData mPage;
+};
+
+/**
+ * Asynchronously tries to get the URL of a page's favicon, then notifies the
+ * given observer.
+ */
+class AsyncGetFaviconURLForPage final : public Runnable
+{
+public:
+ NS_DECL_NSIRUNNABLE
+
+ /**
+ * Constructor.
+ *
+ * @param aPageSpec
+ * URL of the page whose favicon's URL we're fetching
+ * @param aCallback
+ * function to be called once finished
+ */
+ AsyncGetFaviconURLForPage(const nsACString& aPageSpec,
+ nsIFaviconDataCallback* aCallback);
+
+private:
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> mCallback;
+ nsCString mPageSpec;
+};
+
+
+/**
+ * Asynchronously tries to get the URL and data of a page's favicon, then
+ * notifies the given observer.
+ */
+class AsyncGetFaviconDataForPage final : public Runnable
+{
+public:
+ NS_DECL_NSIRUNNABLE
+
+ /**
+ * Constructor.
+ *
+ * @param aPageSpec
+ * URL of the page whose favicon URL and data we're fetching
+ * @param aCallback
+ * function to be called once finished
+ */
+ AsyncGetFaviconDataForPage(const nsACString& aPageSpec,
+ nsIFaviconDataCallback* aCallback);
+
+private:
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> mCallback;
+ nsCString mPageSpec;
+};
+
+class AsyncReplaceFaviconData final : public Runnable
+{
+public:
+ NS_DECL_NSIRUNNABLE
+
+ explicit AsyncReplaceFaviconData(const IconData& aIcon);
+
+private:
+ nsresult RemoveIconDataCacheEntry();
+
+ IconData mIcon;
+};
+
+/**
+ * Notifies the icon change to favicon observers.
+ */
+class NotifyIconObservers final : public Runnable
+{
+public:
+ NS_DECL_NSIRUNNABLE
+
+ /**
+ * Constructor.
+ *
+ * @param aIcon
+ * Icon information. Can be empty if no icon is associated to the page.
+ * @param aPage
+ * Page to which the icon information applies.
+ * @param aCallback
+ * Function to be notified in all cases.
+ */
+ NotifyIconObservers(const IconData& aIcon,
+ const PageData& aPage,
+ const nsMainThreadPtrHandle<nsIFaviconDataCallback>& aCallback);
+
+private:
+ nsMainThreadPtrHandle<nsIFaviconDataCallback> mCallback;
+ IconData mIcon;
+ PageData mPage;
+
+ void SendGlobalNotifications(nsIURI* aIconURI);
+};
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/Helpers.cpp b/toolkit/components/places/Helpers.cpp
new file mode 100644
index 000000000..66c4e79a9
--- /dev/null
+++ b/toolkit/components/places/Helpers.cpp
@@ -0,0 +1,395 @@
+/* vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "Helpers.h"
+#include "mozIStorageError.h"
+#include "prio.h"
+#include "nsString.h"
+#include "nsNavHistory.h"
+#include "mozilla/Base64.h"
+#include "mozilla/Services.h"
+
+// The length of guids that are used by history and bookmarks.
+#define GUID_LENGTH 12
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncStatementCallback
+
+NS_IMPL_ISUPPORTS(
+ AsyncStatementCallback
+, mozIStorageStatementCallback
+)
+
+NS_IMETHODIMP
+WeakAsyncStatementCallback::HandleResult(mozIStorageResultSet *aResultSet)
+{
+ MOZ_ASSERT(false, "Was not expecting a resultset, but got it.");
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+WeakAsyncStatementCallback::HandleCompletion(uint16_t aReason)
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+WeakAsyncStatementCallback::HandleError(mozIStorageError *aError)
+{
+#ifdef DEBUG
+ int32_t result;
+ nsresult rv = aError->GetResult(&result);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString message;
+ rv = aError->GetMessage(message);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString warnMsg;
+ warnMsg.AppendLiteral("An error occurred while executing an async statement: ");
+ warnMsg.AppendInt(result);
+ warnMsg.Append(' ');
+ warnMsg.Append(message);
+ NS_WARNING(warnMsg.get());
+#endif
+
+ return NS_OK;
+}
+
+#define URI_TO_URLCSTRING(uri, spec) \
+ nsAutoCString spec; \
+ if (NS_FAILED(aURI->GetSpec(spec))) { \
+ return NS_ERROR_UNEXPECTED; \
+ }
+
+// Bind URI to statement by index.
+nsresult // static
+URIBinder::Bind(mozIStorageStatement* aStatement,
+ int32_t aIndex,
+ nsIURI* aURI)
+{
+ NS_ASSERTION(aStatement, "Must have non-null statement");
+ NS_ASSERTION(aURI, "Must have non-null uri");
+
+ URI_TO_URLCSTRING(aURI, spec);
+ return URIBinder::Bind(aStatement, aIndex, spec);
+}
+
+// Statement URLCString to statement by index.
+nsresult // static
+URIBinder::Bind(mozIStorageStatement* aStatement,
+ int32_t index,
+ const nsACString& aURLString)
+{
+ NS_ASSERTION(aStatement, "Must have non-null statement");
+ return aStatement->BindUTF8StringByIndex(
+ index, StringHead(aURLString, URI_LENGTH_MAX)
+ );
+}
+
+// Bind URI to statement by name.
+nsresult // static
+URIBinder::Bind(mozIStorageStatement* aStatement,
+ const nsACString& aName,
+ nsIURI* aURI)
+{
+ NS_ASSERTION(aStatement, "Must have non-null statement");
+ NS_ASSERTION(aURI, "Must have non-null uri");
+
+ URI_TO_URLCSTRING(aURI, spec);
+ return URIBinder::Bind(aStatement, aName, spec);
+}
+
+// Bind URLCString to statement by name.
+nsresult // static
+URIBinder::Bind(mozIStorageStatement* aStatement,
+ const nsACString& aName,
+ const nsACString& aURLString)
+{
+ NS_ASSERTION(aStatement, "Must have non-null statement");
+ return aStatement->BindUTF8StringByName(
+ aName, StringHead(aURLString, URI_LENGTH_MAX)
+ );
+}
+
+// Bind URI to params by index.
+nsresult // static
+URIBinder::Bind(mozIStorageBindingParams* aParams,
+ int32_t aIndex,
+ nsIURI* aURI)
+{
+ NS_ASSERTION(aParams, "Must have non-null statement");
+ NS_ASSERTION(aURI, "Must have non-null uri");
+
+ URI_TO_URLCSTRING(aURI, spec);
+ return URIBinder::Bind(aParams, aIndex, spec);
+}
+
+// Bind URLCString to params by index.
+nsresult // static
+URIBinder::Bind(mozIStorageBindingParams* aParams,
+ int32_t index,
+ const nsACString& aURLString)
+{
+ NS_ASSERTION(aParams, "Must have non-null statement");
+ return aParams->BindUTF8StringByIndex(
+ index, StringHead(aURLString, URI_LENGTH_MAX)
+ );
+}
+
+// Bind URI to params by name.
+nsresult // static
+URIBinder::Bind(mozIStorageBindingParams* aParams,
+ const nsACString& aName,
+ nsIURI* aURI)
+{
+ NS_ASSERTION(aParams, "Must have non-null params array");
+ NS_ASSERTION(aURI, "Must have non-null uri");
+
+ URI_TO_URLCSTRING(aURI, spec);
+ return URIBinder::Bind(aParams, aName, spec);
+}
+
+// Bind URLCString to params by name.
+nsresult // static
+URIBinder::Bind(mozIStorageBindingParams* aParams,
+ const nsACString& aName,
+ const nsACString& aURLString)
+{
+ NS_ASSERTION(aParams, "Must have non-null params array");
+
+ nsresult rv = aParams->BindUTF8StringByName(
+ aName, StringHead(aURLString, URI_LENGTH_MAX)
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+#undef URI_TO_URLCSTRING
+
+nsresult
+GetReversedHostname(nsIURI* aURI, nsString& aRevHost)
+{
+ nsAutoCString forward8;
+ nsresult rv = aURI->GetHost(forward8);
+ // Not all URIs have a host.
+ if (NS_FAILED(rv))
+ return rv;
+
+ // can't do reversing in UTF8, better use 16-bit chars
+ GetReversedHostname(NS_ConvertUTF8toUTF16(forward8), aRevHost);
+ return NS_OK;
+}
+
+void
+GetReversedHostname(const nsString& aForward, nsString& aRevHost)
+{
+ ReverseString(aForward, aRevHost);
+ aRevHost.Append(char16_t('.'));
+}
+
+void
+ReverseString(const nsString& aInput, nsString& aReversed)
+{
+ aReversed.Truncate(0);
+ for (int32_t i = aInput.Length() - 1; i >= 0; i--) {
+ aReversed.Append(aInput[i]);
+ }
+}
+
+#ifdef XP_WIN
+} // namespace places
+} // namespace mozilla
+
+// Included here because windows.h conflicts with the use of mozIStorageError
+// above, but make sure that these are not included inside mozilla::places.
+#include <windows.h>
+#include <wincrypt.h>
+
+namespace mozilla {
+namespace places {
+#endif
+
+static
+nsresult
+GenerateRandomBytes(uint32_t aSize,
+ uint8_t* _buffer)
+{
+ // On Windows, we'll use its built-in cryptographic API.
+#if defined(XP_WIN)
+ HCRYPTPROV cryptoProvider;
+ BOOL rc = CryptAcquireContext(&cryptoProvider, 0, 0, PROV_RSA_FULL,
+ CRYPT_VERIFYCONTEXT | CRYPT_SILENT);
+ if (rc) {
+ rc = CryptGenRandom(cryptoProvider, aSize, _buffer);
+ (void)CryptReleaseContext(cryptoProvider, 0);
+ }
+ return rc ? NS_OK : NS_ERROR_FAILURE;
+
+ // On Unix, we'll just read in from /dev/urandom.
+#elif defined(XP_UNIX)
+ NS_ENSURE_ARG_MAX(aSize, INT32_MAX);
+ PRFileDesc* urandom = PR_Open("/dev/urandom", PR_RDONLY, 0);
+ nsresult rv = NS_ERROR_FAILURE;
+ if (urandom) {
+ int32_t bytesRead = PR_Read(urandom, _buffer, aSize);
+ if (bytesRead == static_cast<int32_t>(aSize)) {
+ rv = NS_OK;
+ }
+ (void)PR_Close(urandom);
+ }
+ return rv;
+#endif
+}
+
+nsresult
+GenerateGUID(nsCString& _guid)
+{
+ _guid.Truncate();
+
+ // Request raw random bytes and base64url encode them. For each set of three
+ // bytes, we get one character.
+ const uint32_t kRequiredBytesLength =
+ static_cast<uint32_t>(GUID_LENGTH / 4 * 3);
+
+ uint8_t buffer[kRequiredBytesLength];
+ nsresult rv = GenerateRandomBytes(kRequiredBytesLength, buffer);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = Base64URLEncode(kRequiredBytesLength, buffer,
+ Base64URLEncodePaddingPolicy::Omit, _guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NS_ASSERTION(_guid.Length() == GUID_LENGTH, "GUID is not the right size!");
+ return NS_OK;
+}
+
+bool
+IsValidGUID(const nsACString& aGUID)
+{
+ nsCString::size_type len = aGUID.Length();
+ if (len != GUID_LENGTH) {
+ return false;
+ }
+
+ for (nsCString::size_type i = 0; i < len; i++ ) {
+ char c = aGUID[i];
+ if ((c >= 'a' && c <= 'z') || // a-z
+ (c >= 'A' && c <= 'Z') || // A-Z
+ (c >= '0' && c <= '9') || // 0-9
+ c == '-' || c == '_') { // - or _
+ continue;
+ }
+ return false;
+ }
+ return true;
+}
+
+void
+TruncateTitle(const nsACString& aTitle, nsACString& aTrimmed)
+{
+ aTrimmed = aTitle;
+ if (aTitle.Length() > TITLE_LENGTH_MAX) {
+ aTrimmed = StringHead(aTitle, TITLE_LENGTH_MAX);
+ }
+}
+
+PRTime
+RoundToMilliseconds(PRTime aTime) {
+ return aTime - (aTime % PR_USEC_PER_MSEC);
+}
+
+PRTime
+RoundedPRNow() {
+ return RoundToMilliseconds(PR_Now());
+}
+
+void
+ForceWALCheckpoint()
+{
+ RefPtr<Database> DB = Database::GetDatabase();
+ if (DB) {
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = DB->GetAsyncStatement(
+ "pragma wal_checkpoint "
+ );
+ if (stmt) {
+ nsCOMPtr<mozIStoragePendingStatement> handle;
+ (void)stmt->ExecuteAsync(nullptr, getter_AddRefs(handle));
+ }
+ }
+}
+
+bool
+GetHiddenState(bool aIsRedirect,
+ uint32_t aTransitionType)
+{
+ return aTransitionType == nsINavHistoryService::TRANSITION_FRAMED_LINK ||
+ aTransitionType == nsINavHistoryService::TRANSITION_EMBED ||
+ aIsRedirect;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// PlacesEvent
+
+PlacesEvent::PlacesEvent(const char* aTopic)
+: mTopic(aTopic)
+{
+}
+
+NS_IMETHODIMP
+PlacesEvent::Run()
+{
+ Notify();
+ return NS_OK;
+}
+
+void
+PlacesEvent::Notify()
+{
+ NS_ASSERTION(NS_IsMainThread(), "Must only be used on the main thread!");
+ nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
+ if (obs) {
+ (void)obs->NotifyObservers(nullptr, mTopic, nullptr);
+ }
+}
+
+NS_IMPL_ISUPPORTS_INHERITED0(
+ PlacesEvent
+, Runnable
+)
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncStatementCallbackNotifier
+
+NS_IMETHODIMP
+AsyncStatementCallbackNotifier::HandleCompletion(uint16_t aReason)
+{
+ if (aReason != mozIStorageStatementCallback::REASON_FINISHED)
+ return NS_ERROR_UNEXPECTED;
+
+ nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+ if (obs) {
+ (void)obs->NotifyObservers(nullptr, mTopic, nullptr);
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// AsyncStatementCallbackNotifier
+
+NS_IMETHODIMP
+AsyncStatementTelemetryTimer::HandleCompletion(uint16_t aReason)
+{
+ if (aReason == mozIStorageStatementCallback::REASON_FINISHED) {
+ Telemetry::AccumulateTimeDelta(mHistogramId, mStart);
+ }
+ return NS_OK;
+}
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/Helpers.h b/toolkit/components/places/Helpers.h
new file mode 100644
index 000000000..654e42539
--- /dev/null
+++ b/toolkit/components/places/Helpers.h
@@ -0,0 +1,296 @@
+/* vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_Helpers_h_
+#define mozilla_places_Helpers_h_
+
+/**
+ * This file contains helper classes used by various bits of Places code.
+ */
+
+#include "mozilla/storage.h"
+#include "nsIURI.h"
+#include "nsThreadUtils.h"
+#include "nsProxyRelease.h"
+#include "prtime.h"
+#include "mozilla/Telemetry.h"
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// Asynchronous Statement Callback Helper
+
+class WeakAsyncStatementCallback : public mozIStorageStatementCallback
+{
+public:
+ NS_DECL_MOZISTORAGESTATEMENTCALLBACK
+ WeakAsyncStatementCallback() {}
+
+protected:
+ virtual ~WeakAsyncStatementCallback() {}
+};
+
+class AsyncStatementCallback : public WeakAsyncStatementCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+ AsyncStatementCallback() {}
+
+protected:
+ virtual ~AsyncStatementCallback() {}
+};
+
+/**
+ * Macros to use in place of NS_DECL_MOZISTORAGESTATEMENTCALLBACK to declare the
+ * methods this class assumes silent or notreached.
+ */
+#define NS_DECL_ASYNCSTATEMENTCALLBACK \
+ NS_IMETHOD HandleResult(mozIStorageResultSet *) override; \
+ NS_IMETHOD HandleCompletion(uint16_t) override;
+
+/**
+ * Utils to bind a specified URI (or URL) to a statement or binding params, at
+ * the specified index or name.
+ * @note URIs are always bound as UTF8.
+ */
+class URIBinder // static
+{
+public:
+ // Bind URI to statement by index.
+ static nsresult Bind(mozIStorageStatement* statement,
+ int32_t index,
+ nsIURI* aURI);
+ // Statement URLCString to statement by index.
+ static nsresult Bind(mozIStorageStatement* statement,
+ int32_t index,
+ const nsACString& aURLString);
+ // Bind URI to statement by name.
+ static nsresult Bind(mozIStorageStatement* statement,
+ const nsACString& aName,
+ nsIURI* aURI);
+ // Bind URLCString to statement by name.
+ static nsresult Bind(mozIStorageStatement* statement,
+ const nsACString& aName,
+ const nsACString& aURLString);
+ // Bind URI to params by index.
+ static nsresult Bind(mozIStorageBindingParams* aParams,
+ int32_t index,
+ nsIURI* aURI);
+ // Bind URLCString to params by index.
+ static nsresult Bind(mozIStorageBindingParams* aParams,
+ int32_t index,
+ const nsACString& aURLString);
+ // Bind URI to params by name.
+ static nsresult Bind(mozIStorageBindingParams* aParams,
+ const nsACString& aName,
+ nsIURI* aURI);
+ // Bind URLCString to params by name.
+ static nsresult Bind(mozIStorageBindingParams* aParams,
+ const nsACString& aName,
+ const nsACString& aURLString);
+};
+
+/**
+ * This extracts the hostname from the URI and reverses it in the
+ * form that we use (always ending with a "."). So
+ * "http://microsoft.com/" becomes "moc.tfosorcim."
+ *
+ * The idea behind this is that we can create an index over the items in
+ * the reversed host name column, and then query for as much or as little
+ * of the host name as we feel like.
+ *
+ * For example, the query "host >= 'gro.allizom.' AND host < 'gro.allizom/'
+ * Matches all host names ending in '.mozilla.org', including
+ * 'developer.mozilla.org' and just 'mozilla.org' (since we define all
+ * reversed host names to end in a period, even 'mozilla.org' matches).
+ * The important thing is that this operation uses the index. Any substring
+ * calls in a select statement (even if it's for the beginning of a string)
+ * will bypass any indices and will be slow).
+ *
+ * @param aURI
+ * URI that contains spec to reverse
+ * @param aRevHost
+ * Out parameter
+ */
+nsresult GetReversedHostname(nsIURI* aURI, nsString& aRevHost);
+
+/**
+ * Similar method to GetReversedHostName but for strings
+ */
+void GetReversedHostname(const nsString& aForward, nsString& aRevHost);
+
+/**
+ * Reverses a string.
+ *
+ * @param aInput
+ * The string to be reversed
+ * @param aReversed
+ * Output parameter will contain the reversed string
+ */
+void ReverseString(const nsString& aInput, nsString& aReversed);
+
+/**
+ * Generates an 12 character guid to be used by bookmark and history entries.
+ *
+ * @note This guid uses the characters a-z, A-Z, 0-9, '-', and '_'.
+ */
+nsresult GenerateGUID(nsCString& _guid);
+
+/**
+ * Determines if the string is a valid guid or not.
+ *
+ * @param aGUID
+ * The guid to test.
+ * @return true if it is a valid guid, false otherwise.
+ */
+bool IsValidGUID(const nsACString& aGUID);
+
+/**
+ * Truncates the title if it's longer than TITLE_LENGTH_MAX.
+ *
+ * @param aTitle
+ * The title to truncate (if necessary)
+ * @param aTrimmed
+ * Output parameter to return the trimmed string
+ */
+void TruncateTitle(const nsACString& aTitle, nsACString& aTrimmed);
+
+/**
+ * Round down a PRTime value to milliseconds precision (...000).
+ *
+ * @param aTime
+ * a PRTime value.
+ * @return aTime rounded down to milliseconds precision.
+ */
+PRTime RoundToMilliseconds(PRTime aTime);
+
+/**
+ * Round down PR_Now() to milliseconds precision.
+ *
+ * @return @see PR_Now, RoundToMilliseconds.
+ */
+PRTime RoundedPRNow();
+
+/**
+ * Used to finalize a statementCache on a specified thread.
+ */
+template<typename StatementType>
+class FinalizeStatementCacheProxy : public Runnable
+{
+public:
+ /**
+ * Constructor.
+ *
+ * @param aStatementCache
+ * The statementCache that should be finalized.
+ * @param aOwner
+ * The object that owns the statement cache. This runnable will hold
+ * a strong reference to it so aStatementCache will not disappear from
+ * under us.
+ */
+ FinalizeStatementCacheProxy(
+ mozilla::storage::StatementCache<StatementType>& aStatementCache,
+ nsISupports* aOwner
+ )
+ : mStatementCache(aStatementCache)
+ , mOwner(aOwner)
+ , mCallingThread(do_GetCurrentThread())
+ {
+ }
+
+ NS_IMETHOD Run() override
+ {
+ mStatementCache.FinalizeStatements();
+ // Release the owner back on the calling thread.
+ NS_ProxyRelease(mCallingThread, mOwner.forget());
+ return NS_OK;
+ }
+
+protected:
+ mozilla::storage::StatementCache<StatementType>& mStatementCache;
+ nsCOMPtr<nsISupports> mOwner;
+ nsCOMPtr<nsIThread> mCallingThread;
+};
+
+/**
+ * Forces a WAL checkpoint. This will cause all transactions stored in the
+ * journal file to be committed to the main database.
+ *
+ * @note The checkpoint will force a fsync/flush.
+ */
+void ForceWALCheckpoint();
+
+/**
+ * Determines if a visit should be marked as hidden given its transition type
+ * and whether or not it was a redirect.
+ *
+ * @param aIsRedirect
+ * True if this visit was a redirect, false otherwise.
+ * @param aTransitionType
+ * The transition type of the visit.
+ * @return true if this visit should be hidden.
+ */
+bool GetHiddenState(bool aIsRedirect,
+ uint32_t aTransitionType);
+
+/**
+ * Notifies a specified topic via the observer service.
+ */
+class PlacesEvent : public Runnable
+{
+public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_NSIRUNNABLE
+
+ explicit PlacesEvent(const char* aTopic);
+protected:
+ ~PlacesEvent() {}
+ void Notify();
+
+ const char* const mTopic;
+};
+
+/**
+ * Used to notify a topic to system observers on async execute completion.
+ */
+class AsyncStatementCallbackNotifier : public AsyncStatementCallback
+{
+public:
+ explicit AsyncStatementCallbackNotifier(const char* aTopic)
+ : mTopic(aTopic)
+ {
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason);
+
+private:
+ const char* mTopic;
+};
+
+/**
+ * Used to notify a topic to system observers on async execute completion.
+ */
+class AsyncStatementTelemetryTimer : public AsyncStatementCallback
+{
+public:
+ explicit AsyncStatementTelemetryTimer(Telemetry::ID aHistogramId,
+ TimeStamp aStart = TimeStamp::Now())
+ : mHistogramId(aHistogramId)
+ , mStart(aStart)
+ {
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason);
+
+private:
+ const Telemetry::ID mHistogramId;
+ const TimeStamp mStart;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_Helpers_h_
diff --git a/toolkit/components/places/History.cpp b/toolkit/components/places/History.cpp
new file mode 100644
index 000000000..61f78cb83
--- /dev/null
+++ b/toolkit/components/places/History.cpp
@@ -0,0 +1,2977 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/DebugOnly.h"
+#include "mozilla/MemoryReporting.h"
+
+#include "mozilla/dom/ContentChild.h"
+#include "mozilla/dom/ContentParent.h"
+#include "nsXULAppAPI.h"
+
+#include "History.h"
+#include "nsNavHistory.h"
+#include "nsNavBookmarks.h"
+#include "nsAnnotationService.h"
+#include "Helpers.h"
+#include "PlaceInfo.h"
+#include "VisitInfo.h"
+#include "nsPlacesMacros.h"
+
+#include "mozilla/storage.h"
+#include "mozilla/dom/Link.h"
+#include "nsDocShellCID.h"
+#include "mozilla/Services.h"
+#include "nsThreadUtils.h"
+#include "nsNetUtil.h"
+#include "nsIFileURL.h"
+#include "nsIXPConnect.h"
+#include "mozilla/Unused.h"
+#include "nsContentUtils.h" // for nsAutoScriptBlocker
+#include "nsJSUtils.h"
+#include "mozilla/ipc/URIUtils.h"
+#include "nsPrintfCString.h"
+#include "nsTHashtable.h"
+#include "jsapi.h"
+
+// Initial size for the cache holding visited status observers.
+#define VISIT_OBSERVERS_INITIAL_CACHE_LENGTH 64
+
+// Initial length for the visits removal hash.
+#define VISITS_REMOVAL_INITIAL_HASH_LENGTH 64
+
+using namespace mozilla::dom;
+using namespace mozilla::ipc;
+using mozilla::Unused;
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// Global Defines
+
+#define URI_VISITED "visited"
+#define URI_NOT_VISITED "not visited"
+#define URI_VISITED_RESOLUTION_TOPIC "visited-status-resolution"
+// Observer event fired after a visit has been registered in the DB.
+#define URI_VISIT_SAVED "uri-visit-saved"
+
+#define DESTINATIONFILEURI_ANNO \
+ NS_LITERAL_CSTRING("downloads/destinationFileURI")
+#define DESTINATIONFILENAME_ANNO \
+ NS_LITERAL_CSTRING("downloads/destinationFileName")
+
+////////////////////////////////////////////////////////////////////////////////
+//// VisitData
+
+struct VisitData {
+ VisitData()
+ : placeId(0)
+ , visitId(0)
+ , hidden(true)
+ , shouldUpdateHidden(true)
+ , typed(false)
+ , transitionType(UINT32_MAX)
+ , visitTime(0)
+ , frecency(-1)
+ , lastVisitId(0)
+ , lastVisitTime(0)
+ , visitCount(0)
+ , referrerVisitId(0)
+ , titleChanged(false)
+ , shouldUpdateFrecency(true)
+ {
+ guid.SetIsVoid(true);
+ title.SetIsVoid(true);
+ }
+
+ explicit VisitData(nsIURI* aURI,
+ nsIURI* aReferrer = nullptr)
+ : placeId(0)
+ , visitId(0)
+ , hidden(true)
+ , shouldUpdateHidden(true)
+ , typed(false)
+ , transitionType(UINT32_MAX)
+ , visitTime(0)
+ , frecency(-1)
+ , lastVisitId(0)
+ , lastVisitTime(0)
+ , visitCount(0)
+ , referrerVisitId(0)
+ , titleChanged(false)
+ , shouldUpdateFrecency(true)
+ {
+ MOZ_ASSERT(aURI);
+ if (aURI) {
+ (void)aURI->GetSpec(spec);
+ (void)GetReversedHostname(aURI, revHost);
+ }
+ if (aReferrer) {
+ (void)aReferrer->GetSpec(referrerSpec);
+ }
+ guid.SetIsVoid(true);
+ title.SetIsVoid(true);
+ }
+
+ /**
+ * Sets the transition type of the visit, as well as if it was typed.
+ *
+ * @param aTransitionType
+ * The transition type constant to set. Must be one of the
+ * TRANSITION_ constants on nsINavHistoryService.
+ */
+ void SetTransitionType(uint32_t aTransitionType)
+ {
+ typed = aTransitionType == nsINavHistoryService::TRANSITION_TYPED;
+ transitionType = aTransitionType;
+ }
+
+ int64_t placeId;
+ nsCString guid;
+ int64_t visitId;
+ nsCString spec;
+ nsString revHost;
+ bool hidden;
+ bool shouldUpdateHidden;
+ bool typed;
+ uint32_t transitionType;
+ PRTime visitTime;
+ int32_t frecency;
+ int64_t lastVisitId;
+ PRTime lastVisitTime;
+ uint32_t visitCount;
+
+ /**
+ * Stores the title. If this is empty (IsEmpty() returns true), then the
+ * title should be removed from the Place. If the title is void (IsVoid()
+ * returns true), then no title has been set on this object, and titleChanged
+ * should remain false.
+ */
+ nsString title;
+
+ nsCString referrerSpec;
+ int64_t referrerVisitId;
+
+ // TODO bug 626836 hook up hidden and typed change tracking too!
+ bool titleChanged;
+
+ // Indicates whether frecency should be updated for this visit.
+ bool shouldUpdateFrecency;
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// RemoveVisitsFilter
+
+/**
+ * Used to store visit filters for RemoveVisits.
+ */
+struct RemoveVisitsFilter {
+ RemoveVisitsFilter()
+ : transitionType(UINT32_MAX)
+ {
+ }
+
+ uint32_t transitionType;
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// PlaceHashKey
+
+class PlaceHashKey : public nsCStringHashKey
+{
+public:
+ explicit PlaceHashKey(const nsACString& aSpec)
+ : nsCStringHashKey(&aSpec)
+ , mVisitCount(0)
+ , mBookmarked(false)
+#ifdef DEBUG
+ , mIsInitialized(false)
+#endif
+ {
+ }
+
+ explicit PlaceHashKey(const nsACString* aSpec)
+ : nsCStringHashKey(aSpec)
+ , mVisitCount(0)
+ , mBookmarked(false)
+#ifdef DEBUG
+ , mIsInitialized(false)
+#endif
+ {
+ }
+
+ PlaceHashKey(const PlaceHashKey& aOther)
+ : nsCStringHashKey(&aOther.GetKey())
+ {
+ MOZ_ASSERT(false, "Do not call me!");
+ }
+
+ void SetProperties(uint32_t aVisitCount, bool aBookmarked)
+ {
+ mVisitCount = aVisitCount;
+ mBookmarked = aBookmarked;
+#ifdef DEBUG
+ mIsInitialized = true;
+#endif
+ }
+
+ uint32_t VisitCount() const
+ {
+#ifdef DEBUG
+ MOZ_ASSERT(mIsInitialized, "PlaceHashKey::mVisitCount not set");
+#endif
+ return mVisitCount;
+ }
+
+ bool IsBookmarked() const
+ {
+#ifdef DEBUG
+ MOZ_ASSERT(mIsInitialized, "PlaceHashKey::mBookmarked not set");
+#endif
+ return mBookmarked;
+ }
+
+ // Array of VisitData objects.
+ nsTArray<VisitData> mVisits;
+private:
+ // Visit count for this place.
+ uint32_t mVisitCount;
+ // Whether this place is bookmarked.
+ bool mBookmarked;
+#ifdef DEBUG
+ // Whether previous attributes are set.
+ bool mIsInitialized;
+#endif
+};
+
+////////////////////////////////////////////////////////////////////////////////
+//// Anonymous Helpers
+
+namespace {
+
+/**
+ * Convert the given js value to a js array.
+ *
+ * @param [in] aValue
+ * the JS value to convert.
+ * @param [in] aCtx
+ * The JSContext for aValue.
+ * @param [out] _array
+ * the JS array.
+ * @param [out] _arrayLength
+ * _array's length.
+ */
+nsresult
+GetJSArrayFromJSValue(JS::Handle<JS::Value> aValue,
+ JSContext* aCtx,
+ JS::MutableHandle<JSObject*> _array,
+ uint32_t* _arrayLength) {
+ if (aValue.isObjectOrNull()) {
+ JS::Rooted<JSObject*> val(aCtx, aValue.toObjectOrNull());
+ bool isArray;
+ if (!JS_IsArrayObject(aCtx, val, &isArray)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ if (isArray) {
+ _array.set(val);
+ (void)JS_GetArrayLength(aCtx, _array, _arrayLength);
+ NS_ENSURE_ARG(*_arrayLength > 0);
+ return NS_OK;
+ }
+ }
+
+ // Build a temporary array to store this one item so the code below can
+ // just loop.
+ *_arrayLength = 1;
+ _array.set(JS_NewArrayObject(aCtx, 0));
+ NS_ENSURE_TRUE(_array, NS_ERROR_OUT_OF_MEMORY);
+
+ bool rc = JS_DefineElement(aCtx, _array, 0, aValue, 0);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ return NS_OK;
+}
+
+/**
+ * Attemps to convert a given js value to a nsIURI object.
+ * @param aCtx
+ * The JSContext for aValue.
+ * @param aValue
+ * The JS value to convert.
+ * @return the nsIURI object, or null if aValue is not a nsIURI object.
+ */
+already_AddRefed<nsIURI>
+GetJSValueAsURI(JSContext* aCtx,
+ const JS::Value& aValue) {
+ if (!aValue.isPrimitive()) {
+ nsCOMPtr<nsIXPConnect> xpc = mozilla::services::GetXPConnect();
+
+ nsCOMPtr<nsIXPConnectWrappedNative> wrappedObj;
+ nsresult rv = xpc->GetWrappedNativeOfJSObject(aCtx, aValue.toObjectOrNull(),
+ getter_AddRefs(wrappedObj));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+ nsCOMPtr<nsIURI> uri = do_QueryWrappedNative(wrappedObj);
+ return uri.forget();
+ }
+ return nullptr;
+}
+
+/**
+ * Obtains an nsIURI from the "uri" property of a JSObject.
+ *
+ * @param aCtx
+ * The JSContext for aObject.
+ * @param aObject
+ * The JSObject to get the URI from.
+ * @param aProperty
+ * The name of the property to get the URI from.
+ * @return the URI if it exists.
+ */
+already_AddRefed<nsIURI>
+GetURIFromJSObject(JSContext* aCtx,
+ JS::Handle<JSObject *> aObject,
+ const char* aProperty)
+{
+ JS::Rooted<JS::Value> uriVal(aCtx);
+ bool rc = JS_GetProperty(aCtx, aObject, aProperty, &uriVal);
+ NS_ENSURE_TRUE(rc, nullptr);
+ return GetJSValueAsURI(aCtx, uriVal);
+}
+
+/**
+ * Attemps to convert a JS value to a string.
+ * @param aCtx
+ * The JSContext for aObject.
+ * @param aValue
+ * The JS value to convert.
+ * @param _string
+ * The string to populate with the value, or set it to void.
+ */
+void
+GetJSValueAsString(JSContext* aCtx,
+ const JS::Value& aValue,
+ nsString& _string) {
+ if (aValue.isUndefined() ||
+ !(aValue.isNull() || aValue.isString())) {
+ _string.SetIsVoid(true);
+ return;
+ }
+
+ // |null| in JS maps to the empty string.
+ if (aValue.isNull()) {
+ _string.Truncate();
+ return;
+ }
+
+ if (!AssignJSString(aCtx, _string, aValue.toString())) {
+ _string.SetIsVoid(true);
+ }
+}
+
+/**
+ * Obtains the specified property of a JSObject.
+ *
+ * @param aCtx
+ * The JSContext for aObject.
+ * @param aObject
+ * The JSObject to get the string from.
+ * @param aProperty
+ * The property to get the value from.
+ * @param _string
+ * The string to populate with the value, or set it to void.
+ */
+void
+GetStringFromJSObject(JSContext* aCtx,
+ JS::Handle<JSObject *> aObject,
+ const char* aProperty,
+ nsString& _string)
+{
+ JS::Rooted<JS::Value> val(aCtx);
+ bool rc = JS_GetProperty(aCtx, aObject, aProperty, &val);
+ if (!rc) {
+ _string.SetIsVoid(true);
+ return;
+ }
+ else {
+ GetJSValueAsString(aCtx, val, _string);
+ }
+}
+
+/**
+ * Obtains the specified property of a JSObject.
+ *
+ * @param aCtx
+ * The JSContext for aObject.
+ * @param aObject
+ * The JSObject to get the int from.
+ * @param aProperty
+ * The property to get the value from.
+ * @param _int
+ * The integer to populate with the value on success.
+ */
+template <typename IntType>
+nsresult
+GetIntFromJSObject(JSContext* aCtx,
+ JS::Handle<JSObject *> aObject,
+ const char* aProperty,
+ IntType* _int)
+{
+ JS::Rooted<JS::Value> value(aCtx);
+ bool rc = JS_GetProperty(aCtx, aObject, aProperty, &value);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ if (value.isUndefined()) {
+ return NS_ERROR_INVALID_ARG;
+ }
+ NS_ENSURE_ARG(value.isPrimitive());
+ NS_ENSURE_ARG(value.isNumber());
+
+ double num;
+ rc = JS::ToNumber(aCtx, value, &num);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ NS_ENSURE_ARG(IntType(num) == num);
+
+ *_int = IntType(num);
+ return NS_OK;
+}
+
+/**
+ * Obtains the specified property of a JSObject.
+ *
+ * @pre aArray must be an Array object.
+ *
+ * @param aCtx
+ * The JSContext for aArray.
+ * @param aArray
+ * The JSObject to get the object from.
+ * @param aIndex
+ * The index to get the object from.
+ * @param objOut
+ * Set to the JSObject pointer on success.
+ */
+nsresult
+GetJSObjectFromArray(JSContext* aCtx,
+ JS::Handle<JSObject*> aArray,
+ uint32_t aIndex,
+ JS::MutableHandle<JSObject*> objOut)
+{
+ JS::Rooted<JS::Value> value(aCtx);
+ bool rc = JS_GetElement(aCtx, aArray, aIndex, &value);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ NS_ENSURE_ARG(!value.isPrimitive());
+ objOut.set(&value.toObject());
+ return NS_OK;
+}
+
+class VisitedQuery final : public AsyncStatementCallback,
+ public mozIStorageCompletionCallback
+{
+public:
+ NS_DECL_ISUPPORTS_INHERITED
+
+ static nsresult Start(nsIURI* aURI,
+ mozIVisitedStatusCallback* aCallback=nullptr)
+ {
+ NS_PRECONDITION(aURI, "Null URI");
+
+ // If we are a content process, always remote the request to the
+ // parent process.
+ if (XRE_IsContentProcess()) {
+ URIParams uri;
+ SerializeURI(aURI, uri);
+
+ mozilla::dom::ContentChild* cpc =
+ mozilla::dom::ContentChild::GetSingleton();
+ NS_ASSERTION(cpc, "Content Protocol is NULL!");
+ (void)cpc->SendStartVisitedQuery(uri);
+ return NS_OK;
+ }
+
+ nsMainThreadPtrHandle<mozIVisitedStatusCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitedStatusCallback>(aCallback));
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ if (navHistory->hasEmbedVisit(aURI)) {
+ RefPtr<VisitedQuery> cb = new VisitedQuery(aURI, callback, true);
+ NS_ENSURE_TRUE(cb, NS_ERROR_OUT_OF_MEMORY);
+ // As per IHistory contract, we must notify asynchronously.
+ NS_DispatchToMainThread(NewRunnableMethod(cb, &VisitedQuery::NotifyVisitedStatus));
+
+ return NS_OK;
+ }
+
+ History* history = History::GetService();
+ NS_ENSURE_STATE(history);
+ RefPtr<VisitedQuery> cb = new VisitedQuery(aURI, callback);
+ NS_ENSURE_TRUE(cb, NS_ERROR_OUT_OF_MEMORY);
+ nsresult rv = history->GetIsVisitedStatement(cb);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ // Note: the return value matters here. We call into this method, it's not
+ // just xpcom boilerplate.
+ NS_IMETHOD Complete(nsresult aResult, nsISupports* aStatement) override
+ {
+ NS_ENSURE_SUCCESS(aResult, aResult);
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = do_QueryInterface(aStatement);
+ NS_ENSURE_STATE(stmt);
+ // Bind by index for performance.
+ nsresult rv = URIBinder::Bind(stmt, 0, mURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> handle;
+ return stmt->ExecuteAsync(this, getter_AddRefs(handle));
+ }
+
+ NS_IMETHOD HandleResult(mozIStorageResultSet* aResults) override
+ {
+ // If this method is called, we've gotten results, which means we have a
+ // visit.
+ mIsVisited = true;
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleError(mozIStorageError* aError) override
+ {
+ // mIsVisited is already set to false, and that's the assumption we will
+ // make if an error occurred.
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason) override
+ {
+ if (aReason != mozIStorageStatementCallback::REASON_FINISHED) {
+ return NS_OK;
+ }
+
+ nsresult rv = NotifyVisitedStatus();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ nsresult NotifyVisitedStatus()
+ {
+ // If an external handling callback is provided, just notify through it.
+ if (!!mCallback) {
+ mCallback->IsVisited(mURI, mIsVisited);
+ return NS_OK;
+ }
+
+ if (mIsVisited) {
+ History* history = History::GetService();
+ NS_ENSURE_STATE(history);
+ history->NotifyVisited(mURI);
+ }
+
+ nsCOMPtr<nsIObserverService> observerService =
+ mozilla::services::GetObserverService();
+ if (observerService) {
+ nsAutoString status;
+ if (mIsVisited) {
+ status.AssignLiteral(URI_VISITED);
+ }
+ else {
+ status.AssignLiteral(URI_NOT_VISITED);
+ }
+ (void)observerService->NotifyObservers(mURI,
+ URI_VISITED_RESOLUTION_TOPIC,
+ status.get());
+ }
+
+ return NS_OK;
+ }
+
+private:
+ explicit VisitedQuery(nsIURI* aURI,
+ const nsMainThreadPtrHandle<mozIVisitedStatusCallback>& aCallback,
+ bool aIsVisited=false)
+ : mURI(aURI)
+ , mCallback(aCallback)
+ , mIsVisited(aIsVisited)
+ {
+ }
+
+ ~VisitedQuery()
+ {
+ }
+
+ nsCOMPtr<nsIURI> mURI;
+ nsMainThreadPtrHandle<mozIVisitedStatusCallback> mCallback;
+ bool mIsVisited;
+};
+
+NS_IMPL_ISUPPORTS_INHERITED(
+ VisitedQuery
+, AsyncStatementCallback
+, mozIStorageCompletionCallback
+)
+
+/**
+ * Notifies observers about a visit.
+ */
+class NotifyVisitObservers : public Runnable
+{
+public:
+ explicit NotifyVisitObservers(VisitData& aPlace)
+ : mPlace(aPlace)
+ , mHistory(History::GetService())
+ {
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ // We are in the main thread, no need to lock.
+ if (mHistory->IsShuttingDown()) {
+ // If we are shutting down, we cannot notify the observers.
+ return NS_OK;
+ }
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ if (!navHistory) {
+ NS_WARNING("Trying to notify about a visit but cannot get the history service!");
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), mPlace.spec));
+ if (!uri) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ // Notify the visit. Note that TRANSITION_EMBED visits are never added
+ // to the database, thus cannot be queried and we don't notify them.
+ if (mPlace.transitionType != nsINavHistoryService::TRANSITION_EMBED) {
+ navHistory->NotifyOnVisit(uri, mPlace.visitId, mPlace.visitTime,
+ mPlace.referrerVisitId, mPlace.transitionType,
+ mPlace.guid, mPlace.hidden,
+ mPlace.visitCount + 1, // Add current visit.
+ static_cast<uint32_t>(mPlace.typed));
+ }
+
+ nsCOMPtr<nsIObserverService> obsService =
+ mozilla::services::GetObserverService();
+ if (obsService) {
+ DebugOnly<nsresult> rv =
+ obsService->NotifyObservers(uri, URI_VISIT_SAVED, nullptr);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Could not notify observers");
+ }
+
+ History* history = History::GetService();
+ NS_ENSURE_STATE(history);
+ history->AppendToRecentlyVisitedURIs(uri);
+ history->NotifyVisited(uri);
+
+ return NS_OK;
+ }
+private:
+ VisitData mPlace;
+ RefPtr<History> mHistory;
+};
+
+/**
+ * Notifies observers about a pages title changing.
+ */
+class NotifyTitleObservers : public Runnable
+{
+public:
+ /**
+ * Notifies observers on the main thread.
+ *
+ * @param aSpec
+ * The spec of the URI to notify about.
+ * @param aTitle
+ * The new title to notify about.
+ */
+ NotifyTitleObservers(const nsCString& aSpec,
+ const nsString& aTitle,
+ const nsCString& aGUID)
+ : mSpec(aSpec)
+ , mTitle(aTitle)
+ , mGUID(aGUID)
+ {
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_OUT_OF_MEMORY);
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), mSpec));
+ if (!uri) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ navHistory->NotifyTitleChange(uri, mTitle, mGUID);
+
+ return NS_OK;
+ }
+private:
+ const nsCString mSpec;
+ const nsString mTitle;
+ const nsCString mGUID;
+};
+
+/**
+ * Helper class for methods which notify their callers through the
+ * mozIVisitInfoCallback interface.
+ */
+class NotifyPlaceInfoCallback : public Runnable
+{
+public:
+ NotifyPlaceInfoCallback(const nsMainThreadPtrHandle<mozIVisitInfoCallback>& aCallback,
+ const VisitData& aPlace,
+ bool aIsSingleVisit,
+ nsresult aResult)
+ : mCallback(aCallback)
+ , mPlace(aPlace)
+ , mResult(aResult)
+ , mIsSingleVisit(aIsSingleVisit)
+ {
+ MOZ_ASSERT(aCallback, "Must pass a non-null callback!");
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ bool hasValidURIs = true;
+ nsCOMPtr<nsIURI> referrerURI;
+ if (!mPlace.referrerSpec.IsEmpty()) {
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(referrerURI), mPlace.referrerSpec));
+ hasValidURIs = !!referrerURI;
+ }
+
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), mPlace.spec));
+ hasValidURIs = hasValidURIs && !!uri;
+
+ nsCOMPtr<mozIPlaceInfo> place;
+ if (mIsSingleVisit) {
+ nsCOMPtr<mozIVisitInfo> visit =
+ new VisitInfo(mPlace.visitId, mPlace.visitTime, mPlace.transitionType,
+ referrerURI.forget());
+ PlaceInfo::VisitsArray visits;
+ (void)visits.AppendElement(visit);
+
+ // The frecency isn't exposed because it may not reflect the updated value
+ // in the case of InsertVisitedURIs.
+ place =
+ new PlaceInfo(mPlace.placeId, mPlace.guid, uri.forget(), mPlace.title,
+ -1, visits);
+ }
+ else {
+ // Same as above.
+ place =
+ new PlaceInfo(mPlace.placeId, mPlace.guid, uri.forget(), mPlace.title,
+ -1);
+ }
+
+ if (NS_SUCCEEDED(mResult) && hasValidURIs) {
+ (void)mCallback->HandleResult(place);
+ } else {
+ (void)mCallback->HandleError(mResult, place);
+ }
+
+ return NS_OK;
+ }
+
+private:
+ nsMainThreadPtrHandle<mozIVisitInfoCallback> mCallback;
+ VisitData mPlace;
+ const nsresult mResult;
+ bool mIsSingleVisit;
+};
+
+/**
+ * Notifies a callback object when the operation is complete.
+ */
+class NotifyCompletion : public Runnable
+{
+public:
+ explicit NotifyCompletion(const nsMainThreadPtrHandle<mozIVisitInfoCallback>& aCallback)
+ : mCallback(aCallback)
+ {
+ MOZ_ASSERT(aCallback, "Must pass a non-null callback!");
+ }
+
+ NS_IMETHOD Run() override
+ {
+ if (NS_IsMainThread()) {
+ (void)mCallback->HandleCompletion();
+ }
+ else {
+ (void)NS_DispatchToMainThread(this);
+ }
+ return NS_OK;
+ }
+
+private:
+ nsMainThreadPtrHandle<mozIVisitInfoCallback> mCallback;
+};
+
+/**
+ * Checks to see if we can add aURI to history, and dispatches an error to
+ * aCallback (if provided) if we cannot.
+ *
+ * @param aURI
+ * The URI to check.
+ * @param [optional] aGUID
+ * The guid of the URI to check. This is passed back to the callback.
+ * @param [optional] aCallback
+ * The callback to notify if the URI cannot be added to history.
+ * @return true if the URI can be added to history, false otherwise.
+ */
+bool
+CanAddURI(nsIURI* aURI,
+ const nsCString& aGUID = EmptyCString(),
+ mozIVisitInfoCallback* aCallback = nullptr)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, false);
+
+ bool canAdd;
+ nsresult rv = navHistory->CanAddURI(aURI, &canAdd);
+ if (NS_SUCCEEDED(rv) && canAdd) {
+ return true;
+ };
+
+ // We cannot add the URI. Notify the callback, if we were given one.
+ if (aCallback) {
+ VisitData place(aURI);
+ place.guid = aGUID;
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyPlaceInfoCallback(callback, place, true, NS_ERROR_INVALID_ARG);
+ (void)NS_DispatchToMainThread(event);
+ }
+
+ return false;
+}
+
+/**
+ * Adds a visit to the database.
+ */
+class InsertVisitedURIs final: public Runnable
+{
+public:
+ /**
+ * Adds a visit to the database asynchronously.
+ *
+ * @param aConnection
+ * The database connection to use for these operations.
+ * @param aPlaces
+ * The locations to record visits.
+ * @param [optional] aCallback
+ * The callback to notify about the visit.
+ */
+ static nsresult Start(mozIStorageConnection* aConnection,
+ nsTArray<VisitData>& aPlaces,
+ mozIVisitInfoCallback* aCallback = nullptr)
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+ MOZ_ASSERT(aPlaces.Length() > 0, "Must pass a non-empty array!");
+
+ // Make sure nsNavHistory service is up before proceeding:
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ MOZ_ASSERT(navHistory, "Could not get nsNavHistory?!");
+ if (!navHistory) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+ RefPtr<InsertVisitedURIs> event =
+ new InsertVisitedURIs(aConnection, aPlaces, callback);
+
+ // Get the target thread, and then start the work!
+ nsCOMPtr<nsIEventTarget> target = do_GetInterface(aConnection);
+ NS_ENSURE_TRUE(target, NS_ERROR_UNEXPECTED);
+ nsresult rv = target->Dispatch(event, NS_DISPATCH_NORMAL);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(!NS_IsMainThread(), "This should not be called on the main thread");
+
+ // Prevent the main thread from shutting down while this is running.
+ MutexAutoLock lockedScope(mHistory->GetShutdownMutex());
+ if (mHistory->IsShuttingDown()) {
+ // If we were already shutting down, we cannot insert the URIs.
+ return NS_OK;
+ }
+
+ mozStorageTransaction transaction(mDBConn, false,
+ mozIStorageConnection::TRANSACTION_IMMEDIATE);
+
+ VisitData* lastFetchedPlace = nullptr;
+ for (nsTArray<VisitData>::size_type i = 0; i < mPlaces.Length(); i++) {
+ VisitData& place = mPlaces.ElementAt(i);
+
+ // Fetching from the database can overwrite this information, so save it
+ // apart.
+ bool typed = place.typed;
+ bool hidden = place.hidden;
+
+ // We can avoid a database lookup if it's the same place as the last
+ // visit we added.
+ bool known = lastFetchedPlace && lastFetchedPlace->spec.Equals(place.spec);
+ if (!known) {
+ nsresult rv = mHistory->FetchPageInfo(place, &known);
+ if (NS_FAILED(rv)) {
+ if (!!mCallback) {
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyPlaceInfoCallback(mCallback, place, true, rv);
+ return NS_DispatchToMainThread(event);
+ }
+ return NS_OK;
+ }
+ lastFetchedPlace = &mPlaces.ElementAt(i);
+ } else {
+ // Copy over the data from the already known place.
+ place.placeId = lastFetchedPlace->placeId;
+ place.guid = lastFetchedPlace->guid;
+ place.lastVisitId = lastFetchedPlace->visitId;
+ place.lastVisitTime = lastFetchedPlace->visitTime;
+ place.titleChanged = !lastFetchedPlace->title.Equals(place.title);
+ place.frecency = lastFetchedPlace->frecency;
+ // Add one visit for the previous loop.
+ place.visitCount = ++(*lastFetchedPlace).visitCount;
+ }
+
+ // If any transition is typed, ensure the page is marked as typed.
+ if (typed != lastFetchedPlace->typed) {
+ place.typed = true;
+ }
+
+ // If any transition is visible, ensure the page is marked as visible.
+ if (hidden != lastFetchedPlace->hidden) {
+ place.hidden = false;
+ }
+
+ // If this is a new page, or the existing page was already visible,
+ // there's no need to try to unhide it.
+ if (!known || !lastFetchedPlace->hidden) {
+ place.shouldUpdateHidden = false;
+ }
+
+ FetchReferrerInfo(place);
+
+ nsresult rv = DoDatabaseInserts(known, place);
+ if (!!mCallback) {
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyPlaceInfoCallback(mCallback, place, true, rv);
+ nsresult rv2 = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv2, rv2);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIRunnable> event = new NotifyVisitObservers(place);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Notify about title change if needed.
+ if ((!known && !place.title.IsVoid()) || place.titleChanged) {
+ event = new NotifyTitleObservers(place.spec, place.title, place.guid);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ nsresult rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+private:
+ InsertVisitedURIs(mozIStorageConnection* aConnection,
+ nsTArray<VisitData>& aPlaces,
+ const nsMainThreadPtrHandle<mozIVisitInfoCallback>& aCallback)
+ : mDBConn(aConnection)
+ , mCallback(aCallback)
+ , mHistory(History::GetService())
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ mPlaces.SwapElements(aPlaces);
+
+#ifdef DEBUG
+ for (nsTArray<VisitData>::size_type i = 0; i < mPlaces.Length(); i++) {
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ASSERT(NS_SUCCEEDED(NS_NewURI(getter_AddRefs(uri), mPlaces[i].spec)));
+ MOZ_ASSERT(CanAddURI(uri),
+ "Passed a VisitData with a URI we cannot add to history!");
+ }
+#endif
+ }
+
+ /**
+ * Inserts or updates the entry in moz_places for this visit, adds the visit,
+ * and updates the frecency of the place.
+ *
+ * @param aKnown
+ * True if we already have an entry for this place in moz_places, false
+ * otherwise.
+ * @param aPlace
+ * The place we are adding a visit for.
+ */
+ nsresult DoDatabaseInserts(bool aKnown,
+ VisitData& aPlace)
+ {
+ MOZ_ASSERT(!NS_IsMainThread(), "This should not be called on the main thread");
+
+ // If the page was in moz_places, we need to update the entry.
+ nsresult rv;
+ if (aKnown) {
+ rv = mHistory->UpdatePlace(aPlace);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ // Otherwise, the page was not in moz_places, so now we have to add it.
+ else {
+ rv = mHistory->InsertPlace(aPlace);
+ NS_ENSURE_SUCCESS(rv, rv);
+ aPlace.placeId = nsNavHistory::sLastInsertedPlaceId;
+ }
+ MOZ_ASSERT(aPlace.placeId > 0);
+
+ rv = AddVisit(aPlace);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // TODO (bug 623969) we shouldn't update this after each visit, but
+ // rather only for each unique place to save disk I/O.
+
+ // Don't update frecency if the page should not appear in autocomplete.
+ if (aPlace.shouldUpdateFrecency) {
+ rv = UpdateFrecency(aPlace);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+ }
+
+ /**
+ * Fetches information about a referrer for aPlace if it was a recent
+ * visit or not.
+ *
+ * @param aPlace
+ * The VisitData for the visit we will eventually add.
+ *
+ */
+ void FetchReferrerInfo(VisitData& aPlace)
+ {
+ if (aPlace.referrerSpec.IsEmpty()) {
+ return;
+ }
+
+ VisitData referrer;
+ referrer.spec = aPlace.referrerSpec;
+ // If the referrer is the same as the page, we don't need to fetch it.
+ if (aPlace.referrerSpec.Equals(aPlace.spec)) {
+ referrer = aPlace;
+ // The page last visit id is also the referrer visit id.
+ aPlace.referrerVisitId = aPlace.lastVisitId;
+ } else {
+ bool exists = false;
+ if (NS_SUCCEEDED(mHistory->FetchPageInfo(referrer, &exists)) && exists) {
+ // Copy the referrer last visit id.
+ aPlace.referrerVisitId = referrer.lastVisitId;
+ }
+ }
+
+ // Check if the page has effectively been visited recently, otherwise
+ // discard the referrer info.
+ if (!aPlace.referrerVisitId || !referrer.lastVisitTime ||
+ aPlace.visitTime - referrer.lastVisitTime > RECENT_EVENT_THRESHOLD) {
+ // We will not be using the referrer data.
+ aPlace.referrerSpec.Truncate();
+ aPlace.referrerVisitId = 0;
+ }
+ }
+
+ /**
+ * Adds a visit for _place and updates it with the right visit id.
+ *
+ * @param _place
+ * The VisitData for the place we need to know visit information about.
+ */
+ nsresult AddVisit(VisitData& _place)
+ {
+ MOZ_ASSERT(_place.placeId > 0);
+
+ nsresult rv;
+ nsCOMPtr<mozIStorageStatement> stmt;
+ stmt = mHistory->GetStatement(
+ "INSERT INTO moz_historyvisits "
+ "(from_visit, place_id, visit_date, visit_type, session) "
+ "VALUES (:from_visit, :page_id, :visit_date, :visit_type, 0) "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), _place.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("from_visit"),
+ _place.referrerVisitId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("visit_date"),
+ _place.visitTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+ uint32_t transitionType = _place.transitionType;
+ MOZ_ASSERT(transitionType >= nsINavHistoryService::TRANSITION_LINK &&
+ transitionType <= nsINavHistoryService::TRANSITION_RELOAD,
+ "Invalid transition type!");
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("visit_type"),
+ transitionType);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ _place.visitId = nsNavHistory::sLastInsertedVisitId;
+ MOZ_ASSERT(_place.visitId > 0);
+
+ return NS_OK;
+ }
+
+ /**
+ * Updates the frecency, and possibly the hidden-ness of aPlace.
+ *
+ * @param aPlace
+ * The VisitData for the place we want to update.
+ */
+ nsresult UpdateFrecency(const VisitData& aPlace)
+ {
+ MOZ_ASSERT(aPlace.shouldUpdateFrecency);
+ MOZ_ASSERT(aPlace.placeId > 0);
+
+ nsresult rv;
+ { // First, set our frecency to the proper value.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ stmt = mHistory->GetStatement(
+ "UPDATE moz_places "
+ "SET frecency = NOTIFY_FRECENCY("
+ "CALCULATE_FRECENCY(:page_id), "
+ "url, guid, hidden, last_visit_date"
+ ") "
+ "WHERE id = :page_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), aPlace.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (!aPlace.hidden && aPlace.shouldUpdateHidden) {
+ // Mark the page as not hidden if the frecency is now nonzero.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ stmt = mHistory->GetStatement(
+ "UPDATE moz_places "
+ "SET hidden = 0 "
+ "WHERE id = :page_id AND frecency <> 0"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), aPlace.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+ }
+
+ mozIStorageConnection* mDBConn;
+
+ nsTArray<VisitData> mPlaces;
+
+ nsMainThreadPtrHandle<mozIVisitInfoCallback> mCallback;
+
+ /**
+ * Strong reference to the History object because we do not want it to
+ * disappear out from under us.
+ */
+ RefPtr<History> mHistory;
+};
+
+class GetPlaceInfo final : public Runnable {
+public:
+ /**
+ * Get the place info for a given place (by GUID or URI) asynchronously.
+ */
+ static nsresult Start(mozIStorageConnection* aConnection,
+ VisitData& aPlace,
+ mozIVisitInfoCallback* aCallback) {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+ RefPtr<GetPlaceInfo> event = new GetPlaceInfo(aPlace, callback);
+
+ // Get the target thread, and then start the work!
+ nsCOMPtr<nsIEventTarget> target = do_GetInterface(aConnection);
+ NS_ENSURE_TRUE(target, NS_ERROR_UNEXPECTED);
+ nsresult rv = target->Dispatch(event, NS_DISPATCH_NORMAL);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(!NS_IsMainThread(), "This should not be called on the main thread");
+
+ bool exists;
+ nsresult rv = mHistory->FetchPageInfo(mPlace, &exists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!exists)
+ rv = NS_ERROR_NOT_AVAILABLE;
+
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyPlaceInfoCallback(mCallback, mPlace, false, rv);
+
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+private:
+ GetPlaceInfo(VisitData& aPlace,
+ const nsMainThreadPtrHandle<mozIVisitInfoCallback>& aCallback)
+ : mPlace(aPlace)
+ , mCallback(aCallback)
+ , mHistory(History::GetService())
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+ }
+
+ VisitData mPlace;
+ nsMainThreadPtrHandle<mozIVisitInfoCallback> mCallback;
+ RefPtr<History> mHistory;
+};
+
+/**
+ * Sets the page title for a page in moz_places (if necessary).
+ */
+class SetPageTitle : public Runnable
+{
+public:
+ /**
+ * Sets a pages title in the database asynchronously.
+ *
+ * @param aConnection
+ * The database connection to use for this operation.
+ * @param aURI
+ * The URI to set the page title on.
+ * @param aTitle
+ * The title to set for the page, if the page exists.
+ */
+ static nsresult Start(mozIStorageConnection* aConnection,
+ nsIURI* aURI,
+ const nsAString& aTitle)
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+ MOZ_ASSERT(aURI, "Must pass a non-null URI object!");
+
+ nsCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ RefPtr<SetPageTitle> event = new SetPageTitle(spec, aTitle);
+
+ // Get the target thread, and then start the work!
+ nsCOMPtr<nsIEventTarget> target = do_GetInterface(aConnection);
+ NS_ENSURE_TRUE(target, NS_ERROR_UNEXPECTED);
+ rv = target->Dispatch(event, NS_DISPATCH_NORMAL);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(!NS_IsMainThread(), "This should not be called on the main thread");
+
+ // First, see if the page exists in the database (we'll need its id later).
+ bool exists;
+ nsresult rv = mHistory->FetchPageInfo(mPlace, &exists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!exists || !mPlace.titleChanged) {
+ // We have no record of this page, or we have no title change, so there
+ // is no need to do any further work.
+ return NS_OK;
+ }
+
+ MOZ_ASSERT(mPlace.placeId > 0,
+ "We somehow have an invalid place id here!");
+
+ // Now we can update our database record.
+ nsCOMPtr<mozIStorageStatement> stmt =
+ mHistory->GetStatement(
+ "UPDATE moz_places "
+ "SET title = :page_title "
+ "WHERE id = :page_id "
+ );
+ NS_ENSURE_STATE(stmt);
+
+ {
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), mPlace.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // Empty strings should clear the title, just like
+ // nsNavHistory::SetPageTitle.
+ if (mPlace.title.IsEmpty()) {
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("page_title"));
+ }
+ else {
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("page_title"),
+ StringHead(mPlace.title, TITLE_LENGTH_MAX));
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyTitleObservers(mPlace.spec, mPlace.title, mPlace.guid);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+private:
+ SetPageTitle(const nsCString& aSpec,
+ const nsAString& aTitle)
+ : mHistory(History::GetService())
+ {
+ mPlace.spec = aSpec;
+ mPlace.title = aTitle;
+ }
+
+ VisitData mPlace;
+
+ /**
+ * Strong reference to the History object because we do not want it to
+ * disappear out from under us.
+ */
+ RefPtr<History> mHistory;
+};
+
+/**
+ * Adds download-specific annotations to a download page.
+ */
+class SetDownloadAnnotations final : public mozIVisitInfoCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit SetDownloadAnnotations(nsIURI* aDestination)
+ : mDestination(aDestination)
+ , mHistory(History::GetService())
+ {
+ MOZ_ASSERT(mDestination);
+ MOZ_ASSERT(NS_IsMainThread());
+ }
+
+ NS_IMETHOD HandleError(nsresult aResultCode, mozIPlaceInfo *aPlaceInfo) override
+ {
+ // Just don't add the annotations in case the visit isn't added.
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleResult(mozIPlaceInfo *aPlaceInfo) override
+ {
+ // Exit silently if the download destination is not a local file.
+ nsCOMPtr<nsIFileURL> destinationFileURL = do_QueryInterface(mDestination);
+ if (!destinationFileURL) {
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIURI> source;
+ nsresult rv = aPlaceInfo->GetUri(getter_AddRefs(source));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIFile> destinationFile;
+ rv = destinationFileURL->GetFile(getter_AddRefs(destinationFile));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoString destinationFileName;
+ rv = destinationFile->GetLeafName(destinationFileName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString destinationURISpec;
+ rv = destinationFileURL->GetSpec(destinationURISpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Use annotations for storing the additional download metadata.
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ NS_ENSURE_TRUE(annosvc, NS_ERROR_OUT_OF_MEMORY);
+
+ rv = annosvc->SetPageAnnotationString(
+ source,
+ DESTINATIONFILEURI_ANNO,
+ NS_ConvertUTF8toUTF16(destinationURISpec),
+ 0,
+ nsIAnnotationService::EXPIRE_WITH_HISTORY
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = annosvc->SetPageAnnotationString(
+ source,
+ DESTINATIONFILENAME_ANNO,
+ destinationFileName,
+ 0,
+ nsIAnnotationService::EXPIRE_WITH_HISTORY
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoString title;
+ rv = aPlaceInfo->GetTitle(title);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // In case we are downloading a file that does not correspond to a web
+ // page for which the title is present, we populate the otherwise empty
+ // history title with the name of the destination file, to allow it to be
+ // visible and searchable in history results.
+ if (title.IsEmpty()) {
+ rv = mHistory->SetURITitle(source, destinationFileName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleCompletion() override
+ {
+ return NS_OK;
+ }
+
+private:
+ ~SetDownloadAnnotations() {}
+
+ nsCOMPtr<nsIURI> mDestination;
+
+ /**
+ * Strong reference to the History object because we do not want it to
+ * disappear out from under us.
+ */
+ RefPtr<History> mHistory;
+};
+NS_IMPL_ISUPPORTS(
+ SetDownloadAnnotations,
+ mozIVisitInfoCallback
+)
+
+/**
+ * Notify removed visits to observers.
+ */
+class NotifyRemoveVisits : public Runnable
+{
+public:
+
+ explicit NotifyRemoveVisits(nsTHashtable<PlaceHashKey>& aPlaces)
+ : mPlaces(VISITS_REMOVAL_INITIAL_HASH_LENGTH)
+ , mHistory(History::GetService())
+ {
+ MOZ_ASSERT(!NS_IsMainThread(),
+ "This should not be called on the main thread");
+ for (auto iter = aPlaces.Iter(); !iter.Done(); iter.Next()) {
+ PlaceHashKey* entry = iter.Get();
+ PlaceHashKey* copy = mPlaces.PutEntry(entry->GetKey());
+ copy->SetProperties(entry->VisitCount(), entry->IsBookmarked());
+ entry->mVisits.SwapElements(copy->mVisits);
+ }
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ // We are in the main thread, no need to lock.
+ if (mHistory->IsShuttingDown()) {
+ // If we are shutting down, we cannot notify the observers.
+ return NS_OK;
+ }
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ if (!navHistory) {
+ NS_WARNING("Cannot notify without the history service!");
+ return NS_OK;
+ }
+
+ // Wrap all notifications in a batch, so the view can handle changes in a
+ // more performant way, by initiating a refresh after a limited number of
+ // single changes.
+ (void)navHistory->BeginUpdateBatch();
+ for (auto iter = mPlaces.Iter(); !iter.Done(); iter.Next()) {
+ PlaceHashKey* entry = iter.Get();
+ const nsTArray<VisitData>& visits = entry->mVisits;
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), visits[0].spec));
+ // Notify an expiration only if we have a valid uri, otherwise
+ // the observer couldn't gather any useful data from the notification.
+ // This should be false only if there's a bug in the code preceding us.
+ if (uri) {
+ bool removingPage = visits.Length() == entry->VisitCount() &&
+ !entry->IsBookmarked();
+
+ // FindRemovableVisits only sets the transition type on the VisitData
+ // objects it collects if the visits were filtered by transition type.
+ // RemoveVisitsFilter currently only supports filtering by transition
+ // type, so FindRemovableVisits will either find all visits, or all
+ // visits of a given type. Therefore, if transitionType is set on this
+ // visit, we pass the transition type to NotifyOnPageExpired which in
+ // turns passes it to OnDeleteVisits to indicate that all visits of a
+ // given type were removed.
+ uint32_t transition = visits[0].transitionType < UINT32_MAX
+ ? visits[0].transitionType
+ : 0;
+ navHistory->NotifyOnPageExpired(uri, visits[0].visitTime, removingPage,
+ visits[0].guid,
+ nsINavHistoryObserver::REASON_DELETED,
+ transition);
+ }
+ }
+ (void)navHistory->EndUpdateBatch();
+
+ return NS_OK;
+ }
+
+private:
+ nsTHashtable<PlaceHashKey> mPlaces;
+
+ /**
+ * Strong reference to the History object because we do not want it to
+ * disappear out from under us.
+ */
+ RefPtr<History> mHistory;
+};
+
+/**
+ * Remove visits from history.
+ */
+class RemoveVisits : public Runnable
+{
+public:
+ /**
+ * Asynchronously removes visits from history.
+ *
+ * @param aConnection
+ * The database connection to use for these operations.
+ * @param aFilter
+ * Filter to remove visits.
+ */
+ static nsresult Start(mozIStorageConnection* aConnection,
+ RemoveVisitsFilter& aFilter)
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ RefPtr<RemoveVisits> event = new RemoveVisits(aConnection, aFilter);
+
+ // Get the target thread, and then start the work!
+ nsCOMPtr<nsIEventTarget> target = do_GetInterface(aConnection);
+ NS_ENSURE_TRUE(target, NS_ERROR_UNEXPECTED);
+ nsresult rv = target->Dispatch(event, NS_DISPATCH_NORMAL);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(!NS_IsMainThread(),
+ "This should not be called on the main thread");
+
+ // Prevent the main thread from shutting down while this is running.
+ MutexAutoLock lockedScope(mHistory->GetShutdownMutex());
+ if (mHistory->IsShuttingDown()) {
+ // If we were already shutting down, we cannot remove the visits.
+ return NS_OK;
+ }
+
+ // Find all the visits relative to the current filters and whether their
+ // pages will be removed or not.
+ nsTHashtable<PlaceHashKey> places(VISITS_REMOVAL_INITIAL_HASH_LENGTH);
+ nsresult rv = FindRemovableVisits(places);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (places.Count() == 0)
+ return NS_OK;
+
+ mozStorageTransaction transaction(mDBConn, false,
+ mozIStorageConnection::TRANSACTION_IMMEDIATE);
+
+ rv = RemoveVisitsFromDatabase();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = RemovePagesFromDatabase(places);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIRunnable> event = new NotifyRemoveVisits(places);
+ rv = NS_DispatchToMainThread(event);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+private:
+ RemoveVisits(mozIStorageConnection* aConnection,
+ RemoveVisitsFilter& aFilter)
+ : mDBConn(aConnection)
+ , mHasTransitionType(false)
+ , mHistory(History::GetService())
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "This should be called on the main thread");
+
+ // Build query conditions.
+ nsTArray<nsCString> conditions;
+ // TODO: add support for binding params when adding further stuff here.
+ if (aFilter.transitionType < UINT32_MAX) {
+ conditions.AppendElement(nsPrintfCString("visit_type = %d", aFilter.transitionType));
+ mHasTransitionType = true;
+ }
+ if (conditions.Length() > 0) {
+ mWhereClause.AppendLiteral (" WHERE ");
+ for (uint32_t i = 0; i < conditions.Length(); ++i) {
+ if (i > 0)
+ mWhereClause.AppendLiteral(" AND ");
+ mWhereClause.Append(conditions[i]);
+ }
+ }
+ }
+
+ /**
+ * Find the list of entries that may be removed from `moz_places`.
+ *
+ * Calling this method makes sense only if we are not clearing the entire history.
+ */
+ nsresult
+ FindRemovableVisits(nsTHashtable<PlaceHashKey>& aPlaces)
+ {
+ MOZ_ASSERT(!NS_IsMainThread(),
+ "This should not be called on the main thread");
+
+ nsCString query("SELECT h.id, url, guid, visit_date, visit_type, "
+ "(SELECT count(*) FROM moz_historyvisits WHERE place_id = h.id) as full_visit_count, "
+ "EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) as bookmarked "
+ "FROM moz_historyvisits "
+ "JOIN moz_places h ON place_id = h.id");
+ query.Append(mWhereClause);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mHistory->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ bool hasResult;
+ nsresult rv;
+ while (NS_SUCCEEDED((rv = stmt->ExecuteStep(&hasResult))) && hasResult) {
+ VisitData visit;
+ rv = stmt->GetInt64(0, &visit.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(1, visit.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(2, visit.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(3, &visit.visitTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (mHasTransitionType) {
+ int32_t transition;
+ rv = stmt->GetInt32(4, &transition);
+ NS_ENSURE_SUCCESS(rv, rv);
+ visit.transitionType = static_cast<uint32_t>(transition);
+ }
+ int32_t visitCount, bookmarked;
+ rv = stmt->GetInt32(5, &visitCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt32(6, &bookmarked);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ PlaceHashKey* entry = aPlaces.GetEntry(visit.spec);
+ if (!entry) {
+ entry = aPlaces.PutEntry(visit.spec);
+ }
+ entry->SetProperties(static_cast<uint32_t>(visitCount), static_cast<bool>(bookmarked));
+ entry->mVisits.AppendElement(visit);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ nsresult
+ RemoveVisitsFromDatabase()
+ {
+ MOZ_ASSERT(!NS_IsMainThread(),
+ "This should not be called on the main thread");
+
+ nsCString query("DELETE FROM moz_historyvisits");
+ query.Append(mWhereClause);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mHistory->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ nsresult rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ nsresult
+ RemovePagesFromDatabase(nsTHashtable<PlaceHashKey>& aPlaces)
+ {
+ MOZ_ASSERT(!NS_IsMainThread(),
+ "This should not be called on the main thread");
+
+ nsCString placeIdsToRemove;
+ for (auto iter = aPlaces.Iter(); !iter.Done(); iter.Next()) {
+ PlaceHashKey* entry = iter.Get();
+ const nsTArray<VisitData>& visits = entry->mVisits;
+ // Only orphan ids should be listed.
+ if (visits.Length() == entry->VisitCount() && !entry->IsBookmarked()) {
+ if (!placeIdsToRemove.IsEmpty())
+ placeIdsToRemove.Append(',');
+ placeIdsToRemove.AppendInt(visits[0].placeId);
+ }
+ }
+
+#ifdef DEBUG
+ {
+ // Ensure that we are not removing any problematic entry.
+ nsCString query("SELECT id FROM moz_places h WHERE id IN (");
+ query.Append(placeIdsToRemove);
+ query.AppendLiteral(") AND ("
+ "EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) OR "
+ "EXISTS(SELECT 1 FROM moz_historyvisits WHERE place_id = h.id) OR "
+ "SUBSTR(h.url, 1, 6) = 'place:' "
+ ")");
+ nsCOMPtr<mozIStorageStatement> stmt = mHistory->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ bool hasResult;
+ MOZ_ASSERT(NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && !hasResult,
+ "Trying to remove a non-oprhan place from the database");
+ }
+#endif
+
+ {
+ nsCString query("DELETE FROM moz_places "
+ "WHERE id IN (");
+ query.Append(placeIdsToRemove);
+ query.Append(')');
+
+ nsCOMPtr<mozIStorageStatement> stmt = mHistory->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ nsresult rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ {
+ // Hosts accumulated during the places delete are updated through a trigger
+ // (see nsPlacesTriggers.h).
+ nsAutoCString query("DELETE FROM moz_updatehosts_temp");
+ nsCOMPtr<mozIStorageStatement> stmt = mHistory->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ nsresult rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+ }
+
+ mozIStorageConnection* mDBConn;
+ bool mHasTransitionType;
+ nsCString mWhereClause;
+
+ /**
+ * Strong reference to the History object because we do not want it to
+ * disappear out from under us.
+ */
+ RefPtr<History> mHistory;
+};
+
+/**
+ * Stores an embed visit, and notifies observers.
+ *
+ * @param aPlace
+ * The VisitData of the visit to store as an embed visit.
+ * @param [optional] aCallback
+ * The mozIVisitInfoCallback to notify, if provided.
+ */
+void
+StoreAndNotifyEmbedVisit(VisitData& aPlace,
+ mozIVisitInfoCallback* aCallback = nullptr)
+{
+ MOZ_ASSERT(aPlace.transitionType == nsINavHistoryService::TRANSITION_EMBED,
+ "Must only pass TRANSITION_EMBED visits to this!");
+ MOZ_ASSERT(NS_IsMainThread(), "Must be called on the main thread!");
+
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), aPlace.spec));
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ if (!navHistory || !uri) {
+ return;
+ }
+
+ navHistory->registerEmbedVisit(uri, aPlace.visitTime);
+
+ if (!!aCallback) {
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+ nsCOMPtr<nsIRunnable> event =
+ new NotifyPlaceInfoCallback(callback, aPlace, true, NS_OK);
+ (void)NS_DispatchToMainThread(event);
+ }
+
+ nsCOMPtr<nsIRunnable> event = new NotifyVisitObservers(aPlace);
+ (void)NS_DispatchToMainThread(event);
+}
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+//// History
+
+History* History::gService = nullptr;
+
+History::History()
+ : mShuttingDown(false)
+ , mShutdownMutex("History::mShutdownMutex")
+ , mObservers(VISIT_OBSERVERS_INITIAL_CACHE_LENGTH)
+ , mRecentlyVisitedURIs(RECENTLY_VISITED_URIS_SIZE)
+{
+ NS_ASSERTION(!gService, "Ruh-roh! This service has already been created!");
+ gService = this;
+
+ nsCOMPtr<nsIObserverService> os = services::GetObserverService();
+ NS_WARNING_ASSERTION(os, "Observer service was not found!");
+ if (os) {
+ (void)os->AddObserver(this, TOPIC_PLACES_SHUTDOWN, false);
+ }
+}
+
+History::~History()
+{
+ UnregisterWeakMemoryReporter(this);
+
+ gService = nullptr;
+
+ NS_ASSERTION(mObservers.Count() == 0,
+ "Not all Links were removed before we disappear!");
+}
+
+void
+History::InitMemoryReporter()
+{
+ RegisterWeakMemoryReporter(this);
+}
+
+NS_IMETHODIMP
+History::NotifyVisited(nsIURI* aURI)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aURI);
+
+ nsAutoScriptBlocker scriptBlocker;
+
+ if (XRE_IsParentProcess()) {
+ nsTArray<ContentParent*> cplist;
+ ContentParent::GetAll(cplist);
+
+ if (!cplist.IsEmpty()) {
+ URIParams uri;
+ SerializeURI(aURI, uri);
+ for (uint32_t i = 0; i < cplist.Length(); ++i) {
+ Unused << cplist[i]->SendNotifyVisited(uri);
+ }
+ }
+ }
+
+ // If we have no observers for this URI, we have nothing to notify about.
+ KeyClass* key = mObservers.GetEntry(aURI);
+ if (!key) {
+ return NS_OK;
+ }
+
+ // Update status of each Link node.
+ {
+ // RemoveEntry will destroy the array, this iterator should not survive it.
+ ObserverArray::ForwardIterator iter(key->array);
+ while (iter.HasMore()) {
+ Link* link = iter.GetNext();
+ link->SetLinkState(eLinkState_Visited);
+ // Verify that the observers hash doesn't mutate while looping through
+ // the links associated with this URI.
+ MOZ_ASSERT(key == mObservers.GetEntry(aURI),
+ "The URIs hash mutated!");
+ }
+ }
+
+ // All the registered nodes can now be removed for this URI.
+ mObservers.RemoveEntry(key);
+ return NS_OK;
+}
+
+class ConcurrentStatementsHolder final : public mozIStorageCompletionCallback {
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit ConcurrentStatementsHolder(mozIStorageConnection* aDBConn)
+ {
+ DebugOnly<nsresult> rv = aDBConn->AsyncClone(true, this);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+
+ NS_IMETHOD Complete(nsresult aStatus, nsISupports* aConnection) override {
+ if (NS_FAILED(aStatus))
+ return NS_OK;
+ mReadOnlyDBConn = do_QueryInterface(aConnection);
+
+ // Now we can create our cached statements.
+
+ if (!mIsVisitedStatement) {
+ (void)mReadOnlyDBConn->CreateAsyncStatement(NS_LITERAL_CSTRING(
+ "SELECT 1 FROM moz_places h "
+ "WHERE url_hash = hash(?1) AND url = ?1 AND last_visit_date NOTNULL "
+ ), getter_AddRefs(mIsVisitedStatement));
+ MOZ_ASSERT(mIsVisitedStatement);
+ nsresult result = mIsVisitedStatement ? NS_OK : NS_ERROR_NOT_AVAILABLE;
+ for (int32_t i = 0; i < mIsVisitedCallbacks.Count(); ++i) {
+ DebugOnly<nsresult> rv;
+ rv = mIsVisitedCallbacks[i]->Complete(result, mIsVisitedStatement);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ mIsVisitedCallbacks.Clear();
+ }
+
+ return NS_OK;
+ }
+
+ void GetIsVisitedStatement(mozIStorageCompletionCallback* aCallback)
+ {
+ if (mIsVisitedStatement) {
+ DebugOnly<nsresult> rv;
+ rv = aCallback->Complete(NS_OK, mIsVisitedStatement);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ } else {
+ DebugOnly<bool> added = mIsVisitedCallbacks.AppendObject(aCallback);
+ MOZ_ASSERT(added);
+ }
+ }
+
+ void Shutdown() {
+ if (mReadOnlyDBConn) {
+ mIsVisitedCallbacks.Clear();
+ DebugOnly<nsresult> rv;
+ if (mIsVisitedStatement) {
+ rv = mIsVisitedStatement->Finalize();
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ rv = mReadOnlyDBConn->AsyncClose(nullptr);
+ MOZ_ASSERT(NS_SUCCEEDED(rv));
+ }
+ }
+
+private:
+ ~ConcurrentStatementsHolder()
+ {
+ }
+
+ nsCOMPtr<mozIStorageAsyncConnection> mReadOnlyDBConn;
+ nsCOMPtr<mozIStorageAsyncStatement> mIsVisitedStatement;
+ nsCOMArray<mozIStorageCompletionCallback> mIsVisitedCallbacks;
+};
+
+NS_IMPL_ISUPPORTS(
+ ConcurrentStatementsHolder
+, mozIStorageCompletionCallback
+)
+
+nsresult
+History::GetIsVisitedStatement(mozIStorageCompletionCallback* aCallback)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ if (mShuttingDown)
+ return NS_ERROR_NOT_AVAILABLE;
+
+ if (!mConcurrentStatementsHolder) {
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+ mConcurrentStatementsHolder = new ConcurrentStatementsHolder(dbConn);
+ }
+ mConcurrentStatementsHolder->GetIsVisitedStatement(aCallback);
+ return NS_OK;
+}
+
+nsresult
+History::InsertPlace(VisitData& aPlace)
+{
+ MOZ_ASSERT(aPlace.placeId == 0, "should not have a valid place id!");
+ MOZ_ASSERT(!aPlace.shouldUpdateHidden, "We should not need to update hidden");
+ MOZ_ASSERT(!NS_IsMainThread(), "must be called off of the main thread!");
+
+ nsCOMPtr<mozIStorageStatement> stmt = GetStatement(
+ "INSERT INTO moz_places "
+ "(url, url_hash, title, rev_host, hidden, typed, frecency, guid) "
+ "VALUES (:url, hash(:url), :title, :rev_host, :hidden, :typed, :frecency, :guid) "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindStringByName(NS_LITERAL_CSTRING("rev_host"),
+ aPlace.revHost);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("url"), aPlace.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsString title = aPlace.title;
+ // Empty strings should have no title, just like nsNavHistory::SetPageTitle.
+ if (title.IsEmpty()) {
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("title"));
+ }
+ else {
+ title.Assign(StringHead(aPlace.title, TITLE_LENGTH_MAX));
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("title"), title);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("typed"), aPlace.typed);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // When inserting a page for a first visit that should not appear in
+ // autocomplete, for example an error page, use a zero frecency.
+ int32_t frecency = aPlace.shouldUpdateFrecency ? aPlace.frecency : 0;
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("frecency"), frecency);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("hidden"), aPlace.hidden);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aPlace.guid.IsVoid()) {
+ rv = GenerateGUID(aPlace.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"), aPlace.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Post an onFrecencyChanged observer notification.
+ const nsNavHistory* navHistory = nsNavHistory::GetConstHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ navHistory->DispatchFrecencyChangedNotification(aPlace.spec, frecency,
+ aPlace.guid,
+ aPlace.hidden,
+ aPlace.visitTime);
+
+ return NS_OK;
+}
+
+nsresult
+History::UpdatePlace(const VisitData& aPlace)
+{
+ MOZ_ASSERT(!NS_IsMainThread(), "must be called off of the main thread!");
+ MOZ_ASSERT(aPlace.placeId > 0, "must have a valid place id!");
+ MOZ_ASSERT(!aPlace.guid.IsVoid(), "must have a guid!");
+
+ nsCOMPtr<mozIStorageStatement> stmt = GetStatement(
+ "UPDATE moz_places "
+ "SET title = :title, "
+ "hidden = :hidden, "
+ "typed = :typed, "
+ "guid = :guid "
+ "WHERE id = :page_id "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv;
+ // Empty strings should clear the title, just like nsNavHistory::SetPageTitle.
+ if (aPlace.title.IsEmpty()) {
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("title"));
+ }
+ else {
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("title"),
+ StringHead(aPlace.title, TITLE_LENGTH_MAX));
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("typed"), aPlace.typed);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("hidden"), aPlace.hidden);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"), aPlace.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"),
+ aPlace.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+History::FetchPageInfo(VisitData& _place, bool* _exists)
+{
+ MOZ_ASSERT(!_place.spec.IsEmpty() || !_place.guid.IsEmpty(), "must have either a non-empty spec or guid!");
+ MOZ_ASSERT(!NS_IsMainThread(), "must be called off of the main thread!");
+
+ nsresult rv;
+
+ // URI takes precedence.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ bool selectByURI = !_place.spec.IsEmpty();
+ if (selectByURI) {
+ stmt = GetStatement(
+ "SELECT guid, id, title, hidden, typed, frecency, visit_count, last_visit_date, "
+ "(SELECT id FROM moz_historyvisits "
+ "WHERE place_id = h.id AND visit_date = h.last_visit_date) AS last_visit_id "
+ "FROM moz_places h "
+ "WHERE url_hash = hash(:page_url) AND url = :page_url "
+ );
+ NS_ENSURE_STATE(stmt);
+
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), _place.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ stmt = GetStatement(
+ "SELECT url, id, title, hidden, typed, frecency, visit_count, last_visit_date, "
+ "(SELECT id FROM moz_historyvisits "
+ "WHERE place_id = h.id AND visit_date = h.last_visit_date) AS last_visit_id "
+ "FROM moz_places h "
+ "WHERE guid = :guid "
+ );
+ NS_ENSURE_STATE(stmt);
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"), _place.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->ExecuteStep(_exists);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!*_exists) {
+ return NS_OK;
+ }
+
+ if (selectByURI) {
+ if (_place.guid.IsEmpty()) {
+ rv = stmt->GetUTF8String(0, _place.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+ else {
+ nsAutoCString spec;
+ rv = stmt->GetUTF8String(0, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ _place.spec = spec;
+ }
+
+ rv = stmt->GetInt64(1, &_place.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoString title;
+ rv = stmt->GetString(2, title);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If the title we were given was void, that means we did not bother to set
+ // it to anything. As a result, ignore the fact that we may have changed the
+ // title (because we don't want to, that would be empty), and set the title
+ // to what is currently stored in the datbase.
+ if (_place.title.IsVoid()) {
+ _place.title = title;
+ }
+ // Otherwise, just indicate if the title has changed.
+ else {
+ _place.titleChanged = !(_place.title.Equals(title) ||
+ (_place.title.IsEmpty() && title.IsVoid()));
+ }
+
+ int32_t hidden;
+ rv = stmt->GetInt32(3, &hidden);
+ NS_ENSURE_SUCCESS(rv, rv);
+ _place.hidden = !!hidden;
+
+ int32_t typed;
+ rv = stmt->GetInt32(4, &typed);
+ NS_ENSURE_SUCCESS(rv, rv);
+ _place.typed = !!typed;
+
+ rv = stmt->GetInt32(5, &_place.frecency);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int32_t visitCount;
+ rv = stmt->GetInt32(6, &visitCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ _place.visitCount = visitCount;
+ rv = stmt->GetInt64(7, &_place.lastVisitTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(8, &_place.lastVisitId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+MOZ_DEFINE_MALLOC_SIZE_OF(HistoryMallocSizeOf)
+
+NS_IMETHODIMP
+History::CollectReports(nsIHandleReportCallback* aHandleReport,
+ nsISupports* aData, bool aAnonymize)
+{
+ MOZ_COLLECT_REPORT(
+ "explicit/history-links-hashtable", KIND_HEAP, UNITS_BYTES,
+ SizeOfIncludingThis(HistoryMallocSizeOf),
+ "Memory used by the hashtable that records changes to the visited state "
+ "of links.");
+
+ return NS_OK;
+}
+
+size_t
+History::SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOfThis)
+{
+ return aMallocSizeOfThis(this) +
+ mObservers.SizeOfExcludingThis(aMallocSizeOfThis);
+}
+
+/* static */
+History*
+History::GetService()
+{
+ if (gService) {
+ return gService;
+ }
+
+ nsCOMPtr<IHistory> service(do_GetService(NS_IHISTORY_CONTRACTID));
+ MOZ_ASSERT(service, "Cannot obtain IHistory service!");
+ NS_ASSERTION(gService, "Our constructor was not run?!");
+
+ return gService;
+}
+
+/* static */
+History*
+History::GetSingleton()
+{
+ if (!gService) {
+ gService = new History();
+ NS_ENSURE_TRUE(gService, nullptr);
+ gService->InitMemoryReporter();
+ }
+
+ NS_ADDREF(gService);
+ return gService;
+}
+
+mozIStorageConnection*
+History::GetDBConn()
+{
+ if (mShuttingDown)
+ return nullptr;
+ if (!mDB) {
+ mDB = Database::GetDatabase();
+ NS_ENSURE_TRUE(mDB, nullptr);
+ }
+ return mDB->MainConn();
+}
+
+void
+History::Shutdown()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ // Prevent other threads from scheduling uses of the DB while we mark
+ // ourselves as shutting down.
+ MutexAutoLock lockedScope(mShutdownMutex);
+ MOZ_ASSERT(!mShuttingDown && "Shutdown was called more than once!");
+
+ mShuttingDown = true;
+
+ if (mConcurrentStatementsHolder) {
+ mConcurrentStatementsHolder->Shutdown();
+ }
+}
+
+void
+History::AppendToRecentlyVisitedURIs(nsIURI* aURI) {
+ // Add a new entry, if necessary.
+ RecentURIKey* entry = mRecentlyVisitedURIs.GetEntry(aURI);
+ if (!entry) {
+ entry = mRecentlyVisitedURIs.PutEntry(aURI);
+ }
+ if (entry) {
+ entry->time = PR_Now();
+ }
+
+ // Remove entries older than RECENTLY_VISITED_URIS_MAX_AGE.
+ for (auto iter = mRecentlyVisitedURIs.Iter(); !iter.Done(); iter.Next()) {
+ RecentURIKey* entry = iter.Get();
+ if ((PR_Now() - entry->time) > RECENTLY_VISITED_URIS_MAX_AGE) {
+ iter.Remove();
+ }
+ }
+}
+
+inline bool
+History::IsRecentlyVisitedURI(nsIURI* aURI) {
+ RecentURIKey* entry = mRecentlyVisitedURIs.GetEntry(aURI);
+ // Check if the entry exists and is younger than RECENTLY_VISITED_URIS_MAX_AGE.
+ return entry && (PR_Now() - entry->time) < RECENTLY_VISITED_URIS_MAX_AGE;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// IHistory
+
+NS_IMETHODIMP
+History::VisitURI(nsIURI* aURI,
+ nsIURI* aLastVisitedURI,
+ uint32_t aFlags)
+{
+ NS_ENSURE_ARG(aURI);
+
+ if (mShuttingDown) {
+ return NS_OK;
+ }
+
+ if (XRE_IsContentProcess()) {
+ URIParams uri;
+ SerializeURI(aURI, uri);
+
+ OptionalURIParams lastVisitedURI;
+ SerializeURI(aLastVisitedURI, lastVisitedURI);
+
+ mozilla::dom::ContentChild* cpc =
+ mozilla::dom::ContentChild::GetSingleton();
+ NS_ASSERTION(cpc, "Content Protocol is NULL!");
+ (void)cpc->SendVisitURI(uri, lastVisitedURI, aFlags);
+ return NS_OK;
+ }
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_OUT_OF_MEMORY);
+
+ // Silently return if URI is something we shouldn't add to DB.
+ bool canAdd;
+ nsresult rv = navHistory->CanAddURI(aURI, &canAdd);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!canAdd) {
+ return NS_OK;
+ }
+
+ // Do not save a reloaded uri if we have visited the same URI recently.
+ bool reload = false;
+ if (aLastVisitedURI) {
+ rv = aURI->Equals(aLastVisitedURI, &reload);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (reload && IsRecentlyVisitedURI(aURI)) {
+ // Regardless we must update the stored visit time.
+ AppendToRecentlyVisitedURIs(aURI);
+ return NS_OK;
+ }
+ }
+
+ nsTArray<VisitData> placeArray(1);
+ NS_ENSURE_TRUE(placeArray.AppendElement(VisitData(aURI, aLastVisitedURI)),
+ NS_ERROR_OUT_OF_MEMORY);
+ VisitData& place = placeArray.ElementAt(0);
+ NS_ENSURE_FALSE(place.spec.IsEmpty(), NS_ERROR_INVALID_ARG);
+
+ place.visitTime = PR_Now();
+
+ // Assigns a type to the edge in the visit linked list. Each type will be
+ // considered differently when weighting the frecency of a location.
+ uint32_t recentFlags = navHistory->GetRecentFlags(aURI);
+ bool isFollowedLink = recentFlags & nsNavHistory::RECENT_ACTIVATED;
+
+ // Embed visits should never be added to the database, and the same is valid
+ // for redirects across frames.
+ // For the above reasoning non-toplevel transitions are handled at first.
+ // if the visit is toplevel or a non-toplevel followed link, then it can be
+ // handled as usual and stored on disk.
+
+ uint32_t transitionType = nsINavHistoryService::TRANSITION_LINK;
+
+ if (!(aFlags & IHistory::TOP_LEVEL) && !isFollowedLink) {
+ // A frame redirected to a new site without user interaction.
+ transitionType = nsINavHistoryService::TRANSITION_EMBED;
+ }
+ else if (aFlags & IHistory::REDIRECT_TEMPORARY) {
+ transitionType = nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY;
+ }
+ else if (aFlags & IHistory::REDIRECT_PERMANENT) {
+ transitionType = nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT;
+ }
+ else if (reload) {
+ transitionType = nsINavHistoryService::TRANSITION_RELOAD;
+ }
+ else if ((recentFlags & nsNavHistory::RECENT_TYPED) &&
+ !(aFlags & IHistory::UNRECOVERABLE_ERROR)) {
+ // Don't mark error pages as typed, even if they were actually typed by
+ // the user. This is useful to limit their score in autocomplete.
+ transitionType = nsINavHistoryService::TRANSITION_TYPED;
+ }
+ else if (recentFlags & nsNavHistory::RECENT_BOOKMARKED) {
+ transitionType = nsINavHistoryService::TRANSITION_BOOKMARK;
+ }
+ else if (!(aFlags & IHistory::TOP_LEVEL) && isFollowedLink) {
+ // User activated a link in a frame.
+ transitionType = nsINavHistoryService::TRANSITION_FRAMED_LINK;
+ }
+
+ place.SetTransitionType(transitionType);
+ place.hidden = GetHiddenState(aFlags & IHistory::REDIRECT_SOURCE,
+ transitionType);
+
+ // Error pages should never be autocompleted.
+ if (aFlags & IHistory::UNRECOVERABLE_ERROR) {
+ place.shouldUpdateFrecency = false;
+ }
+
+ // EMBED visits are session-persistent and should not go through the database.
+ // They exist only to keep track of isVisited status during the session.
+ if (place.transitionType == nsINavHistoryService::TRANSITION_EMBED) {
+ StoreAndNotifyEmbedVisit(place);
+ }
+ else {
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ rv = InsertVisitedURIs::Start(dbConn, placeArray);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Finally, notify that we've been visited.
+ nsCOMPtr<nsIObserverService> obsService =
+ mozilla::services::GetObserverService();
+ if (obsService) {
+ obsService->NotifyObservers(aURI, NS_LINK_VISITED_EVENT_TOPIC, nullptr);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::RegisterVisitedCallback(nsIURI* aURI,
+ Link* aLink)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ASSERTION(aURI, "Must pass a non-null URI!");
+ if (XRE_IsContentProcess()) {
+ NS_PRECONDITION(aLink, "Must pass a non-null Link!");
+ }
+
+ // Obtain our array of observers for this URI.
+#ifdef DEBUG
+ bool keyAlreadyExists = !!mObservers.GetEntry(aURI);
+#endif
+ KeyClass* key = mObservers.PutEntry(aURI);
+ NS_ENSURE_TRUE(key, NS_ERROR_OUT_OF_MEMORY);
+ ObserverArray& observers = key->array;
+
+ if (observers.IsEmpty()) {
+ NS_ASSERTION(!keyAlreadyExists,
+ "An empty key was kept around in our hashtable!");
+
+ // We are the first Link node to ask about this URI, or there are no pending
+ // Links wanting to know about this URI. Therefore, we should query the
+ // database now.
+ nsresult rv = VisitedQuery::Start(aURI);
+
+ // In IPC builds, we are passed a nullptr Link from
+ // ContentParent::RecvStartVisitedQuery. Since we won't be adding a
+ // nullptr entry to our list of observers, and the code after this point
+ // assumes that aLink is non-nullptr, we will need to return now.
+ if (NS_FAILED(rv) || !aLink) {
+ // Remove our array from the hashtable so we don't keep it around.
+ // In some case calling RemoveEntry on the key obtained by PutEntry
+ // crashes for currently unknown reasons. Our suspect is that something
+ // between PutEntry and this call causes a nested loop that either removes
+ // the entry or reallocs the hash.
+ // TODO (Bug 1412647): we must figure the root cause for these issues and
+ // remove this stop-gap crash fix.
+ key = mObservers.GetEntry(aURI);
+ if (key) {
+ mObservers.RemoveEntry(key);
+ }
+ return rv;
+ }
+ }
+ // In IPC builds, we are passed a nullptr Link from
+ // ContentParent::RecvStartVisitedQuery. All of our code after this point
+ // assumes aLink is non-nullptr, so we have to return now.
+ else if (!aLink) {
+ NS_ASSERTION(XRE_IsParentProcess(),
+ "We should only ever get a null Link in the default process!");
+ return NS_OK;
+ }
+
+ // Sanity check that Links are not registered more than once for a given URI.
+ // This will not catch a case where it is registered for two different URIs.
+ NS_ASSERTION(!observers.Contains(aLink),
+ "Already tracking this Link object!");
+
+ // Start tracking our Link.
+ if (!observers.AppendElement(aLink)) {
+ // Curses - unregister and return failure.
+ (void)UnregisterVisitedCallback(aURI, aLink);
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::UnregisterVisitedCallback(nsIURI* aURI,
+ Link* aLink)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ // TODO: aURI is sometimes null - see bug 548685
+ NS_ASSERTION(aURI, "Must pass a non-null URI!");
+ NS_ASSERTION(aLink, "Must pass a non-null Link object!");
+
+ // Get the array, and remove the item from it.
+ KeyClass* key = mObservers.GetEntry(aURI);
+ if (!key) {
+ NS_ERROR("Trying to unregister for a URI that wasn't registered!");
+ return NS_ERROR_UNEXPECTED;
+ }
+ ObserverArray& observers = key->array;
+ if (!observers.RemoveElement(aLink)) {
+ NS_ERROR("Trying to unregister a node that wasn't registered!");
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ // If the array is now empty, we should remove it from the hashtable.
+ if (observers.IsEmpty()) {
+ mObservers.RemoveEntry(aURI);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::SetURITitle(nsIURI* aURI, const nsAString& aTitle)
+{
+ NS_ENSURE_ARG(aURI);
+
+ if (mShuttingDown) {
+ return NS_OK;
+ }
+
+ if (XRE_IsContentProcess()) {
+ URIParams uri;
+ SerializeURI(aURI, uri);
+
+ mozilla::dom::ContentChild * cpc =
+ mozilla::dom::ContentChild::GetSingleton();
+ NS_ASSERTION(cpc, "Content Protocol is NULL!");
+ (void)cpc->SendSetURITitle(uri, PromiseFlatString(aTitle));
+ return NS_OK;
+ }
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+
+ // At first, it seems like nav history should always be available here, no
+ // matter what.
+ //
+ // nsNavHistory fails to register as a service if there is no profile in
+ // place (for instance, if user is choosing a profile).
+ //
+ // Maybe the correct thing to do is to not register this service if no
+ // profile has been selected?
+ //
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_FAILURE);
+
+ bool canAdd;
+ nsresult rv = navHistory->CanAddURI(aURI, &canAdd);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!canAdd) {
+ return NS_OK;
+ }
+
+ // Embed visits don't have a database entry, thus don't set a title on them.
+ if (navHistory->hasEmbedVisit(aURI)) {
+ return NS_OK;
+ }
+
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ rv = SetPageTitle::Start(dbConn, aURI, aTitle);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIDownloadHistory
+
+NS_IMETHODIMP
+History::AddDownload(nsIURI* aSource, nsIURI* aReferrer,
+ PRTime aStartTime, nsIURI* aDestination)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aSource);
+
+ if (mShuttingDown) {
+ return NS_OK;
+ }
+
+ if (XRE_IsContentProcess()) {
+ NS_ERROR("Cannot add downloads to history from content process!");
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_OUT_OF_MEMORY);
+
+ // Silently return if URI is something we shouldn't add to DB.
+ bool canAdd;
+ nsresult rv = navHistory->CanAddURI(aSource, &canAdd);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!canAdd) {
+ return NS_OK;
+ }
+
+ nsTArray<VisitData> placeArray(1);
+ NS_ENSURE_TRUE(placeArray.AppendElement(VisitData(aSource, aReferrer)),
+ NS_ERROR_OUT_OF_MEMORY);
+ VisitData& place = placeArray.ElementAt(0);
+ NS_ENSURE_FALSE(place.spec.IsEmpty(), NS_ERROR_INVALID_ARG);
+
+ place.visitTime = aStartTime;
+ place.SetTransitionType(nsINavHistoryService::TRANSITION_DOWNLOAD);
+ place.hidden = false;
+
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ nsMainThreadPtrHandle<mozIVisitInfoCallback> callback;
+ if (aDestination) {
+ callback = new nsMainThreadPtrHolder<mozIVisitInfoCallback>(new SetDownloadAnnotations(aDestination));
+ }
+
+ rv = InsertVisitedURIs::Start(dbConn, placeArray, callback);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Finally, notify that we've been visited.
+ nsCOMPtr<nsIObserverService> obsService =
+ mozilla::services::GetObserverService();
+ if (obsService) {
+ obsService->NotifyObservers(aSource, NS_LINK_VISITED_EVENT_TOPIC, nullptr);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::RemoveAllDownloads()
+{
+ MOZ_ASSERT(NS_IsMainThread());
+
+ if (mShuttingDown) {
+ return NS_OK;
+ }
+
+ if (XRE_IsContentProcess()) {
+ NS_ERROR("Cannot remove downloads to history from content process!");
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Ensure navHistory is initialized.
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_OUT_OF_MEMORY);
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ RemoveVisitsFilter filter;
+ filter.transitionType = nsINavHistoryService::TRANSITION_DOWNLOAD;
+
+ nsresult rv = RemoveVisits::Start(dbConn, filter);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// mozIAsyncHistory
+
+NS_IMETHODIMP
+History::GetPlacesInfo(JS::Handle<JS::Value> aPlaceIdentifiers,
+ mozIVisitInfoCallback* aCallback,
+ JSContext* aCtx)
+{
+ // Make sure nsNavHistory service is up before proceeding:
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ MOZ_ASSERT(navHistory, "Could not get nsNavHistory?!");
+ if (!navHistory) {
+ return NS_ERROR_FAILURE;
+ }
+
+ uint32_t placesIndentifiersLength;
+ JS::Rooted<JSObject*> placesIndentifiers(aCtx);
+ nsresult rv = GetJSArrayFromJSValue(aPlaceIdentifiers, aCtx,
+ &placesIndentifiers,
+ &placesIndentifiersLength);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsTArray<VisitData> placesInfo;
+ placesInfo.SetCapacity(placesIndentifiersLength);
+ for (uint32_t i = 0; i < placesIndentifiersLength; i++) {
+ JS::Rooted<JS::Value> placeIdentifier(aCtx);
+ bool rc = JS_GetElement(aCtx, placesIndentifiers, i, &placeIdentifier);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+
+ // GUID
+ nsAutoString fatGUID;
+ GetJSValueAsString(aCtx, placeIdentifier, fatGUID);
+ if (!fatGUID.IsVoid()) {
+ NS_ConvertUTF16toUTF8 guid(fatGUID);
+ if (!IsValidGUID(guid))
+ return NS_ERROR_INVALID_ARG;
+
+ VisitData& placeInfo = *placesInfo.AppendElement(VisitData());
+ placeInfo.guid = guid;
+ }
+ else {
+ nsCOMPtr<nsIURI> uri = GetJSValueAsURI(aCtx, placeIdentifier);
+ if (!uri)
+ return NS_ERROR_INVALID_ARG; // neither a guid, nor a uri.
+ placesInfo.AppendElement(VisitData(uri));
+ }
+ }
+
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ for (nsTArray<VisitData>::size_type i = 0; i < placesInfo.Length(); i++) {
+ nsresult rv = GetPlaceInfo::Start(dbConn, placesInfo.ElementAt(i), aCallback);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Be sure to notify that all of our operations are complete. This
+ // is dispatched to the background thread first and redirected to the
+ // main thread from there to make sure that all database notifications
+ // and all embed or canAddURI notifications have finished.
+ if (aCallback) {
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+ nsCOMPtr<nsIEventTarget> backgroundThread = do_GetInterface(dbConn);
+ NS_ENSURE_TRUE(backgroundThread, NS_ERROR_UNEXPECTED);
+ nsCOMPtr<nsIRunnable> event = new NotifyCompletion(callback);
+ return backgroundThread->Dispatch(event, NS_DISPATCH_NORMAL);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::UpdatePlaces(JS::Handle<JS::Value> aPlaceInfos,
+ mozIVisitInfoCallback* aCallback,
+ JSContext* aCtx)
+{
+ NS_ENSURE_TRUE(NS_IsMainThread(), NS_ERROR_UNEXPECTED);
+ NS_ENSURE_TRUE(!aPlaceInfos.isPrimitive(), NS_ERROR_INVALID_ARG);
+
+ uint32_t infosLength;
+ JS::Rooted<JSObject*> infos(aCtx);
+ nsresult rv = GetJSArrayFromJSValue(aPlaceInfos, aCtx, &infos, &infosLength);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsTArray<VisitData> visitData;
+ for (uint32_t i = 0; i < infosLength; i++) {
+ JS::Rooted<JSObject*> info(aCtx);
+ nsresult rv = GetJSObjectFromArray(aCtx, infos, i, &info);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIURI> uri = GetURIFromJSObject(aCtx, info, "uri");
+ nsCString guid;
+ {
+ nsString fatGUID;
+ GetStringFromJSObject(aCtx, info, "guid", fatGUID);
+ if (fatGUID.IsVoid()) {
+ guid.SetIsVoid(true);
+ }
+ else {
+ guid = NS_ConvertUTF16toUTF8(fatGUID);
+ }
+ }
+
+ // Make sure that any uri we are given can be added to history, and if not,
+ // skip it (CanAddURI will notify our callback for us).
+ if (uri && !CanAddURI(uri, guid, aCallback)) {
+ continue;
+ }
+
+ // We must have at least one of uri or guid.
+ NS_ENSURE_ARG(uri || !guid.IsVoid());
+
+ // If we were given a guid, make sure it is valid.
+ bool isValidGUID = IsValidGUID(guid);
+ NS_ENSURE_ARG(guid.IsVoid() || isValidGUID);
+
+ nsString title;
+ GetStringFromJSObject(aCtx, info, "title", title);
+
+ JS::Rooted<JSObject*> visits(aCtx, nullptr);
+ {
+ JS::Rooted<JS::Value> visitsVal(aCtx);
+ bool rc = JS_GetProperty(aCtx, info, "visits", &visitsVal);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ if (!visitsVal.isPrimitive()) {
+ visits = visitsVal.toObjectOrNull();
+ bool isArray;
+ if (!JS_IsArrayObject(aCtx, visits, &isArray)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ if (!isArray) {
+ return NS_ERROR_INVALID_ARG;
+ }
+ }
+ }
+ NS_ENSURE_ARG(visits);
+
+ uint32_t visitsLength = 0;
+ if (visits) {
+ (void)JS_GetArrayLength(aCtx, visits, &visitsLength);
+ }
+ NS_ENSURE_ARG(visitsLength > 0);
+
+ // Check each visit, and build our array of VisitData objects.
+ visitData.SetCapacity(visitData.Length() + visitsLength);
+ for (uint32_t j = 0; j < visitsLength; j++) {
+ JS::Rooted<JSObject*> visit(aCtx);
+ rv = GetJSObjectFromArray(aCtx, visits, j, &visit);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ VisitData& data = *visitData.AppendElement(VisitData(uri));
+ data.title = title;
+ data.guid = guid;
+
+ // We must have a date and a transaction type!
+ rv = GetIntFromJSObject(aCtx, visit, "visitDate", &data.visitTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+ uint32_t transitionType = 0;
+ rv = GetIntFromJSObject(aCtx, visit, "transitionType", &transitionType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_ARG_RANGE(transitionType,
+ nsINavHistoryService::TRANSITION_LINK,
+ nsINavHistoryService::TRANSITION_RELOAD);
+ data.SetTransitionType(transitionType);
+ data.hidden = GetHiddenState(false, transitionType);
+
+ // If the visit is an embed visit, we do not actually add it to the
+ // database.
+ if (transitionType == nsINavHistoryService::TRANSITION_EMBED) {
+ StoreAndNotifyEmbedVisit(data, aCallback);
+ visitData.RemoveElementAt(visitData.Length() - 1);
+ continue;
+ }
+
+ // The referrer is optional.
+ nsCOMPtr<nsIURI> referrer = GetURIFromJSObject(aCtx, visit,
+ "referrerURI");
+ if (referrer) {
+ (void)referrer->GetSpec(data.referrerSpec);
+ }
+ }
+ }
+
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_STATE(dbConn);
+
+ nsMainThreadPtrHandle<mozIVisitInfoCallback>
+ callback(new nsMainThreadPtrHolder<mozIVisitInfoCallback>(aCallback));
+
+ // It is possible that all of the visits we were passed were dissallowed by
+ // CanAddURI, which isn't an error. If we have no visits to add, however,
+ // we should not call InsertVisitedURIs::Start.
+ if (visitData.Length()) {
+ nsresult rv = InsertVisitedURIs::Start(dbConn, visitData, callback);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Be sure to notify that all of our operations are complete. This
+ // is dispatched to the background thread first and redirected to the
+ // main thread from there to make sure that all database notifications
+ // and all embed or canAddURI notifications have finished.
+ if (aCallback) {
+ nsCOMPtr<nsIEventTarget> backgroundThread = do_GetInterface(dbConn);
+ NS_ENSURE_TRUE(backgroundThread, NS_ERROR_UNEXPECTED);
+ nsCOMPtr<nsIRunnable> event = new NotifyCompletion(callback);
+ return backgroundThread->Dispatch(event, NS_DISPATCH_NORMAL);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+History::IsURIVisited(nsIURI* aURI,
+ mozIVisitedStatusCallback* aCallback)
+{
+ NS_ENSURE_STATE(NS_IsMainThread());
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG(aCallback);
+
+ nsresult rv = VisitedQuery::Start(aURI, aCallback);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+History::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData)
+{
+ if (strcmp(aTopic, TOPIC_PLACES_SHUTDOWN) == 0) {
+ Shutdown();
+
+ nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
+ if (os) {
+ (void)os->RemoveObserver(this, TOPIC_PLACES_SHUTDOWN);
+ }
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsISupports
+
+NS_IMPL_ISUPPORTS(
+ History
+, IHistory
+, nsIDownloadHistory
+, mozIAsyncHistory
+, nsIObserver
+, nsIMemoryReporter
+)
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/History.h b/toolkit/components/places/History.h
new file mode 100644
index 000000000..16ae2b5de
--- /dev/null
+++ b/toolkit/components/places/History.h
@@ -0,0 +1,224 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_History_h_
+#define mozilla_places_History_h_
+
+#include "mozilla/IHistory.h"
+#include "mozilla/MemoryReporting.h"
+#include "mozilla/Mutex.h"
+#include "mozIAsyncHistory.h"
+#include "nsIDownloadHistory.h"
+#include "Database.h"
+
+#include "mozilla/dom/Link.h"
+#include "nsTHashtable.h"
+#include "nsString.h"
+#include "nsURIHashKey.h"
+#include "nsTObserverArray.h"
+#include "nsDeque.h"
+#include "nsIMemoryReporter.h"
+#include "nsIObserver.h"
+#include "mozIStorageConnection.h"
+
+namespace mozilla {
+namespace places {
+
+struct VisitData;
+class ConcurrentStatementsHolder;
+
+#define NS_HISTORYSERVICE_CID \
+ {0x0937a705, 0x91a6, 0x417a, {0x82, 0x92, 0xb2, 0x2e, 0xb1, 0x0d, 0xa8, 0x6c}}
+
+// Initial size of mRecentlyVisitedURIs.
+#define RECENTLY_VISITED_URIS_SIZE 64
+// Microseconds after which a visit can be expired from mRecentlyVisitedURIs.
+// When an URI is reloaded we only take into account the first visit to it, and
+// ignore any subsequent visits, if they happen before this time has elapsed.
+// A commonly found case is to reload a page every 5 minutes, so we pick a time
+// larger than that.
+#define RECENTLY_VISITED_URIS_MAX_AGE 6 * 60 * PR_USEC_PER_SEC
+
+class History final : public IHistory
+ , public nsIDownloadHistory
+ , public mozIAsyncHistory
+ , public nsIObserver
+ , public nsIMemoryReporter
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_IHISTORY
+ NS_DECL_NSIDOWNLOADHISTORY
+ NS_DECL_MOZIASYNCHISTORY
+ NS_DECL_NSIOBSERVER
+ NS_DECL_NSIMEMORYREPORTER
+
+ History();
+
+ /**
+ * Obtains the statement to use to check if a URI is visited or not.
+ */
+ nsresult GetIsVisitedStatement(mozIStorageCompletionCallback* aCallback);
+
+ /**
+ * Adds an entry in moz_places with the data in aVisitData.
+ *
+ * @param aVisitData
+ * The visit data to use to populate a new row in moz_places.
+ */
+ nsresult InsertPlace(VisitData& aVisitData);
+
+ /**
+ * Updates an entry in moz_places with the data in aVisitData.
+ *
+ * @param aVisitData
+ * The visit data to use to update the existing row in moz_places.
+ */
+ nsresult UpdatePlace(const VisitData& aVisitData);
+
+ /**
+ * Loads information about the page into _place from moz_places.
+ *
+ * @param _place
+ * The VisitData for the place we need to know information about.
+ * @param [out] _exists
+ * Whether or the page was recorded in moz_places, false otherwise.
+ */
+ nsresult FetchPageInfo(VisitData& _place, bool* _exists);
+
+ /**
+ * Get the number of bytes of memory this History object is using,
+ * including sizeof(*this))
+ */
+ size_t SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf);
+
+ /**
+ * Obtains a pointer to this service.
+ */
+ static History* GetService();
+
+ /**
+ * Obtains a pointer that has had AddRef called on it. Used by the service
+ * manager only.
+ */
+ static History* GetSingleton();
+
+ template<int N>
+ already_AddRefed<mozIStorageStatement>
+ GetStatement(const char (&aQuery)[N])
+ {
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_TRUE(dbConn, nullptr);
+ return mDB->GetStatement(aQuery);
+ }
+
+ already_AddRefed<mozIStorageStatement>
+ GetStatement(const nsACString& aQuery)
+ {
+ mozIStorageConnection* dbConn = GetDBConn();
+ NS_ENSURE_TRUE(dbConn, nullptr);
+ return mDB->GetStatement(aQuery);
+ }
+
+ bool IsShuttingDown() const {
+ return mShuttingDown;
+ }
+ Mutex& GetShutdownMutex() {
+ return mShutdownMutex;
+ }
+
+ /**
+ * Helper function to append a new URI to mRecentlyVisitedURIs. See
+ * mRecentlyVisitedURIs.
+ */
+ void AppendToRecentlyVisitedURIs(nsIURI* aURI);
+
+private:
+ virtual ~History();
+
+ void InitMemoryReporter();
+
+ /**
+ * Obtains a read-write database connection.
+ */
+ mozIStorageConnection* GetDBConn();
+
+ /**
+ * The database handle. This is initialized lazily by the first call to
+ * GetDBConn(), so never use it directly, or, if you really need, always
+ * invoke GetDBConn() before.
+ */
+ RefPtr<mozilla::places::Database> mDB;
+
+ RefPtr<ConcurrentStatementsHolder> mConcurrentStatementsHolder;
+
+ /**
+ * Remove any memory references to tasks and do not take on any more.
+ */
+ void Shutdown();
+
+ static History* gService;
+
+ // Ensures new tasks aren't started on destruction.
+ bool mShuttingDown;
+ // This mutex guards mShuttingDown. Code running in other threads that might
+ // schedule tasks that use the database should grab it and check the value of
+ // mShuttingDown. If we are already shutting down, the code must gracefully
+ // avoid using the db. If we are not, the lock will prevent shutdown from
+ // starting in an unexpected moment.
+ Mutex mShutdownMutex;
+
+ typedef nsTObserverArray<mozilla::dom::Link* > ObserverArray;
+
+ class KeyClass : public nsURIHashKey
+ {
+ public:
+ explicit KeyClass(const nsIURI* aURI)
+ : nsURIHashKey(aURI)
+ {
+ }
+ KeyClass(const KeyClass& aOther)
+ : nsURIHashKey(aOther)
+ {
+ NS_NOTREACHED("Do not call me!");
+ }
+ size_t SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const
+ {
+ return array.ShallowSizeOfExcludingThis(aMallocSizeOf);
+ }
+ ObserverArray array;
+ };
+
+ nsTHashtable<KeyClass> mObservers;
+
+ /**
+ * mRecentlyVisitedURIs remembers URIs which have been recently added to
+ * history, to avoid saving these locations repeatedly in a short period.
+ */
+ class RecentURIKey : public nsURIHashKey
+ {
+ public:
+ explicit RecentURIKey(const nsIURI* aURI) : nsURIHashKey(aURI)
+ {
+ }
+ RecentURIKey(const RecentURIKey& aOther) : nsURIHashKey(aOther)
+ {
+ NS_NOTREACHED("Do not call me!");
+ }
+ MOZ_INIT_OUTSIDE_CTOR PRTime time;
+ };
+ nsTHashtable<RecentURIKey> mRecentlyVisitedURIs;
+ /**
+ * Whether aURI has been visited "recently".
+ * See RECENTLY_VISITED_URIS_MAX_AGE.
+ */
+ bool IsRecentlyVisitedURI(nsIURI* aURI);
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_History_h_
diff --git a/toolkit/components/places/History.jsm b/toolkit/components/places/History.jsm
new file mode 100644
index 000000000..59c24fcc6
--- /dev/null
+++ b/toolkit/components/places/History.jsm
@@ -0,0 +1,1049 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Asynchronous API for managing history.
+ *
+ *
+ * The API makes use of `PageInfo` and `VisitInfo` objects, defined as follows.
+ *
+ * A `PageInfo` object is any object that contains A SUBSET of the
+ * following properties:
+ * - guid: (string)
+ * The globally unique id of the page.
+ * - url: (URL)
+ * or (nsIURI)
+ * or (string)
+ * The full URI of the page. Note that `PageInfo` values passed as
+ * argument may hold `nsIURI` or `string` values for property `url`,
+ * but `PageInfo` objects returned by this module always hold `URL`
+ * values.
+ * - title: (string)
+ * The title associated with the page, if any.
+ * - frecency: (number)
+ * The frecency of the page, if any.
+ * See https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Places/Frecency_algorithm
+ * Note that this property may not be used to change the actualy frecency
+ * score of a page, only to retrieve it. In other words, any `frecency` field
+ * passed as argument to a function of this API will be ignored.
+ * - visits: (Array<VisitInfo>)
+ * All the visits for this page, if any.
+ *
+ * See the documentation of individual methods to find out which properties
+ * are required for `PageInfo` arguments or returned for `PageInfo` results.
+ *
+ * A `VisitInfo` object is any object that contains A SUBSET of the following
+ * properties:
+ * - date: (Date)
+ * The time the visit occurred.
+ * - transition: (number)
+ * How the user reached the page. See constants `TRANSITIONS.*`
+ * for the possible transition types.
+ * - referrer: (URL)
+ * or (nsIURI)
+ * or (string)
+ * The referring URI of this visit. Note that `VisitInfo` passed
+ * as argument may hold `nsIURI` or `string` values for property `referrer`,
+ * but `VisitInfo` objects returned by this module always hold `URL`
+ * values.
+ * See the documentation of individual methods to find out which properties
+ * are required for `VisitInfo` arguments or returned for `VisitInfo` results.
+ *
+ *
+ *
+ * Each successful operation notifies through the nsINavHistoryObserver
+ * interface. To listen to such notifications you must register using
+ * nsINavHistoryService `addObserver` and `removeObserver` methods.
+ * @see nsINavHistoryObserver
+ */
+
+this.EXPORTED_SYMBOLS = [ "History" ];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
+ "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+Cu.importGlobalProperties(["URL"]);
+
+/**
+ * Whenever we update or remove numerous pages, it is preferable
+ * to yield time to the main thread every so often to avoid janking.
+ * These constants determine the maximal number of notifications we
+ * may emit before we yield.
+ */
+const NOTIFICATION_CHUNK_SIZE = 300;
+const ONRESULT_CHUNK_SIZE = 300;
+
+// Timers resolution is not always good, it can have a 16ms precision on Win.
+const TIMERS_RESOLUTION_SKEW_MS = 16;
+
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ * array of nsINavBookmarkObserver objects.
+ * @param notification
+ * the notification name.
+ * @param args
+ * array of arguments to pass to the notification.
+ */
+function notify(observers, notification, args = []) {
+ for (let observer of observers) {
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+}
+
+this.History = Object.freeze({
+ /**
+ * Fetch the available information for one page.
+ *
+ * @param guidOrURI: (URL or nsIURI)
+ * The full URI of the page.
+ * or (string)
+ * Either the full URI of the page or the GUID of the page.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ * @resolves (PageInfo | null) If the page could be found, the information
+ * on that page. Note that this `PageInfo` does NOT contain the visit
+ * data (i.e. `visits` is `undefined`).
+ *
+ * @throws (Error)
+ * If `guidOrURI` does not have the expected type or if it is a string
+ * that may be parsed neither as a valid URL nor as a valid GUID.
+ */
+ fetch: function (guidOrURI) {
+ throw new Error("Method not implemented");
+ },
+
+ /**
+ * Adds a number of visits for a single page.
+ *
+ * Any change may be observed through nsINavHistoryObserver
+ *
+ * @param pageInfo: (PageInfo)
+ * Information on a page. This `PageInfo` MUST contain
+ * - a property `url`, as specified by the definition of `PageInfo`.
+ * - a property `visits`, as specified by the definition of
+ * `PageInfo`, which MUST contain at least one visit.
+ * If a property `title` is provided, the title of the page
+ * is updated.
+ * If the `date` of a visit is not provided, it defaults
+ * to now.
+ * If the `transition` of a visit is not provided, it defaults to
+ * TRANSITION_LINK.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ * @resolves (PageInfo)
+ * A PageInfo object populated with data after the insert is complete.
+ * @rejects (Error)
+ * Rejects if the insert was unsuccessful.
+ *
+ * @throws (Error)
+ * If the `url` specified was for a protocol that should not be
+ * stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
+ * "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
+ * "javascript:", "blob:").
+ * @throws (Error)
+ * If `pageInfo` has an unexpected type.
+ * @throws (Error)
+ * If `pageInfo` does not have a `url`.
+ * @throws (Error)
+ * If `pageInfo` does not have a `visits` property or if the
+ * value of `visits` is ill-typed or is an empty array.
+ * @throws (Error)
+ * If an element of `visits` has an invalid `date`.
+ * @throws (Error)
+ * If an element of `visits` has an invalid `transition`.
+ */
+ insert: function (pageInfo) {
+ if (typeof pageInfo != "object" || !pageInfo) {
+ throw new TypeError("pageInfo must be an object");
+ }
+
+ let info = validatePageInfo(pageInfo);
+
+ return PlacesUtils.withConnectionWrapper("History.jsm: insert",
+ db => insert(db, info));
+ },
+
+ /**
+ * Adds a number of visits for a number of pages.
+ *
+ * Any change may be observed through nsINavHistoryObserver
+ *
+ * @param pageInfos: (Array<PageInfo>)
+ * Information on a page. This `PageInfo` MUST contain
+ * - a property `url`, as specified by the definition of `PageInfo`.
+ * - a property `visits`, as specified by the definition of
+ * `PageInfo`, which MUST contain at least one visit.
+ * If a property `title` is provided, the title of the page
+ * is updated.
+ * If the `date` of a visit is not provided, it defaults
+ * to now.
+ * If the `transition` of a visit is not provided, it defaults to
+ * TRANSITION_LINK.
+ * @param onResult: (function(PageInfo))
+ * A callback invoked for each page inserted.
+ * @param onError: (function(PageInfo))
+ * A callback invoked for each page which generated an error
+ * when an insert was attempted.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ * @resolves (null)
+ * @rejects (Error)
+ * Rejects if all of the inserts were unsuccessful.
+ *
+ * @throws (Error)
+ * If the `url` specified was for a protocol that should not be
+ * stored (e.g. "chrome:", "mailbox:", "about:", "imap:", "news:",
+ * "moz-anno:", "view-source:", "resource:", "data:", "wyciwyg:",
+ * "javascript:", "blob:").
+ * @throws (Error)
+ * If `pageInfos` has an unexpected type.
+ * @throws (Error)
+ * If a `pageInfo` does not have a `url`.
+ * @throws (Error)
+ * If a `PageInfo` does not have a `visits` property or if the
+ * value of `visits` is ill-typed or is an empty array.
+ * @throws (Error)
+ * If an element of `visits` has an invalid `date`.
+ * @throws (Error)
+ * If an element of `visits` has an invalid `transition`.
+ */
+ insertMany: function (pageInfos, onResult, onError) {
+ let infos = [];
+
+ if (!Array.isArray(pageInfos)) {
+ throw new TypeError("pageInfos must be an array");
+ }
+ if (!pageInfos.length) {
+ throw new TypeError("pageInfos may not be an empty array");
+ }
+
+ if (onResult && typeof onResult != "function") {
+ throw new TypeError(`onResult: ${onResult} is not a valid function`);
+ }
+ if (onError && typeof onError != "function") {
+ throw new TypeError(`onError: ${onError} is not a valid function`);
+ }
+
+ for (let pageInfo of pageInfos) {
+ let info = validatePageInfo(pageInfo);
+ infos.push(info);
+ }
+
+ return PlacesUtils.withConnectionWrapper("History.jsm: insertMany",
+ db => insertMany(db, infos, onResult, onError));
+ },
+
+ /**
+ * Remove pages from the database.
+ *
+ * Any change may be observed through nsINavHistoryObserver
+ *
+ *
+ * @param page: (URL or nsIURI)
+ * The full URI of the page.
+ * or (string)
+ * Either the full URI of the page or the GUID of the page.
+ * or (Array<URL|nsIURI|string>)
+ * An array of the above, to batch requests.
+ * @param onResult: (function(PageInfo))
+ * A callback invoked for each page found.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ * @resolve (bool)
+ * `true` if at least one page was removed, `false` otherwise.
+ * @throws (TypeError)
+ * If `pages` has an unexpected type or if a string provided
+ * is neither a valid GUID nor a valid URI or if `pages`
+ * is an empty array.
+ */
+ remove: function (pages, onResult = null) {
+ // Normalize and type-check arguments
+ if (Array.isArray(pages)) {
+ if (pages.length == 0) {
+ throw new TypeError("Expected at least one page");
+ }
+ } else {
+ pages = [pages];
+ }
+
+ let guids = [];
+ let urls = [];
+ for (let page of pages) {
+ // Normalize to URL or GUID, or throw if `page` cannot
+ // be normalized.
+ let normalized = normalizeToURLOrGUID(page);
+ if (typeof normalized === "string") {
+ guids.push(normalized);
+ } else {
+ urls.push(normalized.href);
+ }
+ }
+ let normalizedPages = {guids: guids, urls: urls};
+
+ // At this stage, we know that either `guids` is not-empty
+ // or `urls` is not-empty.
+
+ if (onResult && typeof onResult != "function") {
+ throw new TypeError("Invalid function: " + onResult);
+ }
+
+ return PlacesUtils.withConnectionWrapper("History.jsm: remove",
+ db => remove(db, normalizedPages, onResult));
+ },
+
+ /**
+ * Remove visits matching specific characteristics.
+ *
+ * Any change may be observed through nsINavHistoryObserver.
+ *
+ * @param filter: (object)
+ * The `object` may contain some of the following
+ * properties:
+ * - beginDate: (Date) Remove visits that have
+ * been added since this date (inclusive).
+ * - endDate: (Date) Remove visits that have
+ * been added before this date (inclusive).
+ * - limit: (Number) Limit the number of visits
+ * we remove to this number
+ * - url: (URL) Only remove visits to this URL
+ * If both `beginDate` and `endDate` are specified,
+ * visits between `beginDate` (inclusive) and `end`
+ * (inclusive) are removed.
+ *
+ * @param onResult: (function(VisitInfo), [optional])
+ * A callback invoked for each visit found and removed.
+ * Note that the referrer property of `VisitInfo`
+ * is NOT populated.
+ *
+ * @return (Promise)
+ * @resolve (bool)
+ * `true` if at least one visit was removed, `false`
+ * otherwise.
+ * @throws (TypeError)
+ * If `filter` does not have the expected type, in
+ * particular if the `object` is empty.
+ */
+ removeVisitsByFilter: function(filter, onResult = null) {
+ if (!filter || typeof filter != "object") {
+ throw new TypeError("Expected a filter");
+ }
+
+ let hasBeginDate = "beginDate" in filter;
+ let hasEndDate = "endDate" in filter;
+ let hasURL = "url" in filter;
+ let hasLimit = "limit" in filter;
+ if (hasBeginDate) {
+ ensureDate(filter.beginDate);
+ }
+ if (hasEndDate) {
+ ensureDate(filter.endDate);
+ }
+ if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
+ throw new TypeError("`beginDate` should be at least as old as `endDate`");
+ }
+ if (!hasBeginDate && !hasEndDate && !hasURL && !hasLimit) {
+ throw new TypeError("Expected a non-empty filter");
+ }
+
+ if (hasURL && !(filter.url instanceof URL) && typeof filter.url != "string" &&
+ !(filter.url instanceof Ci.nsIURI)) {
+ throw new TypeError("Expected a valid URL for `url`");
+ }
+
+ if (hasLimit &&
+ (typeof filter.limit != "number" ||
+ filter.limit <= 0 ||
+ !Number.isInteger(filter.limit))) {
+ throw new TypeError("Expected a non-zero positive integer as a limit");
+ }
+
+ if (onResult && typeof onResult != "function") {
+ throw new TypeError("Invalid function: " + onResult);
+ }
+
+ return PlacesUtils.withConnectionWrapper("History.jsm: removeVisitsByFilter",
+ db => removeVisitsByFilter(db, filter, onResult)
+ );
+ },
+
+ /**
+ * Determine if a page has been visited.
+ *
+ * @param pages: (URL or nsIURI)
+ * The full URI of the page.
+ * or (string)
+ * The full URI of the page or the GUID of the page.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ * @resolve (bool)
+ * `true` if the page has been visited, `false` otherwise.
+ * @throws (Error)
+ * If `pages` has an unexpected type or if a string provided
+ * is neither not a valid GUID nor a valid URI.
+ */
+ hasVisits: function(page, onResult) {
+ throw new Error("Method not implemented");
+ },
+
+ /**
+ * Clear all history.
+ *
+ * @return (Promise)
+ * A promise resolved once the operation is complete.
+ */
+ clear() {
+ return PlacesUtils.withConnectionWrapper("History.jsm: clear",
+ clear
+ );
+ },
+
+ /**
+ * Possible values for the `transition` property of `VisitInfo`
+ * objects.
+ */
+
+ TRANSITIONS: {
+ /**
+ * The user followed a link and got a new toplevel window.
+ */
+ LINK: Ci.nsINavHistoryService.TRANSITION_LINK,
+
+ /**
+ * The user typed the page's URL in the URL bar or selected it from
+ * URL bar autocomplete results, clicked on it from a history query
+ * (from the History sidebar, History menu, or history query in the
+ * personal toolbar or Places organizer.
+ */
+ TYPED: Ci.nsINavHistoryService.TRANSITION_TYPED,
+
+ /**
+ * The user followed a bookmark to get to the page.
+ */
+ BOOKMARK: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+
+ /**
+ * Some inner content is loaded. This is true of all images on a
+ * page, and the contents of the iframe. It is also true of any
+ * content in a frame if the user did not explicitly follow a link
+ * to get there.
+ */
+ EMBED: Ci.nsINavHistoryService.TRANSITION_EMBED,
+
+ /**
+ * Set when the transition was a permanent redirect.
+ */
+ REDIRECT_PERMANENT: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+
+ /**
+ * Set when the transition was a temporary redirect.
+ */
+ REDIRECT_TEMPORARY: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
+
+ /**
+ * Set when the transition is a download.
+ */
+ DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+
+ /**
+ * The user followed a link and got a visit in a frame.
+ */
+ FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
+
+ /**
+ * The user reloaded a page.
+ */
+ RELOAD: Ci.nsINavHistoryService.TRANSITION_RELOAD,
+ },
+});
+
+/**
+ * Validate an input PageInfo object, returning a valid PageInfo object.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (PageInfo)
+ */
+function validatePageInfo(pageInfo) {
+ let info = {
+ visits: [],
+ };
+
+ if (!pageInfo.url) {
+ throw new TypeError("PageInfo object must have a url property");
+ }
+
+ info.url = normalizeToURLOrGUID(pageInfo.url);
+
+ if (typeof pageInfo.title === "string") {
+ info.title = pageInfo.title;
+ } else if (pageInfo.title != null && pageInfo.title != undefined) {
+ throw new TypeError(`title property of PageInfo object: ${pageInfo.title} must be a string if provided`);
+ }
+
+ if (!pageInfo.visits || !Array.isArray(pageInfo.visits) || !pageInfo.visits.length) {
+ throw new TypeError("PageInfo object must have an array of visits");
+ }
+ for (let inVisit of pageInfo.visits) {
+ let visit = {
+ date: new Date(),
+ transition: inVisit.transition || History.TRANSITIONS.LINK,
+ };
+
+ if (!isValidTransitionType(visit.transition)) {
+ throw new TypeError(`transition: ${visit.transition} is not a valid transition type`);
+ }
+
+ if (inVisit.date) {
+ ensureDate(inVisit.date);
+ if (inVisit.date > (Date.now() + TIMERS_RESOLUTION_SKEW_MS)) {
+ throw new TypeError(`date: ${inVisit.date} cannot be a future date`);
+ }
+ visit.date = inVisit.date;
+ }
+
+ if (inVisit.referrer) {
+ visit.referrer = normalizeToURLOrGUID(inVisit.referrer);
+ }
+ info.visits.push(visit);
+ }
+ return info;
+}
+
+/**
+ * Convert a PageInfo object into the format expected by updatePlaces.
+ *
+ * Note: this assumes that the PageInfo object has already been validated
+ * via validatePageInfo.
+ *
+ * @param pageInfo: (PageInfo)
+ * @return (info)
+ */
+function convertForUpdatePlaces(pageInfo) {
+ let info = {
+ uri: PlacesUtils.toURI(pageInfo.url),
+ title: pageInfo.title,
+ visits: [],
+ };
+
+ for (let inVisit of pageInfo.visits) {
+ let visit = {
+ visitDate: PlacesUtils.toPRTime(inVisit.date),
+ transitionType: inVisit.transition,
+ referrerURI: (inVisit.referrer) ? PlacesUtils.toURI(inVisit.referrer) : undefined,
+ };
+ info.visits.push(visit);
+ }
+ return info;
+}
+
+/**
+ * Is a value a valid transition type?
+ *
+ * @param transitionType: (String)
+ * @return (Boolean)
+ */
+function isValidTransitionType(transitionType) {
+ return Object.values(History.TRANSITIONS).includes(transitionType);
+}
+
+/**
+ * Normalize a key to either a string (if it is a valid GUID) or an
+ * instance of `URL` (if it is a `URL`, `nsIURI`, or a string
+ * representing a valid url).
+ *
+ * @throws (TypeError)
+ * If the key is neither a valid guid nor a valid url.
+ */
+function normalizeToURLOrGUID(key) {
+ if (typeof key === "string") {
+ // A string may be a URL or a guid
+ if (PlacesUtils.isValidGuid(key)) {
+ return key;
+ }
+ return new URL(key);
+ }
+ if (key instanceof URL) {
+ return key;
+ }
+ if (key instanceof Ci.nsIURI) {
+ return new URL(key.spec);
+ }
+ throw new TypeError("Invalid url or guid: " + key);
+}
+
+/**
+ * Throw if an object is not a Date object.
+ */
+function ensureDate(arg) {
+ if (!arg || typeof arg != "object" || arg.constructor.name != "Date") {
+ throw new TypeError("Expected a Date, got " + arg);
+ }
+}
+
+/**
+ * Convert a list of strings or numbers to its SQL
+ * representation as a string.
+ */
+function sqlList(list) {
+ return list.map(JSON.stringify).join();
+}
+
+/**
+ * Invalidate and recompute the frecency of a list of pages,
+ * informing frecency observers.
+ *
+ * @param db: (Sqlite connection)
+ * @param idList: (Array)
+ * The `moz_places` identifiers for the places to invalidate.
+ * @return (Promise)
+ */
+var invalidateFrecencies = Task.async(function*(db, idList) {
+ if (idList.length == 0) {
+ return;
+ }
+ let ids = sqlList(idList);
+ yield db.execute(
+ `UPDATE moz_places
+ SET frecency = NOTIFY_FRECENCY(
+ CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
+ ) WHERE id in (${ ids })`
+ );
+ yield db.execute(
+ `UPDATE moz_places
+ SET hidden = 0
+ WHERE id in (${ ids })
+ AND frecency <> 0`
+ );
+});
+
+// Inner implementation of History.clear().
+var clear = Task.async(function* (db) {
+ // Remove all history.
+ yield db.execute("DELETE FROM moz_historyvisits");
+
+ // Clear the registered embed visits.
+ PlacesUtils.history.clearEmbedVisits();
+
+ // Expiration will take care of orphans.
+ let observers = PlacesUtils.history.getObservers();
+ notify(observers, "onClearHistory");
+
+ // Invalidate frecencies for the remaining places. This must happen
+ // after the notification to ensure it runs enqueued to expiration.
+ yield db.execute(
+ `UPDATE moz_places SET frecency =
+ (CASE
+ WHEN url_hash BETWEEN hash("place", "prefix_lo") AND
+ hash("place", "prefix_hi")
+ THEN 0
+ ELSE -1
+ END)
+ WHERE frecency > 0`);
+
+ // Notify frecency change observers.
+ notify(observers, "onManyFrecenciesChanged");
+});
+
+/**
+ * Clean up pages whose history has been modified, by either
+ * removing them entirely (if they are marked for removal,
+ * typically because all visits have been removed and there
+ * are no more foreign keys such as bookmarks) or updating
+ * their frecency (otherwise).
+ *
+ * @param db: (Sqlite connection)
+ * The database.
+ * @param pages: (Array of objects)
+ * Pages that have been touched and that need cleaning up.
+ * Each object should have the following properties:
+ * - id: (number) The `moz_places` identifier for the place.
+ * - hasVisits: (boolean) If `true`, there remains at least one
+ * visit to this page, so the page should be kept and its
+ * frecency updated.
+ * - hasForeign: (boolean) If `true`, the page has at least
+ * one foreign reference (i.e. a bookmark), so the page should
+ * be kept and its frecency updated.
+ * @return (Promise)
+ */
+var cleanupPages = Task.async(function*(db, pages) {
+ yield invalidateFrecencies(db, pages.filter(p => p.hasForeign || p.hasVisits).map(p => p.id));
+
+ let pageIdsToRemove = pages.filter(p => !p.hasForeign && !p.hasVisits).map(p => p.id);
+ if (pageIdsToRemove.length > 0) {
+ let idsList = sqlList(pageIdsToRemove);
+ // Note, we are already in a transaction, since callers create it.
+ // Check relations regardless, to avoid creating orphans in case of
+ // async race conditions.
+ yield db.execute(`DELETE FROM moz_places WHERE id IN ( ${ idsList } )
+ AND foreign_count = 0 AND last_visit_date ISNULL`);
+ // Hosts accumulated during the places delete are updated through a trigger
+ // (see nsPlacesTriggers.h).
+ yield db.executeCached(`DELETE FROM moz_updatehosts_temp`);
+
+ // Expire orphans.
+ yield db.executeCached(`
+ DELETE FROM moz_favicons WHERE NOT EXISTS
+ (SELECT 1 FROM moz_places WHERE favicon_id = moz_favicons.id)`);
+ yield db.execute(`DELETE FROM moz_annos
+ WHERE place_id IN ( ${ idsList } )`);
+ yield db.execute(`DELETE FROM moz_inputhistory
+ WHERE place_id IN ( ${ idsList } )`);
+ }
+});
+
+/**
+ * Notify observers that pages have been removed/updated.
+ *
+ * @param db: (Sqlite connection)
+ * The database.
+ * @param pages: (Array of objects)
+ * Pages that have been touched and that need cleaning up.
+ * Each object should have the following properties:
+ * - id: (number) The `moz_places` identifier for the place.
+ * - hasVisits: (boolean) If `true`, there remains at least one
+ * visit to this page, so the page should be kept and its
+ * frecency updated.
+ * - hasForeign: (boolean) If `true`, the page has at least
+ * one foreign reference (i.e. a bookmark), so the page should
+ * be kept and its frecency updated.
+ * @return (Promise)
+ */
+var notifyCleanup = Task.async(function*(db, pages) {
+ let notifiedCount = 0;
+ let observers = PlacesUtils.history.getObservers();
+
+ let reason = Ci.nsINavHistoryObserver.REASON_DELETED;
+
+ for (let page of pages) {
+ let uri = NetUtil.newURI(page.url.href);
+ let guid = page.guid;
+ if (page.hasVisits) {
+ // For the moment, we do not have the necessary observer API
+ // to notify when we remove a subset of visits, see bug 937560.
+ continue;
+ }
+ if (page.hasForeign) {
+ // We have removed all visits, but the page is still alive, e.g.
+ // because of a bookmark.
+ notify(observers, "onDeleteVisits",
+ [uri, /* last visit*/0, guid, reason, -1]);
+ } else {
+ // The page has been entirely removed.
+ notify(observers, "onDeleteURI",
+ [uri, guid, reason]);
+ }
+ if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
+ // Every few notifications, yield time back to the main
+ // thread to avoid jank.
+ yield Promise.resolve();
+ }
+ }
+});
+
+/**
+ * Notify an `onResult` callback of a set of operations
+ * that just took place.
+ *
+ * @param data: (Array)
+ * The data to send to the callback.
+ * @param onResult: (function [optional])
+ * If provided, call `onResult` with `data[0]`, `data[1]`, etc.
+ * Otherwise, do nothing.
+ */
+var notifyOnResult = Task.async(function*(data, onResult) {
+ if (!onResult) {
+ return;
+ }
+ let notifiedCount = 0;
+ for (let info of data) {
+ try {
+ onResult(info);
+ } catch (ex) {
+ // Errors should be reported but should not stop the operation.
+ Promise.reject(ex);
+ }
+ if (++notifiedCount % ONRESULT_CHUNK_SIZE == 0) {
+ // Every few notifications, yield time back to the main
+ // thread to avoid jank.
+ yield Promise.resolve();
+ }
+ }
+});
+
+// Inner implementation of History.removeVisitsByFilter.
+var removeVisitsByFilter = Task.async(function*(db, filter, onResult = null) {
+ // 1. Determine visits that took place during the interval. Note
+ // that the database uses microseconds, while JS uses milliseconds,
+ // so we need to *1000 one way and /1000 the other way.
+ let conditions = [];
+ let args = {};
+ if ("beginDate" in filter) {
+ conditions.push("v.visit_date >= :begin * 1000");
+ args.begin = Number(filter.beginDate);
+ }
+ if ("endDate" in filter) {
+ conditions.push("v.visit_date <= :end * 1000");
+ args.end = Number(filter.endDate);
+ }
+ if ("limit" in filter) {
+ args.limit = Number(filter.limit);
+ }
+
+ let optionalJoin = "";
+ if ("url" in filter) {
+ let url = filter.url;
+ if (url instanceof Ci.nsIURI) {
+ url = filter.url.spec;
+ } else {
+ url = new URL(url).href;
+ }
+ optionalJoin = `JOIN moz_places h ON h.id = v.place_id`;
+ conditions.push("h.url_hash = hash(:url)", "h.url = :url");
+ args.url = url;
+ }
+
+
+ let visitsToRemove = [];
+ let pagesToInspect = new Set();
+ let onResultData = onResult ? [] : null;
+
+ yield db.executeCached(
+ `SELECT v.id, place_id, visit_date / 1000 AS date, visit_type FROM moz_historyvisits v
+ ${optionalJoin}
+ WHERE ${ conditions.join(" AND ") }${ args.limit ? " LIMIT :limit" : "" }`,
+ args,
+ row => {
+ let id = row.getResultByName("id");
+ let place_id = row.getResultByName("place_id");
+ visitsToRemove.push(id);
+ pagesToInspect.add(place_id);
+
+ if (onResult) {
+ onResultData.push({
+ date: new Date(row.getResultByName("date")),
+ transition: row.getResultByName("visit_type")
+ });
+ }
+ }
+ );
+
+ try {
+ if (visitsToRemove.length == 0) {
+ // Nothing to do
+ return false;
+ }
+
+ let pages = [];
+ yield db.executeTransaction(function*() {
+ // 2. Remove all offending visits.
+ yield db.execute(`DELETE FROM moz_historyvisits
+ WHERE id IN (${ sqlList(visitsToRemove) } )`);
+
+ // 3. Find out which pages have been orphaned
+ yield db.execute(
+ `SELECT id, url, guid,
+ (foreign_count != 0) AS has_foreign,
+ (last_visit_date NOTNULL) as has_visits
+ FROM moz_places
+ WHERE id IN (${ sqlList([...pagesToInspect]) })`,
+ null,
+ row => {
+ let page = {
+ id: row.getResultByName("id"),
+ guid: row.getResultByName("guid"),
+ hasForeign: row.getResultByName("has_foreign"),
+ hasVisits: row.getResultByName("has_visits"),
+ url: new URL(row.getResultByName("url")),
+ };
+ pages.push(page);
+ });
+
+ // 4. Clean up and notify
+ yield cleanupPages(db, pages);
+ });
+
+ notifyCleanup(db, pages);
+ notifyOnResult(onResultData, onResult); // don't wait
+ } finally {
+ // Ensure we cleanup embed visits, even if we bailed out early.
+ PlacesUtils.history.clearEmbedVisits();
+ }
+
+ return visitsToRemove.length != 0;
+});
+
+
+// Inner implementation of History.remove.
+var remove = Task.async(function*(db, {guids, urls}, onResult = null) {
+ // 1. Find out what needs to be removed
+ let query =
+ `SELECT id, url, guid, foreign_count, title, frecency
+ FROM moz_places
+ WHERE guid IN (${ sqlList(guids) })
+ OR (url_hash IN (${ urls.map(u => "hash(" + JSON.stringify(u) + ")").join(",") })
+ AND url IN (${ sqlList(urls) }))
+ `;
+
+ let onResultData = onResult ? [] : null;
+ let pages = [];
+ let hasPagesToRemove = false;
+ yield db.execute(query, null, Task.async(function*(row) {
+ let hasForeign = row.getResultByName("foreign_count") != 0;
+ if (!hasForeign) {
+ hasPagesToRemove = true;
+ }
+ let id = row.getResultByName("id");
+ let guid = row.getResultByName("guid");
+ let url = row.getResultByName("url");
+ let page = {
+ id,
+ guid,
+ hasForeign,
+ hasVisits: false,
+ url: new URL(url),
+ };
+ pages.push(page);
+ if (onResult) {
+ onResultData.push({
+ guid: guid,
+ title: row.getResultByName("title"),
+ frecency: row.getResultByName("frecency"),
+ url: new URL(url)
+ });
+ }
+ }));
+
+ try {
+ if (pages.length == 0) {
+ // Nothing to do
+ return false;
+ }
+
+ yield db.executeTransaction(function*() {
+ // 2. Remove all visits to these pages.
+ yield db.execute(`DELETE FROM moz_historyvisits
+ WHERE place_id IN (${ sqlList(pages.map(p => p.id)) })
+ `);
+
+ // 3. Clean up and notify
+ yield cleanupPages(db, pages);
+ });
+
+ notifyCleanup(db, pages);
+ notifyOnResult(onResultData, onResult); // don't wait
+ } finally {
+ // Ensure we cleanup embed visits, even if we bailed out early.
+ PlacesUtils.history.clearEmbedVisits();
+ }
+
+ return hasPagesToRemove;
+});
+
+/**
+ * Merges an updateInfo object, as returned by asyncHistory.updatePlaces
+ * into a PageInfo object as defined in this file.
+ *
+ * @param updateInfo: (Object)
+ * An object that represents a page that is generated by
+ * asyncHistory.updatePlaces.
+ * @param pageInfo: (PageInfo)
+ * An PageInfo object into which to merge the data from updateInfo.
+ * Defaults to an empty object so that this method can be used
+ * to simply convert an updateInfo object into a PageInfo object.
+ *
+ * @return (PageInfo)
+ * A PageInfo object populated with data from updateInfo.
+ */
+function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo={}) {
+ pageInfo.guid = updateInfo.guid;
+ if (!pageInfo.url) {
+ pageInfo.url = new URL(updateInfo.uri.spec);
+ pageInfo.title = updateInfo.title;
+ pageInfo.visits = updateInfo.visits.map(visit => {
+ return {
+ date: PlacesUtils.toDate(visit.visitDate),
+ transition: visit.transitionType,
+ referrer: (visit.referrerURI) ? new URL(visit.referrerURI.spec) : null
+ }
+ });
+ }
+ return pageInfo;
+}
+
+// Inner implementation of History.insert.
+var insert = Task.async(function*(db, pageInfo) {
+ let info = convertForUpdatePlaces(pageInfo);
+
+ return new Promise((resolve, reject) => {
+ PlacesUtils.asyncHistory.updatePlaces(info, {
+ handleError: error => {
+ reject(error);
+ },
+ handleResult: result => {
+ pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo);
+ },
+ handleCompletion: () => {
+ resolve(pageInfo);
+ }
+ });
+ });
+});
+
+// Inner implementation of History.insertMany.
+var insertMany = Task.async(function*(db, pageInfos, onResult, onError) {
+ let infos = [];
+ let onResultData = [];
+ let onErrorData = [];
+
+ for (let pageInfo of pageInfos) {
+ let info = convertForUpdatePlaces(pageInfo);
+ infos.push(info);
+ }
+
+ return new Promise((resolve, reject) => {
+ PlacesUtils.asyncHistory.updatePlaces(infos, {
+ handleError: (resultCode, result) => {
+ let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+ onErrorData.push(pageInfo);
+ },
+ handleResult: result => {
+ let pageInfo = mergeUpdateInfoIntoPageInfo(result);
+ onResultData.push(pageInfo);
+ },
+ handleCompletion: () => {
+ notifyOnResult(onResultData, onResult);
+ notifyOnResult(onErrorData, onError);
+ if (onResultData.length) {
+ resolve();
+ } else {
+ reject({message: "No items were added to history."})
+ }
+ }
+ });
+ });
+});
diff --git a/toolkit/components/places/PageIconProtocolHandler.js b/toolkit/components/places/PageIconProtocolHandler.js
new file mode 100644
index 000000000..05e43ccf3
--- /dev/null
+++ b/toolkit/components/places/PageIconProtocolHandler.js
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import('resource://gre/modules/XPCOMUtils.jsm');
+Cu.import('resource://gre/modules/Services.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+
+function makeDefaultFaviconChannel(uri, loadInfo) {
+ let channel = Services.io.newChannelFromURIWithLoadInfo(
+ PlacesUtils.favicons.defaultFavicon, loadInfo);
+ channel.originalURI = uri;
+ return channel;
+}
+
+function streamDefaultFavicon(uri, loadInfo, outputStream) {
+ try {
+ // Open up a new channel to get that data, and push it to our output stream.
+ // Create a listener to hand data to the pipe's output stream.
+ let listener = Cc["@mozilla.org/network/simple-stream-listener;1"]
+ .createInstance(Ci.nsISimpleStreamListener);
+ listener.init(outputStream, {
+ onStartRequest(request, context) {},
+ onStopRequest(request, context, statusCode) {
+ // We must close the outputStream regardless.
+ outputStream.close();
+ }
+ });
+ let defaultIconChannel = makeDefaultFaviconChannel(uri, loadInfo);
+ defaultIconChannel.asyncOpen2(listener);
+ } catch (ex) {
+ Cu.reportError(ex);
+ outputStream.close();
+ }
+}
+
+function PageIconProtocolHandler() {
+}
+
+PageIconProtocolHandler.prototype = {
+ get scheme() {
+ return "page-icon";
+ },
+
+ get defaultPort() {
+ return -1;
+ },
+
+ get protocolFlags() {
+ return Ci.nsIProtocolHandler.URI_NORELATIVE |
+ Ci.nsIProtocolHandler.URI_NOAUTH |
+ Ci.nsIProtocolHandler.URI_DANGEROUS_TO_LOAD |
+ Ci.nsIProtocolHandler.URI_IS_LOCAL_RESOURCE;
+ },
+
+ newURI(spec, originCharset, baseURI) {
+ let uri = Cc["@mozilla.org/network/simple-uri;1"].createInstance(Ci.nsIURI);
+ uri.spec = spec;
+ return uri;
+ },
+
+ newChannel2(uri, loadInfo) {
+ try {
+ // Create a pipe that will give us an output stream that we can use once
+ // we got all the favicon data.
+ let pipe = Cc["@mozilla.org/pipe;1"]
+ .createInstance(Ci.nsIPipe);
+ pipe.init(true, true, 0, Ci.nsIFaviconService.MAX_FAVICON_BUFFER_SIZE);
+
+ // Create our channel.
+ let channel = Cc['@mozilla.org/network/input-stream-channel;1']
+ .createInstance(Ci.nsIInputStreamChannel);
+ channel.QueryInterface(Ci.nsIChannel);
+ channel.setURI(uri);
+ channel.contentStream = pipe.inputStream;
+ channel.loadInfo = loadInfo;
+
+ let pageURI = NetUtil.newURI(uri.path);
+ PlacesUtils.favicons.getFaviconDataForPage(pageURI, (iconuri, len, data, mime) => {
+ if (len == 0) {
+ channel.contentType = "image/png";
+ streamDefaultFavicon(uri, loadInfo, pipe.outputStream);
+ return;
+ }
+
+ try {
+ channel.contentType = mime;
+ // Pass the icon data to the output stream.
+ let stream = Cc["@mozilla.org/binaryoutputstream;1"]
+ .createInstance(Ci.nsIBinaryOutputStream);
+ stream.setOutputStream(pipe.outputStream);
+ stream.writeByteArray(data, len);
+ stream.close();
+ pipe.outputStream.close();
+ } catch (ex) {
+ channel.contentType = "image/png";
+ streamDefaultFavicon(uri, loadInfo, pipe.outputStream);
+ }
+ });
+
+ return channel;
+ } catch (ex) {
+ return makeDefaultFaviconChannel(uri, loadInfo);
+ }
+ },
+
+ newChannel(uri) {
+ return this.newChannel2(uri, null);
+ },
+
+ allowPort(port, scheme) {
+ return false;
+ },
+
+ classID: Components.ID("{60a1f7c6-4ff9-4a42-84d3-5a185faa6f32}"),
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIProtocolHandler
+ ])
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PageIconProtocolHandler]);
diff --git a/toolkit/components/places/PlaceInfo.cpp b/toolkit/components/places/PlaceInfo.cpp
new file mode 100644
index 000000000..760b0e718
--- /dev/null
+++ b/toolkit/components/places/PlaceInfo.cpp
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "PlaceInfo.h"
+#include "VisitInfo.h"
+#include "nsIURI.h"
+#include "nsServiceManagerUtils.h"
+#include "nsIXPConnect.h"
+#include "mozilla/Services.h"
+#include "jsapi.h"
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// PlaceInfo
+
+PlaceInfo::PlaceInfo(int64_t aId,
+ const nsCString& aGUID,
+ already_AddRefed<nsIURI> aURI,
+ const nsString& aTitle,
+ int64_t aFrecency)
+: mId(aId)
+, mGUID(aGUID)
+, mURI(aURI)
+, mTitle(aTitle)
+, mFrecency(aFrecency)
+, mVisitsAvailable(false)
+{
+ NS_PRECONDITION(mURI, "Must provide a non-null uri!");
+}
+
+PlaceInfo::PlaceInfo(int64_t aId,
+ const nsCString& aGUID,
+ already_AddRefed<nsIURI> aURI,
+ const nsString& aTitle,
+ int64_t aFrecency,
+ const VisitsArray& aVisits)
+: mId(aId)
+, mGUID(aGUID)
+, mURI(aURI)
+, mTitle(aTitle)
+, mFrecency(aFrecency)
+, mVisits(aVisits)
+, mVisitsAvailable(true)
+{
+ NS_PRECONDITION(mURI, "Must provide a non-null uri!");
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// mozIPlaceInfo
+
+NS_IMETHODIMP
+PlaceInfo::GetPlaceId(int64_t* _placeId)
+{
+ *_placeId = mId;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlaceInfo::GetGuid(nsACString& _guid)
+{
+ _guid = mGUID;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlaceInfo::GetUri(nsIURI** _uri)
+{
+ NS_ADDREF(*_uri = mURI);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlaceInfo::GetTitle(nsAString& _title)
+{
+ _title = mTitle;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlaceInfo::GetFrecency(int64_t* _frecency)
+{
+ *_frecency = mFrecency;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PlaceInfo::GetVisits(JSContext* aContext,
+ JS::MutableHandle<JS::Value> _visits)
+{
+ // If the visits data was not provided, return null rather
+ // than an empty array to distinguish this case from the case
+ // of a place without any visit.
+ if (!mVisitsAvailable) {
+ _visits.setNull();
+ return NS_OK;
+ }
+
+ // TODO bug 625913 when we use this in situations that have more than one
+ // visit here, we will likely want to make this cache the value.
+ JS::Rooted<JSObject*> visits(aContext,
+ JS_NewArrayObject(aContext, 0));
+ NS_ENSURE_TRUE(visits, NS_ERROR_OUT_OF_MEMORY);
+
+ JS::Rooted<JSObject*> global(aContext, JS::CurrentGlobalOrNull(aContext));
+ NS_ENSURE_TRUE(global, NS_ERROR_UNEXPECTED);
+
+ nsCOMPtr<nsIXPConnect> xpc = mozilla::services::GetXPConnect();
+
+ for (VisitsArray::size_type idx = 0; idx < mVisits.Length(); idx++) {
+ JS::RootedObject jsobj(aContext);
+ nsresult rv = xpc->WrapNative(aContext, global, mVisits[idx],
+ NS_GET_IID(mozIVisitInfo),
+ jsobj.address());
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_STATE(jsobj);
+
+ bool rc = JS_DefineElement(aContext, visits, idx, jsobj, JSPROP_ENUMERATE);
+ NS_ENSURE_TRUE(rc, NS_ERROR_UNEXPECTED);
+ }
+
+ _visits.setObject(*visits);
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsISupports
+
+NS_IMPL_ISUPPORTS(
+ PlaceInfo
+, mozIPlaceInfo
+)
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/PlaceInfo.h b/toolkit/components/places/PlaceInfo.h
new file mode 100644
index 000000000..b1d3c0893
--- /dev/null
+++ b/toolkit/components/places/PlaceInfo.h
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_PlaceInfo_h__
+#define mozilla_places_PlaceInfo_h__
+
+#include "mozIAsyncHistory.h"
+#include "nsString.h"
+#include "nsTArray.h"
+#include "nsAutoPtr.h"
+#include "mozilla/Attributes.h"
+
+class nsIURI;
+class mozIVisitInfo;
+
+namespace mozilla {
+namespace places {
+
+
+class PlaceInfo final : public mozIPlaceInfo
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZIPLACEINFO
+
+ typedef nsTArray< nsCOMPtr<mozIVisitInfo> > VisitsArray;
+
+ PlaceInfo(int64_t aId, const nsCString& aGUID, already_AddRefed<nsIURI> aURI,
+ const nsString& aTitle, int64_t aFrecency);
+ PlaceInfo(int64_t aId, const nsCString& aGUID, already_AddRefed<nsIURI> aURI,
+ const nsString& aTitle, int64_t aFrecency,
+ const VisitsArray& aVisits);
+
+private:
+ ~PlaceInfo() {}
+
+ const int64_t mId;
+ const nsCString mGUID;
+ nsCOMPtr<nsIURI> mURI;
+ const nsString mTitle;
+ const int64_t mFrecency;
+ const VisitsArray mVisits;
+ bool mVisitsAvailable;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_PlaceInfo_h__
diff --git a/toolkit/components/places/PlacesBackups.jsm b/toolkit/components/places/PlacesBackups.jsm
new file mode 100644
index 000000000..8315aeb3a
--- /dev/null
+++ b/toolkit/components/places/PlacesBackups.jsm
@@ -0,0 +1,550 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 expandtab filetype=javascript
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = ["PlacesBackups"];
+
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cc = Components.classes;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkJSONUtils",
+ "resource://gre/modules/BookmarkJSONUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "localFileCtor",
+ () => Components.Constructor("@mozilla.org/file/local;1",
+ "nsILocalFile", "initWithPath"));
+
+XPCOMUtils.defineLazyGetter(this, "filenamesRegex",
+ () => /^bookmarks-([0-9-]+)(?:_([0-9]+)){0,1}(?:_([a-z0-9=+-]{24})){0,1}\.(json(lz4)?)$/i
+);
+
+/**
+ * Appends meta-data information to a given filename.
+ */
+function appendMetaDataToFilename(aFilename, aMetaData) {
+ let matches = aFilename.match(filenamesRegex);
+ return "bookmarks-" + matches[1] +
+ "_" + aMetaData.count +
+ "_" + aMetaData.hash +
+ "." + matches[4];
+}
+
+/**
+ * Gets the hash from a backup filename.
+ *
+ * @return the extracted hash or null.
+ */
+function getHashFromFilename(aFilename) {
+ let matches = aFilename.match(filenamesRegex);
+ if (matches && matches[3])
+ return matches[3];
+ return null;
+}
+
+/**
+ * Given two filenames, checks if they contain the same date.
+ */
+function isFilenameWithSameDate(aSourceName, aTargetName) {
+ let sourceMatches = aSourceName.match(filenamesRegex);
+ let targetMatches = aTargetName.match(filenamesRegex);
+
+ return sourceMatches && targetMatches &&
+ sourceMatches[1] == targetMatches[1];
+}
+
+/**
+ * Given a filename, searches for another backup with the same date.
+ *
+ * @return OS.File path string or null.
+ */
+function getBackupFileForSameDate(aFilename) {
+ return Task.spawn(function* () {
+ let backupFiles = yield PlacesBackups.getBackupFiles();
+ for (let backupFile of backupFiles) {
+ if (isFilenameWithSameDate(OS.Path.basename(backupFile), aFilename))
+ return backupFile;
+ }
+ return null;
+ });
+}
+
+this.PlacesBackups = {
+ /**
+ * Matches the backup filename:
+ * 0: file name
+ * 1: date in form Y-m-d
+ * 2: bookmarks count
+ * 3: contents hash
+ * 4: file extension
+ */
+ get filenamesRegex() {
+ return filenamesRegex;
+ },
+
+ get folder() {
+ Deprecated.warning(
+ "PlacesBackups.folder is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+ return this._folder;
+ },
+
+ /**
+ * This exists just to avoid spamming deprecate warnings from internal calls
+ * needed to support deprecated methods themselves.
+ */
+ get _folder() {
+ let bookmarksBackupDir = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ bookmarksBackupDir.append(this.profileRelativeFolderPath);
+ if (!bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0700", 8));
+ if (!bookmarksBackupDir.exists())
+ throw ("Unable to create bookmarks backup folder");
+ }
+ delete this._folder;
+ return this._folder = bookmarksBackupDir;
+ },
+
+ /**
+ * Gets backup folder asynchronously.
+ * @return {Promise}
+ * @resolve the folder (the folder string path).
+ */
+ getBackupFolder: function PB_getBackupFolder() {
+ return Task.spawn(function* () {
+ if (this._backupFolder) {
+ return this._backupFolder;
+ }
+ let profileDir = OS.Constants.Path.profileDir;
+ let backupsDirPath = OS.Path.join(profileDir, this.profileRelativeFolderPath);
+ yield OS.File.makeDir(backupsDirPath, { ignoreExisting: true });
+ return this._backupFolder = backupsDirPath;
+ }.bind(this));
+ },
+
+ get profileRelativeFolderPath() {
+ return "bookmarkbackups";
+ },
+
+ /**
+ * Cache current backups in a sorted (by date DESC) array.
+ */
+ get entries() {
+ Deprecated.warning(
+ "PlacesBackups.entries is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+ return this._entries;
+ },
+
+ /**
+ * This exists just to avoid spamming deprecate warnings from internal calls
+ * needed to support deprecated methods themselves.
+ */
+ get _entries() {
+ delete this._entries;
+ this._entries = [];
+ let files = this._folder.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ // A valid backup is any file that matches either the localized or
+ // not-localized filename (bug 445704).
+ if (!entry.isHidden() && filenamesRegex.test(entry.leafName)) {
+ // Remove bogus backups in future dates.
+ if (this.getDateForFile(entry) > new Date()) {
+ entry.remove(false);
+ continue;
+ }
+ this._entries.push(entry);
+ }
+ }
+ this._entries.sort((a, b) => {
+ let aDate = this.getDateForFile(a);
+ let bDate = this.getDateForFile(b);
+ return bDate - aDate;
+ });
+ return this._entries;
+ },
+
+ /**
+ * Cache current backups in a sorted (by date DESC) array.
+ * @return {Promise}
+ * @resolve a sorted array of string paths.
+ */
+ getBackupFiles: function PB_getBackupFiles() {
+ return Task.spawn(function* () {
+ if (this._backupFiles)
+ return this._backupFiles;
+
+ this._backupFiles = [];
+
+ let backupFolderPath = yield this.getBackupFolder();
+ let iterator = new OS.File.DirectoryIterator(backupFolderPath);
+ yield iterator.forEach(function(aEntry) {
+ // Since this is a lazy getter and OS.File I/O is serialized, we can
+ // safely remove .tmp files without risking to remove ongoing backups.
+ if (aEntry.name.endsWith(".tmp")) {
+ OS.File.remove(aEntry.path);
+ return undefined;
+ }
+
+ if (filenamesRegex.test(aEntry.name)) {
+ // Remove bogus backups in future dates.
+ let filePath = aEntry.path;
+ if (this.getDateForFile(filePath) > new Date()) {
+ return OS.File.remove(filePath);
+ }
+ this._backupFiles.push(filePath);
+ }
+
+ return undefined;
+ }.bind(this));
+ iterator.close();
+
+ this._backupFiles.sort((a, b) => {
+ let aDate = this.getDateForFile(a);
+ let bDate = this.getDateForFile(b);
+ return bDate - aDate;
+ });
+
+ return this._backupFiles;
+ }.bind(this));
+ },
+
+ /**
+ * Generates a ISO date string (YYYY-MM-DD) from a Date object.
+ *
+ * @param dateObj
+ * The date object to parse.
+ * @return an ISO date string.
+ */
+ toISODateString: function toISODateString(dateObj) {
+ if (!dateObj || dateObj.constructor.name != "Date" || !dateObj.getTime())
+ throw new Error("invalid date object");
+ let padDate = val => ("0" + val).substr(-2, 2);
+ return [
+ dateObj.getFullYear(),
+ padDate(dateObj.getMonth() + 1),
+ padDate(dateObj.getDate())
+ ].join("-");
+ },
+
+ /**
+ * Creates a filename for bookmarks backup files.
+ *
+ * @param [optional] aDateObj
+ * Date object used to build the filename.
+ * Will use current date if empty.
+ * @param [optional] bool - aCompress
+ * Determines if file extension is json or jsonlz4
+ Default is json
+ * @return A bookmarks backup filename.
+ */
+ getFilenameForDate: function PB_getFilenameForDate(aDateObj, aCompress) {
+ let dateObj = aDateObj || new Date();
+ // Use YYYY-MM-DD (ISO 8601) as it doesn't contain illegal characters
+ // and makes the alphabetical order of multiple backup files more useful.
+ return "bookmarks-" + PlacesBackups.toISODateString(dateObj) + ".json" +
+ (aCompress ? "lz4" : "");
+ },
+
+ /**
+ * Creates a Date object from a backup file. The date is the backup
+ * creation date.
+ *
+ * @param aBackupFile
+ * nsIFile or string path of the backup.
+ * @return A Date object for the backup's creation time.
+ */
+ getDateForFile: function PB_getDateForFile(aBackupFile) {
+ let filename = (aBackupFile instanceof Ci.nsIFile) ? aBackupFile.leafName
+ : OS.Path.basename(aBackupFile);
+ let matches = filename.match(filenamesRegex);
+ if (!matches)
+ throw ("Invalid backup file name: " + filename);
+ return new Date(matches[1].replace(/-/g, "/"));
+ },
+
+ /**
+ * Get the most recent backup file.
+ *
+ * @returns nsIFile backup file
+ */
+ getMostRecent: function PB_getMostRecent() {
+ Deprecated.warning(
+ "PlacesBackups.getMostRecent is deprecated and will be removed in a future version",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=859695");
+
+ for (let i = 0; i < this._entries.length; i++) {
+ let rx = /\.json(lz4)?$/;
+ if (this._entries[i].leafName.match(rx))
+ return this._entries[i];
+ }
+ return null;
+ },
+
+ /**
+ * Get the most recent backup file.
+ *
+ * @return {Promise}
+ * @result the path to the file.
+ */
+ getMostRecentBackup: function PB_getMostRecentBackup() {
+ return Task.spawn(function* () {
+ let entries = yield this.getBackupFiles();
+ for (let entry of entries) {
+ let rx = /\.json(lz4)?$/;
+ if (OS.Path.basename(entry).match(rx)) {
+ return entry;
+ }
+ }
+ return null;
+ }.bind(this));
+ },
+
+ /**
+ * Serializes bookmarks using JSON, and writes to the supplied file.
+ * Note: any item that should not be backed up must be annotated with
+ * "places/excludeFromBackup".
+ *
+ * @param aFilePath
+ * OS.File path for the "bookmarks.json" file to be created.
+ * @return {Promise}
+ * @resolves the number of serialized uri nodes.
+ * @deprecated passing an nsIFile is deprecated
+ */
+ saveBookmarksToJSONFile: function PB_saveBookmarksToJSONFile(aFilePath) {
+ if (aFilePath instanceof Ci.nsIFile) {
+ Deprecated.warning("Passing an nsIFile to PlacesBackups.saveBookmarksToJSONFile " +
+ "is deprecated. Please use an OS.File path instead.",
+ "https://developer.mozilla.org/docs/JavaScript_OS.File");
+ aFilePath = aFilePath.path;
+ }
+ return Task.spawn(function* () {
+ let { count: nodeCount, hash: hash } =
+ yield BookmarkJSONUtils.exportToFile(aFilePath);
+
+ let backupFolderPath = yield this.getBackupFolder();
+ if (OS.Path.dirname(aFilePath) == backupFolderPath) {
+ // We are creating a backup in the default backups folder,
+ // so just update the internal cache.
+ this._entries.unshift(new localFileCtor(aFilePath));
+ if (!this._backupFiles) {
+ yield this.getBackupFiles();
+ }
+ this._backupFiles.unshift(aFilePath);
+ } else {
+ // If we are saving to a folder different than our backups folder, then
+ // we also want to create a new compressed version in it.
+ // This way we ensure the latest valid backup is the same saved by the
+ // user. See bug 424389.
+ let mostRecentBackupFile = yield this.getMostRecentBackup();
+ if (!mostRecentBackupFile ||
+ hash != getHashFromFilename(OS.Path.basename(mostRecentBackupFile))) {
+ let name = this.getFilenameForDate(undefined, true);
+ let newFilename = appendMetaDataToFilename(name,
+ { count: nodeCount,
+ hash: hash });
+ let newFilePath = OS.Path.join(backupFolderPath, newFilename);
+ let backupFile = yield getBackupFileForSameDate(name);
+ if (backupFile) {
+ // There is already a backup for today, replace it.
+ yield OS.File.remove(backupFile, { ignoreAbsent: true });
+ if (!this._backupFiles)
+ yield this.getBackupFiles();
+ else
+ this._backupFiles.shift();
+ this._backupFiles.unshift(newFilePath);
+ } else {
+ // There is no backup for today, add the new one.
+ this._entries.unshift(new localFileCtor(newFilePath));
+ if (!this._backupFiles)
+ yield this.getBackupFiles();
+ this._backupFiles.unshift(newFilePath);
+ }
+ let jsonString = yield OS.File.read(aFilePath);
+ yield OS.File.writeAtomic(newFilePath, jsonString, { compression: "lz4" });
+ }
+ }
+
+ return nodeCount;
+ }.bind(this));
+ },
+
+ /**
+ * Creates a dated backup in <profile>/bookmarkbackups.
+ * Stores the bookmarks using a lz4 compressed JSON file.
+ * Note: any item that should not be backed up must be annotated with
+ * "places/excludeFromBackup".
+ *
+ * @param [optional] int aMaxBackups
+ * The maximum number of backups to keep. If set to 0
+ * all existing backups are removed and aForceBackup is
+ * ignored, so a new one won't be created.
+ * @param [optional] bool aForceBackup
+ * Forces creating a backup even if one was already
+ * created that day (overwrites).
+ * @return {Promise}
+ */
+ create: function PB_create(aMaxBackups, aForceBackup) {
+ let limitBackups = function* () {
+ let backupFiles = yield this.getBackupFiles();
+ if (typeof aMaxBackups == "number" && aMaxBackups > -1 &&
+ backupFiles.length >= aMaxBackups) {
+ let numberOfBackupsToDelete = backupFiles.length - aMaxBackups;
+ while (numberOfBackupsToDelete--) {
+ this._entries.pop();
+ let oldestBackup = this._backupFiles.pop();
+ yield OS.File.remove(oldestBackup);
+ }
+ }
+ }.bind(this);
+
+ return Task.spawn(function* () {
+ if (aMaxBackups === 0) {
+ // Backups are disabled, delete any existing one and bail out.
+ yield limitBackups(0);
+ return;
+ }
+
+ // Ensure to initialize _backupFiles
+ if (!this._backupFiles)
+ yield this.getBackupFiles();
+ let newBackupFilename = this.getFilenameForDate(undefined, true);
+ // If we already have a backup for today we should do nothing, unless we
+ // were required to enforce a new backup.
+ let backupFile = yield getBackupFileForSameDate(newBackupFilename);
+ if (backupFile && !aForceBackup)
+ return;
+
+ if (backupFile) {
+ // In case there is a backup for today we should recreate it.
+ this._backupFiles.shift();
+ this._entries.shift();
+ yield OS.File.remove(backupFile, { ignoreAbsent: true });
+ }
+
+ // Now check the hash of the most recent backup, and try to create a new
+ // backup, if that fails due to hash conflict, just rename the old backup.
+ let mostRecentBackupFile = yield this.getMostRecentBackup();
+ let mostRecentHash = mostRecentBackupFile &&
+ getHashFromFilename(OS.Path.basename(mostRecentBackupFile));
+
+ // Save bookmarks to a backup file.
+ let backupFolder = yield this.getBackupFolder();
+ let newBackupFile = OS.Path.join(backupFolder, newBackupFilename);
+ let newFilenameWithMetaData;
+ try {
+ let { count: nodeCount, hash: hash } =
+ yield BookmarkJSONUtils.exportToFile(newBackupFile,
+ { compress: true,
+ failIfHashIs: mostRecentHash });
+ newFilenameWithMetaData = appendMetaDataToFilename(newBackupFilename,
+ { count: nodeCount,
+ hash: hash });
+ } catch (ex) {
+ if (!ex.becauseSameHash) {
+ throw ex;
+ }
+ // The last backup already contained up-to-date information, just
+ // rename it as if it was today's backup.
+ this._backupFiles.shift();
+ this._entries.shift();
+ newBackupFile = mostRecentBackupFile;
+ // Ensure we retain the proper extension when renaming
+ // the most recent backup file.
+ if (/\.json$/.test(OS.Path.basename(mostRecentBackupFile)))
+ newBackupFilename = this.getFilenameForDate();
+ newFilenameWithMetaData = appendMetaDataToFilename(
+ newBackupFilename,
+ { count: this.getBookmarkCountForFile(mostRecentBackupFile),
+ hash: mostRecentHash });
+ }
+
+ // Append metadata to the backup filename.
+ let newBackupFileWithMetadata = OS.Path.join(backupFolder, newFilenameWithMetaData);
+ yield OS.File.move(newBackupFile, newBackupFileWithMetadata);
+ this._entries.unshift(new localFileCtor(newBackupFileWithMetadata));
+ this._backupFiles.unshift(newBackupFileWithMetadata);
+
+ // Limit the number of backups.
+ yield limitBackups(aMaxBackups);
+ }.bind(this));
+ },
+
+ /**
+ * Gets the bookmark count for backup file.
+ *
+ * @param aFilePath
+ * File path The backup file.
+ *
+ * @return the bookmark count or null.
+ */
+ getBookmarkCountForFile: function PB_getBookmarkCountForFile(aFilePath) {
+ let count = null;
+ let filename = OS.Path.basename(aFilePath);
+ let matches = filename.match(filenamesRegex);
+ if (matches && matches[2])
+ count = matches[2];
+ return count;
+ },
+
+ /**
+ * Gets a bookmarks tree representation usable to create backups in different
+ * file formats. The root or the tree is PlacesUtils.placesRootId.
+ * Items annotated with PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO and all of their
+ * descendants are excluded.
+ *
+ * @return an object representing a tree with the places root as its root.
+ * Each bookmark is represented by an object having these properties:
+ * * id: the item id (make this not enumerable after bug 824502)
+ * * title: the title
+ * * guid: unique id
+ * * parent: item id of the parent folder, not enumerable
+ * * index: the position in the parent
+ * * dateAdded: microseconds from the epoch
+ * * lastModified: microseconds from the epoch
+ * * type: type of the originating node as defined in PlacesUtils
+ * The following properties exist only for a subset of bookmarks:
+ * * annos: array of annotations
+ * * uri: url
+ * * iconuri: favicon's url
+ * * keyword: associated keyword
+ * * charset: last known charset
+ * * tags: csv string of tags
+ * * root: string describing whether this represents a root
+ * * children: array of child items in a folder
+ */
+ getBookmarksTree: Task.async(function* () {
+ let startTime = Date.now();
+ let root = yield PlacesUtils.promiseBookmarksTree(PlacesUtils.bookmarks.rootGuid, {
+ excludeItemsCallback: aItem => {
+ return aItem.annos &&
+ aItem.annos.find(a => a.name == PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO);
+ },
+ includeItemIds: true
+ });
+
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_BACKUPS_BOOKMARKSTREE_MS")
+ .add(Date.now() - startTime);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ }
+ return [root, root.itemsCount];
+ })
+}
+
diff --git a/toolkit/components/places/PlacesCategoriesStarter.js b/toolkit/components/places/PlacesCategoriesStarter.js
new file mode 100644
index 000000000..560bd486a
--- /dev/null
+++ b/toolkit/components/places/PlacesCategoriesStarter.js
@@ -0,0 +1,110 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 expandtab
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Constants
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+// Fired by TelemetryController when async telemetry data should be collected.
+const TOPIC_GATHER_TELEMETRY = "gather-telemetry";
+
+// Seconds between maintenance runs.
+const MAINTENANCE_INTERVAL_SECONDS = 7 * 86400;
+
+// Imports
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesDBUtils",
+ "resource://gre/modules/PlacesDBUtils.jsm");
+
+/**
+ * This component can be used as a starter for modules that have to run when
+ * certain categories are invoked.
+ */
+function PlacesCategoriesStarter()
+{
+ Services.obs.addObserver(this, TOPIC_GATHER_TELEMETRY, false);
+ Services.obs.addObserver(this, PlacesUtils.TOPIC_SHUTDOWN, false);
+
+ // nsINavBookmarkObserver implementation.
+ let notify = () => {
+ if (!this._notifiedBookmarksSvcReady) {
+ // TODO (bug 1145424): for whatever reason, even if we remove this
+ // component from the category (and thus from the category cache we use
+ // to notify), we keep being notified.
+ this._notifiedBookmarksSvcReady = true;
+ // For perf reasons unregister from the category, since no further
+ // notifications are needed.
+ Cc["@mozilla.org/categorymanager;1"]
+ .getService(Ci.nsICategoryManager)
+ .deleteCategoryEntry("bookmark-observers", "PlacesCategoriesStarter", false);
+ // Directly notify PlacesUtils, to ensure it catches the notification.
+ PlacesUtils.observe(null, "bookmarks-service-ready", null);
+ }
+ };
+
+ [ "onItemAdded", "onItemRemoved", "onItemChanged", "onBeginUpdateBatch",
+ "onEndUpdateBatch", "onItemVisited", "onItemMoved"
+ ].forEach(aMethod => this[aMethod] = notify);
+}
+
+PlacesCategoriesStarter.prototype = {
+ // nsIObserver
+
+ observe: function PCS_observe(aSubject, aTopic, aData)
+ {
+ switch (aTopic) {
+ case PlacesUtils.TOPIC_SHUTDOWN:
+ Services.obs.removeObserver(this, PlacesUtils.TOPIC_SHUTDOWN);
+ Services.obs.removeObserver(this, TOPIC_GATHER_TELEMETRY);
+ let globalObj =
+ Cu.getGlobalForObject(PlacesCategoriesStarter.prototype);
+ let descriptor =
+ Object.getOwnPropertyDescriptor(globalObj, "PlacesDBUtils");
+ if (descriptor.value !== undefined) {
+ PlacesDBUtils.shutdown();
+ }
+ break;
+ case TOPIC_GATHER_TELEMETRY:
+ PlacesDBUtils.telemetry();
+ break;
+ case "idle-daily":
+ // Once a week run places.sqlite maintenance tasks.
+ let lastMaintenance = 0;
+ try {
+ lastMaintenance =
+ Services.prefs.getIntPref("places.database.lastMaintenance");
+ } catch (ex) {}
+ let nowSeconds = parseInt(Date.now() / 1000);
+ if (lastMaintenance < nowSeconds - MAINTENANCE_INTERVAL_SECONDS) {
+ PlacesDBUtils.maintenanceOnIdle();
+ }
+ break;
+ default:
+ throw new Error("Trying to handle an unknown category.");
+ }
+ },
+
+ // nsISupports
+
+ classID: Components.ID("803938d5-e26d-4453-bf46-ad4b26e41114"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(PlacesCategoriesStarter),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver
+ , Ci.nsINavBookmarkObserver
+ ])
+};
+
+// Module Registration
+
+var components = [PlacesCategoriesStarter];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
diff --git a/toolkit/components/places/PlacesDBUtils.jsm b/toolkit/components/places/PlacesDBUtils.jsm
new file mode 100644
index 000000000..4ac6ea261
--- /dev/null
+++ b/toolkit/components/places/PlacesDBUtils.jsm
@@ -0,0 +1,1138 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 expandtab filetype=javascript
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+this.EXPORTED_SYMBOLS = [ "PlacesDBUtils" ];
+
+// Constants
+
+const FINISHED_MAINTENANCE_TOPIC = "places-maintenance-finished";
+
+const BYTES_PER_MEBIBYTE = 1048576;
+
+// Smart getters
+
+XPCOMUtils.defineLazyGetter(this, "DBConn", function() {
+ return PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+});
+
+// PlacesDBUtils
+
+this.PlacesDBUtils = {
+ /**
+ * Executes a list of maintenance tasks.
+ * Once finished it will pass a array log to the callback attached to tasks.
+ * FINISHED_MAINTENANCE_TOPIC is notified through observer service on finish.
+ *
+ * @param aTasks
+ * Tasks object to execute.
+ */
+ _executeTasks: function PDBU__executeTasks(aTasks)
+ {
+ if (PlacesDBUtils._isShuttingDown) {
+ aTasks.log("- We are shutting down. Will not schedule the tasks.");
+ aTasks.clear();
+ }
+
+ let task = aTasks.pop();
+ if (task) {
+ task.call(PlacesDBUtils, aTasks);
+ }
+ else {
+ // All tasks have been completed.
+ // Telemetry the time it took for maintenance, if a start time exists.
+ if (aTasks._telemetryStart) {
+ Services.telemetry.getHistogramById("PLACES_IDLE_MAINTENANCE_TIME_MS")
+ .add(Date.now() - aTasks._telemetryStart);
+ aTasks._telemetryStart = 0;
+ }
+
+ if (aTasks.callback) {
+ let scope = aTasks.scope || Cu.getGlobalForObject(aTasks.callback);
+ aTasks.callback.call(scope, aTasks.messages);
+ }
+
+ // Notify observers that maintenance finished.
+ Services.obs.notifyObservers(null, FINISHED_MAINTENANCE_TOPIC, null);
+ }
+ },
+
+ _isShuttingDown : false,
+ shutdown: function PDBU_shutdown() {
+ PlacesDBUtils._isShuttingDown = true;
+ },
+
+ /**
+ * Executes integrity check and common maintenance tasks.
+ *
+ * @param [optional] aCallback
+ * Callback to be invoked when done. The callback will get a array
+ * of log messages.
+ * @param [optional] aScope
+ * Scope for the callback.
+ */
+ maintenanceOnIdle: function PDBU_maintenanceOnIdle(aCallback, aScope)
+ {
+ let tasks = new Tasks([
+ this.checkIntegrity
+ , this.checkCoherence
+ , this._refreshUI
+ ]);
+ tasks._telemetryStart = Date.now();
+ tasks.callback = function() {
+ Services.prefs.setIntPref("places.database.lastMaintenance",
+ parseInt(Date.now() / 1000));
+ if (aCallback)
+ aCallback();
+ }
+ tasks.scope = aScope;
+ this._executeTasks(tasks);
+ },
+
+ /**
+ * Executes integrity check, common and advanced maintenance tasks (like
+ * expiration and vacuum). Will also collect statistics on the database.
+ *
+ * @param [optional] aCallback
+ * Callback to be invoked when done. The callback will get a array
+ * of log messages.
+ * @param [optional] aScope
+ * Scope for the callback.
+ */
+ checkAndFixDatabase: function PDBU_checkAndFixDatabase(aCallback, aScope)
+ {
+ let tasks = new Tasks([
+ this.checkIntegrity
+ , this.checkCoherence
+ , this.expire
+ , this.vacuum
+ , this.stats
+ , this._refreshUI
+ ]);
+ tasks.callback = aCallback;
+ tasks.scope = aScope;
+ this._executeTasks(tasks);
+ },
+
+ /**
+ * Forces a full refresh of Places views.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ _refreshUI: function PDBU__refreshUI(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+
+ // Send batch update notifications to update the UI.
+ PlacesUtils.history.runInBatchMode({
+ runBatched: function (aUserData) {}
+ }, null);
+ PlacesDBUtils._executeTasks(tasks);
+ },
+
+ _handleError: function PDBU__handleError(aError)
+ {
+ Cu.reportError("Async statement execution returned with '" +
+ aError.result + "', '" + aError.message + "'");
+ },
+
+ /**
+ * Tries to execute a REINDEX on the database.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ reindex: function PDBU_reindex(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Reindex");
+
+ let stmt = DBConn.createAsyncStatement("REINDEX");
+ stmt.executeAsync({
+ handleError: PlacesDBUtils._handleError,
+ handleResult: function () {},
+
+ handleCompletion: function (aReason)
+ {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ tasks.log("+ The database has been reindexed");
+ }
+ else {
+ tasks.log("- Unable to reindex database");
+ }
+
+ PlacesDBUtils._executeTasks(tasks);
+ }
+ });
+ stmt.finalize();
+ },
+
+ /**
+ * Checks integrity but does not try to fix the database through a reindex.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ _checkIntegritySkipReindex: function PDBU__checkIntegritySkipReindex(aTasks) {
+ return this.checkIntegrity(aTasks, true);
+ },
+
+ /**
+ * Checks integrity and tries to fix the database through a reindex.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ * @param [optional] aSkipdReindex
+ * Whether to try to reindex database or not.
+ */
+ checkIntegrity: function PDBU_checkIntegrity(aTasks, aSkipReindex)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Integrity check");
+
+ // Run a integrity check, but stop at the first error.
+ let stmt = DBConn.createAsyncStatement("PRAGMA integrity_check(1)");
+ stmt.executeAsync({
+ handleError: PlacesDBUtils._handleError,
+
+ _corrupt: false,
+ handleResult: function (aResultSet)
+ {
+ let row = aResultSet.getNextRow();
+ this._corrupt = row.getResultByIndex(0) != "ok";
+ },
+
+ handleCompletion: function (aReason)
+ {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ if (this._corrupt) {
+ tasks.log("- The database is corrupt");
+ if (aSkipReindex) {
+ tasks.log("- Unable to fix corruption, database will be replaced on next startup");
+ Services.prefs.setBoolPref("places.database.replaceOnStartup", true);
+ tasks.clear();
+ }
+ else {
+ // Try to reindex, this often fixed simple indices corruption.
+ // We insert from the top of the queue, they will run inverse.
+ tasks.push(PlacesDBUtils._checkIntegritySkipReindex);
+ tasks.push(PlacesDBUtils.reindex);
+ }
+ }
+ else {
+ tasks.log("+ The database is sane");
+ }
+ }
+ else {
+ tasks.log("- Unable to check database status");
+ tasks.clear();
+ }
+
+ PlacesDBUtils._executeTasks(tasks);
+ }
+ });
+ stmt.finalize();
+ },
+
+ /**
+ * Checks data coherence and tries to fix most common errors.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ checkCoherence: function PDBU_checkCoherence(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Coherence check");
+
+ let stmts = PlacesDBUtils._getBoundCoherenceStatements();
+ DBConn.executeAsync(stmts, stmts.length, {
+ handleError: PlacesDBUtils._handleError,
+ handleResult: function () {},
+
+ handleCompletion: function (aReason)
+ {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ tasks.log("+ The database is coherent");
+ }
+ else {
+ tasks.log("- Unable to check database coherence");
+ tasks.clear();
+ }
+
+ PlacesDBUtils._executeTasks(tasks);
+ }
+ });
+ stmts.forEach(aStmt => aStmt.finalize());
+ },
+
+ _getBoundCoherenceStatements: function PDBU__getBoundCoherenceStatements()
+ {
+ let cleanupStatements = [];
+
+ // MOZ_ANNO_ATTRIBUTES
+ // A.1 remove obsolete annotations from moz_annos.
+ // The 'weave0' idiom exploits character ordering (0 follows /) to
+ // efficiently select all annos with a 'weave/' prefix.
+ let deleteObsoleteAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_annos
+ WHERE type = 4
+ OR anno_attribute_id IN (
+ SELECT id FROM moz_anno_attributes
+ WHERE name BETWEEN 'weave/' AND 'weave0'
+ )`);
+ cleanupStatements.push(deleteObsoleteAnnos);
+
+ // A.2 remove obsolete annotations from moz_items_annos.
+ let deleteObsoleteItemsAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_items_annos
+ WHERE type = 4
+ OR anno_attribute_id IN (
+ SELECT id FROM moz_anno_attributes
+ WHERE name = 'sync/children'
+ OR name = 'placesInternal/GUID'
+ OR name BETWEEN 'weave/' AND 'weave0'
+ )`);
+ cleanupStatements.push(deleteObsoleteItemsAnnos);
+
+ // A.3 remove unused attributes.
+ let deleteUnusedAnnoAttributes = DBConn.createAsyncStatement(
+ `DELETE FROM moz_anno_attributes WHERE id IN (
+ SELECT id FROM moz_anno_attributes n
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_annos WHERE anno_attribute_id = n.id LIMIT 1)
+ AND NOT EXISTS
+ (SELECT id FROM moz_items_annos WHERE anno_attribute_id = n.id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteUnusedAnnoAttributes);
+
+ // MOZ_ANNOS
+ // B.1 remove annos with an invalid attribute
+ let deleteInvalidAttributeAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_annos WHERE id IN (
+ SELECT id FROM moz_annos a
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_anno_attributes
+ WHERE id = a.anno_attribute_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteInvalidAttributeAnnos);
+
+ // B.2 remove orphan annos
+ let deleteOrphanAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_annos WHERE id IN (
+ SELECT id FROM moz_annos a
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE id = a.place_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteOrphanAnnos);
+
+ // Bookmarks roots
+ // C.1 fix missing Places root
+ // Bug 477739 shows a case where the root could be wrongly removed
+ // due to an endianness issue. We try to fix broken roots here.
+ let selectPlacesRoot = DBConn.createStatement(
+ "SELECT id FROM moz_bookmarks WHERE id = :places_root");
+ selectPlacesRoot.params["places_root"] = PlacesUtils.placesRootId;
+ if (!selectPlacesRoot.executeStep()) {
+ // We are missing the root, try to recreate it.
+ let createPlacesRoot = DBConn.createAsyncStatement(
+ `INSERT INTO moz_bookmarks (id, type, fk, parent, position, title,
+ guid)
+ VALUES (:places_root, 2, NULL, 0, 0, :title, :guid)`);
+ createPlacesRoot.params["places_root"] = PlacesUtils.placesRootId;
+ createPlacesRoot.params["title"] = "";
+ createPlacesRoot.params["guid"] = PlacesUtils.bookmarks.rootGuid;
+ cleanupStatements.push(createPlacesRoot);
+
+ // Now ensure that other roots are children of Places root.
+ let fixPlacesRootChildren = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET parent = :places_root WHERE guid IN
+ ( :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid )`);
+ fixPlacesRootChildren.params["places_root"] = PlacesUtils.placesRootId;
+ fixPlacesRootChildren.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ fixPlacesRootChildren.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ fixPlacesRootChildren.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ fixPlacesRootChildren.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(fixPlacesRootChildren);
+ }
+ selectPlacesRoot.finalize();
+
+ // C.2 fix roots titles
+ // some alpha version has wrong roots title, and this also fixes them if
+ // locale has changed.
+ let updateRootTitleSql = `UPDATE moz_bookmarks SET title = :title
+ WHERE id = :root_id AND title <> :title`;
+ // root
+ let fixPlacesRootTitle = DBConn.createAsyncStatement(updateRootTitleSql);
+ fixPlacesRootTitle.params["root_id"] = PlacesUtils.placesRootId;
+ fixPlacesRootTitle.params["title"] = "";
+ cleanupStatements.push(fixPlacesRootTitle);
+ // bookmarks menu
+ let fixBookmarksMenuTitle = DBConn.createAsyncStatement(updateRootTitleSql);
+ fixBookmarksMenuTitle.params["root_id"] = PlacesUtils.bookmarksMenuFolderId;
+ fixBookmarksMenuTitle.params["title"] =
+ PlacesUtils.getString("BookmarksMenuFolderTitle");
+ cleanupStatements.push(fixBookmarksMenuTitle);
+ // bookmarks toolbar
+ let fixBookmarksToolbarTitle = DBConn.createAsyncStatement(updateRootTitleSql);
+ fixBookmarksToolbarTitle.params["root_id"] = PlacesUtils.toolbarFolderId;
+ fixBookmarksToolbarTitle.params["title"] =
+ PlacesUtils.getString("BookmarksToolbarFolderTitle");
+ cleanupStatements.push(fixBookmarksToolbarTitle);
+ // unsorted bookmarks
+ let fixUnsortedBookmarksTitle = DBConn.createAsyncStatement(updateRootTitleSql);
+ fixUnsortedBookmarksTitle.params["root_id"] = PlacesUtils.unfiledBookmarksFolderId;
+ fixUnsortedBookmarksTitle.params["title"] =
+ PlacesUtils.getString("OtherBookmarksFolderTitle");
+ cleanupStatements.push(fixUnsortedBookmarksTitle);
+ // tags
+ let fixTagsRootTitle = DBConn.createAsyncStatement(updateRootTitleSql);
+ fixTagsRootTitle.params["root_id"] = PlacesUtils.tagsFolderId;
+ fixTagsRootTitle.params["title"] =
+ PlacesUtils.getString("TagsFolderTitle");
+ cleanupStatements.push(fixTagsRootTitle);
+
+ // MOZ_BOOKMARKS
+ // D.1 remove items without a valid place
+ // if fk IS NULL we fix them in D.7
+ let deleteNoPlaceItems = DBConn.createAsyncStatement(
+ `DELETE FROM moz_bookmarks WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT b.id FROM moz_bookmarks b
+ WHERE fk NOT NULL AND b.type = :bookmark_type
+ AND NOT EXISTS (SELECT url FROM moz_places WHERE id = b.fk LIMIT 1)
+ )`);
+ deleteNoPlaceItems.params["bookmark_type"] = PlacesUtils.bookmarks.TYPE_BOOKMARK;
+ deleteNoPlaceItems.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ deleteNoPlaceItems.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ deleteNoPlaceItems.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ deleteNoPlaceItems.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ deleteNoPlaceItems.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(deleteNoPlaceItems);
+
+ // D.2 remove items that are not uri bookmarks from tag containers
+ let deleteBogusTagChildren = DBConn.createAsyncStatement(
+ `DELETE FROM moz_bookmarks WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT b.id FROM moz_bookmarks b
+ WHERE b.parent IN
+ (SELECT id FROM moz_bookmarks WHERE parent = :tags_folder)
+ AND b.type <> :bookmark_type
+ )`);
+ deleteBogusTagChildren.params["tags_folder"] = PlacesUtils.tagsFolderId;
+ deleteBogusTagChildren.params["bookmark_type"] = PlacesUtils.bookmarks.TYPE_BOOKMARK;
+ deleteBogusTagChildren.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ deleteBogusTagChildren.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ deleteBogusTagChildren.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ deleteBogusTagChildren.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ deleteBogusTagChildren.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(deleteBogusTagChildren);
+
+ // D.3 remove empty tags
+ let deleteEmptyTags = DBConn.createAsyncStatement(
+ `DELETE FROM moz_bookmarks WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT b.id FROM moz_bookmarks b
+ WHERE b.id IN
+ (SELECT id FROM moz_bookmarks WHERE parent = :tags_folder)
+ AND NOT EXISTS
+ (SELECT id from moz_bookmarks WHERE parent = b.id LIMIT 1)
+ )`);
+ deleteEmptyTags.params["tags_folder"] = PlacesUtils.tagsFolderId;
+ deleteEmptyTags.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ deleteEmptyTags.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ deleteEmptyTags.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ deleteEmptyTags.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ deleteEmptyTags.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(deleteEmptyTags);
+
+ // D.4 move orphan items to unsorted folder
+ let fixOrphanItems = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET parent = :unsorted_folder WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT b.id FROM moz_bookmarks b
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_bookmarks WHERE id = b.parent LIMIT 1)
+ )`);
+ fixOrphanItems.params["unsorted_folder"] = PlacesUtils.unfiledBookmarksFolderId;
+ fixOrphanItems.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ fixOrphanItems.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ fixOrphanItems.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ fixOrphanItems.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ fixOrphanItems.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(fixOrphanItems);
+
+ // D.6 fix wrong item types
+ // Folders and separators should not have an fk.
+ // If they have a valid fk convert them to bookmarks. Later in D.9 we
+ // will move eventual children to unsorted bookmarks.
+ let fixBookmarksAsFolders = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET type = :bookmark_type WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT id FROM moz_bookmarks b
+ WHERE type IN (:folder_type, :separator_type)
+ AND fk NOTNULL
+ )`);
+ fixBookmarksAsFolders.params["bookmark_type"] = PlacesUtils.bookmarks.TYPE_BOOKMARK;
+ fixBookmarksAsFolders.params["folder_type"] = PlacesUtils.bookmarks.TYPE_FOLDER;
+ fixBookmarksAsFolders.params["separator_type"] = PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ fixBookmarksAsFolders.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ fixBookmarksAsFolders.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ fixBookmarksAsFolders.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ fixBookmarksAsFolders.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ fixBookmarksAsFolders.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(fixBookmarksAsFolders);
+
+ // D.7 fix wrong item types
+ // Bookmarks should have an fk, if they don't have any, convert them to
+ // folders.
+ let fixFoldersAsBookmarks = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET type = :folder_type WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT id FROM moz_bookmarks b
+ WHERE type = :bookmark_type
+ AND fk IS NULL
+ )`);
+ fixFoldersAsBookmarks.params["bookmark_type"] = PlacesUtils.bookmarks.TYPE_BOOKMARK;
+ fixFoldersAsBookmarks.params["folder_type"] = PlacesUtils.bookmarks.TYPE_FOLDER;
+ fixFoldersAsBookmarks.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ fixFoldersAsBookmarks.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ fixFoldersAsBookmarks.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ fixFoldersAsBookmarks.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ fixFoldersAsBookmarks.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(fixFoldersAsBookmarks);
+
+ // D.9 fix wrong parents
+ // Items cannot have separators or other bookmarks
+ // as parent, if they have bad parent move them to unsorted bookmarks.
+ let fixInvalidParents = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET parent = :unsorted_folder WHERE guid NOT IN (
+ :rootGuid, :menuGuid, :toolbarGuid, :unfiledGuid, :tagsGuid /* skip roots */
+ ) AND id IN (
+ SELECT id FROM moz_bookmarks b
+ WHERE EXISTS
+ (SELECT id FROM moz_bookmarks WHERE id = b.parent
+ AND type IN (:bookmark_type, :separator_type)
+ LIMIT 1)
+ )`);
+ fixInvalidParents.params["unsorted_folder"] = PlacesUtils.unfiledBookmarksFolderId;
+ fixInvalidParents.params["bookmark_type"] = PlacesUtils.bookmarks.TYPE_BOOKMARK;
+ fixInvalidParents.params["separator_type"] = PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ fixInvalidParents.params["rootGuid"] = PlacesUtils.bookmarks.rootGuid;
+ fixInvalidParents.params["menuGuid"] = PlacesUtils.bookmarks.menuGuid;
+ fixInvalidParents.params["toolbarGuid"] = PlacesUtils.bookmarks.toolbarGuid;
+ fixInvalidParents.params["unfiledGuid"] = PlacesUtils.bookmarks.unfiledGuid;
+ fixInvalidParents.params["tagsGuid"] = PlacesUtils.bookmarks.tagsGuid;
+ cleanupStatements.push(fixInvalidParents);
+
+ // D.10 recalculate positions
+ // This requires multiple related statements.
+ // We can detect a folder with bad position values comparing the sum of
+ // all distinct position values (+1 since position is 0-based) with the
+ // triangular numbers obtained by the number of children (n).
+ // SUM(DISTINCT position + 1) == (n * (n + 1) / 2).
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ `CREATE TEMP TABLE IF NOT EXISTS moz_bm_reindex_temp (
+ id INTEGER PRIMARY_KEY
+ , parent INTEGER
+ , position INTEGER
+ )`
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ `INSERT INTO moz_bm_reindex_temp
+ SELECT id, parent, 0
+ FROM moz_bookmarks b
+ WHERE parent IN (
+ SELECT parent
+ FROM moz_bookmarks
+ GROUP BY parent
+ HAVING (SUM(DISTINCT position + 1) - (count(*) * (count(*) + 1) / 2)) <> 0
+ )
+ ORDER BY parent ASC, position ASC, ROWID ASC`
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ `CREATE INDEX IF NOT EXISTS moz_bm_reindex_temp_index
+ ON moz_bm_reindex_temp(parent)`
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ `UPDATE moz_bm_reindex_temp SET position = (
+ ROWID - (SELECT MIN(t.ROWID) FROM moz_bm_reindex_temp t
+ WHERE t.parent = moz_bm_reindex_temp.parent)
+ )`
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ `CREATE TEMP TRIGGER IF NOT EXISTS moz_bm_reindex_temp_trigger
+ BEFORE DELETE ON moz_bm_reindex_temp
+ FOR EACH ROW
+ BEGIN
+ UPDATE moz_bookmarks SET position = OLD.position WHERE id = OLD.id;
+ END`
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ "DELETE FROM moz_bm_reindex_temp "
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ "DROP INDEX moz_bm_reindex_temp_index "
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ "DROP TRIGGER moz_bm_reindex_temp_trigger "
+ ));
+ cleanupStatements.push(DBConn.createAsyncStatement(
+ "DROP TABLE moz_bm_reindex_temp "
+ ));
+
+ // D.12 Fix empty-named tags.
+ // Tags were allowed to have empty names due to a UI bug. Fix them
+ // replacing their title with "(notitle)".
+ let fixEmptyNamedTags = DBConn.createAsyncStatement(
+ `UPDATE moz_bookmarks SET title = :empty_title
+ WHERE length(title) = 0 AND type = :folder_type
+ AND parent = :tags_folder`
+ );
+ fixEmptyNamedTags.params["empty_title"] = "(notitle)";
+ fixEmptyNamedTags.params["folder_type"] = PlacesUtils.bookmarks.TYPE_FOLDER;
+ fixEmptyNamedTags.params["tags_folder"] = PlacesUtils.tagsFolderId;
+ cleanupStatements.push(fixEmptyNamedTags);
+
+ // MOZ_FAVICONS
+ // E.1 remove orphan icons
+ let deleteOrphanIcons = DBConn.createAsyncStatement(
+ `DELETE FROM moz_favicons WHERE id IN (
+ SELECT id FROM moz_favicons f
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE favicon_id = f.id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteOrphanIcons);
+
+ // MOZ_HISTORYVISITS
+ // F.1 remove orphan visits
+ let deleteOrphanVisits = DBConn.createAsyncStatement(
+ `DELETE FROM moz_historyvisits WHERE id IN (
+ SELECT id FROM moz_historyvisits v
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE id = v.place_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteOrphanVisits);
+
+ // MOZ_INPUTHISTORY
+ // G.1 remove orphan input history
+ let deleteOrphanInputHistory = DBConn.createAsyncStatement(
+ `DELETE FROM moz_inputhistory WHERE place_id IN (
+ SELECT place_id FROM moz_inputhistory i
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE id = i.place_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteOrphanInputHistory);
+
+ // MOZ_ITEMS_ANNOS
+ // H.1 remove item annos with an invalid attribute
+ let deleteInvalidAttributeItemsAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_items_annos WHERE id IN (
+ SELECT id FROM moz_items_annos t
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_anno_attributes
+ WHERE id = t.anno_attribute_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteInvalidAttributeItemsAnnos);
+
+ // H.2 remove orphan item annos
+ let deleteOrphanItemsAnnos = DBConn.createAsyncStatement(
+ `DELETE FROM moz_items_annos WHERE id IN (
+ SELECT id FROM moz_items_annos t
+ WHERE NOT EXISTS
+ (SELECT id FROM moz_bookmarks WHERE id = t.item_id LIMIT 1)
+ )`);
+ cleanupStatements.push(deleteOrphanItemsAnnos);
+
+ // MOZ_KEYWORDS
+ // I.1 remove unused keywords
+ let deleteUnusedKeywords = DBConn.createAsyncStatement(
+ `DELETE FROM moz_keywords WHERE id IN (
+ SELECT id FROM moz_keywords k
+ WHERE NOT EXISTS
+ (SELECT 1 FROM moz_places h WHERE k.place_id = h.id)
+ )`);
+ cleanupStatements.push(deleteUnusedKeywords);
+
+ // MOZ_PLACES
+ // L.1 fix wrong favicon ids
+ let fixInvalidFaviconIds = DBConn.createAsyncStatement(
+ `UPDATE moz_places SET favicon_id = NULL WHERE id IN (
+ SELECT id FROM moz_places h
+ WHERE favicon_id NOT NULL
+ AND NOT EXISTS
+ (SELECT id FROM moz_favicons WHERE id = h.favicon_id LIMIT 1)
+ )`);
+ cleanupStatements.push(fixInvalidFaviconIds);
+
+ // L.2 recalculate visit_count and last_visit_date
+ let fixVisitStats = DBConn.createAsyncStatement(
+ `UPDATE moz_places
+ SET visit_count = (SELECT count(*) FROM moz_historyvisits
+ WHERE place_id = moz_places.id AND visit_type NOT IN (0,4,7,8,9)),
+ last_visit_date = (SELECT MAX(visit_date) FROM moz_historyvisits
+ WHERE place_id = moz_places.id)
+ WHERE id IN (
+ SELECT h.id FROM moz_places h
+ WHERE visit_count <> (SELECT count(*) FROM moz_historyvisits v
+ WHERE v.place_id = h.id AND visit_type NOT IN (0,4,7,8,9))
+ OR last_visit_date <> (SELECT MAX(visit_date) FROM moz_historyvisits v
+ WHERE v.place_id = h.id)
+ )`);
+ cleanupStatements.push(fixVisitStats);
+
+ // L.3 recalculate hidden for redirects.
+ let fixRedirectsHidden = DBConn.createAsyncStatement(
+ `UPDATE moz_places
+ SET hidden = 1
+ WHERE id IN (
+ SELECT h.id FROM moz_places h
+ JOIN moz_historyvisits src ON src.place_id = h.id
+ JOIN moz_historyvisits dst ON dst.from_visit = src.id AND dst.visit_type IN (5,6)
+ LEFT JOIN moz_bookmarks on fk = h.id AND fk ISNULL
+ GROUP BY src.place_id HAVING count(*) = visit_count
+ )`);
+ cleanupStatements.push(fixRedirectsHidden);
+
+ // L.4 recalculate foreign_count.
+ let fixForeignCount = DBConn.createAsyncStatement(
+ `UPDATE moz_places SET foreign_count =
+ (SELECT count(*) FROM moz_bookmarks WHERE fk = moz_places.id ) +
+ (SELECT count(*) FROM moz_keywords WHERE place_id = moz_places.id )`);
+ cleanupStatements.push(fixForeignCount);
+
+ // L.5 recalculate missing hashes.
+ let fixMissingHashes = DBConn.createAsyncStatement(
+ `UPDATE moz_places SET url_hash = hash(url) WHERE url_hash = 0`);
+ cleanupStatements.push(fixMissingHashes);
+
+ // MAINTENANCE STATEMENTS SHOULD GO ABOVE THIS POINT!
+
+ return cleanupStatements;
+ },
+
+ /**
+ * Tries to vacuum the database.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ vacuum: function PDBU_vacuum(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Vacuum");
+
+ let DBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ DBFile.append("places.sqlite");
+ tasks.log("Initial database size is " +
+ parseInt(DBFile.fileSize / 1024) + " KiB");
+
+ let stmt = DBConn.createAsyncStatement("VACUUM");
+ stmt.executeAsync({
+ handleError: PlacesDBUtils._handleError,
+ handleResult: function () {},
+
+ handleCompletion: function (aReason)
+ {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ tasks.log("+ The database has been vacuumed");
+ let vacuumedDBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ vacuumedDBFile.append("places.sqlite");
+ tasks.log("Final database size is " +
+ parseInt(vacuumedDBFile.fileSize / 1024) + " KiB");
+ }
+ else {
+ tasks.log("- Unable to vacuum database");
+ tasks.clear();
+ }
+
+ PlacesDBUtils._executeTasks(tasks);
+ }
+ });
+ stmt.finalize();
+ },
+
+ /**
+ * Forces a full expiration on the database.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ expire: function PDBU_expire(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Orphans expiration");
+
+ let expiration = Cc["@mozilla.org/places/expiration;1"].
+ getService(Ci.nsIObserver);
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ Services.obs.removeObserver(arguments.callee, aTopic);
+ tasks.log("+ Database cleaned up");
+ PlacesDBUtils._executeTasks(tasks);
+ }, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false);
+
+ // Force an orphans expiration step.
+ expiration.observe(null, "places-debug-start-expiration", 0);
+ },
+
+ /**
+ * Collects statistical data on the database.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ stats: function PDBU_stats(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+ tasks.log("> Statistics");
+
+ let DBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ DBFile.append("places.sqlite");
+ tasks.log("Database size is " + parseInt(DBFile.fileSize / 1024) + " KiB");
+
+ [ "user_version"
+ , "page_size"
+ , "cache_size"
+ , "journal_mode"
+ , "synchronous"
+ ].forEach(function (aPragma) {
+ let stmt = DBConn.createStatement("PRAGMA " + aPragma);
+ stmt.executeStep();
+ tasks.log(aPragma + " is " + stmt.getString(0));
+ stmt.finalize();
+ });
+
+ // Get maximum number of unique URIs.
+ try {
+ let limitURIs = Services.prefs.getIntPref(
+ "places.history.expiration.transient_current_max_pages");
+ tasks.log("History can store a maximum of " + limitURIs + " unique pages");
+ } catch (ex) {}
+
+ let stmt = DBConn.createStatement(
+ "SELECT name FROM sqlite_master WHERE type = :type");
+ stmt.params.type = "table";
+ while (stmt.executeStep()) {
+ let tableName = stmt.getString(0);
+ let countStmt = DBConn.createStatement(
+ `SELECT count(*) FROM ${tableName}`);
+ countStmt.executeStep();
+ tasks.log("Table " + tableName + " has " + countStmt.getInt32(0) + " records");
+ countStmt.finalize();
+ }
+ stmt.reset();
+
+ stmt.params.type = "index";
+ while (stmt.executeStep()) {
+ tasks.log("Index " + stmt.getString(0));
+ }
+ stmt.reset();
+
+ stmt.params.type = "trigger";
+ while (stmt.executeStep()) {
+ tasks.log("Trigger " + stmt.getString(0));
+ }
+ stmt.finalize();
+
+ PlacesDBUtils._executeTasks(tasks);
+ },
+
+ /**
+ * Collects telemetry data and reports it to Telemetry.
+ *
+ * @param [optional] aTasks
+ * Tasks object to execute.
+ */
+ telemetry: function PDBU_telemetry(aTasks)
+ {
+ let tasks = new Tasks(aTasks);
+
+ // This will be populated with one integer property for each probe result,
+ // using the histogram name as key.
+ let probeValues = {};
+
+ // The following array contains an ordered list of entries that are
+ // processed to collect telemetry data. Each entry has these properties:
+ //
+ // histogram: Name of the telemetry histogram to update.
+ // query: This is optional. If present, contains a database command
+ // that will be executed asynchronously, and whose result will
+ // be added to the telemetry histogram.
+ // callback: This is optional. If present, contains a function that must
+ // return the value that will be added to the telemetry
+ // histogram. If a query is also present, its result is passed
+ // as the first argument of the function. If the function
+ // raises an exception, no data is added to the histogram.
+ //
+ // Since all queries are executed in order by the database backend, the
+ // callbacks can also use the result of previous queries stored in the
+ // probeValues object.
+ let probes = [
+ { histogram: "PLACES_PAGES_COUNT",
+ query: "SELECT count(*) FROM moz_places" },
+
+ { histogram: "PLACES_BOOKMARKS_COUNT",
+ query: `SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = b.parent
+ AND t.parent <> :tags_folder
+ WHERE b.type = :type_bookmark` },
+
+ { histogram: "PLACES_TAGS_COUNT",
+ query: `SELECT count(*) FROM moz_bookmarks
+ WHERE parent = :tags_folder` },
+
+ { histogram: "PLACES_KEYWORDS_COUNT",
+ query: "SELECT count(*) FROM moz_keywords" },
+
+ { histogram: "PLACES_SORTED_BOOKMARKS_PERC",
+ query: `SELECT IFNULL(ROUND((
+ SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = b.parent
+ AND t.parent <> :tags_folder AND t.parent > :places_root
+ WHERE b.type = :type_bookmark
+ ) * 100 / (
+ SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = b.parent
+ AND t.parent <> :tags_folder
+ WHERE b.type = :type_bookmark
+ )), 0)` },
+
+ { histogram: "PLACES_TAGGED_BOOKMARKS_PERC",
+ query: `SELECT IFNULL(ROUND((
+ SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = b.parent
+ AND t.parent = :tags_folder
+ ) * 100 / (
+ SELECT count(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = b.parent
+ AND t.parent <> :tags_folder
+ WHERE b.type = :type_bookmark
+ )), 0)` },
+
+ { histogram: "PLACES_DATABASE_FILESIZE_MB",
+ callback: function () {
+ let DBFile = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ DBFile.append("places.sqlite");
+ return parseInt(DBFile.fileSize / BYTES_PER_MEBIBYTE);
+ }
+ },
+
+ { histogram: "PLACES_DATABASE_PAGESIZE_B",
+ query: "PRAGMA page_size /* PlacesDBUtils.jsm PAGESIZE_B */" },
+
+ { histogram: "PLACES_DATABASE_SIZE_PER_PAGE_B",
+ query: "PRAGMA page_count",
+ callback: function (aDbPageCount) {
+ // Note that the database file size would not be meaningful for this
+ // calculation, because the file grows in fixed-size chunks.
+ let dbPageSize = probeValues.PLACES_DATABASE_PAGESIZE_B;
+ let placesPageCount = probeValues.PLACES_PAGES_COUNT;
+ return Math.round((dbPageSize * aDbPageCount) / placesPageCount);
+ }
+ },
+
+ { histogram: "PLACES_ANNOS_BOOKMARKS_COUNT",
+ query: "SELECT count(*) FROM moz_items_annos" },
+
+ { histogram: "PLACES_ANNOS_PAGES_COUNT",
+ query: "SELECT count(*) FROM moz_annos" },
+
+ { histogram: "PLACES_MAINTENANCE_DAYSFROMLAST",
+ callback: function () {
+ try {
+ let lastMaintenance = Services.prefs.getIntPref("places.database.lastMaintenance");
+ let nowSeconds = parseInt(Date.now() / 1000);
+ return parseInt((nowSeconds - lastMaintenance) / 86400);
+ } catch (ex) {
+ return 60;
+ }
+ }
+ },
+ ];
+
+ let params = {
+ tags_folder: PlacesUtils.tagsFolderId,
+ type_folder: PlacesUtils.bookmarks.TYPE_FOLDER,
+ type_bookmark: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ places_root: PlacesUtils.placesRootId
+ };
+
+ for (let i = 0; i < probes.length; i++) {
+ let probe = probes[i];
+
+ let promiseDone = new Promise((resolve, reject) => {
+ if (!("query" in probe)) {
+ resolve([probe]);
+ return;
+ }
+
+ let stmt = DBConn.createAsyncStatement(probe.query);
+ for (let param in params) {
+ if (probe.query.indexOf(":" + param) > 0) {
+ stmt.params[param] = params[param];
+ }
+ }
+
+ try {
+ stmt.executeAsync({
+ handleError: reject,
+ handleResult: function (aResultSet) {
+ let row = aResultSet.getNextRow();
+ resolve([probe, row.getResultByIndex(0)]);
+ },
+ handleCompletion: function () {}
+ });
+ } finally {
+ stmt.finalize();
+ }
+ });
+
+ // Report the result of the probe through Telemetry.
+ // The resulting promise cannot reject.
+ promiseDone.then(
+ // On success
+ ([aProbe, aValue]) => {
+ let value = aValue;
+ try {
+ if ("callback" in aProbe) {
+ value = aProbe.callback(value);
+ }
+ probeValues[aProbe.histogram] = value;
+ Services.telemetry.getHistogramById(aProbe.histogram).add(value);
+ } catch (ex) {
+ Components.utils.reportError("Error adding value " + value +
+ " to histogram " + aProbe.histogram +
+ ": " + ex);
+ }
+ },
+ // On failure
+ this._handleError);
+ }
+
+ PlacesDBUtils._executeTasks(tasks);
+ },
+
+ /**
+ * Runs a list of tasks, notifying log messages to the callback.
+ *
+ * @param aTasks
+ * Array of tasks to be executed, in form of pointers to methods in
+ * this module.
+ * @param [optional] aCallback
+ * Callback to be invoked when done. It will receive an array of
+ * log messages.
+ */
+ runTasks: function PDBU_runTasks(aTasks, aCallback) {
+ let tasks = new Tasks(aTasks);
+ tasks.callback = aCallback;
+ PlacesDBUtils._executeTasks(tasks);
+ }
+};
+
+/**
+ * LIFO tasks stack.
+ *
+ * @param [optional] aTasks
+ * Array of tasks or another Tasks object to clone.
+ */
+function Tasks(aTasks)
+{
+ if (aTasks) {
+ if (Array.isArray(aTasks)) {
+ this._list = aTasks.slice(0, aTasks.length);
+ }
+ // This supports passing in a Tasks-like object, with a "list" property,
+ // for compatibility reasons.
+ else if (typeof(aTasks) == "object" &&
+ (Tasks instanceof Tasks || "list" in aTasks)) {
+ this._list = aTasks.list;
+ this._log = aTasks.messages;
+ this.callback = aTasks.callback;
+ this.scope = aTasks.scope;
+ this._telemetryStart = aTasks._telemetryStart;
+ }
+ }
+}
+
+Tasks.prototype = {
+ _list: [],
+ _log: [],
+ callback: null,
+ scope: null,
+ _telemetryStart: 0,
+
+ /**
+ * Adds a task to the top of the list.
+ *
+ * @param aNewElt
+ * Task to be added.
+ */
+ push: function T_push(aNewElt)
+ {
+ this._list.unshift(aNewElt);
+ },
+
+ /**
+ * Returns and consumes next task.
+ *
+ * @return next task or undefined if no task is left.
+ */
+ pop: function T_pop()
+ {
+ return this._list.shift();
+ },
+
+ /**
+ * Removes all tasks.
+ */
+ clear: function T_clear()
+ {
+ this._list.length = 0;
+ },
+
+ /**
+ * Returns array of tasks ordered from the next to be run to the latest.
+ */
+ get list()
+ {
+ return this._list.slice(0, this._list.length);
+ },
+
+ /**
+ * Adds a message to the log.
+ *
+ * @param aMsg
+ * String message to be added.
+ */
+ log: function T_log(aMsg)
+ {
+ this._log.push(aMsg);
+ },
+
+ /**
+ * Returns array of log messages ordered from oldest to newest.
+ */
+ get messages()
+ {
+ return this._log.slice(0, this._log.length);
+ },
+}
diff --git a/toolkit/components/places/PlacesRemoteTabsAutocompleteProvider.jsm b/toolkit/components/places/PlacesRemoteTabsAutocompleteProvider.jsm
new file mode 100644
index 000000000..d23d5bc6e
--- /dev/null
+++ b/toolkit/components/places/PlacesRemoteTabsAutocompleteProvider.jsm
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Provides functions to handle remote tabs (ie, tabs known by Sync) in
+ * the awesomebar.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["PlacesRemoteTabsAutocompleteProvider"];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "weaveXPCService", function() {
+ return Cc["@mozilla.org/weave/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+});
+
+XPCOMUtils.defineLazyGetter(this, "Weave", () => {
+ try {
+ let {Weave} = Cu.import("resource://services-sync/main.js", {});
+ return Weave;
+ } catch (ex) {
+ // The app didn't build Sync.
+ }
+ return null;
+});
+
+// from MDN...
+function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// Build the in-memory structure we use.
+function buildItems() {
+ let clients = new Map(); // keyed by client guid, value is client
+ let tabs = new Map(); // keyed by string URL, value is {clientId, tab}
+
+ // If Sync isn't initialized (either due to lag at startup or due to no user
+ // being signed in), don't reach in to Weave.Service as that may initialize
+ // Sync unnecessarily - we'll get an observer notification later when it
+ // becomes ready and has synced a list of tabs.
+ if (weaveXPCService.ready) {
+ let engine = Weave.Service.engineManager.get("tabs");
+
+ for (let [guid, client] of Object.entries(engine.getAllClients())) {
+ clients.set(guid, client);
+ for (let tab of client.tabs) {
+ let url = tab.urlHistory[0];
+ tabs.set(url, { clientId: guid, tab });
+ }
+ }
+ }
+ return { clients, tabs };
+}
+
+// Manage the cache of the items we use.
+// The cache itself.
+let _items = null;
+
+// Ensure the cache is good.
+function ensureItems() {
+ if (!_items) {
+ _items = buildItems();
+ }
+ return _items;
+}
+
+// A preference used to disable the showing of icons in remote tab records.
+const PREF_SHOW_REMOTE_ICONS = "services.sync.syncedTabs.showRemoteIcons";
+let showRemoteIcons;
+
+// An observer to invalidate _items and watch for changed prefs.
+function observe(subject, topic, data) {
+ switch (topic) {
+ case "weave:engine:sync:finish":
+ if (data == "tabs") {
+ // The tabs engine just finished syncing, so may have a different list
+ // of tabs then we previously cached.
+ _items = null;
+ }
+ break;
+
+ case "weave:service:start-over":
+ // Sync is being reset due to the user disconnecting - we must invalidate
+ // the cache so we don't supply tabs from a different user.
+ _items = null;
+ break;
+
+ case "nsPref:changed":
+ if (data == PREF_SHOW_REMOTE_ICONS) {
+ try {
+ showRemoteIcons = Services.prefs.getBoolPref(PREF_SHOW_REMOTE_ICONS);
+ } catch (_) {
+ showRemoteIcons = true; // no such pref - default is to show the icons.
+ }
+ }
+ break;
+
+ default:
+ break;
+ }
+}
+
+Services.obs.addObserver(observe, "weave:engine:sync:finish", false);
+Services.obs.addObserver(observe, "weave:service:start-over", false);
+
+// Observe the pref for showing remote icons and prime our bool that reflects its value.
+Services.prefs.addObserver(PREF_SHOW_REMOTE_ICONS, observe, false);
+observe(null, "nsPref:changed", PREF_SHOW_REMOTE_ICONS);
+
+// This public object is a static singleton.
+this.PlacesRemoteTabsAutocompleteProvider = {
+ // a promise that resolves with an array of matching remote tabs.
+ getMatches(searchString) {
+ // If Sync isn't configured we bail early.
+ if (Weave === null ||
+ !Services.prefs.prefHasUserValue("services.sync.username")) {
+ return Promise.resolve([]);
+ }
+
+ let re = new RegExp(escapeRegExp(searchString), "i");
+ let matches = [];
+ let { tabs, clients } = ensureItems();
+ for (let [url, { clientId, tab }] of tabs) {
+ let title = tab.title;
+ if (url.match(re) || (title && title.match(re))) {
+ // lookup the client record.
+ let client = clients.get(clientId);
+ let icon = showRemoteIcons ? tab.icon : null;
+ // create the record we return for auto-complete.
+ let record = {
+ url, title, icon,
+ deviceClass: Weave.Service.clientsEngine.isMobile(clientId) ? "mobile" : "desktop",
+ deviceName: client.clientName,
+ };
+ matches.push(record);
+ }
+ }
+ return Promise.resolve(matches);
+ },
+}
diff --git a/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm b/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
new file mode 100644
index 000000000..f4d8f3973
--- /dev/null
+++ b/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
@@ -0,0 +1,295 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Provides functions to handle search engine URLs in the browser history.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [ "PlacesSearchAutocompleteProvider" ];
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
+ "resource://gre/modules/SearchSuggestionController.jsm");
+
+const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified";
+
+const SearchAutocompleteProviderInternal = {
+ /**
+ * Array of objects in the format returned by findMatchByToken.
+ */
+ priorityMatches: null,
+
+ /**
+ * Array of objects in the format returned by findMatchByAlias.
+ */
+ aliasMatches: null,
+
+ /**
+ * Object for the default search match.
+ **/
+ defaultMatch: null,
+
+ initialize: function () {
+ return new Promise((resolve, reject) => {
+ Services.search.init(status => {
+ if (!Components.isSuccessCode(status)) {
+ reject(new Error("Unable to initialize search service."));
+ }
+
+ try {
+ // The initial loading of the search engines must succeed.
+ this._refresh();
+
+ Services.obs.addObserver(this, SEARCH_ENGINE_TOPIC, true);
+
+ this.initialized = true;
+ resolve();
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+ });
+ },
+
+ initialized: false,
+
+ observe: function (subject, topic, data) {
+ switch (data) {
+ case "engine-added":
+ case "engine-changed":
+ case "engine-removed":
+ case "engine-current":
+ this._refresh();
+ }
+ },
+
+ _refresh: function () {
+ this.priorityMatches = [];
+ this.aliasMatches = [];
+ this.defaultMatch = null;
+
+ let currentEngine = Services.search.currentEngine;
+ // This can be null in XCPShell.
+ if (currentEngine) {
+ this.defaultMatch = {
+ engineName: currentEngine.name,
+ iconUrl: currentEngine.iconURI ? currentEngine.iconURI.spec : null,
+ }
+ }
+
+ // The search engines will always be processed in the order returned by the
+ // search service, which can be defined by the user.
+ Services.search.getVisibleEngines().forEach(e => this._addEngine(e));
+ },
+
+ _addEngine: function (engine) {
+ if (engine.alias) {
+ this.aliasMatches.push({
+ alias: engine.alias,
+ engineName: engine.name,
+ iconUrl: engine.iconURI ? engine.iconURI.spec : null,
+ });
+ }
+
+ let domain = engine.getResultDomain();
+ if (domain) {
+ this.priorityMatches.push({
+ token: domain,
+ // The searchForm property returns a simple URL for the search engine, but
+ // we may need an URL which includes an affiliate code (bug 990799).
+ url: engine.searchForm,
+ engineName: engine.name,
+ iconUrl: engine.iconURI ? engine.iconURI.spec : null,
+ });
+ }
+ },
+
+ getSuggestionController(searchToken, inPrivateContext, maxResults, userContextId) {
+ let engine = Services.search.currentEngine;
+ if (!engine) {
+ return null;
+ }
+ return new SearchSuggestionControllerWrapper(engine, searchToken,
+ inPrivateContext, maxResults,
+ userContextId);
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+ Ci.nsISupportsWeakReference]),
+}
+
+function SearchSuggestionControllerWrapper(engine, searchToken,
+ inPrivateContext, maxResults,
+ userContextId) {
+ this._controller = new SearchSuggestionController();
+ this._controller.maxLocalResults = 0;
+ this._controller.maxRemoteResults = maxResults;
+ let promise = this._controller.fetch(searchToken, inPrivateContext, engine, userContextId);
+ this._suggestions = [];
+ this._success = false;
+ this._promise = promise.then(results => {
+ this._success = true;
+ this._suggestions = (results ? results.remote : null) || [];
+ }).catch(err => {
+ // fetch() rejects its promise if there's a pending request.
+ });
+}
+
+SearchSuggestionControllerWrapper.prototype = {
+
+ /**
+ * Resolved when all suggestions have been fetched.
+ */
+ get fetchCompletePromise() {
+ return this._promise;
+ },
+
+ /**
+ * Returns one suggestion, if any are available. The returned value is an
+ * array [match, suggestion]. If none are available, returns [null, null].
+ * Note that there are two reasons that suggestions might not be available:
+ * all suggestions may have been fetched and consumed, or the fetch may not
+ * have completed yet.
+ *
+ * @return An array [match, suggestion].
+ */
+ consume() {
+ return !this._suggestions.length ? [null, null] :
+ [SearchAutocompleteProviderInternal.defaultMatch,
+ this._suggestions.shift()];
+ },
+
+ /**
+ * Returns the number of fetched suggestions, or -1 if the fetching was
+ * incomplete or failed.
+ */
+ get resultsCount() {
+ return this._success ? this._suggestions.length : -1;
+ },
+
+ /**
+ * Stops the fetch.
+ */
+ stop() {
+ this._controller.stop();
+ },
+};
+
+var gInitializationPromise = null;
+
+this.PlacesSearchAutocompleteProvider = Object.freeze({
+ /**
+ * Starts initializing the component and returns a promise that is resolved or
+ * rejected when initialization finished. The same promise is returned if
+ * this function is called multiple times.
+ */
+ ensureInitialized: function () {
+ if (!gInitializationPromise) {
+ gInitializationPromise = SearchAutocompleteProviderInternal.initialize();
+ }
+ return gInitializationPromise;
+ },
+
+ /**
+ * Matches a given string to an item that should be included by URL search
+ * components, like autocomplete in the address bar.
+ *
+ * @param searchToken
+ * String containing the first part of the matching domain name.
+ *
+ * @return An object with the following properties, or undefined if the token
+ * does not match any relevant URL:
+ * {
+ * token: The full string used to match the search term to the URL.
+ * url: The URL to navigate to if the match is selected.
+ * engineName: The display name of the search engine.
+ * iconUrl: Icon associated to the match, or null if not available.
+ * }
+ */
+ findMatchByToken: Task.async(function* (searchToken) {
+ yield this.ensureInitialized();
+
+ // Match at the beginning for now. In the future, an "options" argument may
+ // allow the matching behavior to be tuned.
+ return SearchAutocompleteProviderInternal.priorityMatches
+ .find(m => m.token.startsWith(searchToken));
+ }),
+
+ /**
+ * Matches a given search string to an item that should be included by
+ * components wishing to search using search engine aliases, like
+ * autocomple.
+ *
+ * @param searchToken
+ * Search string to match exactly a search engine alias.
+ *
+ * @return An object with the following properties, or undefined if the token
+ * does not match any relevant URL:
+ * {
+ * alias: The matched search engine's alias.
+ * engineName: The display name of the search engine.
+ * iconUrl: Icon associated to the match, or null if not available.
+ * }
+ */
+ findMatchByAlias: Task.async(function* (searchToken) {
+ yield this.ensureInitialized();
+
+ return SearchAutocompleteProviderInternal.aliasMatches
+ .find(m => m.alias.toLocaleLowerCase() == searchToken.toLocaleLowerCase());
+ }),
+
+ getDefaultMatch: Task.async(function* () {
+ yield this.ensureInitialized();
+
+ return SearchAutocompleteProviderInternal.defaultMatch;
+ }),
+
+ /**
+ * Synchronously determines if the provided URL represents results from a
+ * search engine, and provides details about the match.
+ *
+ * @param url
+ * String containing the URL to parse.
+ *
+ * @return An object with the following properties, or null if the URL does
+ * not represent a search result:
+ * {
+ * engineName: The display name of the search engine.
+ * terms: The originally sought terms extracted from the URI.
+ * }
+ *
+ * @remarks The asynchronous ensureInitialized function must be called before
+ * this synchronous method can be used.
+ *
+ * @note This API function needs to be synchronous because it is called inside
+ * a row processing callback of Sqlite.jsm, in UnifiedComplete.js.
+ */
+ parseSubmissionURL: function (url) {
+ if (!SearchAutocompleteProviderInternal.initialized) {
+ throw new Error("The component has not been initialized.");
+ }
+
+ let parseUrlResult = Services.search.parseSubmissionURL(url);
+ return parseUrlResult.engine && {
+ engineName: parseUrlResult.engine.name,
+ terms: parseUrlResult.terms,
+ };
+ },
+
+ getSuggestionController(searchToken, inPrivateContext, maxResults, userContextId) {
+ if (!SearchAutocompleteProviderInternal.initialized) {
+ throw new Error("The component has not been initialized.");
+ }
+ return SearchAutocompleteProviderInternal.getSuggestionController(
+ searchToken, inPrivateContext, maxResults, userContextId);
+ },
+});
diff --git a/toolkit/components/places/PlacesSyncUtils.jsm b/toolkit/components/places/PlacesSyncUtils.jsm
new file mode 100644
index 000000000..15dd412e8
--- /dev/null
+++ b/toolkit/components/places/PlacesSyncUtils.jsm
@@ -0,0 +1,1155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["PlacesSyncUtils"];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL", "URLSearchParams"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Log",
+ "resource://gre/modules/Log.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+ "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+
+/**
+ * This module exports functions for Sync to use when applying remote
+ * records. The calls are similar to those in `Bookmarks.jsm` and
+ * `nsINavBookmarksService`, with special handling for smart bookmarks,
+ * tags, keywords, synced annotations, and missing parents.
+ */
+var PlacesSyncUtils = {};
+
+const { SOURCE_SYNC } = Ci.nsINavBookmarksService;
+
+// These are defined as lazy getters to defer initializing the bookmarks
+// service until it's needed.
+XPCOMUtils.defineLazyGetter(this, "ROOT_SYNC_ID_TO_GUID", () => ({
+ menu: PlacesUtils.bookmarks.menuGuid,
+ places: PlacesUtils.bookmarks.rootGuid,
+ tags: PlacesUtils.bookmarks.tagsGuid,
+ toolbar: PlacesUtils.bookmarks.toolbarGuid,
+ unfiled: PlacesUtils.bookmarks.unfiledGuid,
+ mobile: PlacesUtils.bookmarks.mobileGuid,
+}));
+
+XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_SYNC_ID", () => ({
+ [PlacesUtils.bookmarks.menuGuid]: "menu",
+ [PlacesUtils.bookmarks.rootGuid]: "places",
+ [PlacesUtils.bookmarks.tagsGuid]: "tags",
+ [PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
+ [PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
+ [PlacesUtils.bookmarks.mobileGuid]: "mobile",
+}));
+
+XPCOMUtils.defineLazyGetter(this, "ROOTS", () =>
+ Object.keys(ROOT_SYNC_ID_TO_GUID)
+);
+
+const BookmarkSyncUtils = PlacesSyncUtils.bookmarks = Object.freeze({
+ SMART_BOOKMARKS_ANNO: "Places/SmartBookmark",
+ DESCRIPTION_ANNO: "bookmarkProperties/description",
+ SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar",
+ SYNC_PARENT_ANNO: "sync/parent",
+ SYNC_MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
+
+ KINDS: {
+ BOOKMARK: "bookmark",
+ // Microsummaries were removed from Places in bug 524091. For now, Sync
+ // treats them identically to bookmarks. Bug 745410 tracks removing them
+ // entirely.
+ MICROSUMMARY: "microsummary",
+ QUERY: "query",
+ FOLDER: "folder",
+ LIVEMARK: "livemark",
+ SEPARATOR: "separator",
+ },
+
+ get ROOTS() {
+ return ROOTS;
+ },
+
+ /**
+ * Converts a Places GUID to a Sync ID. Sync IDs are identical to Places
+ * GUIDs for all items except roots.
+ */
+ guidToSyncId(guid) {
+ return ROOT_GUID_TO_SYNC_ID[guid] || guid;
+ },
+
+ /**
+ * Converts a Sync record ID to a Places GUID.
+ */
+ syncIdToGuid(syncId) {
+ return ROOT_SYNC_ID_TO_GUID[syncId] || syncId;
+ },
+
+ /**
+ * Fetches the sync IDs for a folder's children, ordered by their position
+ * within the folder.
+ */
+ fetchChildSyncIds: Task.async(function* (parentSyncId) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(parentSyncId);
+ let parentGuid = BookmarkSyncUtils.syncIdToGuid(parentSyncId);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let children = yield fetchAllChildren(db, parentGuid);
+ return children.map(child =>
+ BookmarkSyncUtils.guidToSyncId(child.guid)
+ );
+ }),
+
+ /**
+ * Reorders a folder's children, based on their order in the array of sync
+ * IDs.
+ *
+ * Sync uses this method to reorder all synced children after applying all
+ * incoming records.
+ *
+ */
+ order: Task.async(function* (parentSyncId, childSyncIds) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(parentSyncId);
+ if (!childSyncIds.length) {
+ return undefined;
+ }
+ let parentGuid = BookmarkSyncUtils.syncIdToGuid(parentSyncId);
+ if (parentGuid == PlacesUtils.bookmarks.rootGuid) {
+ // Reordering roots doesn't make sense, but Sync will do this on the
+ // first sync.
+ return undefined;
+ }
+ let orderedChildrenGuids = childSyncIds.map(BookmarkSyncUtils.syncIdToGuid);
+ return PlacesUtils.bookmarks.reorder(parentGuid, orderedChildrenGuids,
+ { source: SOURCE_SYNC });
+ }),
+
+ /**
+ * Removes an item from the database. Options are passed through to
+ * PlacesUtils.bookmarks.remove.
+ */
+ remove: Task.async(function* (syncId, options = {}) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ if (guid in ROOT_GUID_TO_SYNC_ID) {
+ BookmarkSyncLog.warn(`remove: Refusing to remove root ${syncId}`);
+ return null;
+ }
+ return PlacesUtils.bookmarks.remove(guid, Object.assign({}, options, {
+ source: SOURCE_SYNC,
+ }));
+ }),
+
+ /**
+ * Returns true for sync IDs that are considered roots.
+ */
+ isRootSyncID(syncID) {
+ return ROOT_SYNC_ID_TO_GUID.hasOwnProperty(syncID);
+ },
+
+ /**
+ * Changes the GUID of an existing item. This method only allows Places GUIDs
+ * because root sync IDs cannot be changed.
+ *
+ * @return {Promise} resolved once the GUID has been changed.
+ * @resolves to the new GUID.
+ * @rejects if the old GUID does not exist.
+ */
+ changeGuid: Task.async(function* (oldGuid, newGuid) {
+ PlacesUtils.BOOKMARK_VALIDATORS.guid(oldGuid);
+ PlacesUtils.BOOKMARK_VALIDATORS.guid(newGuid);
+
+ let itemId = yield PlacesUtils.promiseItemId(oldGuid);
+ if (PlacesUtils.isRootItem(itemId)) {
+ throw new Error(`Cannot change GUID of Places root ${oldGuid}`);
+ }
+ return PlacesUtils.withConnectionWrapper("BookmarkSyncUtils: changeGuid",
+ Task.async(function* (db) {
+ yield db.executeCached(`UPDATE moz_bookmarks SET guid = :newGuid
+ WHERE id = :itemId`, { newGuid, itemId });
+ PlacesUtils.invalidateCachedGuidFor(itemId);
+ return newGuid;
+ })
+ );
+ }),
+
+ /**
+ * Updates a bookmark with synced properties. Only Sync should call this
+ * method; other callers should use `Bookmarks.update`.
+ *
+ * The following properties are supported:
+ * - kind: Optional.
+ * - guid: Required.
+ * - parentGuid: Optional; reparents the bookmark if specified.
+ * - title: Optional.
+ * - url: Optional.
+ * - tags: Optional; replaces all existing tags.
+ * - keyword: Optional.
+ * - description: Optional.
+ * - loadInSidebar: Optional.
+ * - query: Optional.
+ *
+ * @param info
+ * object representing a bookmark-item, as defined above.
+ *
+ * @return {Promise} resolved when the update is complete.
+ * @resolves to an object representing the updated bookmark.
+ * @rejects if it's not possible to update the given bookmark.
+ * @throws if the arguments are invalid.
+ */
+ update: Task.async(function* (info) {
+ let updateInfo = validateSyncBookmarkObject(info,
+ { syncId: { required: true }
+ });
+
+ return updateSyncBookmark(updateInfo);
+ }),
+
+ /**
+ * Inserts a synced bookmark into the tree. Only Sync should call this
+ * method; other callers should use `Bookmarks.insert`.
+ *
+ * The following properties are supported:
+ * - kind: Required.
+ * - guid: Required.
+ * - parentGuid: Required.
+ * - url: Required for bookmarks.
+ * - query: A smart bookmark query string, optional.
+ * - tags: An optional array of tag strings.
+ * - keyword: An optional keyword string.
+ * - description: An optional description string.
+ * - loadInSidebar: An optional boolean; defaults to false.
+ *
+ * Sync doesn't set the index, since it appends and reorders children
+ * after applying all incoming items.
+ *
+ * @param info
+ * object representing a synced bookmark.
+ *
+ * @return {Promise} resolved when the creation is complete.
+ * @resolves to an object representing the created bookmark.
+ * @rejects if it's not possible to create the requested bookmark.
+ * @throws if the arguments are invalid.
+ */
+ insert: Task.async(function* (info) {
+ let insertInfo = validateNewBookmark(info);
+ return insertSyncBookmark(insertInfo);
+ }),
+
+ /**
+ * Fetches a Sync bookmark object for an item in the tree. The object contains
+ * the following properties, depending on the item's kind:
+ *
+ * - kind (all): A string representing the item's kind.
+ * - syncId (all): The item's sync ID.
+ * - parentSyncId (all): The sync ID of the item's parent.
+ * - parentTitle (all): The title of the item's parent, used for de-duping.
+ * Omitted for the Places root and parents with empty titles.
+ * - title ("bookmark", "folder", "livemark", "query"): The item's title.
+ * Omitted if empty.
+ * - url ("bookmark", "query"): The item's URL.
+ * - tags ("bookmark", "query"): An array containing the item's tags.
+ * - keyword ("bookmark"): The bookmark's keyword, if one exists.
+ * - description ("bookmark", "folder", "livemark"): The item's description.
+ * Omitted if one isn't set.
+ * - loadInSidebar ("bookmark", "query"): Whether to load the bookmark in
+ * the sidebar. Always `false` for queries.
+ * - feed ("livemark"): A `URL` object pointing to the livemark's feed URL.
+ * - site ("livemark"): A `URL` object pointing to the livemark's site URL,
+ * or `null` if one isn't set.
+ * - childSyncIds ("folder"): An array containing the sync IDs of the item's
+ * children, used to determine child order.
+ * - folder ("query"): The tag folder name, if this is a tag query.
+ * - query ("query"): The smart bookmark query name, if this is a smart
+ * bookmark.
+ * - index ("separator"): The separator's position within its parent.
+ */
+ fetch: Task.async(function* (syncId) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ let bookmarkItem = yield PlacesUtils.bookmarks.fetch(guid);
+ if (!bookmarkItem) {
+ return null;
+ }
+
+ // Convert the Places bookmark object to a Sync bookmark and add
+ // kind-specific properties. Titles are required for bookmarks,
+ // folders, and livemarks; optional for queries, and omitted for
+ // separators.
+ let kind = yield getKindForItem(bookmarkItem);
+ let item;
+ switch (kind) {
+ case BookmarkSyncUtils.KINDS.BOOKMARK:
+ case BookmarkSyncUtils.KINDS.MICROSUMMARY:
+ item = yield fetchBookmarkItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.QUERY:
+ item = yield fetchQueryItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.FOLDER:
+ item = yield fetchFolderItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.LIVEMARK:
+ item = yield fetchLivemarkItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.SEPARATOR:
+ item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+ item.index = bookmarkItem.index;
+ break;
+
+ default:
+ throw new Error(`Unknown bookmark kind: ${kind}`);
+ }
+
+ // Sync uses the parent title for de-duping. All Sync bookmark objects
+ // except the Places root should have this property.
+ if (bookmarkItem.parentGuid) {
+ let parent = yield PlacesUtils.bookmarks.fetch(bookmarkItem.parentGuid);
+ item.parentTitle = parent.title || "";
+ }
+
+ return item;
+ }),
+
+ /**
+ * Get the sync record kind for the record with provided sync id.
+ *
+ * @param syncId
+ * Sync ID for the item in question
+ *
+ * @returns {Promise} A promise that resolves with the sync record kind (e.g.
+ * something under `PlacesSyncUtils.bookmarks.KIND`), or
+ * with `null` if no item with that guid exists.
+ * @throws if `guid` is invalid.
+ */
+ getKindForSyncId(syncId) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(syncId);
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ return PlacesUtils.bookmarks.fetch(guid)
+ .then(item => {
+ if (!item) {
+ return null;
+ }
+ return getKindForItem(item)
+ });
+ },
+});
+
+XPCOMUtils.defineLazyGetter(this, "BookmarkSyncLog", () => {
+ return Log.repository.getLogger("BookmarkSyncUtils");
+});
+
+function validateSyncBookmarkObject(input, behavior) {
+ return PlacesUtils.validateItemProperties(
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS, input, behavior);
+}
+
+// Similar to the private `fetchBookmarksByParent` implementation in
+// `Bookmarks.jsm`.
+var fetchAllChildren = Task.async(function* (db, parentGuid) {
+ let rows = yield db.executeCached(`
+ SELECT id, parent, position, type, guid
+ FROM moz_bookmarks
+ WHERE parent = (
+ SELECT id FROM moz_bookmarks WHERE guid = :parentGuid
+ )
+ ORDER BY position`,
+ { parentGuid }
+ );
+ return rows.map(row => ({
+ id: row.getResultByName("id"),
+ parentId: row.getResultByName("parent"),
+ index: row.getResultByName("position"),
+ type: row.getResultByName("type"),
+ guid: row.getResultByName("guid"),
+ }));
+});
+
+// A helper for whenever we want to know if a GUID doesn't exist in the places
+// database. Primarily used to detect orphans on incoming records.
+var GUIDMissing = Task.async(function* (guid) {
+ try {
+ yield PlacesUtils.promiseItemId(guid);
+ return false;
+ } catch (ex) {
+ if (ex.message == "no item found for the given GUID") {
+ return true;
+ }
+ throw ex;
+ }
+});
+
+// Tag queries use a `place:` URL that refers to the tag folder ID. When we
+// apply a synced tag query from a remote client, we need to update the URL to
+// point to the local tag folder.
+var updateTagQueryFolder = Task.async(function* (info) {
+ if (info.kind != BookmarkSyncUtils.KINDS.QUERY || !info.folder || !info.url ||
+ info.url.protocol != "place:") {
+ return info;
+ }
+
+ let params = new URLSearchParams(info.url.pathname);
+ let type = +params.get("type");
+
+ if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+ return info;
+ }
+
+ let id = yield getOrCreateTagFolder(info.folder);
+ BookmarkSyncLog.debug(`updateTagQueryFolder: Tag query folder: ${
+ info.folder} = ${id}`);
+
+ // Rewrite the query to reference the new ID.
+ params.set("folder", id);
+ info.url = new URL(info.url.protocol + params);
+
+ return info;
+});
+
+var annotateOrphan = Task.async(function* (item, requestedParentSyncId) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(item.syncId);
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SYNC_PARENT_ANNO, requestedParentSyncId, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+});
+
+var reparentOrphans = Task.async(function* (item) {
+ if (item.kind != BookmarkSyncUtils.KINDS.FOLDER) {
+ return;
+ }
+ let orphanGuids = yield fetchGuidsWithAnno(BookmarkSyncUtils.SYNC_PARENT_ANNO,
+ item.syncId);
+ let folderGuid = BookmarkSyncUtils.syncIdToGuid(item.syncId);
+ BookmarkSyncLog.debug(`reparentOrphans: Reparenting ${
+ JSON.stringify(orphanGuids)} to ${item.syncId}`);
+ for (let i = 0; i < orphanGuids.length; ++i) {
+ let isReparented = false;
+ try {
+ // Reparenting can fail if we have a corrupted or incomplete tree
+ // where an item's parent is one of its descendants.
+ BookmarkSyncLog.trace(`reparentOrphans: Attempting to move item ${
+ orphanGuids[i]} to new parent ${item.syncId}`);
+ yield PlacesUtils.bookmarks.update({
+ guid: orphanGuids[i],
+ parentGuid: folderGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ source: SOURCE_SYNC,
+ });
+ isReparented = true;
+ } catch (ex) {
+ BookmarkSyncLog.error(`reparentOrphans: Failed to reparent item ${
+ orphanGuids[i]} to ${item.syncId}`, ex);
+ }
+ if (isReparented) {
+ // Remove the annotation once we've reparented the item.
+ let orphanId = yield PlacesUtils.promiseItemId(orphanGuids[i]);
+ PlacesUtils.annotations.removeItemAnnotation(orphanId,
+ BookmarkSyncUtils.SYNC_PARENT_ANNO, SOURCE_SYNC);
+ }
+ }
+});
+
+// Inserts a synced bookmark into the database.
+var insertSyncBookmark = Task.async(function* (insertInfo) {
+ let requestedParentSyncId = insertInfo.parentSyncId;
+ let requestedParentGuid =
+ BookmarkSyncUtils.syncIdToGuid(insertInfo.parentSyncId);
+ let isOrphan = yield GUIDMissing(requestedParentGuid);
+
+ // Default to "unfiled" for new bookmarks if the parent doesn't exist.
+ if (!isOrphan) {
+ BookmarkSyncLog.debug(`insertSyncBookmark: Item ${
+ insertInfo.syncId} is not an orphan`);
+ } else {
+ BookmarkSyncLog.debug(`insertSyncBookmark: Item ${
+ insertInfo.syncId} is an orphan: parent ${
+ insertInfo.parentSyncId} doesn't exist; reparenting to unfiled`);
+ insertInfo.parentSyncId = "unfiled";
+ }
+
+ // If we're inserting a tag query, make sure the tag exists and fix the
+ // folder ID to refer to the local tag folder.
+ insertInfo = yield updateTagQueryFolder(insertInfo);
+
+ let newItem;
+ if (insertInfo.kind == BookmarkSyncUtils.KINDS.LIVEMARK) {
+ newItem = yield insertSyncLivemark(insertInfo);
+ } else {
+ let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
+ let bookmarkItem = yield PlacesUtils.bookmarks.insert(bookmarkInfo);
+ newItem = yield insertBookmarkMetadata(bookmarkItem, insertInfo);
+ }
+
+ if (!newItem) {
+ return null;
+ }
+
+ // If the item is an orphan, annotate it with its real parent sync ID.
+ if (isOrphan) {
+ yield annotateOrphan(newItem, requestedParentSyncId);
+ }
+
+ // Reparent all orphans that expect this folder as the parent.
+ yield reparentOrphans(newItem);
+
+ return newItem;
+});
+
+// Inserts a synced livemark.
+var insertSyncLivemark = Task.async(function* (insertInfo) {
+ if (!insertInfo.feed) {
+ BookmarkSyncLog.debug(`insertSyncLivemark: ${
+ insertInfo.syncId} missing feed URL`);
+ return null;
+ }
+ let livemarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
+ let parentIsLivemark = yield getAnno(livemarkInfo.parentGuid,
+ PlacesUtils.LMANNO_FEEDURI);
+ if (parentIsLivemark) {
+ // A livemark can't be a descendant of another livemark.
+ BookmarkSyncLog.debug(`insertSyncLivemark: Invalid parent ${
+ insertInfo.parentSyncId}; skipping livemark record ${
+ insertInfo.syncId}`);
+ return null;
+ }
+
+ let livemarkItem = yield PlacesUtils.livemarks.addLivemark(livemarkInfo);
+
+ return insertBookmarkMetadata(livemarkItem, insertInfo);
+});
+
+// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync
+// bookmark object.
+var insertBookmarkMetadata = Task.async(function* (bookmarkItem, insertInfo) {
+ let itemId = yield PlacesUtils.promiseItemId(bookmarkItem.guid);
+ let newItem = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (insertInfo.query) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO, insertInfo.query, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.query = insertInfo.query;
+ }
+
+ try {
+ newItem.tags = yield tagItem(bookmarkItem, insertInfo.tags);
+ } catch (ex) {
+ BookmarkSyncLog.warn(`insertBookmarkMetadata: Error tagging item ${
+ insertInfo.syncId}`, ex);
+ }
+
+ if (insertInfo.keyword) {
+ yield PlacesUtils.keywords.insert({
+ keyword: insertInfo.keyword,
+ url: bookmarkItem.url.href,
+ source: SOURCE_SYNC,
+ });
+ newItem.keyword = insertInfo.keyword;
+ }
+
+ if (insertInfo.description) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, insertInfo.description, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.description = insertInfo.description;
+ }
+
+ if (insertInfo.loadInSidebar) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, insertInfo.loadInSidebar, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.loadInSidebar = insertInfo.loadInSidebar;
+ }
+
+ return newItem;
+});
+
+// Determines the Sync record kind for an existing bookmark.
+var getKindForItem = Task.async(function* (item) {
+ switch (item.type) {
+ case PlacesUtils.bookmarks.TYPE_FOLDER: {
+ let isLivemark = yield getAnno(item.guid,
+ PlacesUtils.LMANNO_FEEDURI);
+ return isLivemark ? BookmarkSyncUtils.KINDS.LIVEMARK :
+ BookmarkSyncUtils.KINDS.FOLDER;
+ }
+ case PlacesUtils.bookmarks.TYPE_BOOKMARK:
+ return item.url.protocol == "place:" ?
+ BookmarkSyncUtils.KINDS.QUERY :
+ BookmarkSyncUtils.KINDS.BOOKMARK;
+
+ case PlacesUtils.bookmarks.TYPE_SEPARATOR:
+ return BookmarkSyncUtils.KINDS.SEPARATOR;
+ }
+ return null;
+});
+
+// Returns the `nsINavBookmarksService` bookmark type constant for a Sync
+// record kind.
+function getTypeForKind(kind) {
+ switch (kind) {
+ case BookmarkSyncUtils.KINDS.BOOKMARK:
+ case BookmarkSyncUtils.KINDS.MICROSUMMARY:
+ case BookmarkSyncUtils.KINDS.QUERY:
+ return PlacesUtils.bookmarks.TYPE_BOOKMARK;
+
+ case BookmarkSyncUtils.KINDS.FOLDER:
+ case BookmarkSyncUtils.KINDS.LIVEMARK:
+ return PlacesUtils.bookmarks.TYPE_FOLDER;
+
+ case BookmarkSyncUtils.KINDS.SEPARATOR:
+ return PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ }
+ throw new Error(`Unknown bookmark kind: ${kind}`);
+}
+
+// Determines if a livemark should be reinserted. Returns true if `updateInfo`
+// specifies different feed or site URLs; false otherwise.
+var shouldReinsertLivemark = Task.async(function* (updateInfo) {
+ let hasFeed = updateInfo.hasOwnProperty("feed");
+ let hasSite = updateInfo.hasOwnProperty("site");
+ if (!hasFeed && !hasSite) {
+ return false;
+ }
+ let guid = BookmarkSyncUtils.syncIdToGuid(updateInfo.syncId);
+ let livemark = yield PlacesUtils.livemarks.getLivemark({
+ guid,
+ });
+ if (hasFeed) {
+ let feedURI = PlacesUtils.toURI(updateInfo.feed);
+ if (!livemark.feedURI.equals(feedURI)) {
+ return true;
+ }
+ }
+ if (hasSite) {
+ if (!updateInfo.site) {
+ return !!livemark.siteURI;
+ }
+ let siteURI = PlacesUtils.toURI(updateInfo.site);
+ if (!livemark.siteURI || !siteURI.equals(livemark.siteURI)) {
+ return true;
+ }
+ }
+ return false;
+});
+
+var updateSyncBookmark = Task.async(function* (updateInfo) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(updateInfo.syncId);
+ let oldBookmarkItem = yield PlacesUtils.bookmarks.fetch(guid);
+ if (!oldBookmarkItem) {
+ throw new Error(`Bookmark with sync ID ${
+ updateInfo.syncId} does not exist`);
+ }
+
+ let shouldReinsert = false;
+ let oldKind = yield getKindForItem(oldBookmarkItem);
+ if (updateInfo.hasOwnProperty("kind") && updateInfo.kind != oldKind) {
+ // If the item's aren't the same kind, we can't update the record;
+ // we must remove and reinsert.
+ shouldReinsert = true;
+ if (BookmarkSyncLog.level <= Log.Level.Warn) {
+ let oldSyncId = BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.guid);
+ BookmarkSyncLog.warn(`updateSyncBookmark: Local ${
+ oldSyncId} kind = ${oldKind}; remote ${
+ updateInfo.syncId} kind = ${
+ updateInfo.kind}. Deleting and recreating`);
+ }
+ } else if (oldKind == BookmarkSyncUtils.KINDS.LIVEMARK) {
+ // Similarly, if we're changing a livemark's site or feed URL, we need to
+ // reinsert.
+ shouldReinsert = yield shouldReinsertLivemark(updateInfo);
+ if (BookmarkSyncLog.level <= Log.Level.Debug) {
+ let oldSyncId = BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.guid);
+ BookmarkSyncLog.debug(`updateSyncBookmark: Local ${
+ oldSyncId} and remote ${
+ updateInfo.syncId} livemarks have different URLs`);
+ }
+ }
+
+ if (shouldReinsert) {
+ let newInfo = validateNewBookmark(updateInfo);
+ yield PlacesUtils.bookmarks.remove({
+ guid,
+ source: SOURCE_SYNC,
+ });
+ // A reinsertion likely indicates a confused client, since there aren't
+ // public APIs for changing livemark URLs or an item's kind (e.g., turning
+ // a folder into a separator while preserving its annos and position).
+ // This might be a good case to repair later; for now, we assume Sync has
+ // passed a complete record for the new item, and don't try to merge
+ // `oldBookmarkItem` with `updateInfo`.
+ return insertSyncBookmark(newInfo);
+ }
+
+ let isOrphan = false, requestedParentSyncId;
+ if (updateInfo.hasOwnProperty("parentSyncId")) {
+ requestedParentSyncId = updateInfo.parentSyncId;
+ let oldParentSyncId =
+ BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.parentGuid);
+ if (requestedParentSyncId != oldParentSyncId) {
+ let oldId = yield PlacesUtils.promiseItemId(oldBookmarkItem.guid);
+ if (PlacesUtils.isRootItem(oldId)) {
+ throw new Error(`Cannot move Places root ${oldId}`);
+ }
+ let requestedParentGuid =
+ BookmarkSyncUtils.syncIdToGuid(requestedParentSyncId);
+ isOrphan = yield GUIDMissing(requestedParentGuid);
+ if (!isOrphan) {
+ BookmarkSyncLog.debug(`updateSyncBookmark: Item ${
+ updateInfo.syncId} is not an orphan`);
+ } else {
+ // Don't move the item if the new parent doesn't exist. Instead, mark
+ // the item as an orphan. We'll annotate it with its real parent after
+ // updating.
+ BookmarkSyncLog.trace(`updateSyncBookmark: Item ${
+ updateInfo.syncId} is an orphan: could not find parent ${
+ requestedParentSyncId}`);
+ delete updateInfo.parentSyncId;
+ }
+ } else {
+ // If the parent is the same, just omit it so that `update` doesn't do
+ // extra work.
+ delete updateInfo.parentSyncId;
+ }
+ }
+
+ updateInfo = yield updateTagQueryFolder(updateInfo);
+
+ let bookmarkInfo = syncBookmarkToPlacesBookmark(updateInfo);
+ let newBookmarkItem = shouldUpdateBookmark(bookmarkInfo) ?
+ yield PlacesUtils.bookmarks.update(bookmarkInfo) :
+ oldBookmarkItem;
+ let newItem = yield updateBookmarkMetadata(oldBookmarkItem, newBookmarkItem,
+ updateInfo);
+
+ // If the item is an orphan, annotate it with its real parent sync ID.
+ if (isOrphan) {
+ yield annotateOrphan(newItem, requestedParentSyncId);
+ }
+
+ // Reparent all orphans that expect this folder as the parent.
+ yield reparentOrphans(newItem);
+
+ return newItem;
+});
+
+// Updates tags, keywords, and annotations for an existing bookmark. Returns a
+// Sync bookmark object.
+var updateBookmarkMetadata = Task.async(function* (oldBookmarkItem,
+ newBookmarkItem,
+ updateInfo) {
+ let itemId = yield PlacesUtils.promiseItemId(newBookmarkItem.guid);
+ let newItem = yield placesBookmarkToSyncBookmark(newBookmarkItem);
+
+ try {
+ newItem.tags = yield tagItem(newBookmarkItem, updateInfo.tags);
+ } catch (ex) {
+ BookmarkSyncLog.warn(`updateBookmarkMetadata: Error tagging item ${
+ updateInfo.syncId}`, ex);
+ }
+
+ if (updateInfo.hasOwnProperty("keyword")) {
+ // Unconditionally remove the old keyword.
+ let entry = yield PlacesUtils.keywords.fetch({
+ url: oldBookmarkItem.url.href,
+ });
+ if (entry) {
+ yield PlacesUtils.keywords.remove({
+ keyword: entry.keyword,
+ source: SOURCE_SYNC,
+ });
+ }
+ if (updateInfo.keyword) {
+ yield PlacesUtils.keywords.insert({
+ keyword: updateInfo.keyword,
+ url: newItem.url.href,
+ source: SOURCE_SYNC,
+ });
+ }
+ newItem.keyword = updateInfo.keyword;
+ }
+
+ if (updateInfo.hasOwnProperty("description")) {
+ if (updateInfo.description) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, updateInfo.description, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ } else {
+ PlacesUtils.annotations.removeItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, SOURCE_SYNC);
+ }
+ newItem.description = updateInfo.description;
+ }
+
+ if (updateInfo.hasOwnProperty("loadInSidebar")) {
+ if (updateInfo.loadInSidebar) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, updateInfo.loadInSidebar, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ } else {
+ PlacesUtils.annotations.removeItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, SOURCE_SYNC);
+ }
+ newItem.loadInSidebar = updateInfo.loadInSidebar;
+ }
+
+ if (updateInfo.hasOwnProperty("query")) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO, updateInfo.query, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.query = updateInfo.query;
+ }
+
+ return newItem;
+});
+
+function validateNewBookmark(info) {
+ let insertInfo = validateSyncBookmarkObject(info,
+ { kind: { required: true }
+ , syncId: { required: true }
+ , url: { requiredIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind)
+ , validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , parentSyncId: { required: true }
+ , title: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY
+ , BookmarkSyncUtils.KINDS.FOLDER
+ , BookmarkSyncUtils.KINDS.LIVEMARK ].includes(b.kind) }
+ , query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }
+ , folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }
+ , tags: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , keyword: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , description: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY
+ , BookmarkSyncUtils.KINDS.FOLDER
+ , BookmarkSyncUtils.KINDS.LIVEMARK ].includes(b.kind) }
+ , loadInSidebar: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , feed: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK }
+ , site: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK }
+ });
+
+ return insertInfo;
+}
+
+// Returns an array of GUIDs for items that have an `anno` with the given `val`.
+var fetchGuidsWithAnno = Task.async(function* (anno, val) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(`
+ SELECT b.guid FROM moz_items_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ JOIN moz_bookmarks b ON b.id = a.item_id
+ WHERE n.name = :anno AND
+ a.content = :val`,
+ { anno, val });
+ return rows.map(row => row.getResultByName("guid"));
+});
+
+// Returns the value of an item's annotation, or `null` if it's not set.
+var getAnno = Task.async(function* (guid, anno) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(`
+ SELECT a.content FROM moz_items_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ JOIN moz_bookmarks b ON b.id = a.item_id
+ WHERE b.guid = :guid AND
+ n.name = :anno`,
+ { guid, anno });
+ return rows.length ? rows[0].getResultByName("content") : null;
+});
+
+var tagItem = Task.async(function (item, tags) {
+ if (!item.url) {
+ return [];
+ }
+
+ // Remove leading and trailing whitespace, then filter out empty tags.
+ let newTags = tags.map(tag => tag.trim()).filter(Boolean);
+
+ // Removing the last tagged item will also remove the tag. To preserve
+ // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist.
+ let dummyURI = PlacesUtils.toURI("about:weave#BStore_tagURI");
+ let bookmarkURI = PlacesUtils.toURI(item.url.href);
+ PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC);
+ PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC);
+ PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC);
+ PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC);
+
+ return newTags;
+});
+
+// `PlacesUtils.bookmarks.update` checks if we've supplied enough properties,
+// but doesn't know about additional livemark properties. We check this to avoid
+// having it throw in case we only pass properties like `{ guid, feedURI }`.
+function shouldUpdateBookmark(bookmarkInfo) {
+ return bookmarkInfo.hasOwnProperty("parentGuid") ||
+ bookmarkInfo.hasOwnProperty("title") ||
+ bookmarkInfo.hasOwnProperty("url");
+}
+
+var getTagFolder = Task.async(function* (tag) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let results = yield db.executeCached(`SELECT id FROM moz_bookmarks
+ WHERE parent = :tagsFolder AND title = :tag LIMIT 1`,
+ { tagsFolder: PlacesUtils.bookmarks.tagsFolder, tag });
+ return results.length ? results[0].getResultByName("id") : null;
+});
+
+var getOrCreateTagFolder = Task.async(function* (tag) {
+ let id = yield getTagFolder(tag);
+ if (id) {
+ return id;
+ }
+ // Create the tag if it doesn't exist.
+ let item = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: tag,
+ source: SOURCE_SYNC,
+ });
+ return PlacesUtils.promiseItemId(item.guid);
+});
+
+// Converts a Places bookmark or livemark to a Sync bookmark. This function
+// maps Places GUIDs to sync IDs and filters out extra Places properties like
+// date added, last modified, and index.
+var placesBookmarkToSyncBookmark = Task.async(function* (bookmarkItem) {
+ let item = {};
+
+ for (let prop in bookmarkItem) {
+ switch (prop) {
+ // Sync IDs are identical to Places GUIDs for all items except roots.
+ case "guid":
+ item.syncId = BookmarkSyncUtils.guidToSyncId(bookmarkItem.guid);
+ break;
+
+ case "parentGuid":
+ item.parentSyncId =
+ BookmarkSyncUtils.guidToSyncId(bookmarkItem.parentGuid);
+ break;
+
+ // Sync uses kinds instead of types, which distinguish between folders,
+ // livemarks, bookmarks, and queries.
+ case "type":
+ item.kind = yield getKindForItem(bookmarkItem);
+ break;
+
+ case "title":
+ case "url":
+ item[prop] = bookmarkItem[prop];
+ break;
+
+ // Livemark objects contain additional properties. The feed URL is
+ // required; the site URL is optional.
+ case "feedURI":
+ item.feed = new URL(bookmarkItem.feedURI.spec);
+ break;
+
+ case "siteURI":
+ if (bookmarkItem.siteURI) {
+ item.site = new URL(bookmarkItem.siteURI.spec);
+ }
+ break;
+ }
+ }
+
+ return item;
+});
+
+// Converts a Sync bookmark object to a Places bookmark or livemark object.
+// This function maps sync IDs to Places GUIDs, and filters out extra Sync
+// properties like keywords, tags, and descriptions. Returns an object that can
+// be passed to `PlacesUtils.livemarks.addLivemark` or
+// `PlacesUtils.bookmarks.{insert, update}`.
+function syncBookmarkToPlacesBookmark(info) {
+ let bookmarkInfo = {
+ source: SOURCE_SYNC,
+ };
+
+ for (let prop in info) {
+ switch (prop) {
+ case "kind":
+ bookmarkInfo.type = getTypeForKind(info.kind);
+ break;
+
+ // Convert sync IDs to Places GUIDs for roots.
+ case "syncId":
+ bookmarkInfo.guid = BookmarkSyncUtils.syncIdToGuid(info.syncId);
+ break;
+
+ case "parentSyncId":
+ bookmarkInfo.parentGuid =
+ BookmarkSyncUtils.syncIdToGuid(info.parentSyncId);
+ // Instead of providing an index, Sync reorders children at the end of
+ // the sync using `BookmarkSyncUtils.order`. We explicitly specify the
+ // default index here to prevent `PlacesUtils.bookmarks.update` and
+ // `PlacesUtils.livemarks.addLivemark` from throwing.
+ bookmarkInfo.index = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ break;
+
+ case "title":
+ case "url":
+ bookmarkInfo[prop] = info[prop];
+ break;
+
+ // Livemark-specific properties.
+ case "feed":
+ bookmarkInfo.feedURI = PlacesUtils.toURI(info.feed);
+ break;
+
+ case "site":
+ if (info.site) {
+ bookmarkInfo.siteURI = PlacesUtils.toURI(info.site);
+ }
+ break;
+ }
+ }
+
+ return bookmarkInfo;
+}
+
+// Creates and returns a Sync bookmark object containing the bookmark's
+// tags, keyword, description, and whether it loads in the sidebar.
+var fetchBookmarkItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ item.tags = PlacesUtils.tagging.getTagsForURI(
+ PlacesUtils.toURI(bookmarkItem.url), {});
+
+ let keywordEntry = yield PlacesUtils.keywords.fetch({
+ url: bookmarkItem.url,
+ });
+ if (keywordEntry) {
+ item.keyword = keywordEntry.keyword;
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ item.loadInSidebar = !!(yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.SIDEBAR_ANNO));
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the folder's
+// description and children.
+var fetchFolderItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let children = yield fetchAllChildren(db, bookmarkItem.guid);
+ item.childSyncIds = children.map(child =>
+ BookmarkSyncUtils.guidToSyncId(child.guid)
+ );
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the livemark's
+// description, children (none), feed URI, and site URI.
+var fetchLivemarkItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let feedAnno = yield getAnno(bookmarkItem.guid, PlacesUtils.LMANNO_FEEDURI);
+ item.feed = new URL(feedAnno);
+
+ let siteAnno = yield getAnno(bookmarkItem.guid, PlacesUtils.LMANNO_SITEURI);
+ if (siteAnno) {
+ item.site = new URL(siteAnno);
+ }
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the query's tag
+// folder name and smart bookmark query ID.
+var fetchQueryItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let folder = null;
+ let params = new URLSearchParams(bookmarkItem.url.pathname);
+ let tagFolderId = +params.get("folder");
+ if (tagFolderId) {
+ try {
+ let tagFolderGuid = yield PlacesUtils.promiseItemGuid(tagFolderId);
+ let tagFolder = yield PlacesUtils.bookmarks.fetch(tagFolderGuid);
+ folder = tagFolder.title;
+ } catch (ex) {
+ BookmarkSyncLog.warn("fetchQueryItem: Query " + bookmarkItem.url.href +
+ " points to nonexistent folder " + tagFolderId, ex);
+ }
+ }
+ if (folder != null) {
+ item.folder = folder;
+ }
+
+ let query = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO);
+ if (query) {
+ item.query = query;
+ }
+
+ return item;
+});
diff --git a/toolkit/components/places/PlacesTransactions.jsm b/toolkit/components/places/PlacesTransactions.jsm
new file mode 100644
index 000000000..c355d92b6
--- /dev/null
+++ b/toolkit/components/places/PlacesTransactions.jsm
@@ -0,0 +1,1645 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["PlacesTransactions"];
+
+/**
+ * Overview
+ * --------
+ * This modules serves as the transactions manager for Places (hereinafter PTM).
+ * It implements all the elementary transactions for its UI commands: creating
+ * items, editing their various properties, and so forth.
+ *
+ * Note that since the effect of invoking a Places command is not limited to the
+ * window in which it was performed (e.g. a folder created in the Library may be
+ * the parent of a bookmark created in some browser window), PTM is a singleton.
+ * It's therefore unnecessary to initialize PTM in any way apart importing this
+ * module.
+ *
+ * PTM shares most of its semantics with common command pattern implementations.
+ * However, the asynchronous design of contemporary and future APIs, combined
+ * with the commitment to serialize all UI operations, does make things a little
+ * bit different. For example, when |undo| is called in order to undo the top
+ * undo entry, the caller cannot tell for sure what entry would it be, because
+ * the execution of some transactions is either in process, or enqueued to be.
+ *
+ * Also note that unlike the nsITransactionManager, for example, this API is by
+ * no means generic. That is, it cannot be used to execute anything but the
+ * elementary transactions implemented here (Please file a bug if you find
+ * anything uncovered). More-complex transactions (e.g. creating a folder and
+ * moving a bookmark into it) may be implemented as a batch (see below).
+ *
+ * A note about GUIDs and item-ids
+ * -------------------------------
+ * There's an ongoing effort (see bug 1071511) to deprecate item-ids in Places
+ * in favor of GUIDs. Both because new APIs (e.g. Bookmark.jsm) expose them to
+ * the minimum necessary, and because GUIDs play much better with implementing
+ * |redo|, this API doesn't support item-ids at all, and only accepts bookmark
+ * GUIDs, both for input (e.g. for setting the parent folder for a new bookmark)
+ * and for output (when the GUID for such a bookmark is propagated).
+ *
+ * When working in conjugation with older Places API which only expose item ids,
+ * use PlacesUtils.promiseItemGuid for converting those to GUIDs (note that
+ * for result nodes, the guid is available through their bookmarkGuid getter).
+ * Should you need to convert GUIDs to item-ids, use PlacesUtils.promiseItemId.
+ *
+ * Constructing transactions
+ * -------------------------
+ * At the bottom of this module you will find transactions for all Places UI
+ * commands. They are exposed as constructors set on the PlacesTransactions
+ * object (e.g. PlacesTransactions.NewFolder). The input for this constructors
+ * is taken in the form of a single argument, a plain object consisting of the
+ * properties for the transaction. Input properties may be either required or
+ * optional (for example, |keyword| is required for the EditKeyword transaction,
+ * but optional for the NewBookmark transaction).
+ *
+ * To make things simple, a given input property has the same basic meaning and
+ * valid values across all transactions which accept it in the input object.
+ * Here is a list of all supported input properties along with their expected
+ * values:
+ * - url: a URL object, an nsIURI object, or a href.
+ * - urls: an array of urls, as above.
+ * - feedUrl: an url (as above), holding the url for a live bookmark.
+ * - siteUrl an url (as above), holding the url for the site with which
+ * a live bookmark is associated.
+ * - tag - a string.
+ * - tags: an array of strings.
+ * - guid, parentGuid, newParentGuid: a valid Places GUID string.
+ * - guids: an array of valid Places GUID strings.
+ * - title: a string
+ * - index, newIndex: the position of an item in its containing folder,
+ * starting from 0.
+ * integer and PlacesUtils.bookmarks.DEFAULT_INDEX
+ * - annotation: see PlacesUtils.setAnnotationsForItem
+ * - annotations: an array of annotation objects as above.
+ * - excludingAnnotation: a string (annotation name).
+ * - excludingAnnotations: an array of string (annotation names).
+ *
+ * If a required property is missing in the input object (e.g. not specifying
+ * parentGuid for NewBookmark), or if the value for any of the input properties
+ * is invalid "on the surface" (e.g. a numeric value for GUID, or a string that
+ * isn't 12-characters long), the transaction constructor throws right way.
+ * More complex errors (e.g. passing a non-existent GUID for parentGuid) only
+ * reveal once the transaction is executed.
+ *
+ * Executing Transactions (the |transact| method of transactions)
+ * --------------------------------------------------------------
+ * Once a transaction is created, you must call its |transact| method for it to
+ * be executed and take effect. |transact| is an asynchronous method that takes
+ * no arguments, and returns a promise that resolves once the transaction is
+ * executed. Executing one of the transactions for creating items (NewBookmark,
+ * NewFolder, NewSeparator or NewLivemark) resolve to the new item's GUID.
+ * There's no resolution value for other transactions.
+ * If a transaction fails to execute, |transact| rejects and the transactions
+ * history is not affected.
+ *
+ * |transact| throws if it's called more than once (successfully or not) on the
+ * same transaction object.
+ *
+ * Batches
+ * -------
+ * Sometimes it is useful to "batch" or "merge" transactions. For example,
+ * something like "Bookmark All Tabs" may be implemented as one NewFolder
+ * transaction followed by numerous NewBookmark transactions - all to be undone
+ * or redone in a single undo or redo command. Use |PlacesTransactions.batch|
+ * in such cases. It can take either an array of transactions which will be
+ * executed in the given order and later be treated a a single entry in the
+ * transactions history, or a generator function that is passed to Task.spawn,
+ * that is to "contain" the batch: once the generator function is called a batch
+ * starts, and it lasts until the asynchronous generator iteration is complete
+ * All transactions executed by |transact| during this time are to be treated as
+ * a single entry in the transactions history.
+ *
+ * In both modes, |PlacesTransactions.batch| returns a promise that is to be
+ * resolved when the batch ends. In the array-input mode, there's no resolution
+ * value. In the generator mode, the resolution value is whatever the generator
+ * function returned (the semantics are the same as in Task.spawn, basically).
+ *
+ * The array-input mode of |PlacesTransactions.batch| is useful for implementing
+ * a batch of mostly-independent transaction (for example, |paste| into a folder
+ * can be implemented as a batch of multiple NewBookmark transactions).
+ * The generator mode is useful when the resolution value of executing one
+ * transaction is the input of one more subsequent transaction.
+ *
+ * In the array-input mode, if any transactions fails to execute, the batch
+ * continues (exceptions are logged). Only transactions that were executed
+ * successfully are added to the transactions history.
+ *
+ * WARNING: "nested" batches are not supported, if you call batch while another
+ * batch is still running, the new batch is enqueued with all other PTM work
+ * and thus not run until the running batch ends. The same goes for undo, redo
+ * and clearTransactionsHistory (note batches cannot be done partially, meaning
+ * undo and redo calls that during a batch are just enqueued).
+ *
+ * *****************************************************************************
+ * IT"S PARTICULARLY IMPORTANT NOT TO YIELD ANY PROMISE RETURNED BY ANY OF
+ * THESE METHODS (undo, redo, clearTransactionsHistory) FROM A BATCH FUNCTION.
+ * UNTIL WE FIND A WAY TO THROW IN THAT CASE (SEE BUG 1091446) DOING SO WILL
+ * COMPLETELY BREAK PTM UNTIL SHUTDOWN, NOT ALLOWING THE EXECUTION OF ANY
+ * TRANSACTION!
+ * *****************************************************************************
+ *
+ * Serialization
+ * -------------
+ * All |PlacesTransaction| operations are serialized. That is, even though the
+ * implementation is asynchronous, the order in which PlacesTransactions methods
+ * is called does guarantee the order in which they are to be invoked.
+ *
+ * The only exception to this rule is |transact| calls done during a batch (see
+ * above). |transact| calls are serialized with each other (and with undo, redo
+ * and clearTransactionsHistory), but they are, of course, not serialized with
+ * batches.
+ *
+ * The transactions-history structure
+ * ----------------------------------
+ * The transactions-history is a two-dimensional stack of transactions: the
+ * transactions are ordered in reverse to the order they were committed.
+ * It's two-dimensional because PTM allows batching transactions together for
+ * the purpose of undo or redo (see Batches above).
+ *
+ * The undoPosition property is set to the index of the top entry. If there is
+ * no entry at that index, there is nothing to undo.
+ * Entries prior to undoPosition, if any, are redo entries, the first one being
+ * the top redo entry.
+ *
+ * [ [2nd redo txn, 1st redo txn], <= 2nd redo entry
+ * [2nd redo txn, 1st redo txn], <= 1st redo entry
+ * [1st undo txn, 2nd undo txn], <= 1st undo entry
+ * [1st undo txn, 2nd undo txn] <= 2nd undo entry ]
+ * undoPostion: 2.
+ *
+ * Note that when a new entry is created, all redo entries are removed.
+ */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "console",
+ "resource://gre/modules/Console.jsm");
+
+Components.utils.importGlobalProperties(["URL"]);
+
+var TransactionsHistory = [];
+TransactionsHistory.__proto__ = {
+ __proto__: Array.prototype,
+
+ // The index of the first undo entry (if any) - See the documentation
+ // at the top of this file.
+ _undoPosition: 0,
+ get undoPosition() {
+ return this._undoPosition;
+ },
+
+ // Handy shortcuts
+ get topUndoEntry() {
+ return this.undoPosition < this.length ? this[this.undoPosition] : null;
+ },
+ get topRedoEntry() {
+ return this.undoPosition > 0 ? this[this.undoPosition - 1] : null;
+ },
+
+ // Outside of this module, the API of transactions is inaccessible, and so
+ // are any internal properties. To achieve that, transactions are proxified
+ // in their constructors. This maps the proxies to their respective raw
+ // objects.
+ proxifiedToRaw: new WeakMap(),
+
+ /**
+ * Proxify a transaction object for consumers.
+ * @param aRawTransaction
+ * the raw transaction object.
+ * @return the proxified transaction object.
+ * @see getRawTransaction for retrieving the raw transaction.
+ */
+ proxifyTransaction: function (aRawTransaction) {
+ let proxy = Object.freeze({
+ transact() {
+ return TransactionsManager.transact(this);
+ }
+ });
+ this.proxifiedToRaw.set(proxy, aRawTransaction);
+ return proxy;
+ },
+
+ /**
+ * Check if the given object is a the proxy object for some transaction.
+ * @param aValue
+ * any JS value.
+ * @return true if aValue is the proxy object for some transaction, false
+ * otherwise.
+ */
+ isProxifiedTransactionObject(aValue) {
+ return this.proxifiedToRaw.has(aValue);
+ },
+
+ /**
+ * Get the raw transaction for the given proxy.
+ * @param aProxy
+ * the proxy object
+ * @return the transaction proxified by aProxy; |undefined| is returned if
+ * aProxy is not a proxified transaction.
+ */
+ getRawTransaction(aProxy) {
+ return this.proxifiedToRaw.get(aProxy);
+ },
+
+ /**
+ * Add a transaction either as a new entry, if forced or if there are no undo
+ * entries, or to the top undo entry.
+ *
+ * @param aProxifiedTransaction
+ * the proxified transaction object to be added to the transaction
+ * history.
+ * @param [optional] aForceNewEntry
+ * Force a new entry for the transaction. Default: false.
+ * If false, an entry will we created only if there's no undo entry
+ * to extend.
+ */
+ add(aProxifiedTransaction, aForceNewEntry = false) {
+ if (!this.isProxifiedTransactionObject(aProxifiedTransaction))
+ throw new Error("aProxifiedTransaction is not a proxified transaction");
+
+ if (this.length == 0 || aForceNewEntry) {
+ this.clearRedoEntries();
+ this.unshift([aProxifiedTransaction]);
+ }
+ else {
+ this[this.undoPosition].unshift(aProxifiedTransaction);
+ }
+ },
+
+ /**
+ * Clear all undo entries.
+ */
+ clearUndoEntries() {
+ if (this.undoPosition < this.length)
+ this.splice(this.undoPosition);
+ },
+
+ /**
+ * Clear all redo entries.
+ */
+ clearRedoEntries() {
+ if (this.undoPosition > 0) {
+ this.splice(0, this.undoPosition);
+ this._undoPosition = 0;
+ }
+ },
+
+ /**
+ * Clear all entries.
+ */
+ clearAllEntries() {
+ if (this.length > 0) {
+ this.splice(0);
+ this._undoPosition = 0;
+ }
+ }
+};
+
+
+var PlacesTransactions = {
+ /**
+ * @see Batches in the module documentation.
+ */
+ batch(aToBatch) {
+ if (Array.isArray(aToBatch)) {
+ if (aToBatch.length == 0)
+ throw new Error("aToBatch must not be an empty array");
+
+ if (aToBatch.some(
+ o => !TransactionsHistory.isProxifiedTransactionObject(o))) {
+ throw new Error("aToBatch contains non-transaction element");
+ }
+ return TransactionsManager.batch(function* () {
+ for (let txn of aToBatch) {
+ try {
+ yield txn.transact();
+ }
+ catch (ex) {
+ console.error(ex);
+ }
+ }
+ });
+ }
+ if (typeof(aToBatch) == "function") {
+ return TransactionsManager.batch(aToBatch);
+ }
+
+ throw new Error("aToBatch must be either a function or a transactions array");
+ },
+
+ /**
+ * Asynchronously undo the transaction immediately after the current undo
+ * position in the transactions history in the reverse order, if any, and
+ * adjusts the undo position.
+ *
+ * @return {Promises). The promise always resolves.
+ * @note All undo manager operations are queued. This means that transactions
+ * history may change by the time your request is fulfilled.
+ */
+ undo() {
+ return TransactionsManager.undo();
+ },
+
+ /**
+ * Asynchronously redo the transaction immediately before the current undo
+ * position in the transactions history, if any, and adjusts the undo
+ * position.
+ *
+ * @return {Promises). The promise always resolves.
+ * @note All undo manager operations are queued. This means that transactions
+ * history may change by the time your request is fulfilled.
+ */
+ redo() {
+ return TransactionsManager.redo();
+ },
+
+ /**
+ * Asynchronously clear the undo, redo, or all entries from the transactions
+ * history.
+ *
+ * @param [optional] aUndoEntries
+ * Whether or not to clear undo entries. Default: true.
+ * @param [optional] aRedoEntries
+ * Whether or not to clear undo entries. Default: true.
+ *
+ * @return {Promises). The promise always resolves.
+ * @throws if both aUndoEntries and aRedoEntries are false.
+ * @note All undo manager operations are queued. This means that transactions
+ * history may change by the time your request is fulfilled.
+ */
+ clearTransactionsHistory(aUndoEntries = true, aRedoEntries = true) {
+ return TransactionsManager.clearTransactionsHistory(aUndoEntries, aRedoEntries);
+ },
+
+ /**
+ * The numbers of entries in the transactions history.
+ */
+ get length() {
+ return TransactionsHistory.length;
+ },
+
+ /**
+ * Get the transaction history entry at a given index. Each entry consists
+ * of one or more transaction objects.
+ *
+ * @param aIndex
+ * the index of the entry to retrieve.
+ * @return an array of transaction objects in their undo order (that is,
+ * reversely to the order they were executed).
+ * @throw if aIndex is invalid (< 0 or >= length).
+ * @note the returned array is a clone of the history entry and is not
+ * kept in sync with the original entry if it changes.
+ */
+ entry(aIndex) {
+ if (!Number.isInteger(aIndex) || aIndex < 0 || aIndex >= this.length)
+ throw new Error("Invalid index");
+
+ return TransactionsHistory[aIndex];
+ },
+
+ /**
+ * The index of the top undo entry in the transactions history.
+ * If there are no undo entries, it equals to |length|.
+ * Entries past this point
+ * Entries at and past this point are redo entries.
+ */
+ get undoPosition() {
+ return TransactionsHistory.undoPosition;
+ },
+
+ /**
+ * Shortcut for accessing the top undo entry in the transaction history.
+ */
+ get topUndoEntry() {
+ return TransactionsHistory.topUndoEntry;
+ },
+
+ /**
+ * Shortcut for accessing the top redo entry in the transaction history.
+ */
+ get topRedoEntry() {
+ return TransactionsHistory.topRedoEntry;
+ }
+};
+
+/**
+ * Helper for serializing the calls to TransactionsManager methods. It allows
+ * us to guarantee that the order in which TransactionsManager asynchronous
+ * methods are called also enforces the order in which they're executed, and
+ * that they are never executed in parallel.
+ *
+ * In other words: Enqueuer.enqueue(aFunc1); Enqueuer.enqueue(aFunc2) is roughly
+ * the same as Task.spawn(aFunc1).then(Task.spawn(aFunc2)).
+ */
+function Enqueuer() {
+ this._promise = Promise.resolve();
+}
+Enqueuer.prototype = {
+ /**
+ * Spawn a functions once all previous functions enqueued are done running,
+ * and all promises passed to alsoWaitFor are no longer pending.
+ *
+ * @param aFunc
+ * @see Task.spawn.
+ * @return a promise that resolves once aFunc is done running. The promise
+ * "mirrors" the promise returned by aFunc.
+ */
+ enqueue(aFunc) {
+ let promise = this._promise.then(Task.async(aFunc));
+
+ // Propagate exceptions to the caller, but dismiss them internally.
+ this._promise = promise.catch(console.error);
+ return promise;
+ },
+
+ /**
+ * Same as above, but for a promise returned by a function that already run.
+ * This is useful, for example, for serializing transact calls with undo calls,
+ * even though transact has its own Enqueuer.
+ *
+ * @param aPromise
+ * any promise.
+ */
+ alsoWaitFor(aPromise) {
+ // We don't care if aPromise resolves or rejects, but just that is not
+ // pending anymore.
+ let promise = aPromise.catch(console.error);
+ this._promise = Promise.all([this._promise, promise]);
+ },
+
+ /**
+ * The promise for this queue.
+ */
+ get promise() {
+ return this._promise;
+ }
+};
+
+var TransactionsManager = {
+ // See the documentation at the top of this file. |transact| calls are not
+ // serialized with |batch| calls.
+ _mainEnqueuer: new Enqueuer(),
+ _transactEnqueuer: new Enqueuer(),
+
+ // Is a batch in progress? set when we enter a batch function and unset when
+ // it's execution is done.
+ _batching: false,
+
+ // If a batch started, this indicates if we've already created an entry in the
+ // transactions history for the batch (i.e. if at least one transaction was
+ // executed successfully).
+ _createdBatchEntry: false,
+
+ // Transactions object should never be recycled (that is, |execute| should
+ // only be called once (or not at all) after they're constructed.
+ // This keeps track of all transactions which were executed.
+ _executedTransactions: new WeakSet(),
+
+ transact(aTxnProxy) {
+ let rawTxn = TransactionsHistory.getRawTransaction(aTxnProxy);
+ if (!rawTxn)
+ throw new Error("|transact| was called with an unexpected object");
+
+ if (this._executedTransactions.has(rawTxn))
+ throw new Error("Transactions objects may not be recycled.");
+
+ // Add it in advance so one doesn't accidentally do
+ // sameTxn.transact(); sameTxn.transact();
+ this._executedTransactions.add(rawTxn);
+
+ let promise = this._transactEnqueuer.enqueue(function* () {
+ // Don't try to catch exceptions. If execute fails, we better not add the
+ // transaction to the undo stack.
+ let retval = yield rawTxn.execute();
+
+ let forceNewEntry = !this._batching || !this._createdBatchEntry;
+ TransactionsHistory.add(aTxnProxy, forceNewEntry);
+ if (this._batching)
+ this._createdBatchEntry = true;
+
+ this._updateCommandsOnActiveWindow();
+ return retval;
+ }.bind(this));
+ this._mainEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ batch(aTask) {
+ return this._mainEnqueuer.enqueue(function* () {
+ this._batching = true;
+ this._createdBatchEntry = false;
+ let rv;
+ try {
+ // We should return here, but bug 958949 makes that impossible.
+ rv = (yield Task.spawn(aTask));
+ }
+ finally {
+ this._batching = false;
+ this._createdBatchEntry = false;
+ }
+ return rv;
+ }.bind(this));
+ },
+
+ /**
+ * Undo the top undo entry, if any, and update the undo position accordingly.
+ */
+ undo() {
+ let promise = this._mainEnqueuer.enqueue(function* () {
+ let entry = TransactionsHistory.topUndoEntry;
+ if (!entry)
+ return;
+
+ for (let txnProxy of entry) {
+ try {
+ yield TransactionsHistory.getRawTransaction(txnProxy).undo();
+ }
+ catch (ex) {
+ // If one transaction is broken, it's not safe to work with any other
+ // undo entry. Report the error and clear the undo history.
+ console.error(ex,
+ "Couldn't undo a transaction, clearing all undo entries.");
+ TransactionsHistory.clearUndoEntries();
+ return;
+ }
+ }
+ TransactionsHistory._undoPosition++;
+ this._updateCommandsOnActiveWindow();
+ }.bind(this));
+ this._transactEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ /**
+ * Redo the top redo entry, if any, and update the undo position accordingly.
+ */
+ redo() {
+ let promise = this._mainEnqueuer.enqueue(function* () {
+ let entry = TransactionsHistory.topRedoEntry;
+ if (!entry)
+ return;
+
+ for (let i = entry.length - 1; i >= 0; i--) {
+ let transaction = TransactionsHistory.getRawTransaction(entry[i]);
+ try {
+ if (transaction.redo)
+ yield transaction.redo();
+ else
+ yield transaction.execute();
+ }
+ catch (ex) {
+ // If one transaction is broken, it's not safe to work with any other
+ // redo entry. Report the error and clear the undo history.
+ console.error(ex,
+ "Couldn't redo a transaction, clearing all redo entries.");
+ TransactionsHistory.clearRedoEntries();
+ return;
+ }
+ }
+ TransactionsHistory._undoPosition--;
+ this._updateCommandsOnActiveWindow();
+ }.bind(this));
+
+ this._transactEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ clearTransactionsHistory(aUndoEntries, aRedoEntries) {
+ let promise = this._mainEnqueuer.enqueue(function* () {
+ if (aUndoEntries && aRedoEntries)
+ TransactionsHistory.clearAllEntries();
+ else if (aUndoEntries)
+ TransactionsHistory.clearUndoEntries();
+ else if (aRedoEntries)
+ TransactionsHistory.clearRedoEntries();
+ else
+ throw new Error("either aUndoEntries or aRedoEntries should be true");
+ }.bind(this));
+
+ this._transactEnqueuer.alsoWaitFor(promise);
+ return promise;
+ },
+
+ // Updates commands in the undo group of the active window commands.
+ // Inactive windows commands will be updated on focus.
+ _updateCommandsOnActiveWindow() {
+ // Updating "undo" will cause a group update including "redo".
+ try {
+ let win = Services.focus.activeWindow;
+ if (win)
+ win.updateCommands("undo");
+ }
+ catch (ex) { console.error(ex, "Couldn't update undo commands"); }
+ }
+};
+
+/**
+ * Internal helper for defining the standard transactions and their input.
+ * It takes the required and optional properties, and generates the public
+ * constructor (which takes the input in the form of a plain object) which,
+ * when called, creates the argument-less "public" |execute| method by binding
+ * the input properties to the function arguments (required properties first,
+ * then the optional properties).
+ *
+ * If this seems confusing, look at the consumers.
+ *
+ * This magic serves two purposes:
+ * (1) It completely hides the transactions' internals from the module
+ * consumers.
+ * (2) It keeps each transaction implementation to what is about, bypassing
+ * all this bureaucracy while still validating input appropriately.
+ */
+function DefineTransaction(aRequiredProps = [], aOptionalProps = []) {
+ for (let prop of [...aRequiredProps, ...aOptionalProps]) {
+ if (!DefineTransaction.inputProps.has(prop))
+ throw new Error("Property '" + prop + "' is not defined");
+ }
+
+ let ctor = function (aInput) {
+ // We want to support both syntaxes:
+ // let t = new PlacesTransactions.NewBookmark(),
+ // let t = PlacesTransactions.NewBookmark()
+ if (this == PlacesTransactions)
+ return new ctor(aInput);
+
+ if (aRequiredProps.length > 0 || aOptionalProps.length > 0) {
+ // Bind the input properties to the arguments of execute.
+ let input = DefineTransaction.verifyInput(aInput, aRequiredProps,
+ aOptionalProps);
+ let executeArgs = [this,
+ ...aRequiredProps.map(prop => input[prop]),
+ ...aOptionalProps.map(prop => input[prop])];
+ this.execute = Function.bind.apply(this.execute, executeArgs);
+ }
+ return TransactionsHistory.proxifyTransaction(this);
+ };
+ return ctor;
+}
+
+function simpleValidateFunc(aCheck) {
+ return v => {
+ if (!aCheck(v))
+ throw new Error("Invalid value");
+ return v;
+ };
+}
+
+DefineTransaction.strValidate = simpleValidateFunc(v => typeof(v) == "string");
+DefineTransaction.strOrNullValidate =
+ simpleValidateFunc(v => typeof(v) == "string" || v === null);
+DefineTransaction.indexValidate =
+ simpleValidateFunc(v => Number.isInteger(v) &&
+ v >= PlacesUtils.bookmarks.DEFAULT_INDEX);
+DefineTransaction.guidValidate =
+ simpleValidateFunc(v => /^[a-zA-Z0-9\-_]{12}$/.test(v));
+
+function isPrimitive(v) {
+ return v === null || (typeof(v) != "object" && typeof(v) != "function");
+}
+
+DefineTransaction.annotationObjectValidate = function (obj) {
+ let checkProperty = (aPropName, aRequired, aCheckFunc) => {
+ if (aPropName in obj)
+ return aCheckFunc(obj[aPropName]);
+
+ return !aRequired;
+ };
+
+ if (obj &&
+ checkProperty("name", true, v => typeof(v) == "string" && v.length > 0) &&
+ checkProperty("expires", false, Number.isInteger) &&
+ checkProperty("flags", false, Number.isInteger) &&
+ checkProperty("value", false, isPrimitive) ) {
+ // Nothing else should be set
+ let validKeys = ["name", "value", "flags", "expires"];
+ if (Object.keys(obj).every( (k) => validKeys.includes(k)))
+ return obj;
+ }
+ throw new Error("Invalid annotation object");
+};
+
+DefineTransaction.urlValidate = function(url) {
+ // When this module is updated to use Bookmarks.jsm, we should actually
+ // convert nsIURIs/spec to URL objects.
+ if (url instanceof Components.interfaces.nsIURI)
+ return url;
+ let spec = url instanceof URL ? url.href : url;
+ return NetUtil.newURI(spec);
+};
+
+DefineTransaction.inputProps = new Map();
+DefineTransaction.defineInputProps =
+function (aNames, aValidationFunction, aDefaultValue) {
+ for (let name of aNames) {
+ // Workaround bug 449811.
+ let propName = name;
+ this.inputProps.set(propName, {
+ validateValue: function (aValue) {
+ if (aValue === undefined)
+ return aDefaultValue;
+ try {
+ return aValidationFunction(aValue);
+ }
+ catch (ex) {
+ throw new Error(`Invalid value for input property ${propName}`);
+ }
+ },
+
+ validateInput: function (aInput, aRequired) {
+ if (aRequired && !(propName in aInput))
+ throw new Error(`Required input property is missing: ${propName}`);
+ return this.validateValue(aInput[propName]);
+ },
+
+ isArrayProperty: false
+ });
+ }
+};
+
+DefineTransaction.defineArrayInputProp =
+function (aName, aBasePropertyName) {
+ let baseProp = this.inputProps.get(aBasePropertyName);
+ if (!baseProp)
+ throw new Error(`Unknown input property: ${aBasePropertyName}`);
+
+ this.inputProps.set(aName, {
+ validateValue: function (aValue) {
+ if (aValue == undefined)
+ return [];
+
+ if (!Array.isArray(aValue))
+ throw new Error(`${aName} input property value must be an array`);
+
+ // This also takes care of abandoning the global scope of the input
+ // array (through Array.prototype).
+ return aValue.map(baseProp.validateValue);
+ },
+
+ // We allow setting either the array property itself (e.g. urls), or a
+ // single element of it (url, in that example), that is then transformed
+ // into a single-element array.
+ validateInput: function (aInput, aRequired) {
+ if (aName in aInput) {
+ // It's not allowed to set both though.
+ if (aBasePropertyName in aInput) {
+ throw new Error(`It is not allowed to set both ${aName} and
+ ${aBasePropertyName} as input properties`);
+ }
+ let array = this.validateValue(aInput[aName]);
+ if (aRequired && array.length == 0) {
+ throw new Error(`Empty array passed for required input property:
+ ${aName}`);
+ }
+ return array;
+ }
+ // If the property is required and it's not set as is, check if the base
+ // property is set.
+ if (aRequired && !(aBasePropertyName in aInput))
+ throw new Error(`Required input property is missing: ${aName}`);
+
+ if (aBasePropertyName in aInput)
+ return [baseProp.validateValue(aInput[aBasePropertyName])];
+
+ return [];
+ },
+
+ isArrayProperty: true
+ });
+};
+
+DefineTransaction.validatePropertyValue =
+function (aProp, aInput, aRequired) {
+ return this.inputProps.get(aProp).validateInput(aInput, aRequired);
+};
+
+DefineTransaction.getInputObjectForSingleValue =
+function (aInput, aRequiredProps, aOptionalProps) {
+ // The following input forms may be deduced from a single value:
+ // * a single required property with or without optional properties (the given
+ // value is set to the required property).
+ // * a single optional property with no required properties.
+ if (aRequiredProps.length > 1 ||
+ (aRequiredProps.length == 0 && aOptionalProps.length > 1)) {
+ throw new Error("Transaction input isn't an object");
+ }
+
+ let propName = aRequiredProps.length == 1 ?
+ aRequiredProps[0] : aOptionalProps[0];
+ let propValue =
+ this.inputProps.get(propName).isArrayProperty && !Array.isArray(aInput) ?
+ [aInput] : aInput;
+ return { [propName]: propValue };
+};
+
+DefineTransaction.verifyInput =
+function (aInput, aRequiredProps = [], aOptionalProps = []) {
+ if (aRequiredProps.length == 0 && aOptionalProps.length == 0)
+ return {};
+
+ // If there's just a single required/optional property, we allow passing it
+ // as is, so, for example, one could do PlacesTransactions.RemoveItem(myGuid)
+ // rather than PlacesTransactions.RemoveItem({ guid: myGuid}).
+ // This shortcut isn't supported for "complex" properties - e.g. one cannot
+ // pass an annotation object this way (note there is no use case for this at
+ // the moment anyway).
+ let input = aInput;
+ let isSinglePropertyInput =
+ isPrimitive(aInput) ||
+ Array.isArray(aInput) ||
+ (aInput instanceof Components.interfaces.nsISupports);
+ if (isSinglePropertyInput) {
+ input = this.getInputObjectForSingleValue(aInput,
+ aRequiredProps,
+ aOptionalProps);
+ }
+
+ let fixedInput = { };
+ for (let prop of aRequiredProps) {
+ fixedInput[prop] = this.validatePropertyValue(prop, input, true);
+ }
+ for (let prop of aOptionalProps) {
+ fixedInput[prop] = this.validatePropertyValue(prop, input, false);
+ }
+
+ return fixedInput;
+};
+
+// Update the documentation at the top of this module if you add or
+// remove properties.
+DefineTransaction.defineInputProps(["url", "feedUrl", "siteUrl"],
+ DefineTransaction.urlValidate, null);
+DefineTransaction.defineInputProps(["guid", "parentGuid", "newParentGuid"],
+ DefineTransaction.guidValidate);
+DefineTransaction.defineInputProps(["title"],
+ DefineTransaction.strOrNullValidate, null);
+DefineTransaction.defineInputProps(["keyword", "oldKeyword", "postData", "tag",
+ "excludingAnnotation"],
+ DefineTransaction.strValidate, "");
+DefineTransaction.defineInputProps(["index", "newIndex"],
+ DefineTransaction.indexValidate,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+DefineTransaction.defineInputProps(["annotation"],
+ DefineTransaction.annotationObjectValidate);
+DefineTransaction.defineArrayInputProp("guids", "guid");
+DefineTransaction.defineArrayInputProp("urls", "url");
+DefineTransaction.defineArrayInputProp("tags", "tag");
+DefineTransaction.defineArrayInputProp("annotations", "annotation");
+DefineTransaction.defineArrayInputProp("excludingAnnotations",
+ "excludingAnnotation");
+
+/**
+ * Internal helper for implementing the execute method of NewBookmark, NewFolder
+ * and NewSeparator.
+ *
+ * @param aTransaction
+ * The transaction object
+ * @param aParentGuid
+ * The GUID of the parent folder
+ * @param aCreateItemFunction(aParentId, aGuidToRestore)
+ * The function to be called for creating the item on execute and redo.
+ * It should return the itemId for the new item
+ * - aGuidToRestore - the GUID to set for the item (used for redo).
+ * @param [optional] aOnUndo
+ * an additional function to call after undo
+ * @param [optional] aOnRedo
+ * an additional function to call after redo
+ */
+function* ExecuteCreateItem(aTransaction, aParentGuid, aCreateItemFunction,
+ aOnUndo = null, aOnRedo = null) {
+ let parentId = yield PlacesUtils.promiseItemId(aParentGuid),
+ itemId = yield aCreateItemFunction(parentId, ""),
+ guid = yield PlacesUtils.promiseItemGuid(itemId);
+
+ // On redo, we'll restore the date-added and last-modified properties.
+ let dateAdded = 0, lastModified = 0;
+ aTransaction.undo = function* () {
+ if (dateAdded == 0) {
+ dateAdded = PlacesUtils.bookmarks.getItemDateAdded(itemId);
+ lastModified = PlacesUtils.bookmarks.getItemLastModified(itemId);
+ }
+ PlacesUtils.bookmarks.removeItem(itemId);
+ if (aOnUndo) {
+ yield aOnUndo();
+ }
+ };
+ aTransaction.redo = function* () {
+ parentId = yield PlacesUtils.promiseItemId(aParentGuid);
+ itemId = yield aCreateItemFunction(parentId, guid);
+ if (aOnRedo)
+ yield aOnRedo();
+
+ // aOnRedo is called first to make sure it doesn't override
+ // lastModified.
+ PlacesUtils.bookmarks.setItemDateAdded(itemId, dateAdded);
+ PlacesUtils.bookmarks.setItemLastModified(itemId, lastModified);
+ PlacesUtils.bookmarks.setItemLastModified(parentId, dateAdded);
+ };
+ return guid;
+}
+
+/**
+ * Creates items (all types) from a bookmarks tree representation, as defined
+ * in PlacesUtils.promiseBookmarksTree.
+ *
+ * @param aBookmarksTree
+ * the bookmarks tree object. You may pass either a bookmarks tree
+ * returned by promiseBookmarksTree, or a manually defined one.
+ * @param [optional] aRestoring (default: false)
+ * Whether or not the items are restored. Only in restore mode, are
+ * the guid, dateAdded and lastModified properties honored.
+ * @param [optional] aExcludingAnnotations
+ * Array of annotations names to ignore in aBookmarksTree. This argument
+ * is ignored if aRestoring is set.
+ * @note the id, root and charset properties of items in aBookmarksTree are
+ * always ignored. The index property is ignored for all items but the
+ * root one.
+ * @return {Promise}
+ */
+function* createItemsFromBookmarksTree(aBookmarksTree, aRestoring = false,
+ aExcludingAnnotations = []) {
+ function extractLivemarkDetails(aAnnos) {
+ let feedURI = null, siteURI = null;
+ aAnnos = aAnnos.filter(
+ aAnno => {
+ switch (aAnno.name) {
+ case PlacesUtils.LMANNO_FEEDURI:
+ feedURI = NetUtil.newURI(aAnno.value);
+ return false;
+ case PlacesUtils.LMANNO_SITEURI:
+ siteURI = NetUtil.newURI(aAnno.value);
+ return false;
+ default:
+ return true;
+ }
+ } );
+ return [feedURI, siteURI];
+ }
+
+ function* createItem(aItem,
+ aParentGuid,
+ aIndex = PlacesUtils.bookmarks.DEFAULT_INDEX) {
+ let itemId;
+ let guid = aRestoring ? aItem.guid : undefined;
+ let parentId = yield PlacesUtils.promiseItemId(aParentGuid);
+ let annos = aItem.annos ? [...aItem.annos] : [];
+ switch (aItem.type) {
+ case PlacesUtils.TYPE_X_MOZ_PLACE: {
+ let uri = NetUtil.newURI(aItem.uri);
+ itemId = PlacesUtils.bookmarks.insertBookmark(
+ parentId, uri, aIndex, aItem.title, guid);
+ if ("keyword" in aItem) {
+ yield PlacesUtils.keywords.insert({
+ keyword: aItem.keyword,
+ url: uri.spec
+ });
+ }
+ if ("tags" in aItem) {
+ PlacesUtils.tagging.tagURI(uri, aItem.tags.split(","));
+ }
+ break;
+ }
+ case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: {
+ // Either a folder or a livemark
+ let [feedURI, siteURI] = extractLivemarkDetails(annos);
+ if (!feedURI) {
+ itemId = PlacesUtils.bookmarks.createFolder(
+ parentId, aItem.title, aIndex, guid);
+ if (guid === undefined)
+ guid = yield PlacesUtils.promiseItemGuid(itemId);
+ if ("children" in aItem) {
+ for (let child of aItem.children) {
+ yield createItem(child, guid);
+ }
+ }
+ }
+ else {
+ let livemark =
+ yield PlacesUtils.livemarks.addLivemark({ title: aItem.title
+ , feedURI: feedURI
+ , siteURI: siteURI
+ , parentId: parentId
+ , index: aIndex
+ , guid: guid});
+ itemId = livemark.id;
+ }
+ break;
+ }
+ case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: {
+ itemId = PlacesUtils.bookmarks.insertSeparator(parentId, aIndex, guid);
+ break;
+ }
+ }
+ if (annos.length > 0) {
+ if (!aRestoring && aExcludingAnnotations.length > 0) {
+ annos = annos.filter(a => !aExcludingAnnotations.includes(a.name));
+
+ }
+
+ PlacesUtils.setAnnotationsForItem(itemId, annos);
+ }
+
+ if (aRestoring) {
+ if ("dateAdded" in aItem)
+ PlacesUtils.bookmarks.setItemDateAdded(itemId, aItem.dateAdded);
+ if ("lastModified" in aItem)
+ PlacesUtils.bookmarks.setItemLastModified(itemId, aItem.lastModified);
+ }
+ return itemId;
+ }
+ return yield createItem(aBookmarksTree,
+ aBookmarksTree.parentGuid,
+ aBookmarksTree.index);
+}
+
+/** ***************************************************************************
+ * The Standard Places Transactions.
+ *
+ * See the documentation at the top of this file. The valid values for input
+ * are also documented there.
+ *****************************************************************************/
+
+var PT = PlacesTransactions;
+
+/**
+ * Transaction for creating a bookmark.
+ *
+ * Required Input Properties: url, parentGuid.
+ * Optional Input Properties: index, title, keyword, annotations, tags.
+ *
+ * When this transaction is executed, it's resolved to the new bookmark's GUID.
+ */
+PT.NewBookmark = DefineTransaction(["parentGuid", "url"],
+ ["index", "title", "keyword", "postData",
+ "annotations", "tags"]);
+PT.NewBookmark.prototype = Object.seal({
+ execute: function (aParentGuid, aURI, aIndex, aTitle,
+ aKeyword, aPostData, aAnnos, aTags) {
+ return ExecuteCreateItem(this, aParentGuid,
+ function* (parentId, guidToRestore = "") {
+ let itemId = PlacesUtils.bookmarks.insertBookmark(
+ parentId, aURI, aIndex, aTitle, guidToRestore);
+
+ if (aKeyword) {
+ yield PlacesUtils.keywords.insert({
+ url: aURI.spec,
+ keyword: aKeyword,
+ postData: aPostData
+ });
+ }
+ if (aAnnos.length) {
+ PlacesUtils.setAnnotationsForItem(itemId, aAnnos);
+ }
+ if (aTags.length > 0) {
+ let currentTags = PlacesUtils.tagging.getTagsForURI(aURI);
+ aTags = aTags.filter(t => !currentTags.includes(t));
+ PlacesUtils.tagging.tagURI(aURI, aTags);
+ }
+
+ return itemId;
+ },
+ function _additionalOnUndo() {
+ if (aTags.length > 0) {
+ PlacesUtils.tagging.untagURI(aURI, aTags);
+ }
+ });
+ }
+});
+
+/**
+ * Transaction for creating a folder.
+ *
+ * Required Input Properties: title, parentGuid.
+ * Optional Input Properties: index, annotations.
+ *
+ * When this transaction is executed, it's resolved to the new folder's GUID.
+ */
+PT.NewFolder = DefineTransaction(["parentGuid", "title"],
+ ["index", "annotations"]);
+PT.NewFolder.prototype = Object.seal({
+ execute: function (aParentGuid, aTitle, aIndex, aAnnos) {
+ return ExecuteCreateItem(this, aParentGuid,
+ function* (parentId, guidToRestore = "") {
+ let itemId = PlacesUtils.bookmarks.createFolder(
+ parentId, aTitle, aIndex, guidToRestore);
+ if (aAnnos.length > 0)
+ PlacesUtils.setAnnotationsForItem(itemId, aAnnos);
+ return itemId;
+ });
+ }
+});
+
+/**
+ * Transaction for creating a separator.
+ *
+ * Required Input Properties: parentGuid.
+ * Optional Input Properties: index.
+ *
+ * When this transaction is executed, it's resolved to the new separator's
+ * GUID.
+ */
+PT.NewSeparator = DefineTransaction(["parentGuid"], ["index"]);
+PT.NewSeparator.prototype = Object.seal({
+ execute: function (aParentGuid, aIndex) {
+ return ExecuteCreateItem(this, aParentGuid,
+ function* (parentId, guidToRestore = "") {
+ let itemId = PlacesUtils.bookmarks.insertSeparator(
+ parentId, aIndex, guidToRestore);
+ return itemId;
+ });
+ }
+});
+
+/**
+ * Transaction for creating a live bookmark (see mozIAsyncLivemarks for the
+ * semantics).
+ *
+ * Required Input Properties: feedUrl, title, parentGuid.
+ * Optional Input Properties: siteUrl, index, annotations.
+ *
+ * When this transaction is executed, it's resolved to the new livemark's
+ * GUID.
+ */
+PT.NewLivemark = DefineTransaction(["feedUrl", "title", "parentGuid"],
+ ["siteUrl", "index", "annotations"]);
+PT.NewLivemark.prototype = Object.seal({
+ execute: function* (aFeedURI, aTitle, aParentGuid, aSiteURI, aIndex, aAnnos) {
+ let livemarkInfo = { title: aTitle
+ , feedURI: aFeedURI
+ , siteURI: aSiteURI
+ , index: aIndex };
+ let createItem = function* () {
+ livemarkInfo.parentId = yield PlacesUtils.promiseItemId(aParentGuid);
+ let livemark = yield PlacesUtils.livemarks.addLivemark(livemarkInfo);
+ if (aAnnos.length > 0)
+ PlacesUtils.setAnnotationsForItem(livemark.id, aAnnos);
+
+ if ("dateAdded" in livemarkInfo) {
+ PlacesUtils.bookmarks.setItemDateAdded(livemark.id,
+ livemarkInfo.dateAdded);
+ PlacesUtils.bookmarks.setItemLastModified(livemark.id,
+ livemarkInfo.lastModified);
+ }
+ return livemark;
+ };
+
+ let livemark = yield createItem();
+ this.undo = function* () {
+ livemarkInfo.guid = livemark.guid;
+ if (!("dateAdded" in livemarkInfo)) {
+ livemarkInfo.dateAdded =
+ PlacesUtils.bookmarks.getItemDateAdded(livemark.id);
+ livemarkInfo.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(livemark.id);
+ }
+ yield PlacesUtils.livemarks.removeLivemark(livemark);
+ };
+ this.redo = function* () {
+ livemark = yield createItem();
+ };
+ return livemark.guid;
+ }
+});
+
+/**
+ * Transaction for moving an item.
+ *
+ * Required Input Properties: guid, newParentGuid.
+ * Optional Input Properties newIndex.
+ */
+PT.Move = DefineTransaction(["guid", "newParentGuid"], ["newIndex"]);
+PT.Move.prototype = Object.seal({
+ execute: function* (aGuid, aNewParentGuid, aNewIndex) {
+ let itemId = yield PlacesUtils.promiseItemId(aGuid),
+ oldParentId = PlacesUtils.bookmarks.getFolderIdForItem(itemId),
+ oldIndex = PlacesUtils.bookmarks.getItemIndex(itemId),
+ newParentId = yield PlacesUtils.promiseItemId(aNewParentGuid);
+
+ PlacesUtils.bookmarks.moveItem(itemId, newParentId, aNewIndex);
+
+ let undoIndex = PlacesUtils.bookmarks.getItemIndex(itemId);
+ this.undo = () => {
+ // Moving down in the same parent takes in count removal of the item
+ // so to revert positions we must move to oldIndex + 1
+ if (newParentId == oldParentId && oldIndex > undoIndex)
+ PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex + 1);
+ else
+ PlacesUtils.bookmarks.moveItem(itemId, oldParentId, oldIndex);
+ };
+ }
+});
+
+/**
+ * Transaction for setting the title for an item.
+ *
+ * Required Input Properties: guid, title.
+ */
+PT.EditTitle = DefineTransaction(["guid", "title"]);
+PT.EditTitle.prototype = Object.seal({
+ execute: function* (aGuid, aTitle) {
+ let itemId = yield PlacesUtils.promiseItemId(aGuid),
+ oldTitle = PlacesUtils.bookmarks.getItemTitle(itemId);
+ PlacesUtils.bookmarks.setItemTitle(itemId, aTitle);
+ this.undo = () => { PlacesUtils.bookmarks.setItemTitle(itemId, oldTitle); };
+ }
+});
+
+/**
+ * Transaction for setting the URI for an item.
+ *
+ * Required Input Properties: guid, url.
+ */
+PT.EditUrl = DefineTransaction(["guid", "url"]);
+PT.EditUrl.prototype = Object.seal({
+ execute: function* (aGuid, aURI) {
+ let itemId = yield PlacesUtils.promiseItemId(aGuid),
+ oldURI = PlacesUtils.bookmarks.getBookmarkURI(itemId),
+ oldURITags = PlacesUtils.tagging.getTagsForURI(oldURI),
+ newURIAdditionalTags = null;
+ PlacesUtils.bookmarks.changeBookmarkURI(itemId, aURI);
+
+ // Move tags from old URI to new URI.
+ if (oldURITags.length > 0) {
+ // Only untag the old URI if this is the only bookmark.
+ if (PlacesUtils.getBookmarksForURI(oldURI, {}).length == 0)
+ PlacesUtils.tagging.untagURI(oldURI, oldURITags);
+
+ let currentNewURITags = PlacesUtils.tagging.getTagsForURI(aURI);
+ newURIAdditionalTags = oldURITags.filter(t => !currentNewURITags.includes(t));
+ if (newURIAdditionalTags)
+ PlacesUtils.tagging.tagURI(aURI, newURIAdditionalTags);
+ }
+
+ this.undo = () => {
+ PlacesUtils.bookmarks.changeBookmarkURI(itemId, oldURI);
+ // Move tags from new URI to old URI.
+ if (oldURITags.length > 0) {
+ // Only untag the new URI if this is the only bookmark.
+ if (newURIAdditionalTags && newURIAdditionalTags.length > 0 &&
+ PlacesUtils.getBookmarksForURI(aURI, {}).length == 0) {
+ PlacesUtils.tagging.untagURI(aURI, newURIAdditionalTags);
+ }
+
+ PlacesUtils.tagging.tagURI(oldURI, oldURITags);
+ }
+ };
+ }
+});
+
+/**
+ * Transaction for setting annotations for an item.
+ *
+ * Required Input Properties: guid, annotationObject
+ */
+PT.Annotate = DefineTransaction(["guids", "annotations"]);
+PT.Annotate.prototype = {
+ *execute(aGuids, aNewAnnos) {
+ let undoAnnosForItem = new Map(); // itemId => undoAnnos;
+ for (let guid of aGuids) {
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ let currentAnnos = PlacesUtils.getAnnotationsForItem(itemId);
+
+ let undoAnnos = [];
+ for (let newAnno of aNewAnnos) {
+ let currentAnno = currentAnnos.find(a => a.name == newAnno.name);
+ if (currentAnno) {
+ undoAnnos.push(currentAnno);
+ }
+ else {
+ // An unset value removes the annotation.
+ undoAnnos.push({ name: newAnno.name });
+ }
+ }
+ undoAnnosForItem.set(itemId, undoAnnos);
+
+ PlacesUtils.setAnnotationsForItem(itemId, aNewAnnos);
+ }
+
+ this.undo = function() {
+ for (let [itemId, undoAnnos] of undoAnnosForItem) {
+ PlacesUtils.setAnnotationsForItem(itemId, undoAnnos);
+ }
+ };
+ this.redo = function* () {
+ for (let guid of aGuids) {
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ PlacesUtils.setAnnotationsForItem(itemId, aNewAnnos);
+ }
+ };
+ }
+};
+
+/**
+ * Transaction for setting the keyword for a bookmark.
+ *
+ * Required Input Properties: guid, keyword.
+ */
+PT.EditKeyword = DefineTransaction(["guid", "keyword"],
+ ["postData", "oldKeyword"]);
+PT.EditKeyword.prototype = Object.seal({
+ execute: function* (aGuid, aKeyword, aPostData, aOldKeyword) {
+ let url;
+ let oldKeywordEntry;
+ if (aOldKeyword) {
+ oldKeywordEntry = yield PlacesUtils.keywords.fetch(aOldKeyword);
+ url = oldKeywordEntry.url;
+ yield PlacesUtils.keywords.remove(aOldKeyword);
+ }
+
+ if (aKeyword) {
+ if (!url) {
+ url = (yield PlacesUtils.bookmarks.fetch(aGuid)).url;
+ }
+ yield PlacesUtils.keywords.insert({
+ url: url,
+ keyword: aKeyword,
+ postData: aPostData || (oldKeywordEntry ? oldKeywordEntry.postData : "")
+ });
+ }
+
+ this.undo = function* () {
+ if (aKeyword) {
+ yield PlacesUtils.keywords.remove(aKeyword);
+ }
+ if (oldKeywordEntry) {
+ yield PlacesUtils.keywords.insert(oldKeywordEntry);
+ }
+ };
+ }
+});
+
+/**
+ * Transaction for sorting a folder by name.
+ *
+ * Required Input Properties: guid.
+ */
+PT.SortByName = DefineTransaction(["guid"]);
+PT.SortByName.prototype = {
+ execute: function* (aGuid) {
+ let itemId = yield PlacesUtils.promiseItemId(aGuid),
+ oldOrder = [], // [itemId] = old index
+ contents = PlacesUtils.getFolderContents(itemId, false, false).root,
+ count = contents.childCount;
+
+ // Sort between separators.
+ let newOrder = [], // nodes, in the new order.
+ preSep = []; // Temporary array for sorting each group of nodes.
+ let sortingMethod = (a, b) => {
+ if (PlacesUtils.nodeIsContainer(a) && !PlacesUtils.nodeIsContainer(b))
+ return -1;
+ if (!PlacesUtils.nodeIsContainer(a) && PlacesUtils.nodeIsContainer(b))
+ return 1;
+ return a.title.localeCompare(b.title);
+ };
+
+ for (let i = 0; i < count; ++i) {
+ let node = contents.getChild(i);
+ oldOrder[node.itemId] = i;
+ if (PlacesUtils.nodeIsSeparator(node)) {
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ preSep.splice(0, preSep.length);
+ }
+ newOrder.push(node);
+ }
+ else
+ preSep.push(node);
+ }
+ contents.containerOpen = false;
+
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ }
+
+ // Set the nex indexes.
+ let callback = {
+ runBatched: function() {
+ for (let i = 0; i < newOrder.length; ++i) {
+ PlacesUtils.bookmarks.setItemIndex(newOrder[i].itemId, i);
+ }
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+
+ this.undo = () => {
+ let callback = {
+ runBatched: function() {
+ for (let item in oldOrder) {
+ PlacesUtils.bookmarks.setItemIndex(item, oldOrder[item]);
+ }
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+ };
+ }
+};
+
+/**
+ * Transaction for removing an item (any type).
+ *
+ * Required Input Properties: guids.
+ */
+PT.Remove = DefineTransaction(["guids"]);
+PT.Remove.prototype = {
+ *execute(aGuids) {
+ function promiseBookmarksTree(guid) {
+ try {
+ return PlacesUtils.promiseBookmarksTree(guid);
+ }
+ catch (ex) {
+ throw new Error("Failed to get info for the specified item (guid: " +
+ guid + "). Ex: " + ex);
+ }
+ }
+
+ let toRestore = [];
+ for (let guid of aGuids) {
+ toRestore.push(yield promiseBookmarksTree(guid));
+ }
+
+ let removeThem = Task.async(function* () {
+ for (let guid of aGuids) {
+ PlacesUtils.bookmarks.removeItem(yield PlacesUtils.promiseItemId(guid));
+ }
+ });
+ yield removeThem();
+
+ this.undo = Task.async(function* () {
+ for (let info of toRestore) {
+ yield createItemsFromBookmarksTree(info, true);
+ }
+ });
+ this.redo = removeThem;
+ }
+};
+
+/**
+ * Transactions for removing all bookmarks for one or more urls.
+ *
+ * Required Input Properties: urls.
+ */
+PT.RemoveBookmarksForUrls = DefineTransaction(["urls"]);
+PT.RemoveBookmarksForUrls.prototype = {
+ *execute(aUrls) {
+ let guids = [];
+ for (let url of aUrls) {
+ yield PlacesUtils.bookmarks.fetch({ url }, info => {
+ guids.push(info.guid);
+ });
+ }
+ let removeTxn = TransactionsHistory.getRawTransaction(PT.Remove(guids));
+ yield removeTxn.execute();
+ this.undo = removeTxn.undo.bind(removeTxn);
+ this.redo = removeTxn.redo.bind(removeTxn);
+ }
+};
+
+/**
+ * Transaction for tagging urls.
+ *
+ * Required Input Properties: urls, tags.
+ */
+PT.Tag = DefineTransaction(["urls", "tags"]);
+PT.Tag.prototype = {
+ execute: function* (aURIs, aTags) {
+ let onUndo = [], onRedo = [];
+ for (let uri of aURIs) {
+ // Workaround bug 449811.
+ let currentURI = uri;
+
+ let promiseIsBookmarked = function* () {
+ let deferred = Promise.defer();
+ PlacesUtils.asyncGetBookmarkIds(
+ currentURI, ids => { deferred.resolve(ids.length > 0); });
+ return deferred.promise;
+ };
+
+ if (yield promiseIsBookmarked(currentURI)) {
+ // Tagging is only allowed for bookmarked URIs (but see 424160).
+ let createTxn = TransactionsHistory.getRawTransaction(
+ PT.NewBookmark({ url: currentURI
+ , tags: aTags
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid }));
+ yield createTxn.execute();
+ onUndo.unshift(createTxn.undo.bind(createTxn));
+ onRedo.push(createTxn.redo.bind(createTxn));
+ }
+ else {
+ let currentTags = PlacesUtils.tagging.getTagsForURI(currentURI);
+ let newTags = aTags.filter(t => !currentTags.includes(t));
+ PlacesUtils.tagging.tagURI(currentURI, newTags);
+ onUndo.unshift(() => {
+ PlacesUtils.tagging.untagURI(currentURI, newTags);
+ });
+ onRedo.push(() => {
+ PlacesUtils.tagging.tagURI(currentURI, newTags);
+ });
+ }
+ }
+ this.undo = function* () {
+ for (let f of onUndo) {
+ yield f();
+ }
+ };
+ this.redo = function* () {
+ for (let f of onRedo) {
+ yield f();
+ }
+ };
+ }
+};
+
+/**
+ * Transaction for removing tags from a URI.
+ *
+ * Required Input Properties: urls.
+ * Optional Input Properties: tags.
+ *
+ * If |tags| is not set, all tags set for |url| are removed.
+ */
+PT.Untag = DefineTransaction(["urls"], ["tags"]);
+PT.Untag.prototype = {
+ execute: function* (aURIs, aTags) {
+ let onUndo = [], onRedo = [];
+ for (let uri of aURIs) {
+ // Workaround bug 449811.
+ let currentURI = uri;
+ let tagsToRemove;
+ let tagsSet = PlacesUtils.tagging.getTagsForURI(currentURI);
+ if (aTags.length > 0)
+ tagsToRemove = aTags.filter(t => tagsSet.includes(t));
+ else
+ tagsToRemove = tagsSet;
+ PlacesUtils.tagging.untagURI(currentURI, tagsToRemove);
+ onUndo.unshift(() => {
+ PlacesUtils.tagging.tagURI(currentURI, tagsToRemove);
+ });
+ onRedo.push(() => {
+ PlacesUtils.tagging.untagURI(currentURI, tagsToRemove);
+ });
+ }
+ this.undo = function* () {
+ for (let f of onUndo) {
+ yield f();
+ }
+ };
+ this.redo = function* () {
+ for (let f of onRedo) {
+ yield f();
+ }
+ };
+ }
+};
+
+/**
+ * Transaction for copying an item.
+ *
+ * Required Input Properties: guid, newParentGuid
+ * Optional Input Properties: newIndex, excludingAnnotations.
+ */
+PT.Copy = DefineTransaction(["guid", "newParentGuid"],
+ ["newIndex", "excludingAnnotations"]);
+PT.Copy.prototype = {
+ execute: function* (aGuid, aNewParentGuid, aNewIndex, aExcludingAnnotations) {
+ let creationInfo = null;
+ try {
+ creationInfo = yield PlacesUtils.promiseBookmarksTree(aGuid);
+ }
+ catch (ex) {
+ throw new Error("Failed to get info for the specified item (guid: " +
+ aGuid + "). Ex: " + ex);
+ }
+ creationInfo.parentGuid = aNewParentGuid;
+ creationInfo.index = aNewIndex;
+
+ let newItemId =
+ yield createItemsFromBookmarksTree(creationInfo, false,
+ aExcludingAnnotations);
+ let newItemInfo = null;
+ this.undo = function* () {
+ if (!newItemInfo) {
+ let newItemGuid = yield PlacesUtils.promiseItemGuid(newItemId);
+ newItemInfo = yield PlacesUtils.promiseBookmarksTree(newItemGuid);
+ }
+ PlacesUtils.bookmarks.removeItem(newItemId);
+ };
+ this.redo = function* () {
+ newItemId = yield createItemsFromBookmarksTree(newItemInfo, true);
+ }
+
+ return yield PlacesUtils.promiseItemGuid(newItemId);
+ }
+};
diff --git a/toolkit/components/places/PlacesUtils.jsm b/toolkit/components/places/PlacesUtils.jsm
new file mode 100644
index 000000000..4b7bcb82a
--- /dev/null
+++ b/toolkit/components/places/PlacesUtils.jsm
@@ -0,0 +1,3863 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.EXPORTED_SYMBOLS = [
+ "PlacesUtils"
+, "PlacesAggregatedTransaction"
+, "PlacesCreateFolderTransaction"
+, "PlacesCreateBookmarkTransaction"
+, "PlacesCreateSeparatorTransaction"
+, "PlacesCreateLivemarkTransaction"
+, "PlacesMoveItemTransaction"
+, "PlacesRemoveItemTransaction"
+, "PlacesEditItemTitleTransaction"
+, "PlacesEditBookmarkURITransaction"
+, "PlacesSetItemAnnotationTransaction"
+, "PlacesSetPageAnnotationTransaction"
+, "PlacesEditBookmarkKeywordTransaction"
+, "PlacesEditBookmarkPostDataTransaction"
+, "PlacesEditItemDateAddedTransaction"
+, "PlacesEditItemLastModifiedTransaction"
+, "PlacesSortFolderByNameTransaction"
+, "PlacesTagURITransaction"
+, "PlacesUntagURITransaction"
+];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks",
+ "resource://gre/modules/Bookmarks.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "History",
+ "resource://gre/modules/History.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
+ "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
+ "resource://gre/modules/PlacesSyncUtils.jsm");
+
+// The minimum amount of transactions before starting a batch. Usually we do
+// do incremental updates, a batch will cause views to completely
+// refresh instead.
+const MIN_TRANSACTIONS_FOR_BATCH = 5;
+
+// On Mac OSX, the transferable system converts "\r\n" to "\n\n", where
+// we really just want "\n". On other platforms, the transferable system
+// converts "\r\n" to "\n".
+const NEWLINE = AppConstants.platform == "macosx" ? "\n" : "\r\n";
+
+function QI_node(aNode, aIID) {
+ var result = null;
+ try {
+ result = aNode.QueryInterface(aIID);
+ }
+ catch (e) {
+ }
+ return result;
+}
+function asContainer(aNode) {
+ return QI_node(aNode, Ci.nsINavHistoryContainerResultNode);
+}
+function asQuery(aNode) {
+ return QI_node(aNode, Ci.nsINavHistoryQueryResultNode);
+}
+
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ * array of nsINavBookmarkObserver objects.
+ * @param notification
+ * the notification name.
+ * @param args
+ * array of arguments to pass to the notification.
+ */
+function notify(observers, notification, args) {
+ for (let observer of observers) {
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+}
+
+/**
+ * Sends a keyword change notification.
+ *
+ * @param url
+ * the url to notify about.
+ * @param keyword
+ * The keyword to notify, or empty string if a keyword was removed.
+ */
+function* notifyKeywordChange(url, keyword, source) {
+ // Notify bookmarks about the removal.
+ let bookmarks = [];
+ yield PlacesUtils.bookmarks.fetch({ url }, b => bookmarks.push(b));
+ // We don't want to yield in the gIgnoreKeywordNotifications section.
+ for (let bookmark of bookmarks) {
+ bookmark.id = yield PlacesUtils.promiseItemId(bookmark.guid);
+ bookmark.parentId = yield PlacesUtils.promiseItemId(bookmark.parentGuid);
+ }
+ let observers = PlacesUtils.bookmarks.getObservers();
+ gIgnoreKeywordNotifications = true;
+ for (let bookmark of bookmarks) {
+ notify(observers, "onItemChanged", [ bookmark.id, "keyword", false,
+ keyword,
+ bookmark.lastModified * 1000,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid, bookmark.parentGuid,
+ "", source
+ ]);
+ }
+ gIgnoreKeywordNotifications = false;
+}
+
+/**
+ * Serializes the given node in JSON format.
+ *
+ * @param aNode
+ * An nsINavHistoryResultNode
+ * @param aIsLivemark
+ * Whether the node represents a livemark.
+ */
+function serializeNode(aNode, aIsLivemark) {
+ let data = {};
+
+ data.title = aNode.title;
+ data.id = aNode.itemId;
+ data.livemark = aIsLivemark;
+
+ let guid = aNode.bookmarkGuid;
+ if (guid) {
+ data.itemGuid = guid;
+ if (aNode.parent)
+ data.parent = aNode.parent.itemId;
+ let grandParent = aNode.parent && aNode.parent.parent;
+ if (grandParent)
+ data.grandParentId = grandParent.itemId;
+
+ data.dateAdded = aNode.dateAdded;
+ data.lastModified = aNode.lastModified;
+
+ let annos = PlacesUtils.getAnnotationsForItem(data.id);
+ if (annos.length > 0)
+ data.annos = annos;
+ }
+
+ if (PlacesUtils.nodeIsURI(aNode)) {
+ // Check for url validity.
+ NetUtil.newURI(aNode.uri);
+
+ // Tag root accepts only folder nodes, not URIs.
+ if (data.parent == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+
+ if (aNode.tags)
+ data.tags = aNode.tags;
+ }
+ else if (PlacesUtils.nodeIsContainer(aNode)) {
+ // Tag containers accept only uri nodes.
+ if (data.grandParentId == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ let concreteId = PlacesUtils.getConcreteItemId(aNode);
+ if (concreteId != -1) {
+ // This is a bookmark or a tag container.
+ if (PlacesUtils.nodeIsQuery(aNode) || concreteId != aNode.itemId) {
+ // This is a folder shortcut.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+ data.concreteId = concreteId;
+ }
+ else {
+ // This is a bookmark folder.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
+ }
+ }
+ else {
+ // This is a grouped container query, dynamically generated.
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ data.uri = aNode.uri;
+ }
+ }
+ else if (PlacesUtils.nodeIsSeparator(aNode)) {
+ // Tag containers don't accept separators.
+ if (data.parent == PlacesUtils.tagsFolderId ||
+ data.grandParentId == PlacesUtils.tagsFolderId)
+ throw new Error("Unexpected node type");
+
+ data.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
+ }
+
+ return JSON.stringify(data);
+}
+
+// Imposed to limit database size.
+const DB_URL_LENGTH_MAX = 65536;
+const DB_TITLE_LENGTH_MAX = 4096;
+
+/**
+ * List of bookmark object validators, one per each known property.
+ * Validators must throw if the property value is invalid and return a fixed up
+ * version of the value, if needed.
+ */
+const BOOKMARK_VALIDATORS = Object.freeze({
+ guid: simpleValidateFunc(v => PlacesUtils.isValidGuid(v)),
+ parentGuid: simpleValidateFunc(v => typeof(v) == "string" &&
+ /^[a-zA-Z0-9\-_]{12}$/.test(v)),
+ index: simpleValidateFunc(v => Number.isInteger(v) &&
+ v >= PlacesUtils.bookmarks.DEFAULT_INDEX),
+ dateAdded: simpleValidateFunc(v => v.constructor.name == "Date"),
+ lastModified: simpleValidateFunc(v => v.constructor.name == "Date"),
+ type: simpleValidateFunc(v => Number.isInteger(v) &&
+ [ PlacesUtils.bookmarks.TYPE_BOOKMARK
+ , PlacesUtils.bookmarks.TYPE_FOLDER
+ , PlacesUtils.bookmarks.TYPE_SEPARATOR ].includes(v)),
+ title: v => {
+ simpleValidateFunc(val => val === null || typeof(val) == "string").call(this, v);
+ if (!v)
+ return null;
+ return v.slice(0, DB_TITLE_LENGTH_MAX);
+ },
+ url: v => {
+ simpleValidateFunc(val => (typeof(val) == "string" && val.length <= DB_URL_LENGTH_MAX) ||
+ (val instanceof Ci.nsIURI && val.spec.length <= DB_URL_LENGTH_MAX) ||
+ (val instanceof URL && val.href.length <= DB_URL_LENGTH_MAX)
+ ).call(this, v);
+ if (typeof(v) === "string")
+ return new URL(v);
+ if (v instanceof Ci.nsIURI)
+ return new URL(v.spec);
+ return v;
+ },
+ source: simpleValidateFunc(v => Number.isInteger(v) &&
+ Object.values(PlacesUtils.bookmarks.SOURCES).includes(v)),
+});
+
+// Sync bookmark records can contain additional properties.
+const SYNC_BOOKMARK_VALIDATORS = Object.freeze({
+ // Sync uses Places GUIDs for all records except roots.
+ syncId: simpleValidateFunc(v => typeof v == "string" && (
+ (PlacesSyncUtils.bookmarks.ROOTS.includes(v) ||
+ PlacesUtils.isValidGuid(v)))),
+ parentSyncId: v => SYNC_BOOKMARK_VALIDATORS.syncId(v),
+ // Sync uses kinds instead of types, which distinguish between livemarks,
+ // queries, and smart bookmarks.
+ kind: simpleValidateFunc(v => typeof v == "string" &&
+ Object.values(PlacesSyncUtils.bookmarks.KINDS).includes(v)),
+ query: simpleValidateFunc(v => v === null || (typeof v == "string" && v)),
+ folder: simpleValidateFunc(v => typeof v == "string" && v &&
+ v.length <= Ci.nsITaggingService.MAX_TAG_LENGTH),
+ tags: v => {
+ if (v === null) {
+ return [];
+ }
+ if (!Array.isArray(v)) {
+ throw new Error("Invalid tag array");
+ }
+ for (let tag of v) {
+ if (typeof tag != "string" || !tag ||
+ tag.length > Ci.nsITaggingService.MAX_TAG_LENGTH) {
+ throw new Error(`Invalid tag: ${tag}`);
+ }
+ }
+ return v;
+ },
+ keyword: simpleValidateFunc(v => v === null || typeof v == "string"),
+ description: simpleValidateFunc(v => v === null || typeof v == "string"),
+ loadInSidebar: simpleValidateFunc(v => v === true || v === false),
+ feed: v => v === null ? v : BOOKMARK_VALIDATORS.url(v),
+ site: v => v === null ? v : BOOKMARK_VALIDATORS.url(v),
+ title: BOOKMARK_VALIDATORS.title,
+ url: BOOKMARK_VALIDATORS.url,
+});
+
+this.PlacesUtils = {
+ // Place entries that are containers, e.g. bookmark folders or queries.
+ TYPE_X_MOZ_PLACE_CONTAINER: "text/x-moz-place-container",
+ // Place entries that are bookmark separators.
+ TYPE_X_MOZ_PLACE_SEPARATOR: "text/x-moz-place-separator",
+ // Place entries that are not containers or separators
+ TYPE_X_MOZ_PLACE: "text/x-moz-place",
+ // Place entries in shortcut url format (url\ntitle)
+ TYPE_X_MOZ_URL: "text/x-moz-url",
+ // Place entries formatted as HTML anchors
+ TYPE_HTML: "text/html",
+ // Place entries as raw URL text
+ TYPE_UNICODE: "text/unicode",
+ // Used to track the action that populated the clipboard.
+ TYPE_X_MOZ_PLACE_ACTION: "text/x-moz-place-action",
+
+ EXCLUDE_FROM_BACKUP_ANNO: "places/excludeFromBackup",
+ LMANNO_FEEDURI: "livemark/feedURI",
+ LMANNO_SITEURI: "livemark/siteURI",
+ POST_DATA_ANNO: "bookmarkProperties/POSTData",
+ READ_ONLY_ANNO: "placesInternal/READ_ONLY",
+ CHARSET_ANNO: "URIProperties/characterSet",
+ MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
+
+ TOPIC_SHUTDOWN: "places-shutdown",
+ TOPIC_INIT_COMPLETE: "places-init-complete",
+ TOPIC_DATABASE_LOCKED: "places-database-locked",
+ TOPIC_EXPIRATION_FINISHED: "places-expiration-finished",
+ TOPIC_FEEDBACK_UPDATED: "places-autocomplete-feedback-updated",
+ TOPIC_FAVICONS_EXPIRED: "places-favicons-expired",
+ TOPIC_VACUUM_STARTING: "places-vacuum-starting",
+ TOPIC_BOOKMARKS_RESTORE_BEGIN: "bookmarks-restore-begin",
+ TOPIC_BOOKMARKS_RESTORE_SUCCESS: "bookmarks-restore-success",
+ TOPIC_BOOKMARKS_RESTORE_FAILED: "bookmarks-restore-failed",
+
+ asContainer: aNode => asContainer(aNode),
+ asQuery: aNode => asQuery(aNode),
+
+ endl: NEWLINE,
+
+ /**
+ * Makes a URI from a spec.
+ * @param aSpec
+ * The string spec of the URI
+ * @returns A URI object for the spec.
+ */
+ _uri: function PU__uri(aSpec) {
+ return NetUtil.newURI(aSpec);
+ },
+
+ /**
+ * Is a string a valid GUID?
+ *
+ * @param guid: (String)
+ * @return (Boolean)
+ */
+ isValidGuid(guid) {
+ return typeof guid == "string" && guid &&
+ (/^[a-zA-Z0-9\-_]{12}$/.test(guid));
+ },
+
+ /**
+ * Converts a string or n URL object to an nsIURI.
+ *
+ * @param url (URL) or (String)
+ * the URL to convert.
+ * @return nsIURI for the given URL.
+ */
+ toURI(url) {
+ url = (url instanceof URL) ? url.href : url;
+
+ return NetUtil.newURI(url);
+ },
+
+ /**
+ * Convert a Date object to a PRTime (microseconds).
+ *
+ * @param date
+ * the Date object to convert.
+ * @return microseconds from the epoch.
+ */
+ toPRTime(date) {
+ return date * 1000;
+ },
+
+ /**
+ * Convert a PRTime to a Date object.
+ *
+ * @param time
+ * microseconds from the epoch.
+ * @return a Date object.
+ */
+ toDate(time) {
+ return new Date(parseInt(time / 1000));
+ },
+
+ /**
+ * Wraps a string in a nsISupportsString wrapper.
+ * @param aString
+ * The string to wrap.
+ * @returns A nsISupportsString object containing a string.
+ */
+ toISupportsString: function PU_toISupportsString(aString) {
+ let s = Cc["@mozilla.org/supports-string;1"].
+ createInstance(Ci.nsISupportsString);
+ s.data = aString;
+ return s;
+ },
+
+ getFormattedString: function PU_getFormattedString(key, params) {
+ return bundle.formatStringFromName(key, params, params.length);
+ },
+
+ getString: function PU_getString(key) {
+ return bundle.GetStringFromName(key);
+ },
+
+ /**
+ * Makes a moz-action URI for the given action and set of parameters.
+ *
+ * @param type
+ * The action type.
+ * @param params
+ * A JS object of action params.
+ * @returns A moz-action URI as a string.
+ */
+ mozActionURI(type, params) {
+ let encodedParams = {};
+ for (let key in params) {
+ // Strip null or undefined.
+ // Regardless, don't encode them or they would be converted to a string.
+ if (params[key] === null || params[key] === undefined) {
+ continue;
+ }
+ encodedParams[key] = encodeURIComponent(params[key]);
+ }
+ return "moz-action:" + type + "," + JSON.stringify(encodedParams);
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Bookmark folder.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Bookmark folder, false otherwise
+ */
+ nodeIsFolder: function PU_nodeIsFolder(aNode) {
+ return (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER ||
+ aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT);
+ },
+
+ /**
+ * Determines whether or not a ResultNode represents a bookmarked URI.
+ * @param aNode
+ * A result node
+ * @returns true if the node represents a bookmarked URI, false otherwise
+ */
+ nodeIsBookmark: function PU_nodeIsBookmark(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI &&
+ aNode.itemId != -1;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Bookmark separator.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Bookmark separator, false otherwise
+ */
+ nodeIsSeparator: function PU_nodeIsSeparator(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a URL item.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a URL item, false otherwise
+ */
+ nodeIsURI: function PU_nodeIsURI(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a Query item.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a Query item, false otherwise
+ */
+ nodeIsQuery: function PU_nodeIsQuery(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY;
+ },
+
+ /**
+ * Generator for a node's ancestors.
+ * @param aNode
+ * A result node
+ */
+ nodeAncestors: function* PU_nodeAncestors(aNode) {
+ let node = aNode.parent;
+ while (node) {
+ yield node;
+ node = node.parent;
+ }
+ },
+
+ /**
+ * Checks validity of an object, filling up default values for optional
+ * properties.
+ *
+ * @param validators (object)
+ * An object containing input validators. Keys should be field names;
+ * values should be validation functions.
+ * @param props (object)
+ * The object to validate.
+ * @param behavior (object) [optional]
+ * Object defining special behavior for some of the properties.
+ * The following behaviors may be optionally set:
+ * - requiredIf: if the provided condition is satisfied, then this
+ * property is required.
+ * - validIf: if the provided condition is not satisfied, then this
+ * property is invalid.
+ * - defaultValue: an undefined property should default to this value.
+ *
+ * @return a validated and normalized item.
+ * @throws if the object contains invalid data.
+ * @note any unknown properties are pass-through.
+ */
+ validateItemProperties(validators, props, behavior={}) {
+ if (!props)
+ throw new Error("Input should be a valid object");
+ // Make a shallow copy of `props` to avoid mutating the original object
+ // when filling in defaults.
+ let input = Object.assign({}, props);
+ let normalizedInput = {};
+ let required = new Set();
+ for (let prop in behavior) {
+ if (behavior[prop].hasOwnProperty("required") && behavior[prop].required) {
+ required.add(prop);
+ }
+ if (behavior[prop].hasOwnProperty("requiredIf") && behavior[prop].requiredIf(input)) {
+ required.add(prop);
+ }
+ if (behavior[prop].hasOwnProperty("validIf") && input[prop] !== undefined &&
+ !behavior[prop].validIf(input)) {
+ throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
+ }
+ if (behavior[prop].hasOwnProperty("defaultValue") && input[prop] === undefined) {
+ input[prop] = behavior[prop].defaultValue;
+ }
+ }
+
+ for (let prop in input) {
+ if (required.has(prop)) {
+ required.delete(prop);
+ } else if (input[prop] === undefined) {
+ // Skip undefined properties that are not required.
+ continue;
+ }
+ if (validators.hasOwnProperty(prop)) {
+ try {
+ normalizedInput[prop] = validators[prop](input[prop], input);
+ } catch (ex) {
+ throw new Error(`Invalid value for property '${prop}': ${input[prop]}`);
+ }
+ }
+ }
+ if (required.size > 0)
+ throw new Error(`The following properties were expected: ${[...required].join(", ")}`);
+ return normalizedInput;
+ },
+
+ BOOKMARK_VALIDATORS,
+ SYNC_BOOKMARK_VALIDATORS,
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver
+ , Ci.nsITransactionListener
+ ]),
+
+ _shutdownFunctions: [],
+ registerShutdownFunction: function PU_registerShutdownFunction(aFunc)
+ {
+ // If this is the first registered function, add the shutdown observer.
+ if (this._shutdownFunctions.length == 0) {
+ Services.obs.addObserver(this, this.TOPIC_SHUTDOWN, false);
+ }
+ this._shutdownFunctions.push(aFunc);
+ },
+
+ // nsIObserver
+ observe: function PU_observe(aSubject, aTopic, aData)
+ {
+ switch (aTopic) {
+ case this.TOPIC_SHUTDOWN:
+ Services.obs.removeObserver(this, this.TOPIC_SHUTDOWN);
+ while (this._shutdownFunctions.length > 0) {
+ this._shutdownFunctions.shift().apply(this);
+ }
+ if (this._bookmarksServiceObserversQueue.length > 0) {
+ // Since we are shutting down, there's no reason to add the observers.
+ this._bookmarksServiceObserversQueue.length = 0;
+ }
+ break;
+ case "bookmarks-service-ready":
+ this._bookmarksServiceReady = true;
+ while (this._bookmarksServiceObserversQueue.length > 0) {
+ let observerInfo = this._bookmarksServiceObserversQueue.shift();
+ this.bookmarks.addObserver(observerInfo.observer, observerInfo.weak);
+ }
+
+ // Initialize the keywords cache to start observing bookmarks
+ // notifications. This is needed as far as we support both the old and
+ // the new bookmarking APIs at the same time.
+ gKeywordsCachePromise.catch(Cu.reportError);
+ break;
+ }
+ },
+
+ onPageAnnotationSet: function() {},
+ onPageAnnotationRemoved: function() {},
+
+
+ // nsITransactionListener
+
+ didDo: function PU_didDo(aManager, aTransaction, aDoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didUndo: function PU_didUndo(aManager, aTransaction, aUndoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didRedo: function PU_didRedo(aManager, aTransaction, aRedoResult)
+ {
+ updateCommandsOnActiveWindow();
+ },
+
+ didBeginBatch: function PU_didBeginBatch(aManager, aResult)
+ {
+ // A no-op transaction is pushed to the stack, in order to make safe and
+ // easy to implement "Undo" an unknown number of transactions (including 0),
+ // "above" beginBatch and endBatch. Otherwise,implementing Undo that way
+ // head to dataloss: for example, if no changes were done in the
+ // edit-item panel, the last transaction on the undo stack would be the
+ // initial createItem transaction, or even worse, the batched editing of
+ // some other item.
+ // DO NOT MOVE this to the window scope, that would leak (bug 490068)!
+ this.transactionManager.doTransaction({ doTransaction: function() {},
+ undoTransaction: function() {},
+ redoTransaction: function() {},
+ isTransient: false,
+ merge: function() { return false; }
+ });
+ },
+
+ willDo: function PU_willDo() {},
+ willUndo: function PU_willUndo() {},
+ willRedo: function PU_willRedo() {},
+ willBeginBatch: function PU_willBeginBatch() {},
+ willEndBatch: function PU_willEndBatch() {},
+ didEndBatch: function PU_didEndBatch() {},
+ willMerge: function PU_willMerge() {},
+ didMerge: function PU_didMerge() {},
+
+ /**
+ * Determines whether or not a ResultNode is a host container.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a host container, false otherwise
+ */
+ nodeIsHost: function PU_nodeIsHost(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ aNode.parent &&
+ asQuery(aNode.parent).queryOptions.resultType ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a day container.
+ * @param node
+ * A NavHistoryResultNode
+ * @returns true if the node is a day container, false otherwise
+ */
+ nodeIsDay: function PU_nodeIsDay(aNode) {
+ var resultType;
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ aNode.parent &&
+ ((resultType = asQuery(aNode.parent).queryOptions.resultType) ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY);
+ },
+
+ /**
+ * Determines whether or not a result-node is a tag container.
+ * @param aNode
+ * A result-node
+ * @returns true if the node is a tag container, false otherwise
+ */
+ nodeIsTagQuery: function PU_nodeIsTagQuery(aNode) {
+ return aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY &&
+ asQuery(aNode).queryOptions.resultType ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS;
+ },
+
+ /**
+ * Determines whether or not a ResultNode is a container.
+ * @param aNode
+ * A result node
+ * @returns true if the node is a container item, false otherwise
+ */
+ containerTypes: [Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
+ Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT,
+ Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY],
+ nodeIsContainer: function PU_nodeIsContainer(aNode) {
+ return this.containerTypes.includes(aNode.type);
+ },
+
+ /**
+ * Determines whether or not a ResultNode is an history related container.
+ * @param node
+ * A result node
+ * @returns true if the node is an history related container, false otherwise
+ */
+ nodeIsHistoryContainer: function PU_nodeIsHistoryContainer(aNode) {
+ var resultType;
+ return this.nodeIsQuery(aNode) &&
+ ((resultType = asQuery(aNode).queryOptions.resultType) ==
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY ||
+ this.nodeIsDay(aNode) ||
+ this.nodeIsHost(aNode));
+ },
+
+ /**
+ * Gets the concrete item-id for the given node. Generally, this is just
+ * node.itemId, but for folder-shortcuts that's node.folderItemId.
+ */
+ getConcreteItemId: function PU_getConcreteItemId(aNode) {
+ if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT)
+ return asQuery(aNode).folderItemId;
+ else if (PlacesUtils.nodeIsTagQuery(aNode)) {
+ // RESULTS_AS_TAG_CONTENTS queries are similar to folder shortcuts
+ // so we can still get the concrete itemId for them.
+ var queries = aNode.getQueries();
+ var folders = queries[0].getFolders();
+ return folders[0];
+ }
+ return aNode.itemId;
+ },
+
+ /**
+ * Gets the concrete item-guid for the given node. For everything but folder
+ * shortcuts, this is just node.bookmarkGuid. For folder shortcuts, this is
+ * node.targetFolderGuid (see nsINavHistoryService.idl for the semantics).
+ *
+ * @param aNode
+ * a result node.
+ * @return the concrete item-guid for aNode.
+ * @note unlike getConcreteItemId, this doesn't allow retrieving the guid of a
+ * ta container.
+ */
+ getConcreteItemGuid(aNode) {
+ if (aNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT)
+ return asQuery(aNode).targetFolderGuid;
+ return aNode.bookmarkGuid;
+ },
+
+ /**
+ * Reverse a host based on the moz_places algorithm, that is reverse the host
+ * string and add a trailing period. For example "google.com" becomes
+ * "moc.elgoog.".
+ *
+ * @param url
+ * the URL to generate a rev host for.
+ * @return the reversed host string.
+ */
+ getReversedHost(url) {
+ return url.host.split("").reverse().join("") + ".";
+ },
+
+ /**
+ * String-wraps a result node according to the rules of the specified
+ * content type for copy or move operations.
+ *
+ * @param aNode
+ * The Result node to wrap (serialize)
+ * @param aType
+ * The content type to serialize as
+ * @param [optional] aFeedURI
+ * Used instead of the node's URI if provided.
+ * This is useful for wrapping a livemark as TYPE_X_MOZ_URL,
+ * TYPE_HTML or TYPE_UNICODE.
+ * @return A string serialization of the node
+ */
+ wrapNode(aNode, aType, aFeedURI) {
+ // when wrapping a node, we want all the items, even if the original
+ // query options are excluding them.
+ // This can happen when copying from the left hand pane of the bookmarks
+ // organizer.
+ // @return [node, shouldClose]
+ function gatherDataFromNode(node, gatherDataFunc) {
+ if (PlacesUtils.nodeIsFolder(node) &&
+ node.type != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT &&
+ asQuery(node).queryOptions.excludeItems) {
+ let folderRoot = PlacesUtils.getFolderContents(node.itemId, false, true).root;
+ try {
+ return gatherDataFunc(folderRoot);
+ } finally {
+ folderRoot.containerOpen = false;
+ }
+ }
+ // If we didn't create our own query, do not alter the node's state.
+ return gatherDataFunc(node);
+ }
+
+ function gatherDataHtml(node) {
+ let htmlEscape = s => s.replace(/&/g, "&amp;")
+ .replace(/>/g, "&gt;")
+ .replace(/</g, "&lt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&apos;");
+
+ // escape out potential HTML in the title
+ let escapedTitle = node.title ? htmlEscape(node.title) : "";
+
+ if (aFeedURI) {
+ return `<A HREF="${aFeedURI}">${escapedTitle}</A>${NEWLINE}`;
+ }
+
+ if (PlacesUtils.nodeIsContainer(node)) {
+ asContainer(node);
+ let wasOpen = node.containerOpen;
+ if (!wasOpen)
+ node.containerOpen = true;
+
+ let childString = "<DL><DT>" + escapedTitle + "</DT>" + NEWLINE;
+ let cc = node.childCount;
+ for (let i = 0; i < cc; ++i) {
+ childString += "<DD>"
+ + NEWLINE
+ + gatherDataHtml(node.getChild(i))
+ + "</DD>"
+ + NEWLINE;
+ }
+ node.containerOpen = wasOpen;
+ return childString + "</DL>" + NEWLINE;
+ }
+ if (PlacesUtils.nodeIsURI(node))
+ return `<A HREF="${node.uri}">${escapedTitle}</A>${NEWLINE}`;
+ if (PlacesUtils.nodeIsSeparator(node))
+ return "<HR>" + NEWLINE;
+ return "";
+ }
+
+ function gatherDataText(node) {
+ if (aFeedURI) {
+ return aFeedURI;
+ }
+
+ if (PlacesUtils.nodeIsContainer(node)) {
+ asContainer(node);
+ let wasOpen = node.containerOpen;
+ if (!wasOpen)
+ node.containerOpen = true;
+
+ let childString = node.title + NEWLINE;
+ let cc = node.childCount;
+ for (let i = 0; i < cc; ++i) {
+ let child = node.getChild(i);
+ let suffix = i < (cc - 1) ? NEWLINE : "";
+ childString += gatherDataText(child) + suffix;
+ }
+ node.containerOpen = wasOpen;
+ return childString;
+ }
+ if (PlacesUtils.nodeIsURI(node))
+ return node.uri;
+ if (PlacesUtils.nodeIsSeparator(node))
+ return "--------------------";
+ return "";
+ }
+
+ switch (aType) {
+ case this.TYPE_X_MOZ_PLACE:
+ case this.TYPE_X_MOZ_PLACE_SEPARATOR:
+ case this.TYPE_X_MOZ_PLACE_CONTAINER: {
+ // Serialize the node to JSON.
+ return serializeNode(aNode, aFeedURI);
+ }
+ case this.TYPE_X_MOZ_URL: {
+ if (aFeedURI || PlacesUtils.nodeIsURI(aNode))
+ return (aFeedURI || aNode.uri) + NEWLINE + aNode.title;
+ return "";
+ }
+ case this.TYPE_HTML: {
+ return gatherDataFromNode(aNode, gatherDataHtml);
+ }
+ }
+
+ // Otherwise, we wrap as TYPE_UNICODE.
+ return gatherDataFromNode(aNode, gatherDataText);
+ },
+
+ /**
+ * Unwraps data from the Clipboard or the current Drag Session.
+ * @param blob
+ * A blob (string) of data, in some format we potentially know how
+ * to parse.
+ * @param type
+ * The content type of the blob.
+ * @returns An array of objects representing each item contained by the source.
+ */
+ unwrapNodes: function PU_unwrapNodes(blob, type) {
+ // We split on "\n" because the transferable system converts "\r\n" to "\n"
+ var nodes = [];
+ switch (type) {
+ case this.TYPE_X_MOZ_PLACE:
+ case this.TYPE_X_MOZ_PLACE_SEPARATOR:
+ case this.TYPE_X_MOZ_PLACE_CONTAINER:
+ nodes = JSON.parse("[" + blob + "]");
+ break;
+ case this.TYPE_X_MOZ_URL: {
+ let parts = blob.split("\n");
+ // data in this type has 2 parts per entry, so if there are fewer
+ // than 2 parts left, the blob is malformed and we should stop
+ // but drag and drop of files from the shell has parts.length = 1
+ if (parts.length != 1 && parts.length % 2)
+ break;
+ for (let i = 0; i < parts.length; i=i+2) {
+ let uriString = parts[i];
+ let titleString = "";
+ if (parts.length > i+1)
+ titleString = parts[i+1];
+ else {
+ // for drag and drop of files, try to use the leafName as title
+ try {
+ titleString = this._uri(uriString).QueryInterface(Ci.nsIURL)
+ .fileName;
+ }
+ catch (e) {}
+ }
+ // note: this._uri() will throw if uriString is not a valid URI
+ if (this._uri(uriString)) {
+ nodes.push({ uri: uriString,
+ title: titleString ? titleString : uriString,
+ type: this.TYPE_X_MOZ_URL });
+ }
+ }
+ break;
+ }
+ case this.TYPE_UNICODE: {
+ let parts = blob.split("\n");
+ for (let i = 0; i < parts.length; i++) {
+ let uriString = parts[i];
+ // text/uri-list is converted to TYPE_UNICODE but it could contain
+ // comments line prepended by #, we should skip them
+ if (uriString.substr(0, 1) == '\x23')
+ continue;
+ // note: this._uri() will throw if uriString is not a valid URI
+ if (uriString != "" && this._uri(uriString))
+ nodes.push({ uri: uriString,
+ title: uriString,
+ type: this.TYPE_X_MOZ_URL });
+ }
+ break;
+ }
+ default:
+ throw Cr.NS_ERROR_INVALID_ARG;
+ }
+ return nodes;
+ },
+
+ /**
+ * Generates a nsINavHistoryResult for the contents of a folder.
+ * @param folderId
+ * The folder to open
+ * @param [optional] excludeItems
+ * True to hide all items (individual bookmarks). This is used on
+ * the left places pane so you just get a folder hierarchy.
+ * @param [optional] expandQueries
+ * True to make query items expand as new containers. For managing,
+ * you want this to be false, for menus and such, you want this to
+ * be true.
+ * @returns A nsINavHistoryResult containing the contents of the
+ * folder. The result.root is guaranteed to be open.
+ */
+ getFolderContents:
+ function PU_getFolderContents(aFolderId, aExcludeItems, aExpandQueries) {
+ var query = this.history.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ var options = this.history.getNewQueryOptions();
+ options.excludeItems = aExcludeItems;
+ options.expandQueries = aExpandQueries;
+
+ var result = this.history.executeQuery(query, options);
+ result.root.containerOpen = true;
+ return result;
+ },
+
+ /**
+ * Fetch all annotations for a URI, including all properties of each
+ * annotation which would be required to recreate it.
+ * @param aURI
+ * The URI for which annotations are to be retrieved.
+ * @return Array of objects, each containing the following properties:
+ * name, flags, expires, value
+ */
+ getAnnotationsForURI: function PU_getAnnotationsForURI(aURI) {
+ var annosvc = this.annotations;
+ var annos = [], val = null;
+ var annoNames = annosvc.getPageAnnotationNames(aURI);
+ for (var i = 0; i < annoNames.length; i++) {
+ var flags = {}, exp = {}, storageType = {};
+ annosvc.getPageAnnotationInfo(aURI, annoNames[i], flags, exp, storageType);
+ val = annosvc.getPageAnnotation(aURI, annoNames[i]);
+ annos.push({name: annoNames[i],
+ flags: flags.value,
+ expires: exp.value,
+ value: val});
+ }
+ return annos;
+ },
+
+ /**
+ * Fetch all annotations for an item, including all properties of each
+ * annotation which would be required to recreate it.
+ * @param aItemId
+ * The identifier of the itme for which annotations are to be
+ * retrieved.
+ * @return Array of objects, each containing the following properties:
+ * name, flags, expires, mimeType, type, value
+ */
+ getAnnotationsForItem: function PU_getAnnotationsForItem(aItemId) {
+ var annosvc = this.annotations;
+ var annos = [], val = null;
+ var annoNames = annosvc.getItemAnnotationNames(aItemId);
+ for (var i = 0; i < annoNames.length; i++) {
+ var flags = {}, exp = {}, storageType = {};
+ annosvc.getItemAnnotationInfo(aItemId, annoNames[i], flags, exp, storageType);
+ val = annosvc.getItemAnnotation(aItemId, annoNames[i]);
+ annos.push({name: annoNames[i],
+ flags: flags.value,
+ expires: exp.value,
+ value: val});
+ }
+ return annos;
+ },
+
+ /**
+ * Annotate a URI with a batch of annotations.
+ * @param aURI
+ * The URI for which annotations are to be set.
+ * @param aAnnotations
+ * Array of objects, each containing the following properties:
+ * name, flags, expires.
+ * If the value for an annotation is not set it will be removed.
+ */
+ setAnnotationsForURI: function PU_setAnnotationsForURI(aURI, aAnnos) {
+ var annosvc = this.annotations;
+ aAnnos.forEach(function(anno) {
+ if (anno.value === undefined || anno.value === null) {
+ annosvc.removePageAnnotation(aURI, anno.name);
+ }
+ else {
+ let flags = ("flags" in anno) ? anno.flags : 0;
+ let expires = ("expires" in anno) ?
+ anno.expires : Ci.nsIAnnotationService.EXPIRE_NEVER;
+ annosvc.setPageAnnotation(aURI, anno.name, anno.value, flags, expires);
+ }
+ });
+ },
+
+ /**
+ * Annotate an item with a batch of annotations.
+ * @param aItemId
+ * The identifier of the item for which annotations are to be set
+ * @param aAnnotations
+ * Array of objects, each containing the following properties:
+ * name, flags, expires.
+ * If the value for an annotation is not set it will be removed.
+ */
+ setAnnotationsForItem: function PU_setAnnotationsForItem(aItemId, aAnnos, aSource) {
+ var annosvc = this.annotations;
+
+ aAnnos.forEach(function(anno) {
+ if (anno.value === undefined || anno.value === null) {
+ annosvc.removeItemAnnotation(aItemId, anno.name, aSource);
+ }
+ else {
+ let flags = ("flags" in anno) ? anno.flags : 0;
+ let expires = ("expires" in anno) ?
+ anno.expires : Ci.nsIAnnotationService.EXPIRE_NEVER;
+ annosvc.setItemAnnotation(aItemId, anno.name, anno.value, flags,
+ expires, aSource);
+ }
+ });
+ },
+
+ // Identifier getters for special folders.
+ // You should use these everywhere PlacesUtils is available to avoid XPCOM
+ // traversal just to get roots' ids.
+ get placesRootId() {
+ delete this.placesRootId;
+ return this.placesRootId = this.bookmarks.placesRoot;
+ },
+
+ get bookmarksMenuFolderId() {
+ delete this.bookmarksMenuFolderId;
+ return this.bookmarksMenuFolderId = this.bookmarks.bookmarksMenuFolder;
+ },
+
+ get toolbarFolderId() {
+ delete this.toolbarFolderId;
+ return this.toolbarFolderId = this.bookmarks.toolbarFolder;
+ },
+
+ get tagsFolderId() {
+ delete this.tagsFolderId;
+ return this.tagsFolderId = this.bookmarks.tagsFolder;
+ },
+
+ get unfiledBookmarksFolderId() {
+ delete this.unfiledBookmarksFolderId;
+ return this.unfiledBookmarksFolderId = this.bookmarks.unfiledBookmarksFolder;
+ },
+
+ get mobileFolderId() {
+ delete this.mobileFolderId;
+ return this.mobileFolderId = this.bookmarks.mobileFolder;
+ },
+
+ /**
+ * Checks if aItemId is a root.
+ *
+ * @param aItemId
+ * item id to look for.
+ * @returns true if aItemId is a root, false otherwise.
+ */
+ isRootItem: function PU_isRootItem(aItemId) {
+ return aItemId == PlacesUtils.bookmarksMenuFolderId ||
+ aItemId == PlacesUtils.toolbarFolderId ||
+ aItemId == PlacesUtils.unfiledBookmarksFolderId ||
+ aItemId == PlacesUtils.tagsFolderId ||
+ aItemId == PlacesUtils.placesRootId ||
+ aItemId == PlacesUtils.mobileFolderId;
+ },
+
+ /**
+ * Set the POST data associated with a bookmark, if any.
+ * Used by POST keywords.
+ * @param aBookmarkId
+ *
+ * @deprecated Use PlacesUtils.keywords.insert() API instead.
+ */
+ setPostDataForBookmark(aBookmarkId, aPostData) {
+ if (!aPostData)
+ throw new Error("Must provide valid POST data");
+ // For now we don't have a unified API to create a keyword with postData,
+ // thus here we can just try to complete a keyword that should already exist
+ // without any post data.
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `UPDATE moz_keywords SET post_data = :post_data
+ WHERE id = (SELECT k.id FROM moz_keywords k
+ JOIN moz_bookmarks b ON b.fk = k.place_id
+ WHERE b.id = :item_id
+ AND post_data ISNULL
+ LIMIT 1)`);
+ stmt.params.item_id = aBookmarkId;
+ stmt.params.post_data = aPostData;
+ try {
+ stmt.execute();
+ }
+ finally {
+ stmt.finalize();
+ }
+
+ // Update the cache.
+ return Task.spawn(function* () {
+ let guid = yield PlacesUtils.promiseItemGuid(aBookmarkId);
+ let bm = yield PlacesUtils.bookmarks.fetch(guid);
+
+ // Fetch keywords for this href.
+ let cache = yield gKeywordsCachePromise;
+ for (let [ , entry ] of cache) {
+ // Set the POST data on keywords not having it.
+ if (entry.url.href == bm.url.href && !entry.postData) {
+ entry.postData = aPostData;
+ }
+ }
+ }).catch(Cu.reportError);
+ },
+
+ /**
+ * Get the POST data associated with a bookmark, if any.
+ * @param aBookmarkId
+ * @returns string of POST data if set for aBookmarkId. null otherwise.
+ *
+ * @deprecated Use PlacesUtils.keywords.fetch() API instead.
+ */
+ getPostDataForBookmark(aBookmarkId) {
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `SELECT k.post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ JOIN moz_bookmarks b ON b.fk = h.id
+ WHERE b.id = :item_id`);
+ stmt.params.item_id = aBookmarkId;
+ try {
+ if (!stmt.executeStep())
+ return null;
+ return stmt.row.post_data;
+ }
+ finally {
+ stmt.finalize();
+ }
+ },
+
+ /**
+ * Get the URI (and any associated POST data) for a given keyword.
+ * @param aKeyword string keyword
+ * @returns an array containing a string URL and a string of POST data
+ *
+ * @deprecated
+ */
+ getURLAndPostDataForKeyword(aKeyword) {
+ Deprecated.warning("getURLAndPostDataForKeyword() is deprecated, please " +
+ "use PlacesUtils.keywords.fetch() instead",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=1100294");
+
+ let stmt = PlacesUtils.history.DBConnection.createStatement(
+ `SELECT h.url, k.post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ WHERE k.keyword = :keyword`);
+ stmt.params.keyword = aKeyword.toLowerCase();
+ try {
+ if (!stmt.executeStep())
+ return [ null, null ];
+ return [ stmt.row.url, stmt.row.post_data ];
+ }
+ finally {
+ stmt.finalize();
+ }
+ },
+
+ /**
+ * Get all bookmarks for a URL, excluding items under tags.
+ */
+ getBookmarksForURI:
+ function PU_getBookmarksForURI(aURI) {
+ var bmkIds = this.bookmarks.getBookmarkIdsForURI(aURI);
+
+ // filter the ids list
+ return bmkIds.filter(function(aID) {
+ var parentId = this.bookmarks.getFolderIdForItem(aID);
+ var grandparentId = this.bookmarks.getFolderIdForItem(parentId);
+ // item under a tag container
+ if (grandparentId == this.tagsFolderId)
+ return false;
+ return true;
+ }, this);
+ },
+
+ /**
+ * Get the most recently added/modified bookmark for a URL, excluding items
+ * under tags.
+ *
+ * @param aURI
+ * nsIURI of the page we will look for.
+ * @returns itemId of the found bookmark, or -1 if nothing is found.
+ */
+ getMostRecentBookmarkForURI:
+ function PU_getMostRecentBookmarkForURI(aURI) {
+ var bmkIds = this.bookmarks.getBookmarkIdsForURI(aURI);
+ for (var i = 0; i < bmkIds.length; i++) {
+ // Find the first folder which isn't a tag container
+ var itemId = bmkIds[i];
+ var parentId = this.bookmarks.getFolderIdForItem(itemId);
+ // Optimization: if this is a direct child of a root we don't need to
+ // check if its grandparent is a tag.
+ if (parentId == this.unfiledBookmarksFolderId ||
+ parentId == this.toolbarFolderId ||
+ parentId == this.bookmarksMenuFolderId)
+ return itemId;
+
+ var grandparentId = this.bookmarks.getFolderIdForItem(parentId);
+ if (grandparentId != this.tagsFolderId)
+ return itemId;
+ }
+ return -1;
+ },
+
+ /**
+ * Returns a nsNavHistoryContainerResultNode with forced excludeItems and
+ * expandQueries.
+ * @param aNode
+ * The node to convert
+ * @param [optional] excludeItems
+ * True to hide all items (individual bookmarks). This is used on
+ * the left places pane so you just get a folder hierarchy.
+ * @param [optional] expandQueries
+ * True to make query items expand as new containers. For managing,
+ * you want this to be false, for menus and such, you want this to
+ * be true.
+ * @returns A nsINavHistoryContainerResultNode containing the unfiltered
+ * contents of the container.
+ * @note The returned container node could be open or closed, we don't
+ * guarantee its status.
+ */
+ getContainerNodeWithOptions:
+ function PU_getContainerNodeWithOptions(aNode, aExcludeItems, aExpandQueries) {
+ if (!this.nodeIsContainer(aNode))
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ // excludeItems is inherited by child containers in an excludeItems view.
+ var excludeItems = asQuery(aNode).queryOptions.excludeItems ||
+ asQuery(aNode.parentResult.root).queryOptions.excludeItems;
+ // expandQueries is inherited by child containers in an expandQueries view.
+ var expandQueries = asQuery(aNode).queryOptions.expandQueries &&
+ asQuery(aNode.parentResult.root).queryOptions.expandQueries;
+
+ // If our options are exactly what we expect, directly return the node.
+ if (excludeItems == aExcludeItems && expandQueries == aExpandQueries)
+ return aNode;
+
+ // Otherwise, get contents manually.
+ var queries = {}, options = {};
+ this.history.queryStringToQueries(aNode.uri, queries, {}, options);
+ options.value.excludeItems = aExcludeItems;
+ options.value.expandQueries = aExpandQueries;
+ return this.history.executeQueries(queries.value,
+ queries.value.length,
+ options.value).root;
+ },
+
+ /**
+ * Returns true if a container has uri nodes in its first level.
+ * Has better performance than (getURLsForContainerNode(node).length > 0).
+ * @param aNode
+ * The container node to search through.
+ * @returns true if the node contains uri nodes, false otherwise.
+ */
+ hasChildURIs: function PU_hasChildURIs(aNode) {
+ if (!this.nodeIsContainer(aNode))
+ return false;
+
+ let root = this.getContainerNodeWithOptions(aNode, false, true);
+ let result = root.parentResult;
+ let didSuppressNotifications = false;
+ let wasOpen = root.containerOpen;
+ if (!wasOpen) {
+ didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = true;
+
+ root.containerOpen = true;
+ }
+
+ let found = false;
+ for (let i = 0; i < root.childCount && !found; i++) {
+ let child = root.getChild(i);
+ if (this.nodeIsURI(child))
+ found = true;
+ }
+
+ if (!wasOpen) {
+ root.containerOpen = false;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = false;
+ }
+ return found;
+ },
+
+ /**
+ * Returns an array containing all the uris in the first level of the
+ * passed in container.
+ * If you only need to know if the node contains uris, use hasChildURIs.
+ * @param aNode
+ * The container node to search through
+ * @returns array of uris in the first level of the container.
+ */
+ getURLsForContainerNode: function PU_getURLsForContainerNode(aNode) {
+ let urls = [];
+ if (!this.nodeIsContainer(aNode))
+ return urls;
+
+ let root = this.getContainerNodeWithOptions(aNode, false, true);
+ let result = root.parentResult;
+ let wasOpen = root.containerOpen;
+ let didSuppressNotifications = false;
+ if (!wasOpen) {
+ didSuppressNotifications = result.suppressNotifications;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = true;
+
+ root.containerOpen = true;
+ }
+
+ for (let i = 0; i < root.childCount; ++i) {
+ let child = root.getChild(i);
+ if (this.nodeIsURI(child))
+ urls.push({uri: child.uri, isBookmark: this.nodeIsBookmark(child)});
+ }
+
+ if (!wasOpen) {
+ root.containerOpen = false;
+ if (!didSuppressNotifications)
+ result.suppressNotifications = false;
+ }
+ return urls;
+ },
+
+ /**
+ * Gets a shared Sqlite.jsm readonly connection to the Places database,
+ * usable only for SELECT queries.
+ *
+ * This is intended to be used mostly internally, components outside of
+ * Places should, when possible, use API calls and file bugs to get proper
+ * APIs, where they are missing.
+ * Keep in mind the Places DB schema is by no means frozen or even stable.
+ * Your custom queries can - and will - break overtime.
+ *
+ * Example:
+ * let db = yield PlacesUtils.promiseDBConnection();
+ * let rows = yield db.executeCached(sql, params);
+ */
+ promiseDBConnection: () => gAsyncDBConnPromised,
+
+ /**
+ * Performs a read/write operation on the Places database through a Sqlite.jsm
+ * wrapped connection to the Places database.
+ *
+ * This is intended to be used only by Places itself, always use APIs if you
+ * need to modify the Places database. Use promiseDBConnection if you need to
+ * SELECT from the database and there's no covering API.
+ * Keep in mind the Places DB schema is by no means frozen or even stable.
+ * Your custom queries can - and will - break overtime.
+ *
+ * As all operations on the Places database are asynchronous, if shutdown
+ * is initiated while an operation is pending, this could cause dataloss.
+ * Using `withConnectionWrapper` ensures that shutdown waits until all
+ * operations are complete before proceeding.
+ *
+ * Example:
+ * yield withConnectionWrapper("Bookmarks: Remove a bookmark", Task.async(function*(db) {
+ * // Proceed with the db, asynchronously.
+ * // Shutdown will not interrupt operations that take place here.
+ * }));
+ *
+ * @param {string} name The name of the operation. Used for debugging, logging
+ * and crash reporting.
+ * @param {function(db)} task A function that takes as argument a Sqlite.jsm
+ * connection and returns a Promise. Shutdown is guaranteed to not interrupt
+ * execution of `task`.
+ */
+ withConnectionWrapper: (name, task) => {
+ if (!name) {
+ throw new TypeError("Expecting a user-readable name");
+ }
+ return Task.spawn(function*() {
+ let db = yield gAsyncDBWrapperPromised;
+ return db.executeBeforeShutdown(name, task);
+ });
+ },
+
+ /**
+ * Given a uri returns list of itemIds associated to it.
+ *
+ * @param aURI
+ * nsIURI or spec of the page.
+ * @param aCallback
+ * Function to be called when done.
+ * The function will receive an array of itemIds associated to aURI and
+ * aURI itself.
+ *
+ * @return A object with a .cancel() method allowing to cancel the request.
+ *
+ * @note Children of live bookmarks folders are excluded. The callback function is
+ * not invoked if the request is cancelled or hits an error.
+ */
+ asyncGetBookmarkIds: function PU_asyncGetBookmarkIds(aURI, aCallback)
+ {
+ let abort = false;
+ let itemIds = [];
+ Task.spawn(function* () {
+ let conn = yield this.promiseDBConnection();
+ const QUERY_STR = `SELECT b.id FROM moz_bookmarks b
+ JOIN moz_places h on h.id = b.fk
+ WHERE h.url_hash = hash(:url) AND h.url = :url`;
+ let spec = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ yield conn.executeCached(QUERY_STR, { url: spec }, aRow => {
+ if (abort)
+ throw StopIteration;
+ itemIds.push(aRow.getResultByIndex(0));
+ });
+ if (!abort)
+ aCallback(itemIds, aURI);
+ }.bind(this)).then(null, Cu.reportError);
+ return { cancel: () => { abort = true; } };
+ },
+
+ /**
+ * Lazily adds a bookmarks observer, waiting for the bookmarks service to be
+ * alive before registering the observer. This is especially useful in the
+ * startup path, to avoid initializing the service just to add an observer.
+ *
+ * @param aObserver
+ * Object implementing nsINavBookmarkObserver
+ * @param [optional]aWeakOwner
+ * Whether to use weak ownership.
+ *
+ * @note Correct functionality of lazy observers relies on the fact Places
+ * notifies categories before real observers, and uses
+ * PlacesCategoriesStarter component to kick-off the registration.
+ */
+ _bookmarksServiceReady: false,
+ _bookmarksServiceObserversQueue: [],
+ addLazyBookmarkObserver:
+ function PU_addLazyBookmarkObserver(aObserver, aWeakOwner) {
+ if (this._bookmarksServiceReady) {
+ this.bookmarks.addObserver(aObserver, aWeakOwner === true);
+ return;
+ }
+ this._bookmarksServiceObserversQueue.push({ observer: aObserver,
+ weak: aWeakOwner === true });
+ },
+
+ /**
+ * Removes a bookmarks observer added through addLazyBookmarkObserver.
+ *
+ * @param aObserver
+ * Object implementing nsINavBookmarkObserver
+ */
+ removeLazyBookmarkObserver:
+ function PU_removeLazyBookmarkObserver(aObserver) {
+ if (this._bookmarksServiceReady) {
+ this.bookmarks.removeObserver(aObserver);
+ return;
+ }
+ let index = -1;
+ for (let i = 0;
+ i < this._bookmarksServiceObserversQueue.length && index == -1; i++) {
+ if (this._bookmarksServiceObserversQueue[i].observer === aObserver)
+ index = i;
+ }
+ if (index != -1) {
+ this._bookmarksServiceObserversQueue.splice(index, 1);
+ }
+ },
+
+ /**
+ * Sets the character-set for a URI.
+ *
+ * @param aURI nsIURI
+ * @param aCharset character-set value.
+ * @return {Promise}
+ */
+ setCharsetForURI: function PU_setCharsetForURI(aURI, aCharset) {
+ let deferred = Promise.defer();
+
+ // Delaying to catch issues with asynchronous behavior while waiting
+ // to implement asynchronous annotations in bug 699844.
+ Services.tm.mainThread.dispatch(function() {
+ if (aCharset && aCharset.length > 0) {
+ PlacesUtils.annotations.setPageAnnotation(
+ aURI, PlacesUtils.CHARSET_ANNO, aCharset, 0,
+ Ci.nsIAnnotationService.EXPIRE_NEVER);
+ } else {
+ PlacesUtils.annotations.removePageAnnotation(
+ aURI, PlacesUtils.CHARSET_ANNO);
+ }
+ deferred.resolve();
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+
+ return deferred.promise;
+ },
+
+ /**
+ * Gets the last saved character-set for a URI.
+ *
+ * @param aURI nsIURI
+ * @return {Promise}
+ * @resolve a character-set or null.
+ */
+ getCharsetForURI: function PU_getCharsetForURI(aURI) {
+ let deferred = Promise.defer();
+
+ Services.tm.mainThread.dispatch(function() {
+ let charset = null;
+
+ try {
+ charset = PlacesUtils.annotations.getPageAnnotation(aURI,
+ PlacesUtils.CHARSET_ANNO);
+ } catch (ex) { }
+
+ deferred.resolve(charset);
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+
+ return deferred.promise;
+ },
+
+ /**
+ * Promised wrapper for mozIAsyncHistory::getPlacesInfo for a single place.
+ *
+ * @param aPlaceIdentifier
+ * either an nsIURI or a GUID (@see getPlacesInfo)
+ * @resolves to the place info object handed to handleResult.
+ */
+ promisePlaceInfo: function PU_promisePlaceInfo(aPlaceIdentifier) {
+ let deferred = Promise.defer();
+ PlacesUtils.asyncHistory.getPlacesInfo(aPlaceIdentifier, {
+ _placeInfo: null,
+ handleResult: function handleResult(aPlaceInfo) {
+ this._placeInfo = aPlaceInfo;
+ },
+ handleError: function handleError(aResultCode, aPlaceInfo) {
+ deferred.reject(new Components.Exception("Error", aResultCode));
+ },
+ handleCompletion: function() {
+ deferred.resolve(this._placeInfo);
+ }
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Gets favicon data for a given page url.
+ *
+ * @param aPageUrl url of the page to look favicon for.
+ * @resolves to an object representing a favicon entry, having the following
+ * properties: { uri, dataLen, data, mimeType }
+ * @rejects JavaScript exception if the given url has no associated favicon.
+ */
+ promiseFaviconData: function (aPageUrl) {
+ let deferred = Promise.defer();
+ PlacesUtils.favicons.getFaviconDataForPage(NetUtil.newURI(aPageUrl),
+ function (aURI, aDataLen, aData, aMimeType) {
+ if (aURI) {
+ deferred.resolve({ uri: aURI,
+ dataLen: aDataLen,
+ data: aData,
+ mimeType: aMimeType });
+ } else {
+ deferred.reject();
+ }
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Gets the favicon link url (moz-anno:) for a given page url.
+ *
+ * @param aPageURL url of the page to lookup the favicon for.
+ * @resolves to the nsIURL of the favicon link
+ * @rejects if the given url has no associated favicon.
+ */
+ promiseFaviconLinkUrl: function (aPageUrl) {
+ let deferred = Promise.defer();
+ if (!(aPageUrl instanceof Ci.nsIURI))
+ aPageUrl = NetUtil.newURI(aPageUrl);
+
+ PlacesUtils.favicons.getFaviconURLForPage(aPageUrl, uri => {
+ if (uri) {
+ uri = PlacesUtils.favicons.getFaviconLinkForIcon(uri);
+ deferred.resolve(uri);
+ } else {
+ deferred.reject("favicon not found for uri");
+ }
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Get the unique id for an item (a bookmark, a folder or a separator) given
+ * its item id.
+ *
+ * @param aItemId
+ * an item id
+ * @return {Promise}
+ * @resolves to the GUID.
+ * @rejects if aItemId is invalid.
+ */
+ promiseItemGuid(aItemId) {
+ return GuidHelper.getItemGuid(aItemId)
+ },
+
+ /**
+ * Get the item id for an item (a bookmark, a folder or a separator) given
+ * its unique id.
+ *
+ * @param aGuid
+ * an item GUID
+ * @return {Promise}
+ * @resolves to the GUID.
+ * @rejects if there's no item for the given GUID.
+ */
+ promiseItemId(aGuid) {
+ return GuidHelper.getItemId(aGuid)
+ },
+
+ /**
+ * Invalidate the GUID cache for the given itemId.
+ *
+ * @param aItemId
+ * an item id
+ */
+ invalidateCachedGuidFor(aItemId) {
+ GuidHelper.invalidateCacheForItemId(aItemId)
+ },
+
+ /**
+ * Asynchronously retrieve a JS-object representation of a places bookmarks
+ * item (a bookmark, a folder, or a separator) along with all of its
+ * descendants.
+ *
+ * @param [optional] aItemGuid
+ * the (topmost) item to be queried. If it's not passed, the places
+ * root is queried: that is, you get a representation of the entire
+ * bookmarks hierarchy.
+ * @param [optional] aOptions
+ * Options for customizing the query behavior, in the form of a JS
+ * object with any of the following properties:
+ * - excludeItemsCallback: a function for excluding items, along with
+ * their descendants. Given an item object (that has everything set
+ * apart its potential children data), it should return true if the
+ * item should be excluded. Once an item is excluded, the function
+ * isn't called for any of its descendants. This isn't called for
+ * the root item.
+ * WARNING: since the function may be called for each item, using
+ * this option can slow down the process significantly if the
+ * callback does anything that's not relatively trivial. It is
+ * highly recommended to avoid any synchronous I/O or DB queries.
+ * - includeItemIds: opt-in to include the deprecated id property.
+ * Use it if you must. It'll be removed once the switch to GUIDs is
+ * complete.
+ *
+ * @return {Promise}
+ * @resolves to a JS object that represents either a single item or a
+ * bookmarks tree. Each node in the tree has the following properties set:
+ * - guid (string): the item's GUID (same as aItemGuid for the top item).
+ * - [deprecated] id (number): the item's id. This is only if
+ * aOptions.includeItemIds is set.
+ * - type (string): the item's type. @see PlacesUtils.TYPE_X_*
+ * - title (string): the item's title. If it has no title, this property
+ * isn't set.
+ * - dateAdded (number, microseconds from the epoch): the date-added value of
+ * the item.
+ * - lastModified (number, microseconds from the epoch): the last-modified
+ * value of the item.
+ * - annos (see getAnnotationsForItem): the item's annotations. This is not
+ * set if there are no annotations set for the item).
+ * - index: the item's index under it's parent.
+ *
+ * The root object (i.e. the one for aItemGuid) also has the following
+ * properties set:
+ * - parentGuid (string): the GUID of the root's parent. This isn't set if
+ * the root item is the places root.
+ * - itemsCount (number, not enumerable): the number of items, including the
+ * root item itself, which are represented in the resolved object.
+ *
+ * Bookmark items also have the following properties:
+ * - uri (string): the item's url.
+ * - tags (string): csv string of the bookmark's tags.
+ * - charset (string): the last known charset of the bookmark.
+ * - keyword (string): the bookmark's keyword (unset if none).
+ * - postData (string): the bookmark's keyword postData (unset if none).
+ * - iconuri (string): the bookmark's favicon url.
+ * The last four properties are not set at all if they're irrelevant (e.g.
+ * |charset| is not set if no charset was previously set for the bookmark
+ * url).
+ *
+ * Folders may also have the following properties:
+ * - children (array): the folder's children information, each of them
+ * having the same set of properties as above.
+ *
+ * @rejects if the query failed for any reason.
+ * @note if aItemGuid points to a non-existent item, the returned promise is
+ * resolved to null.
+ */
+ promiseBookmarksTree: Task.async(function* (aItemGuid = "", aOptions = {}) {
+ let createItemInfoObject = function* (aRow, aIncludeParentGuid) {
+ let item = {};
+ let copyProps = (...props) => {
+ for (let prop of props) {
+ let val = aRow.getResultByName(prop);
+ if (val !== null)
+ item[prop] = val;
+ }
+ };
+ copyProps("guid", "title", "index", "dateAdded", "lastModified");
+ if (aIncludeParentGuid)
+ copyProps("parentGuid");
+
+ let itemId = aRow.getResultByName("id");
+ if (aOptions.includeItemIds)
+ item.id = itemId;
+
+ // Cache it for promiseItemId consumers regardless.
+ GuidHelper.updateCache(itemId, item.guid);
+
+ let type = aRow.getResultByName("type");
+ if (type == Ci.nsINavBookmarksService.TYPE_BOOKMARK)
+ copyProps("charset", "tags", "iconuri");
+
+ // Add annotations.
+ if (aRow.getResultByName("has_annos")) {
+ try {
+ item.annos = PlacesUtils.getAnnotationsForItem(itemId);
+ } catch (e) {
+ Cu.reportError("Unexpected error while reading annotations " + e);
+ }
+ }
+
+ switch (type) {
+ case Ci.nsINavBookmarksService.TYPE_BOOKMARK:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE;
+ // If this throws due to an invalid url, the item will be skipped.
+ item.uri = NetUtil.newURI(aRow.getResultByName("url")).spec;
+ // Keywords are cached, so this should be decently fast.
+ let entry = yield PlacesUtils.keywords.fetch({ url: item.uri });
+ if (entry) {
+ item.keyword = entry.keyword;
+ item.postData = entry.postData;
+ }
+ break;
+ case Ci.nsINavBookmarksService.TYPE_FOLDER:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER;
+ // Mark root folders.
+ if (itemId == PlacesUtils.placesRootId)
+ item.root = "placesRoot";
+ else if (itemId == PlacesUtils.bookmarksMenuFolderId)
+ item.root = "bookmarksMenuFolder";
+ else if (itemId == PlacesUtils.unfiledBookmarksFolderId)
+ item.root = "unfiledBookmarksFolder";
+ else if (itemId == PlacesUtils.toolbarFolderId)
+ item.root = "toolbarFolder";
+ else if (itemId == PlacesUtils.mobileFolderId)
+ item.root = "mobileFolder";
+ break;
+ case Ci.nsINavBookmarksService.TYPE_SEPARATOR:
+ item.type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR;
+ break;
+ default:
+ Cu.reportError("Unexpected bookmark type");
+ break;
+ }
+ return item;
+ }.bind(this);
+
+ const QUERY_STR =
+ `/* do not warn (bug no): cannot use an index */
+ WITH RECURSIVE
+ descendants(fk, level, type, id, guid, parent, parentGuid, position,
+ title, dateAdded, lastModified) AS (
+ SELECT b1.fk, 0, b1.type, b1.id, b1.guid, b1.parent,
+ (SELECT guid FROM moz_bookmarks WHERE id = b1.parent),
+ b1.position, b1.title, b1.dateAdded, b1.lastModified
+ FROM moz_bookmarks b1 WHERE b1.guid=:item_guid
+ UNION ALL
+ SELECT b2.fk, level + 1, b2.type, b2.id, b2.guid, b2.parent,
+ descendants.guid, b2.position, b2.title, b2.dateAdded,
+ b2.lastModified
+ FROM moz_bookmarks b2
+ JOIN descendants ON b2.parent = descendants.id AND b2.id <> :tags_folder)
+ SELECT d.level, d.id, d.guid, d.parent, d.parentGuid, d.type,
+ d.position AS [index], d.title, d.dateAdded, d.lastModified,
+ h.url, f.url AS iconuri,
+ (SELECT GROUP_CONCAT(t.title, ',')
+ FROM moz_bookmarks b2
+ JOIN moz_bookmarks t ON t.id = +b2.parent AND t.parent = :tags_folder
+ WHERE b2.fk = h.id
+ ) AS tags,
+ EXISTS (SELECT 1 FROM moz_items_annos
+ WHERE item_id = d.id LIMIT 1) AS has_annos,
+ (SELECT a.content FROM moz_annos a
+ JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id
+ WHERE place_id = h.id AND n.name = :charset_anno
+ ) AS charset
+ FROM descendants d
+ LEFT JOIN moz_bookmarks b3 ON b3.id = d.parent
+ LEFT JOIN moz_places h ON h.id = d.fk
+ LEFT JOIN moz_favicons f ON f.id = h.favicon_id
+ ORDER BY d.level, d.parent, d.position`;
+
+
+ if (!aItemGuid)
+ aItemGuid = this.bookmarks.rootGuid;
+
+ let hasExcludeItemsCallback =
+ aOptions.hasOwnProperty("excludeItemsCallback");
+ let excludedParents = new Set();
+ let shouldExcludeItem = (aItem, aParentGuid) => {
+ let exclude = excludedParents.has(aParentGuid) ||
+ aOptions.excludeItemsCallback(aItem);
+ if (exclude) {
+ if (aItem.type == this.TYPE_X_MOZ_PLACE_CONTAINER)
+ excludedParents.add(aItem.guid);
+ }
+ return exclude;
+ };
+
+ let rootItem = null;
+ let parentsMap = new Map();
+ let conn = yield this.promiseDBConnection();
+ let rows = yield conn.executeCached(QUERY_STR,
+ { tags_folder: PlacesUtils.tagsFolderId,
+ charset_anno: PlacesUtils.CHARSET_ANNO,
+ item_guid: aItemGuid });
+ let yieldCounter = 0;
+ for (let row of rows) {
+ let item;
+ if (!rootItem) {
+ try {
+ // This is the first row.
+ rootItem = item = yield createItemInfoObject(row, true);
+ Object.defineProperty(rootItem, "itemsCount", { value: 1
+ , writable: true
+ , enumerable: false
+ , configurable: false });
+ } catch (ex) {
+ throw new Error("Failed to fetch the data for the root item " + ex);
+ }
+ } else {
+ try {
+ // Our query guarantees that we always visit parents ahead of their
+ // children.
+ item = yield createItemInfoObject(row, false);
+ let parentGuid = row.getResultByName("parentGuid");
+ if (hasExcludeItemsCallback && shouldExcludeItem(item, parentGuid))
+ continue;
+
+ let parentItem = parentsMap.get(parentGuid);
+ if ("children" in parentItem)
+ parentItem.children.push(item);
+ else
+ parentItem.children = [item];
+
+ rootItem.itemsCount++;
+ } catch (ex) {
+ // This is a bogus child, report and skip it.
+ Cu.reportError("Failed to fetch the data for an item " + ex);
+ continue;
+ }
+ }
+
+ if (item.type == this.TYPE_X_MOZ_PLACE_CONTAINER)
+ parentsMap.set(item.guid, item);
+
+ // With many bookmarks we end up stealing the CPU - even with yielding!
+ // So we let everyone else have a go every few items (bug 1186714).
+ if (++yieldCounter % 50 == 0) {
+ yield new Promise(resolve => {
+ Services.tm.currentThread.dispatch(resolve, Ci.nsIThread.DISPATCH_NORMAL);
+ });
+ }
+ }
+
+ return rootItem;
+ })
+};
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "history", function() {
+ let hs = Cc["@mozilla.org/browser/nav-history-service;1"]
+ .getService(Ci.nsINavHistoryService)
+ .QueryInterface(Ci.nsIBrowserHistory)
+ .QueryInterface(Ci.nsPIPlacesDatabase);
+ return Object.freeze(new Proxy(hs, {
+ get: function(target, name) {
+ let property, object;
+ if (name in target) {
+ property = target[name];
+ object = target;
+ } else {
+ property = History[name];
+ object = History;
+ }
+ if (typeof property == "function") {
+ return property.bind(object);
+ }
+ return property;
+ }
+ }));
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "asyncHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "bhistory", function() {
+ return PlacesUtils.history;
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "favicons",
+ "@mozilla.org/browser/favicon-service;1",
+ "mozIAsyncFavicons");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "bookmarks", () => {
+ let bm = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
+ .getService(Ci.nsINavBookmarksService);
+ return Object.freeze(new Proxy(bm, {
+ get: (target, name) => target.hasOwnProperty(name) ? target[name]
+ : Bookmarks[name]
+ }));
+});
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "annotations",
+ "@mozilla.org/browser/annotation-service;1",
+ "nsIAnnotationService");
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "tagging",
+ "@mozilla.org/browser/tagging-service;1",
+ "nsITaggingService");
+
+XPCOMUtils.defineLazyServiceGetter(PlacesUtils, "livemarks",
+ "@mozilla.org/browser/livemark-service;2",
+ "mozIAsyncLivemarks");
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "keywords", () => Keywords);
+
+XPCOMUtils.defineLazyGetter(PlacesUtils, "transactionManager", function() {
+ let tm = Cc["@mozilla.org/transactionmanager;1"].
+ createInstance(Ci.nsITransactionManager);
+ tm.AddListener(PlacesUtils);
+ this.registerShutdownFunction(function () {
+ // Clear all references to local transactions in the transaction manager,
+ // this prevents from leaking it.
+ this.transactionManager.RemoveListener(this);
+ this.transactionManager.clear();
+ });
+
+ // Bug 750269
+ // The transaction manager keeps strong references to transactions, and by
+ // that, also to the global for each transaction. A transaction, however,
+ // could be either the transaction itself (for which the global is this
+ // module) or some js-proxy in another global, usually a window. The later
+ // would leak because the transaction lifetime (in the manager's stacks)
+ // is independent of the global from which doTransaction was called.
+ // To avoid such a leak, we hide the native doTransaction from callers,
+ // and let each doTransaction call go through this module.
+ // Doing so ensures that, as long as the transaction is any of the
+ // PlacesXXXTransaction objects declared in this module, the object
+ // referenced by the transaction manager has the module itself as global.
+ return Object.create(tm, {
+ "doTransaction": {
+ value: function(aTransaction) {
+ tm.doTransaction(aTransaction);
+ }
+ }
+ });
+});
+
+XPCOMUtils.defineLazyGetter(this, "bundle", function() {
+ const PLACES_STRING_BUNDLE_URI = "chrome://places/locale/places.properties";
+ return Cc["@mozilla.org/intl/stringbundle;1"].
+ getService(Ci.nsIStringBundleService).
+ createBundle(PLACES_STRING_BUNDLE_URI);
+});
+
+/**
+ * Setup internal databases for closing properly during shutdown.
+ *
+ * 1. Places initiates shutdown.
+ * 2. Before places can move to the step where it closes the low-level connection,
+ * we need to make sure that we have closed `conn`.
+ * 3. Before we can close `conn`, we need to make sure that all external clients
+ * have stopped using `conn`.
+ * 4. Before we can close Sqlite, we need to close `conn`.
+ */
+function setupDbForShutdown(conn, name) {
+ try {
+ let state = "0. Not started.";
+ let promiseClosed = new Promise((resolve, reject) => {
+ // The service initiates shutdown.
+ // Before it can safely close its connection, we need to make sure
+ // that we have closed the high-level connection.
+ try {
+ AsyncShutdown.placesClosingInternalConnection.addBlocker(`${name} closing as part of Places shutdown`,
+ Task.async(function*() {
+ state = "1. Service has initiated shutdown";
+
+ // At this stage, all external clients have finished using the
+ // database. We just need to close the high-level connection.
+ yield conn.close();
+ state = "2. Closed Sqlite.jsm connection.";
+
+ resolve();
+ }),
+ () => state
+ );
+ } catch (ex) {
+ // It's too late to block shutdown, just close the connection.
+ conn.close();
+ reject(ex);
+ }
+ });
+
+ // Make sure that Sqlite.jsm doesn't close until we are done
+ // with the high-level connection.
+ Sqlite.shutdown.addBlocker(`${name} must be closed before Sqlite.jsm`,
+ () => promiseClosed.catch(Cu.reportError),
+ () => state
+ );
+ } catch (ex) {
+ // It's too late to block shutdown, just close the connection.
+ conn.close();
+ throw ex;
+ }
+}
+
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBConnPromised",
+ () => Sqlite.cloneStorageConnection({
+ connection: PlacesUtils.history.DBConnection,
+ readOnly: true
+ }).then(conn => {
+ setupDbForShutdown(conn, "PlacesUtils read-only connection");
+ return conn;
+ }).catch(Cu.reportError)
+);
+
+XPCOMUtils.defineLazyGetter(this, "gAsyncDBWrapperPromised",
+ () => Sqlite.wrapStorageConnection({
+ connection: PlacesUtils.history.DBConnection,
+ }).then(conn => {
+ setupDbForShutdown(conn, "PlacesUtils wrapped connection");
+ return conn;
+ }).catch(Cu.reportError)
+);
+
+/**
+ * Keywords management API.
+ * Sooner or later these keywords will merge with search keywords, this is an
+ * interim API that should then be replaced by a unified one.
+ * Keywords are associated with URLs and can have POST data.
+ * A single URL can have multiple keywords, provided they differ by POST data.
+ */
+var Keywords = {
+ /**
+ * Fetches a keyword entry based on keyword or URL.
+ *
+ * @param keywordOrEntry
+ * Either the keyword to fetch or an entry providing keyword
+ * or url property to find keywords for. If both properties are set,
+ * this returns their intersection.
+ * @param onResult [optional]
+ * Callback invoked for each found entry.
+ * @return {Promise}
+ * @resolves to an object in the form: { keyword, url, postData },
+ * or null if a keyword entry was not found.
+ */
+ fetch(keywordOrEntry, onResult=null) {
+ if (typeof(keywordOrEntry) == "string")
+ keywordOrEntry = { keyword: keywordOrEntry };
+
+ if (keywordOrEntry === null || typeof(keywordOrEntry) != "object" ||
+ (("keyword" in keywordOrEntry) && typeof(keywordOrEntry.keyword) != "string"))
+ throw new Error("Invalid keyword");
+
+ let hasKeyword = "keyword" in keywordOrEntry;
+ let hasUrl = "url" in keywordOrEntry;
+
+ if (!hasKeyword && !hasUrl)
+ throw new Error("At least keyword or url must be provided");
+ if (onResult && typeof onResult != "function")
+ throw new Error("onResult callback must be a valid function");
+
+ if (hasUrl)
+ keywordOrEntry.url = new URL(keywordOrEntry.url);
+ if (hasKeyword)
+ keywordOrEntry.keyword = keywordOrEntry.keyword.trim().toLowerCase();
+
+ let safeOnResult = entry => {
+ if (onResult) {
+ try {
+ onResult(entry);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ };
+
+ return gKeywordsCachePromise.then(cache => {
+ let entries = [];
+ if (hasKeyword) {
+ let entry = cache.get(keywordOrEntry.keyword);
+ if (entry)
+ entries.push(entry);
+ }
+ if (hasUrl) {
+ for (let entry of cache.values()) {
+ if (entry.url.href == keywordOrEntry.url.href)
+ entries.push(entry);
+ }
+ }
+
+ entries = entries.filter(e => {
+ return (!hasUrl || e.url.href == keywordOrEntry.url.href) &&
+ (!hasKeyword || e.keyword == keywordOrEntry.keyword);
+ });
+
+ entries.forEach(safeOnResult);
+ return entries.length ? entries[0] : null;
+ });
+ },
+
+ /**
+ * Adds a new keyword and postData for the given URL.
+ *
+ * @param keywordEntry
+ * An object describing the keyword to insert, in the form:
+ * {
+ * keyword: non-empty string,
+ * URL: URL or href to associate to the keyword,
+ * postData: optional POST data to associate to the keyword
+ * }
+ * @note Do not define a postData property if there isn't any POST data.
+ * @resolves when the addition is complete.
+ */
+ insert(keywordEntry) {
+ if (!keywordEntry || typeof keywordEntry != "object")
+ throw new Error("Input should be a valid object");
+
+ if (!("keyword" in keywordEntry) || !keywordEntry.keyword ||
+ typeof(keywordEntry.keyword) != "string")
+ throw new Error("Invalid keyword");
+ if (("postData" in keywordEntry) && keywordEntry.postData &&
+ typeof(keywordEntry.postData) != "string")
+ throw new Error("Invalid POST data");
+ if (!("url" in keywordEntry))
+ throw new Error("undefined is not a valid URL");
+ let { keyword, url,
+ source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = keywordEntry;
+ keyword = keyword.trim().toLowerCase();
+ let postData = keywordEntry.postData || null;
+ // This also checks href for validity
+ url = new URL(url);
+
+ return PlacesUtils.withConnectionWrapper("Keywords.insert", Task.async(function*(db) {
+ let cache = yield gKeywordsCachePromise;
+
+ // Trying to set the same keyword is a no-op.
+ let oldEntry = cache.get(keyword);
+ if (oldEntry && oldEntry.url.href == url.href &&
+ oldEntry.postData == keywordEntry.postData) {
+ return;
+ }
+
+ // A keyword can only be associated to a single page.
+ // If another page is using the new keyword, we must update the keyword
+ // entry.
+ // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete
+ // trigger.
+ if (oldEntry) {
+ yield db.executeCached(
+ `UPDATE moz_keywords
+ SET place_id = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ post_data = :post_data
+ WHERE keyword = :keyword
+ `, { url: url.href, keyword: keyword, post_data: postData });
+ yield notifyKeywordChange(oldEntry.url.href, "", source);
+ } else {
+ // An entry for the given page could be missing, in such a case we need to
+ // create it. The IGNORE conflict can trigger on `guid`.
+ yield db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
+ VALUES (:url, hash(:url), :rev_host, 0, :frecency,
+ IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ GENERATE_GUID()))
+ `, { url: url.href, rev_host: PlacesUtils.getReversedHost(url),
+ frecency: url.protocol == "place:" ? 0 : -1 });
+ yield db.executeCached(
+ `INSERT INTO moz_keywords (keyword, place_id, post_data)
+ VALUES (:keyword, (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :post_data)
+ `, { url: url.href, keyword: keyword, post_data: postData });
+ }
+
+ cache.set(keyword, { keyword, url, postData });
+
+ // In any case, notify about the new keyword.
+ yield notifyKeywordChange(url.href, keyword, source);
+ }.bind(this))
+ );
+ },
+
+ /**
+ * Removes a keyword.
+ *
+ * @param keyword
+ * The keyword to remove.
+ * @return {Promise}
+ * @resolves when the removal is complete.
+ */
+ remove(keywordOrEntry) {
+ if (typeof(keywordOrEntry) == "string")
+ keywordOrEntry = { keyword: keywordOrEntry };
+
+ if (keywordOrEntry === null || typeof(keywordOrEntry) != "object" ||
+ !keywordOrEntry.keyword || typeof keywordOrEntry.keyword != "string")
+ throw new Error("Invalid keyword");
+
+ let { keyword,
+ source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = keywordOrEntry;
+ keyword = keywordOrEntry.keyword.trim().toLowerCase();
+ return PlacesUtils.withConnectionWrapper("Keywords.remove", Task.async(function*(db) {
+ let cache = yield gKeywordsCachePromise;
+ if (!cache.has(keyword))
+ return;
+ let { url } = cache.get(keyword);
+ cache.delete(keyword);
+
+ yield db.execute(`DELETE FROM moz_keywords WHERE keyword = :keyword`,
+ { keyword });
+
+ // Notify bookmarks about the removal.
+ yield notifyKeywordChange(url.href, "", source);
+ }.bind(this))) ;
+ }
+};
+
+// Set by the keywords API to distinguish notifications fired by the old API.
+// Once the old API will be gone, we can remove this and stop observing.
+var gIgnoreKeywordNotifications = false;
+
+XPCOMUtils.defineLazyGetter(this, "gKeywordsCachePromise", () =>
+ PlacesUtils.withConnectionWrapper("PlacesUtils: gKeywordsCachePromise",
+ Task.async(function*(db) {
+ let cache = new Map();
+ let rows = yield db.execute(
+ `SELECT keyword, url, post_data
+ FROM moz_keywords k
+ JOIN moz_places h ON h.id = k.place_id
+ `);
+ for (let row of rows) {
+ let keyword = row.getResultByName("keyword");
+ let entry = { keyword,
+ url: new URL(row.getResultByName("url")),
+ postData: row.getResultByName("post_data") };
+ cache.set(keyword, entry);
+ }
+
+ // Helper to get a keyword from an href.
+ function keywordsForHref(href) {
+ let keywords = [];
+ for (let [ key, val ] of cache) {
+ if (val.url.href == href)
+ keywords.push(key);
+ }
+ return keywords;
+ }
+
+ // Start observing changes to bookmarks. For now we are going to keep that
+ // relation for backwards compatibility reasons, but mostly because we are
+ // lacking a UI to manage keywords directly.
+ let observer = {
+ QueryInterface: XPCOMUtils.generateQI(Ci.nsINavBookmarkObserver),
+ onBeginUpdateBatch() {},
+ onEndUpdateBatch() {},
+ onItemAdded() {},
+ onItemVisited() {},
+ onItemMoved() {},
+
+ onItemRemoved(id, parentId, index, itemType, uri, guid, parentGuid) {
+ if (itemType != PlacesUtils.bookmarks.TYPE_BOOKMARK)
+ return;
+
+ let keywords = keywordsForHref(uri.spec);
+ // This uri has no keywords associated, so there's nothing to do.
+ if (keywords.length == 0)
+ return;
+
+ Task.spawn(function* () {
+ // If the uri is not bookmarked anymore, we can remove this keyword.
+ let bookmark = yield PlacesUtils.bookmarks.fetch({ url: uri });
+ if (!bookmark) {
+ for (let keyword of keywords) {
+ yield PlacesUtils.keywords.remove(keyword);
+ }
+ }
+ }).catch(Cu.reportError);
+ },
+
+ onItemChanged(id, prop, isAnno, val, lastMod, itemType, parentId, guid,
+ parentGuid, oldVal) {
+ if (gIgnoreKeywordNotifications) {
+ return;
+ }
+
+ if (prop == "keyword") {
+ this._onKeywordChanged(guid, val).catch(Cu.reportError);
+ } else if (prop == "uri") {
+ this._onUrlChanged(guid, val, oldVal).catch(Cu.reportError);
+ }
+ },
+
+ _onKeywordChanged: Task.async(function* (guid, keyword) {
+ let bookmark = yield PlacesUtils.bookmarks.fetch(guid);
+ // Due to mixed sync/async operations, by this time the bookmark could
+ // have disappeared and we already handle removals in onItemRemoved.
+ if (!bookmark) {
+ return;
+ }
+
+ if (keyword.length == 0) {
+ // We are removing a keyword.
+ let keywords = keywordsForHref(bookmark.url.href)
+ for (let kw of keywords) {
+ cache.delete(kw);
+ }
+ } else {
+ // We are adding a new keyword.
+ cache.set(keyword, { keyword, url: bookmark.url });
+ }
+ }),
+
+ _onUrlChanged: Task.async(function* (guid, url, oldUrl) {
+ // Check if the old url is associated with keywords.
+ let entries = [];
+ yield PlacesUtils.keywords.fetch({ url: oldUrl }, e => entries.push(e));
+ if (entries.length == 0) {
+ return;
+ }
+
+ // Move the keywords to the new url.
+ for (let entry of entries) {
+ yield PlacesUtils.keywords.remove(entry.keyword);
+ entry.url = new URL(url);
+ yield PlacesUtils.keywords.insert(entry);
+ }
+ }),
+ };
+
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ PlacesUtils.registerShutdownFunction(() => {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ });
+ return cache;
+ })
+));
+
+// Sometime soon, likely as part of the transition to mozIAsyncBookmarks,
+// itemIds will be deprecated in favour of GUIDs, which play much better
+// with multiple undo/redo operations. Because these GUIDs are already stored,
+// and because we don't want to revise the transactions API once more when this
+// happens, transactions are set to work with GUIDs exclusively, in the sense
+// that they may never expose itemIds, nor do they accept them as input.
+// More importantly, transactions which add or remove items guarantee to
+// restore the GUIDs on undo/redo, so that the following transactions that may
+// done or undo can assume the items they're interested in are stil accessible
+// through the same GUID.
+// The current bookmarks API, however, doesn't expose the necessary means for
+// working with GUIDs. So, until it does, this helper object accesses the
+// Places database directly in order to switch between GUIDs and itemIds, and
+// "restore" GUIDs on items re-created items.
+var GuidHelper = {
+ // Cache for GUID<->itemId paris.
+ guidsForIds: new Map(),
+ idsForGuids: new Map(),
+
+ getItemId: Task.async(function* (aGuid) {
+ let cached = this.idsForGuids.get(aGuid);
+ if (cached !== undefined)
+ return cached;
+
+ let itemId = yield PlacesUtils.withConnectionWrapper("GuidHelper.getItemId",
+ Task.async(function* (db) {
+ let rows = yield db.executeCached(
+ "SELECT b.id, b.guid from moz_bookmarks b WHERE b.guid = :guid LIMIT 1",
+ { guid: aGuid });
+ if (rows.length == 0)
+ throw new Error("no item found for the given GUID");
+
+ return rows[0].getResultByName("id");
+ }));
+
+ this.updateCache(itemId, aGuid);
+ return itemId;
+ }),
+
+ getItemGuid: Task.async(function* (aItemId) {
+ let cached = this.guidsForIds.get(aItemId);
+ if (cached !== undefined)
+ return cached;
+
+ let guid = yield PlacesUtils.withConnectionWrapper("GuidHelper.getItemGuid",
+ Task.async(function* (db) {
+
+ let rows = yield db.executeCached(
+ "SELECT b.id, b.guid from moz_bookmarks b WHERE b.id = :id LIMIT 1",
+ { id: aItemId });
+ if (rows.length == 0)
+ throw new Error("no item found for the given itemId");
+
+ return rows[0].getResultByName("guid");
+ }));
+
+ this.updateCache(aItemId, guid);
+ return guid;
+ }),
+
+ /**
+ * Updates the cache.
+ *
+ * @note This is the only place where the cache should be populated,
+ * invalidation relies on both Maps being populated at the same time.
+ */
+ updateCache(aItemId, aGuid) {
+ if (typeof(aItemId) != "number" || aItemId <= 0)
+ throw new Error("Trying to update the GUIDs cache with an invalid itemId");
+ if (typeof(aGuid) != "string" || !/^[a-zA-Z0-9\-_]{12}$/.test(aGuid))
+ throw new Error("Trying to update the GUIDs cache with an invalid GUID");
+ this.ensureObservingRemovedItems();
+ this.guidsForIds.set(aItemId, aGuid);
+ this.idsForGuids.set(aGuid, aItemId);
+ },
+
+ invalidateCacheForItemId(aItemId) {
+ let guid = this.guidsForIds.get(aItemId);
+ this.guidsForIds.delete(aItemId);
+ this.idsForGuids.delete(guid);
+ },
+
+ ensureObservingRemovedItems: function () {
+ if (!("observer" in this)) {
+ /**
+ * This observers serves two purposes:
+ * (1) Invalidate cached id<->GUID paris on when items are removed.
+ * (2) Cache GUIDs given us free of charge by onItemAdded/onItemRemoved.
+ * So, for exmaple, when the NewBookmark needs the new GUID, we already
+ * have it cached.
+ */
+ this.observer = {
+ onItemAdded: (aItemId, aParentId, aIndex, aItemType, aURI, aTitle,
+ aDateAdded, aGuid, aParentGuid) => {
+ this.updateCache(aItemId, aGuid);
+ this.updateCache(aParentId, aParentGuid);
+ },
+ onItemRemoved:
+ (aItemId, aParentId, aIndex, aItemTyep, aURI, aGuid, aParentGuid) => {
+ this.guidsForIds.delete(aItemId);
+ this.idsForGuids.delete(aGuid);
+ this.updateCache(aParentId, aParentGuid);
+ },
+
+ QueryInterface: XPCOMUtils.generateQI(Ci.nsINavBookmarkObserver),
+
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onItemChanged: function() {},
+ onItemVisited: function() {},
+ onItemMoved: function() {},
+ };
+ PlacesUtils.bookmarks.addObserver(this.observer, false);
+ PlacesUtils.registerShutdownFunction(() => {
+ PlacesUtils.bookmarks.removeObserver(this.observer);
+ });
+ }
+ }
+};
+
+// Transactions handlers.
+
+/**
+ * Updates commands in the undo group of the active window commands.
+ * Inactive windows commands will be updated on focus.
+ */
+function updateCommandsOnActiveWindow()
+{
+ let win = Services.focus.activeWindow;
+ if (win && win instanceof Ci.nsIDOMWindow) {
+ // Updating "undo" will cause a group update including "redo".
+ win.updateCommands("undo");
+ }
+}
+
+
+/**
+ * Used to cache bookmark information in transactions.
+ *
+ * @note To avoid leaks any non-primitive property should be copied.
+ * @note Used internally, DO NOT EXPORT.
+ */
+function TransactionItemCache()
+{
+}
+
+TransactionItemCache.prototype = {
+ set id(v) {
+ this._id = (parseInt(v) > 0 ? v : null);
+ },
+ get id() {
+ return this._id || -1;
+ },
+ set parentId(v) {
+ this._parentId = (parseInt(v) > 0 ? v : null);
+ },
+ get parentId() {
+ return this._parentId || -1;
+ },
+ keyword: null,
+ title: null,
+ dateAdded: null,
+ lastModified: null,
+ postData: null,
+ itemType: null,
+ set uri(v) {
+ this._uri = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get uri() {
+ return this._uri || null;
+ },
+ set feedURI(v) {
+ this._feedURI = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get feedURI() {
+ return this._feedURI || null;
+ },
+ set siteURI(v) {
+ this._siteURI = (v instanceof Ci.nsIURI ? v.clone() : null);
+ },
+ get siteURI() {
+ return this._siteURI || null;
+ },
+ set index(v) {
+ this._index = (parseInt(v) >= 0 ? v : null);
+ },
+ // Index can be 0.
+ get index() {
+ return this._index != null ? this._index : PlacesUtils.bookmarks.DEFAULT_INDEX;
+ },
+ set annotations(v) {
+ this._annotations = Array.isArray(v) ? Cu.cloneInto(v, {}) : null;
+ },
+ get annotations() {
+ return this._annotations || null;
+ },
+ set tags(v) {
+ this._tags = (v && Array.isArray(v) ? Array.prototype.slice.call(v) : null);
+ },
+ get tags() {
+ return this._tags || null;
+ },
+};
+
+
+/**
+ * Base transaction implementation.
+ *
+ * @note used internally, DO NOT EXPORT.
+ */
+function BaseTransaction()
+{
+}
+
+BaseTransaction.prototype = {
+ name: null,
+ set childTransactions(v) {
+ this._childTransactions = (Array.isArray(v) ? Array.prototype.slice.call(v) : null);
+ },
+ get childTransactions() {
+ return this._childTransactions || null;
+ },
+ doTransaction: function BTXN_doTransaction() {},
+ redoTransaction: function BTXN_redoTransaction() {
+ return this.doTransaction();
+ },
+ undoTransaction: function BTXN_undoTransaction() {},
+ merge: function BTXN_merge() {
+ return false;
+ },
+ get isTransient() {
+ return false;
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsITransaction
+ ]),
+};
+
+
+/**
+ * Transaction for performing several Places Transactions in a single batch.
+ *
+ * @param aName
+ * title of the aggregate transactions
+ * @param aTransactions
+ * an array of transactions to perform
+ *
+ * @return nsITransaction object
+ */
+this.PlacesAggregatedTransaction =
+ function PlacesAggregatedTransaction(aName, aTransactions)
+{
+ // Copy the transactions array to decouple it from its prototype, which
+ // otherwise keeps alive its associated global object.
+ this.childTransactions = aTransactions;
+ this.name = aName;
+ this.item = new TransactionItemCache();
+
+ // Check child transactions number. We will batch if we have more than
+ // MIN_TRANSACTIONS_FOR_BATCH total number of transactions.
+ let countTransactions = function(aTransactions, aTxnCount)
+ {
+ for (let i = 0;
+ i < aTransactions.length && aTxnCount < MIN_TRANSACTIONS_FOR_BATCH;
+ ++i, ++aTxnCount) {
+ let txn = aTransactions[i];
+ if (txn.childTransactions && txn.childTransactions.length > 0)
+ aTxnCount = countTransactions(txn.childTransactions, aTxnCount);
+ }
+ return aTxnCount;
+ }
+
+ let txnCount = countTransactions(this.childTransactions, 0);
+ this._useBatch = txnCount >= MIN_TRANSACTIONS_FOR_BATCH;
+}
+
+PlacesAggregatedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function ATXN_doTransaction()
+ {
+ this._isUndo = false;
+ if (this._useBatch)
+ PlacesUtils.bookmarks.runInBatchMode(this, null);
+ else
+ this.runBatched(false);
+ },
+
+ undoTransaction: function ATXN_undoTransaction()
+ {
+ this._isUndo = true;
+ if (this._useBatch)
+ PlacesUtils.bookmarks.runInBatchMode(this, null);
+ else
+ this.runBatched(true);
+ },
+
+ runBatched: function ATXN_runBatched()
+ {
+ // Use a copy of the transactions array, so we won't reverse the original
+ // one on undoing.
+ let transactions = this.childTransactions.slice(0);
+ if (this._isUndo)
+ transactions.reverse();
+ for (let i = 0; i < transactions.length; ++i) {
+ let txn = transactions[i];
+ if (this.item.parentId != -1)
+ txn.item.parentId = this.item.parentId;
+ if (this._isUndo)
+ txn.undoTransaction();
+ else
+ txn.doTransaction();
+ }
+ }
+};
+
+
+/**
+ * Transaction for creating a new folder.
+ *
+ * @param aTitle
+ * the title for the new folder
+ * @param aParentId
+ * the id of the parent folder in which the new folder should be added
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new folder
+ * @param [optional] aChildTransactions
+ * array of transactions for items to be created in the new folder
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateFolderTransaction =
+ function PlacesCreateFolderTransaction(aTitle, aParentId, aIndex, aAnnotations,
+ aChildTransactions)
+{
+ this.item = new TransactionItemCache();
+ this.item.title = aTitle;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.annotations = aAnnotations;
+ this.childTransactions = aChildTransactions;
+}
+
+PlacesCreateFolderTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CFTXN_doTransaction()
+ {
+ this.item.id = PlacesUtils.bookmarks.createFolder(this.item.parentId,
+ this.item.title,
+ this.item.index);
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Set the new parent id into child transactions.
+ for (let i = 0; i < this.childTransactions.length; ++i) {
+ this.childTransactions[i].item.parentId = this.item.id;
+ }
+
+ let txn = new PlacesAggregatedTransaction("Create folder childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ },
+
+ undoTransaction: function CFTXN_undoTransaction()
+ {
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ let txn = new PlacesAggregatedTransaction("Create folder childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+
+ // Remove item only after all child transactions have been reverted.
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new bookmark.
+ *
+ * @param aURI
+ * the nsIURI of the new bookmark
+ * @param aParentId
+ * the id of the folder in which the bookmark should be added.
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ * @param [optional] aTitle
+ * the title of the new bookmark
+ * @param [optional] aKeyword
+ * the keyword for the new bookmark
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new bookmark
+ * @param [optional] aChildTransactions
+ * child transactions to commit after creating the bookmark. Prefer
+ * using any of the arguments above if possible. In general, a child
+ * transations should be used only if the change it does has to be
+ * reverted manually when removing the bookmark item.
+ * a child transaction must support setting its bookmark-item
+ * identifier via an "id" js setter.
+ * @param [optional] aPostData
+ * keyword's POST data, if available.
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateBookmarkTransaction =
+ function PlacesCreateBookmarkTransaction(aURI, aParentId, aIndex, aTitle,
+ aKeyword, aAnnotations,
+ aChildTransactions, aPostData)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.title = aTitle;
+ this.item.keyword = aKeyword;
+ this.item.postData = aPostData;
+ this.item.annotations = aAnnotations;
+ this.childTransactions = aChildTransactions;
+}
+
+PlacesCreateBookmarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CITXN_doTransaction()
+ {
+ this.item.id = PlacesUtils.bookmarks.insertBookmark(this.item.parentId,
+ this.item.uri,
+ this.item.index,
+ this.item.title);
+ if (this.item.keyword) {
+ PlacesUtils.bookmarks.setKeywordForBookmark(this.item.id,
+ this.item.keyword);
+ if (this.item.postData) {
+ PlacesUtils.setPostDataForBookmark(this.item.id,
+ this.item.postData);
+ }
+ }
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Set the new item id into child transactions.
+ for (let i = 0; i < this.childTransactions.length; ++i) {
+ this.childTransactions[i].item.id = this.item.id;
+ }
+ let txn = new PlacesAggregatedTransaction("Create item childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ },
+
+ undoTransaction: function CITXN_undoTransaction()
+ {
+ if (this.childTransactions && this.childTransactions.length > 0) {
+ // Undo transactions should always be done in reverse order.
+ let txn = new PlacesAggregatedTransaction("Create item childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+
+ // Remove item only after all child transactions have been reverted.
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new separator.
+ *
+ * @param aParentId
+ * the id of the folder in which the separator should be added
+ * @param [optional] aIndex
+ * the index of the item in aParentId
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateSeparatorTransaction =
+ function PlacesCreateSeparatorTransaction(aParentId, aIndex)
+{
+ this.item = new TransactionItemCache();
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+}
+
+PlacesCreateSeparatorTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CSTXN_doTransaction()
+ {
+ this.item.id =
+ PlacesUtils.bookmarks.insertSeparator(this.item.parentId, this.item.index);
+ },
+
+ undoTransaction: function CSTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }
+};
+
+
+/**
+ * Transaction for creating a new livemark item.
+ *
+ * @see mozIAsyncLivemarks for documentation regarding the arguments.
+ *
+ * @param aFeedURI
+ * nsIURI of the feed
+ * @param [optional] aSiteURI
+ * nsIURI of the page serving the feed
+ * @param aTitle
+ * title for the livemark
+ * @param aParentId
+ * the id of the folder in which the livemark should be added
+ * @param [optional] aIndex
+ * the index of the livemark in aParentId
+ * @param [optional] aAnnotations
+ * array of annotations to set for the new livemark.
+ *
+ * @return nsITransaction object
+ */
+this.PlacesCreateLivemarkTransaction =
+ function PlacesCreateLivemarkTransaction(aFeedURI, aSiteURI, aTitle, aParentId,
+ aIndex, aAnnotations)
+{
+ this.item = new TransactionItemCache();
+ this.item.feedURI = aFeedURI;
+ this.item.siteURI = aSiteURI;
+ this.item.title = aTitle;
+ this.item.parentId = aParentId;
+ this.item.index = aIndex;
+ this.item.annotations = aAnnotations;
+}
+
+PlacesCreateLivemarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function CLTXN_doTransaction()
+ {
+ this._promise = PlacesUtils.livemarks.addLivemark(
+ { title: this.item.title
+ , feedURI: this.item.feedURI
+ , parentId: this.item.parentId
+ , index: this.item.index
+ , siteURI: this.item.siteURI
+ }).then(aLivemark => {
+ this.item.id = aLivemark.id;
+ if (this.item.annotations && this.item.annotations.length > 0) {
+ PlacesUtils.setAnnotationsForItem(this.item.id,
+ this.item.annotations);
+ }
+ }, Cu.reportError);
+ },
+
+ undoTransaction: function CLTXN_undoTransaction()
+ {
+ // The getLivemark callback may fail, but it is used just to serialize,
+ // so it doesn't matter.
+ this._promise = PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(null, null).then( () => {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ });
+ }
+};
+
+
+/**
+ * Transaction for removing a livemark item.
+ *
+ * @param aLivemarkId
+ * the identifier of the folder for the livemark.
+ *
+ * @return nsITransaction object
+ * @note used internally by PlacesRemoveItemTransaction, DO NOT EXPORT.
+ */
+function PlacesRemoveLivemarkTransaction(aLivemarkId)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aLivemarkId;
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+
+ let annos = PlacesUtils.getAnnotationsForItem(this.item.id);
+ // Exclude livemark service annotations, those will be recreated automatically
+ let annosToExclude = [PlacesUtils.LMANNO_FEEDURI,
+ PlacesUtils.LMANNO_SITEURI];
+ this.item.annotations = annos.filter(function(aValue, aIndex, aArray) {
+ return !annosToExclude.includes(aValue.name);
+ });
+ this.item.dateAdded = PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+}
+
+PlacesRemoveLivemarkTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function RLTXN_doTransaction()
+ {
+ PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(aLivemark => {
+ this.item.feedURI = aLivemark.feedURI;
+ this.item.siteURI = aLivemark.siteURI;
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ }, Cu.reportError);
+ },
+
+ undoTransaction: function RLTXN_undoTransaction()
+ {
+ // Undo work must be serialized, otherwise won't be able to know the
+ // feedURI and siteURI of the livemark.
+ // The getLivemark callback is expected to receive a failure status but it
+ // is used just to serialize, so doesn't matter.
+ PlacesUtils.livemarks.getLivemark({ id: this.item.id })
+ .then(null, () => {
+ PlacesUtils.livemarks.addLivemark({ parentId: this.item.parentId
+ , title: this.item.title
+ , siteURI: this.item.siteURI
+ , feedURI: this.item.feedURI
+ , index: this.item.index
+ , lastModified: this.item.lastModified
+ }).then(
+ aLivemark => {
+ let itemId = aLivemark.id;
+ PlacesUtils.bookmarks.setItemDateAdded(itemId, this.item.dateAdded);
+ PlacesUtils.setAnnotationsForItem(itemId, this.item.annotations);
+ }, Cu.reportError);
+ });
+ }
+};
+
+
+/**
+ * Transaction for moving an Item.
+ *
+ * @param aItemId
+ * the id of the item to move
+ * @param aNewParentId
+ * id of the new parent to move to
+ * @param aNewIndex
+ * index of the new position to move to
+ *
+ * @return nsITransaction object
+ */
+this.PlacesMoveItemTransaction =
+ function PlacesMoveItemTransaction(aItemId, aNewParentId, aNewIndex)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+ this.new = new TransactionItemCache();
+ this.new.parentId = aNewParentId;
+ this.new.index = aNewIndex;
+}
+
+PlacesMoveItemTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function MITXN_doTransaction()
+ {
+ this.item.index = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+ PlacesUtils.bookmarks.moveItem(this.item.id,
+ this.new.parentId, this.new.index);
+ this._undoIndex = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+ },
+
+ undoTransaction: function MITXN_undoTransaction()
+ {
+ // moving down in the same parent takes in count removal of the item
+ // so to revert positions we must move to oldIndex + 1
+ if (this.new.parentId == this.item.parentId &&
+ this.item.index > this._undoIndex) {
+ PlacesUtils.bookmarks.moveItem(this.item.id, this.item.parentId,
+ this.item.index + 1);
+ }
+ else {
+ PlacesUtils.bookmarks.moveItem(this.item.id, this.item.parentId,
+ this.item.index);
+ }
+ }
+};
+
+
+/**
+ * Transaction for removing an Item
+ *
+ * @param aItemId
+ * id of the item to remove
+ *
+ * @return nsITransaction object
+ */
+this.PlacesRemoveItemTransaction =
+ function PlacesRemoveItemTransaction(aItemId)
+{
+ if (PlacesUtils.isRootItem(aItemId))
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ // if the item lives within a tag container, use the tagging transactions
+ let parent = PlacesUtils.bookmarks.getFolderIdForItem(aItemId);
+ let grandparent = PlacesUtils.bookmarks.getFolderIdForItem(parent);
+ if (grandparent == PlacesUtils.tagsFolderId) {
+ let uri = PlacesUtils.bookmarks.getBookmarkURI(aItemId);
+ return new PlacesUntagURITransaction(uri, [parent]);
+ }
+
+ // if the item is a livemark container we will not save its children.
+ if (PlacesUtils.annotations.itemHasAnnotation(aItemId,
+ PlacesUtils.LMANNO_FEEDURI))
+ return new PlacesRemoveLivemarkTransaction(aItemId);
+
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.itemType = PlacesUtils.bookmarks.getItemType(this.item.id);
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ this.childTransactions = this._getFolderContentsTransactions();
+ // Remove this folder itself.
+ let txn = PlacesUtils.bookmarks.getRemoveFolderTransaction(this.item.id);
+ this.childTransactions.push(txn);
+ }
+ else if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
+ this.item.uri = PlacesUtils.bookmarks.getBookmarkURI(this.item.id);
+ this.item.keyword =
+ PlacesUtils.bookmarks.getKeywordForBookmark(this.item.id);
+ if (this.item.keyword)
+ this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
+ }
+
+ if (this.item.itemType != Ci.nsINavBookmarksService.TYPE_SEPARATOR)
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+
+ this.item.parentId = PlacesUtils.bookmarks.getFolderIdForItem(this.item.id);
+ this.item.annotations = PlacesUtils.getAnnotationsForItem(this.item.id);
+ this.item.dateAdded = PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+}
+
+PlacesRemoveItemTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function RITXN_doTransaction()
+ {
+ this.item.index = PlacesUtils.bookmarks.getItemIndex(this.item.id);
+
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ let txn = new PlacesAggregatedTransaction("Remove item childTxn",
+ this.childTransactions);
+ txn.doTransaction();
+ }
+ else {
+ // Before removing the bookmark, save its tags.
+ let tags = this.item.uri ?
+ PlacesUtils.tagging.getTagsForURI(this.item.uri) : null;
+
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+
+ // If this was the last bookmark (excluding tag-items) for this url,
+ // persist the tags.
+ if (tags && PlacesUtils.getMostRecentBookmarkForURI(this.item.uri) == -1) {
+ this.item.tags = tags;
+ }
+ }
+ },
+
+ undoTransaction: function RITXN_undoTransaction()
+ {
+ if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) {
+ this.item.id = PlacesUtils.bookmarks.insertBookmark(this.item.parentId,
+ this.item.uri,
+ this.item.index,
+ this.item.title);
+ if (this.item.tags && this.item.tags.length > 0)
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ if (this.item.keyword) {
+ PlacesUtils.bookmarks.setKeywordForBookmark(this.item.id,
+ this.item.keyword);
+ if (this.item.postData) {
+ PlacesUtils.bookmarks.setPostDataForBookmark(this.item.id);
+ }
+ }
+ }
+ else if (this.item.itemType == Ci.nsINavBookmarksService.TYPE_FOLDER) {
+ let txn = new PlacesAggregatedTransaction("Remove item childTxn",
+ this.childTransactions);
+ txn.undoTransaction();
+ }
+ else { // TYPE_SEPARATOR
+ this.item.id = PlacesUtils.bookmarks.insertSeparator(this.item.parentId,
+ this.item.index);
+ }
+
+ if (this.item.annotations && this.item.annotations.length > 0)
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.item.dateAdded);
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.item.lastModified);
+ },
+
+ /**
+ * Returns a flat, ordered list of transactions for a depth-first recreation
+ * of items within this folder.
+ */
+ _getFolderContentsTransactions:
+ function RITXN__getFolderContentsTransactions()
+ {
+ let transactions = [];
+ let contents =
+ PlacesUtils.getFolderContents(this.item.id, false, false).root;
+ for (let i = 0; i < contents.childCount; ++i) {
+ let txn = new PlacesRemoveItemTransaction(contents.getChild(i).itemId);
+ transactions.push(txn);
+ }
+ contents.containerOpen = false;
+ // Reverse transactions to preserve parent-child relationship.
+ return transactions.reverse();
+ }
+};
+
+
+/**
+ * Transaction for editting a bookmark's title.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewTitle
+ * new title for the item to edit
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemTitleTransaction =
+ function PlacesEditItemTitleTransaction(aItemId, aNewTitle)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.title = aNewTitle;
+}
+
+PlacesEditItemTitleTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EITTXN_doTransaction()
+ {
+ this.item.title = PlacesUtils.bookmarks.getItemTitle(this.item.id);
+ PlacesUtils.bookmarks.setItemTitle(this.item.id, this.new.title);
+ },
+
+ undoTransaction: function EITTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemTitle(this.item.id, this.item.title);
+ }
+};
+
+
+/**
+ * Transaction for editing a bookmark's uri.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aNewURI
+ * new uri for the bookmark
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkURITransaction =
+ function PlacesEditBookmarkURITransaction(aItemId, aNewURI) {
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.uri = aNewURI;
+}
+
+PlacesEditBookmarkURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EBUTXN_doTransaction()
+ {
+ this.item.uri = PlacesUtils.bookmarks.getBookmarkURI(this.item.id);
+ PlacesUtils.bookmarks.changeBookmarkURI(this.item.id, this.new.uri);
+ // move tags from old URI to new URI
+ this.item.tags = PlacesUtils.tagging.getTagsForURI(this.item.uri);
+ if (this.item.tags.length > 0) {
+ // only untag the old URI if this is the only bookmark
+ if (PlacesUtils.getBookmarksForURI(this.item.uri, {}).length == 0)
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ PlacesUtils.tagging.tagURI(this.new.uri, this.item.tags);
+ }
+ },
+
+ undoTransaction: function EBUTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.changeBookmarkURI(this.item.id, this.item.uri);
+ // move tags from new URI to old URI
+ if (this.item.tags.length > 0) {
+ // only untag the new URI if this is the only bookmark
+ if (PlacesUtils.getBookmarksForURI(this.new.uri, {}).length == 0)
+ PlacesUtils.tagging.untagURI(this.new.uri, this.item.tags);
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ }
+ }
+};
+
+
+/**
+ * Transaction for setting/unsetting an item annotation
+ *
+ * @param aItemId
+ * id of the item where to set annotation
+ * @param aAnnotationObject
+ * Object representing an annotation, containing the following
+ * properties: name, flags, expires, value.
+ * If value is null the annotation will be removed
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSetItemAnnotationTransaction =
+ function PlacesSetItemAnnotationTransaction(aItemId, aAnnotationObject)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.annotations = [aAnnotationObject];
+}
+
+PlacesSetItemAnnotationTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SIATXN_doTransaction()
+ {
+ let annoName = this.new.annotations[0].name;
+ if (PlacesUtils.annotations.itemHasAnnotation(this.item.id, annoName)) {
+ // fill the old anno if it is set
+ let flags = {}, expires = {}, type = {};
+ PlacesUtils.annotations.getItemAnnotationInfo(this.item.id, annoName, flags,
+ expires, type);
+ let value = PlacesUtils.annotations.getItemAnnotation(this.item.id,
+ annoName);
+ this.item.annotations = [{ name: annoName,
+ type: type.value,
+ flags: flags.value,
+ value: value,
+ expires: expires.value }];
+ }
+ else {
+ // create an empty old anno
+ this.item.annotations = [{ name: annoName,
+ flags: 0,
+ value: null,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER }];
+ }
+
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.new.annotations);
+ },
+
+ undoTransaction: function SIATXN_undoTransaction()
+ {
+ PlacesUtils.setAnnotationsForItem(this.item.id, this.item.annotations);
+ }
+};
+
+
+/**
+ * Transaction for setting/unsetting a page annotation
+ *
+ * @param aURI
+ * URI of the page where to set annotation
+ * @param aAnnotationObject
+ * Object representing an annotation, containing the following
+ * properties: name, flags, expires, value.
+ * If value is null the annotation will be removed
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSetPageAnnotationTransaction =
+ function PlacesSetPageAnnotationTransaction(aURI, aAnnotationObject)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.new = new TransactionItemCache();
+ this.new.annotations = [aAnnotationObject];
+}
+
+PlacesSetPageAnnotationTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SPATXN_doTransaction()
+ {
+ let annoName = this.new.annotations[0].name;
+ if (PlacesUtils.annotations.pageHasAnnotation(this.item.uri, annoName)) {
+ // fill the old anno if it is set
+ let flags = {}, expires = {}, type = {};
+ PlacesUtils.annotations.getPageAnnotationInfo(this.item.uri, annoName, flags,
+ expires, type);
+ let value = PlacesUtils.annotations.getPageAnnotation(this.item.uri,
+ annoName);
+ this.item.annotations = [{ name: annoName,
+ flags: flags.value,
+ value: value,
+ expires: expires.value }];
+ }
+ else {
+ // create an empty old anno
+ this.item.annotations = [{ name: annoName,
+ type: Ci.nsIAnnotationService.TYPE_STRING,
+ flags: 0,
+ value: null,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER }];
+ }
+
+ PlacesUtils.setAnnotationsForURI(this.item.uri, this.new.annotations);
+ },
+
+ undoTransaction: function SPATXN_undoTransaction()
+ {
+ PlacesUtils.setAnnotationsForURI(this.item.uri, this.item.annotations);
+ }
+};
+
+
+/**
+ * Transaction for editing a bookmark's keyword.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aNewKeyword
+ * new keyword for the bookmark
+ * @param aNewPostData [optional]
+ * new keyword's POST data, if available
+ * @param aOldKeyword [optional]
+ * old keyword of the bookmark
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkKeywordTransaction =
+ function PlacesEditBookmarkKeywordTransaction(aItemId, aNewKeyword,
+ aNewPostData, aOldKeyword) {
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.item.keyword = aOldKeyword;
+ this.item.href = (PlacesUtils.bookmarks.getBookmarkURI(aItemId)).spec;
+ this.new = new TransactionItemCache();
+ this.new.keyword = aNewKeyword;
+ this.new.postData = aNewPostData
+}
+
+PlacesEditBookmarkKeywordTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EBKTXN_doTransaction()
+ {
+ let done = false;
+ Task.spawn(function* () {
+ if (this.item.keyword) {
+ let oldEntry = yield PlacesUtils.keywords.fetch(this.item.keyword);
+ this.item.postData = oldEntry.postData;
+ yield PlacesUtils.keywords.remove(this.item.keyword);
+ }
+
+ if (this.new.keyword) {
+ yield PlacesUtils.keywords.insert({
+ url: this.item.href,
+ keyword: this.new.keyword,
+ postData: this.new.postData || this.item.postData
+ });
+ }
+ }.bind(this)).catch(Cu.reportError)
+ .then(() => done = true);
+ // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
+ // events loop :(
+ let thread = Services.tm.currentThread;
+ while (!done) {
+ thread.processNextEvent(true);
+ }
+ },
+
+ undoTransaction: function EBKTXN_undoTransaction()
+ {
+
+ let done = false;
+ Task.spawn(function* () {
+ if (this.new.keyword) {
+ yield PlacesUtils.keywords.remove(this.new.keyword);
+ }
+
+ if (this.item.keyword) {
+ yield PlacesUtils.keywords.insert({
+ url: this.item.href,
+ keyword: this.item.keyword,
+ postData: this.item.postData
+ });
+ }
+ }.bind(this)).catch(Cu.reportError)
+ .then(() => done = true);
+ // TODO: Until we can move to PlacesTransactions.jsm, we must spin the
+ // events loop :(
+ let thread = Services.tm.currentThread;
+ while (!done) {
+ thread.processNextEvent(true);
+ }
+ }
+};
+
+
+/**
+ * Transaction for editing the post data associated with a bookmark.
+ *
+ * @param aItemId
+ * id of the bookmark to edit
+ * @param aPostData
+ * post data
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditBookmarkPostDataTransaction =
+ function PlacesEditBookmarkPostDataTransaction(aItemId, aPostData)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.postData = aPostData;
+}
+
+PlacesEditBookmarkPostDataTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction() {
+ // Setting null postData is not supported by the current schema.
+ if (this.new.postData) {
+ this.item.postData = PlacesUtils.getPostDataForBookmark(this.item.id);
+ PlacesUtils.setPostDataForBookmark(this.item.id, this.new.postData);
+ }
+ },
+
+ undoTransaction() {
+ // Setting null postData is not supported by the current schema.
+ if (this.item.postData) {
+ PlacesUtils.setPostDataForBookmark(this.item.id, this.item.postData);
+ }
+ }
+};
+
+
+/**
+ * Transaction for editing an item's date added property.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewDateAdded
+ * new date added for the item
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemDateAddedTransaction =
+ function PlacesEditItemDateAddedTransaction(aItemId, aNewDateAdded)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.dateAdded = aNewDateAdded;
+}
+
+PlacesEditItemDateAddedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function EIDATXN_doTransaction()
+ {
+ // Child transactions have the id set as parentId.
+ if (this.item.id == -1 && this.item.parentId != -1)
+ this.item.id = this.item.parentId;
+ this.item.dateAdded =
+ PlacesUtils.bookmarks.getItemDateAdded(this.item.id);
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.new.dateAdded);
+ },
+
+ undoTransaction: function EIDATXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemDateAdded(this.item.id, this.item.dateAdded);
+ }
+};
+
+
+/**
+ * Transaction for editing an item's last modified time.
+ *
+ * @param aItemId
+ * id of the item to edit
+ * @param aNewLastModified
+ * new last modified date for the item
+ *
+ * @return nsITransaction object
+ */
+this.PlacesEditItemLastModifiedTransaction =
+ function PlacesEditItemLastModifiedTransaction(aItemId, aNewLastModified)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aItemId;
+ this.new = new TransactionItemCache();
+ this.new.lastModified = aNewLastModified;
+}
+
+PlacesEditItemLastModifiedTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction:
+ function EILMTXN_doTransaction()
+ {
+ // Child transactions have the id set as parentId.
+ if (this.item.id == -1 && this.item.parentId != -1)
+ this.item.id = this.item.parentId;
+ this.item.lastModified =
+ PlacesUtils.bookmarks.getItemLastModified(this.item.id);
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.new.lastModified);
+ },
+
+ undoTransaction:
+ function EILMTXN_undoTransaction()
+ {
+ PlacesUtils.bookmarks.setItemLastModified(this.item.id,
+ this.item.lastModified);
+ }
+};
+
+
+/**
+ * Transaction for sorting a folder by name
+ *
+ * @param aFolderId
+ * id of the folder to sort
+ *
+ * @return nsITransaction object
+ */
+this.PlacesSortFolderByNameTransaction =
+ function PlacesSortFolderByNameTransaction(aFolderId)
+{
+ this.item = new TransactionItemCache();
+ this.item.id = aFolderId;
+}
+
+PlacesSortFolderByNameTransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function SFBNTXN_doTransaction()
+ {
+ this._oldOrder = [];
+
+ let contents =
+ PlacesUtils.getFolderContents(this.item.id, false, false).root;
+ let count = contents.childCount;
+
+ // sort between separators
+ let newOrder = [];
+ let preSep = []; // temporary array for sorting each group of items
+ let sortingMethod =
+ function (a, b) {
+ if (PlacesUtils.nodeIsContainer(a) && !PlacesUtils.nodeIsContainer(b))
+ return -1;
+ if (!PlacesUtils.nodeIsContainer(a) && PlacesUtils.nodeIsContainer(b))
+ return 1;
+ return a.title.localeCompare(b.title);
+ };
+
+ for (let i = 0; i < count; ++i) {
+ let item = contents.getChild(i);
+ this._oldOrder[item.itemId] = i;
+ if (PlacesUtils.nodeIsSeparator(item)) {
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ preSep.splice(0, preSep.length);
+ }
+ newOrder.push(item);
+ }
+ else
+ preSep.push(item);
+ }
+ contents.containerOpen = false;
+
+ if (preSep.length > 0) {
+ preSep.sort(sortingMethod);
+ newOrder = newOrder.concat(preSep);
+ }
+
+ // set the nex indexes
+ let callback = {
+ runBatched: function() {
+ for (let i = 0; i < newOrder.length; ++i) {
+ PlacesUtils.bookmarks.setItemIndex(newOrder[i].itemId, i);
+ }
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+ },
+
+ undoTransaction: function SFBNTXN_undoTransaction()
+ {
+ let callback = {
+ _self: this,
+ runBatched: function() {
+ for (let item in this._self._oldOrder)
+ PlacesUtils.bookmarks.setItemIndex(item, this._self._oldOrder[item]);
+ }
+ };
+ PlacesUtils.bookmarks.runInBatchMode(callback, null);
+ }
+};
+
+
+/**
+ * Transaction for tagging a URL with the given set of tags. Current tags set
+ * for the URL persist. It's the caller's job to check whether or not aURI
+ * was already tagged by any of the tags in aTags, undoing this tags
+ * transaction removes them all from aURL!
+ *
+ * @param aURI
+ * the URL to tag.
+ * @param aTags
+ * Array of tags to set for the given URL.
+ */
+this.PlacesTagURITransaction =
+ function PlacesTagURITransaction(aURI, aTags)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ this.item.tags = aTags;
+}
+
+PlacesTagURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function TUTXN_doTransaction()
+ {
+ if (PlacesUtils.getMostRecentBookmarkForURI(this.item.uri) == -1) {
+ // There is no bookmark for this uri, but we only allow to tag bookmarks.
+ // Force an unfiled bookmark first.
+ this.item.id =
+ PlacesUtils.bookmarks
+ .insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ this.item.uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ PlacesUtils.history.getPageTitle(this.item.uri));
+ }
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ },
+
+ undoTransaction: function TUTXN_undoTransaction()
+ {
+ if (this.item.id != -1) {
+ PlacesUtils.bookmarks.removeItem(this.item.id);
+ this.item.id = -1;
+ }
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ }
+};
+
+
+/**
+ * Transaction for removing tags from a URL. It's the caller's job to check
+ * whether or not aURI isn't tagged by any of the tags in aTags, undoing this
+ * tags transaction adds them all to aURL!
+ *
+ * @param aURI
+ * the URL to un-tag.
+ * @param aTags
+ * Array of tags to unset. pass null to remove all tags from the given
+ * url.
+ */
+this.PlacesUntagURITransaction =
+ function PlacesUntagURITransaction(aURI, aTags)
+{
+ this.item = new TransactionItemCache();
+ this.item.uri = aURI;
+ if (aTags) {
+ // Within this transaction, we cannot rely on tags given by itemId
+ // since the tag containers may be gone after we call untagURI.
+ // Thus, we convert each tag given by its itemId to name.
+ let tags = [];
+ for (let i = 0; i < aTags.length; ++i) {
+ if (typeof(aTags[i]) == "number")
+ tags.push(PlacesUtils.bookmarks.getItemTitle(aTags[i]));
+ else
+ tags.push(aTags[i]);
+ }
+ this.item.tags = tags;
+ }
+}
+
+PlacesUntagURITransaction.prototype = {
+ __proto__: BaseTransaction.prototype,
+
+ doTransaction: function UTUTXN_doTransaction()
+ {
+ // Filter tags existing on the bookmark, otherwise on undo we may try to
+ // set nonexistent tags.
+ let tags = PlacesUtils.tagging.getTagsForURI(this.item.uri);
+ this.item.tags = this.item.tags.filter(function (aTag) {
+ return tags.includes(aTag);
+ });
+ PlacesUtils.tagging.untagURI(this.item.uri, this.item.tags);
+ },
+
+ undoTransaction: function UTUTXN_undoTransaction()
+ {
+ PlacesUtils.tagging.tagURI(this.item.uri, this.item.tags);
+ }
+};
+
+/**
+ * Executes a boolean validate function, throwing if it returns false.
+ *
+ * @param boolValidateFn
+ * A boolean validate function.
+ * @return the input value.
+ * @throws if input doesn't pass the validate function.
+ */
+function simpleValidateFunc(boolValidateFn) {
+ return (v, input) => {
+ if (!boolValidateFn(v, input))
+ throw new Error("Invalid value");
+ return v;
+ };
+}
diff --git a/toolkit/components/places/SQLFunctions.cpp b/toolkit/components/places/SQLFunctions.cpp
new file mode 100644
index 000000000..e3cc7d7f0
--- /dev/null
+++ b/toolkit/components/places/SQLFunctions.cpp
@@ -0,0 +1,941 @@
+/* vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/storage.h"
+#include "nsString.h"
+#include "nsUnicharUtils.h"
+#include "nsWhitespaceTokenizer.h"
+#include "nsEscape.h"
+#include "mozIPlacesAutoComplete.h"
+#include "SQLFunctions.h"
+#include "nsMathUtils.h"
+#include "nsUTF8Utils.h"
+#include "nsINavHistoryService.h"
+#include "nsPrintfCString.h"
+#include "nsNavHistory.h"
+#include "mozilla/Likely.h"
+#include "nsVariant.h"
+#include "mozilla/HashFunctions.h"
+
+// Maximum number of chars to search through.
+// MatchAutoCompleteFunction won't look for matches over this threshold.
+#define MAX_CHARS_TO_SEARCH_THROUGH 255
+
+using namespace mozilla::storage;
+
+// Keep the GUID-related parts of this file in sync with toolkit/downloads/SQLFunctions.cpp!
+
+////////////////////////////////////////////////////////////////////////////////
+//// Anonymous Helpers
+
+namespace {
+
+ typedef nsACString::const_char_iterator const_char_iterator;
+
+ /**
+ * Get a pointer to the word boundary after aStart if aStart points to an
+ * ASCII letter (i.e. [a-zA-Z]). Otherwise, return aNext, which we assume
+ * points to the next character in the UTF-8 sequence.
+ *
+ * We define a word boundary as anything that's not [a-z] -- this lets us
+ * match CamelCase words.
+ *
+ * @param aStart the beginning of the UTF-8 sequence
+ * @param aNext the next character in the sequence
+ * @param aEnd the first byte which is not part of the sequence
+ *
+ * @return a pointer to the next word boundary after aStart
+ */
+ static
+ MOZ_ALWAYS_INLINE const_char_iterator
+ nextWordBoundary(const_char_iterator const aStart,
+ const_char_iterator const aNext,
+ const_char_iterator const aEnd) {
+
+ const_char_iterator cur = aStart;
+ if (('a' <= *cur && *cur <= 'z') ||
+ ('A' <= *cur && *cur <= 'Z')) {
+
+ // Since we'll halt as soon as we see a non-ASCII letter, we can do a
+ // simple byte-by-byte comparison here and avoid the overhead of a
+ // UTF8CharEnumerator.
+ do {
+ cur++;
+ } while (cur < aEnd && 'a' <= *cur && *cur <= 'z');
+ }
+ else {
+ cur = aNext;
+ }
+
+ return cur;
+ }
+
+ enum FindInStringBehavior {
+ eFindOnBoundary,
+ eFindAnywhere
+ };
+
+ /**
+ * findAnywhere and findOnBoundary do almost the same thing, so it's natural
+ * to implement them in terms of a single function. They're both
+ * performance-critical functions, however, and checking aBehavior makes them
+ * a bit slower. Our solution is to define findInString as MOZ_ALWAYS_INLINE
+ * and rely on the compiler to optimize out the aBehavior check.
+ *
+ * @param aToken
+ * The token we're searching for
+ * @param aSourceString
+ * The string in which we're searching
+ * @param aBehavior
+ * eFindOnBoundary if we should only consider matchines which occur on
+ * word boundaries, or eFindAnywhere if we should consider matches
+ * which appear anywhere.
+ *
+ * @return true if aToken was found in aSourceString, false otherwise.
+ */
+ static
+ MOZ_ALWAYS_INLINE bool
+ findInString(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString,
+ FindInStringBehavior aBehavior)
+ {
+ // CaseInsensitiveUTF8CharsEqual assumes that there's at least one byte in
+ // the both strings, so don't pass an empty token here.
+ NS_PRECONDITION(!aToken.IsEmpty(), "Don't search for an empty token!");
+
+ // We cannot match anything if there is nothing to search.
+ if (aSourceString.IsEmpty()) {
+ return false;
+ }
+
+ const_char_iterator tokenStart(aToken.BeginReading()),
+ tokenEnd(aToken.EndReading()),
+ sourceStart(aSourceString.BeginReading()),
+ sourceEnd(aSourceString.EndReading());
+
+ do {
+ // We are on a word boundary (if aBehavior == eFindOnBoundary). See if
+ // aToken matches sourceStart.
+
+ // Check whether the first character in the token matches the character
+ // at sourceStart. At the same time, get a pointer to the next character
+ // in both the token and the source.
+ const_char_iterator sourceNext, tokenCur;
+ bool error;
+ if (CaseInsensitiveUTF8CharsEqual(sourceStart, tokenStart,
+ sourceEnd, tokenEnd,
+ &sourceNext, &tokenCur, &error)) {
+
+ // We don't need to check |error| here -- if
+ // CaseInsensitiveUTF8CharCompare encounters an error, it'll also
+ // return false and we'll catch the error outside the if.
+
+ const_char_iterator sourceCur = sourceNext;
+ while (true) {
+ if (tokenCur >= tokenEnd) {
+ // We matched the whole token!
+ return true;
+ }
+
+ if (sourceCur >= sourceEnd) {
+ // We ran into the end of source while matching a token. This
+ // means we'll never find the token we're looking for.
+ return false;
+ }
+
+ if (!CaseInsensitiveUTF8CharsEqual(sourceCur, tokenCur,
+ sourceEnd, tokenEnd,
+ &sourceCur, &tokenCur, &error)) {
+ // sourceCur doesn't match tokenCur (or there's an error), so break
+ // out of this loop.
+ break;
+ }
+ }
+ }
+
+ // If something went wrong above, get out of here!
+ if (MOZ_UNLIKELY(error)) {
+ return false;
+ }
+
+ // We didn't match the token. If we're searching for matches on word
+ // boundaries, skip to the next word boundary. Otherwise, advance
+ // forward one character, using the sourceNext pointer we saved earlier.
+
+ if (aBehavior == eFindOnBoundary) {
+ sourceStart = nextWordBoundary(sourceStart, sourceNext, sourceEnd);
+ }
+ else {
+ sourceStart = sourceNext;
+ }
+
+ } while (sourceStart < sourceEnd);
+
+ return false;
+ }
+
+ static
+ MOZ_ALWAYS_INLINE nsDependentCString
+ getSharedString(mozIStorageValueArray* aValues, uint32_t aIndex) {
+ uint32_t len;
+ const char* str = aValues->AsSharedUTF8String(aIndex, &len);
+ if (!str) {
+ return nsDependentCString("", (uint32_t)0);
+ }
+ return nsDependentCString(str, len);
+ }
+
+} // End anonymous namespace
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// AutoComplete Matching Function
+
+ /* static */
+ nsresult
+ MatchAutoCompleteFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<MatchAutoCompleteFunction> function =
+ new MatchAutoCompleteFunction();
+
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("autocomplete_match"), kArgIndexLength, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ /* static */
+ nsDependentCSubstring
+ MatchAutoCompleteFunction::fixupURISpec(const nsACString &aURISpec,
+ int32_t aMatchBehavior,
+ nsACString &aSpecBuf)
+ {
+ nsDependentCSubstring fixedSpec;
+
+ // Try to unescape the string. If that succeeds and yields a different
+ // string which is also valid UTF-8, we'll use it.
+ // Otherwise, we will simply use our original string.
+ bool unescaped = NS_UnescapeURL(aURISpec.BeginReading(),
+ aURISpec.Length(), esc_SkipControl, aSpecBuf);
+ if (unescaped && IsUTF8(aSpecBuf)) {
+ fixedSpec.Rebind(aSpecBuf, 0);
+ } else {
+ fixedSpec.Rebind(aURISpec, 0);
+ }
+
+ if (aMatchBehavior == mozIPlacesAutoComplete::MATCH_ANYWHERE_UNMODIFIED)
+ return fixedSpec;
+
+ if (StringBeginsWith(fixedSpec, NS_LITERAL_CSTRING("http://"))) {
+ fixedSpec.Rebind(fixedSpec, 7);
+ } else if (StringBeginsWith(fixedSpec, NS_LITERAL_CSTRING("https://"))) {
+ fixedSpec.Rebind(fixedSpec, 8);
+ } else if (StringBeginsWith(fixedSpec, NS_LITERAL_CSTRING("ftp://"))) {
+ fixedSpec.Rebind(fixedSpec, 6);
+ }
+
+ if (StringBeginsWith(fixedSpec, NS_LITERAL_CSTRING("www."))) {
+ fixedSpec.Rebind(fixedSpec, 4);
+ }
+
+ return fixedSpec;
+ }
+
+ /* static */
+ bool
+ MatchAutoCompleteFunction::findAnywhere(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString)
+ {
+ // We can't use FindInReadable here; it works only for ASCII.
+
+ return findInString(aToken, aSourceString, eFindAnywhere);
+ }
+
+ /* static */
+ bool
+ MatchAutoCompleteFunction::findOnBoundary(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString)
+ {
+ return findInString(aToken, aSourceString, eFindOnBoundary);
+ }
+
+ /* static */
+ bool
+ MatchAutoCompleteFunction::findBeginning(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString)
+ {
+ NS_PRECONDITION(!aToken.IsEmpty(), "Don't search for an empty token!");
+
+ // We can't use StringBeginsWith here, unfortunately. Although it will
+ // happily take a case-insensitive UTF8 comparator, it eventually calls
+ // nsACString::Equals, which checks that the two strings contain the same
+ // number of bytes before calling the comparator. Two characters may be
+ // case-insensitively equal while taking up different numbers of bytes, so
+ // this is not what we want.
+
+ const_char_iterator tokenStart(aToken.BeginReading()),
+ tokenEnd(aToken.EndReading()),
+ sourceStart(aSourceString.BeginReading()),
+ sourceEnd(aSourceString.EndReading());
+
+ bool dummy;
+ while (sourceStart < sourceEnd &&
+ CaseInsensitiveUTF8CharsEqual(sourceStart, tokenStart,
+ sourceEnd, tokenEnd,
+ &sourceStart, &tokenStart, &dummy)) {
+
+ // We found the token!
+ if (tokenStart >= tokenEnd) {
+ return true;
+ }
+ }
+
+ // We don't need to check CaseInsensitiveUTF8CharsEqual's error condition
+ // (stored in |dummy|), since the function will return false if it
+ // encounters an error.
+
+ return false;
+ }
+
+ /* static */
+ bool
+ MatchAutoCompleteFunction::findBeginningCaseSensitive(
+ const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString)
+ {
+ NS_PRECONDITION(!aToken.IsEmpty(), "Don't search for an empty token!");
+
+ return StringBeginsWith(aSourceString, aToken);
+ }
+
+ /* static */
+ MatchAutoCompleteFunction::searchFunctionPtr
+ MatchAutoCompleteFunction::getSearchFunction(int32_t aBehavior)
+ {
+ switch (aBehavior) {
+ case mozIPlacesAutoComplete::MATCH_ANYWHERE:
+ case mozIPlacesAutoComplete::MATCH_ANYWHERE_UNMODIFIED:
+ return findAnywhere;
+ case mozIPlacesAutoComplete::MATCH_BEGINNING:
+ return findBeginning;
+ case mozIPlacesAutoComplete::MATCH_BEGINNING_CASE_SENSITIVE:
+ return findBeginningCaseSensitive;
+ case mozIPlacesAutoComplete::MATCH_BOUNDARY:
+ default:
+ return findOnBoundary;
+ };
+ }
+
+ NS_IMPL_ISUPPORTS(
+ MatchAutoCompleteFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ MatchAutoCompleteFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ // Macro to make the code a bit cleaner and easier to read. Operates on
+ // searchBehavior.
+ int32_t searchBehavior = aArguments->AsInt32(kArgIndexSearchBehavior);
+ #define HAS_BEHAVIOR(aBitName) \
+ (searchBehavior & mozIPlacesAutoComplete::BEHAVIOR_##aBitName)
+
+ nsDependentCString searchString =
+ getSharedString(aArguments, kArgSearchString);
+ nsDependentCString url =
+ getSharedString(aArguments, kArgIndexURL);
+
+ int32_t matchBehavior = aArguments->AsInt32(kArgIndexMatchBehavior);
+
+ // We only want to filter javascript: URLs if we are not supposed to search
+ // for them, and the search does not start with "javascript:".
+ if (matchBehavior != mozIPlacesAutoComplete::MATCH_ANYWHERE_UNMODIFIED &&
+ StringBeginsWith(url, NS_LITERAL_CSTRING("javascript:")) &&
+ !HAS_BEHAVIOR(JAVASCRIPT) &&
+ !StringBeginsWith(searchString, NS_LITERAL_CSTRING("javascript:"))) {
+ NS_ADDREF(*_result = new IntegerVariant(0));
+ return NS_OK;
+ }
+
+ int32_t visitCount = aArguments->AsInt32(kArgIndexVisitCount);
+ bool typed = aArguments->AsInt32(kArgIndexTyped) ? true : false;
+ bool bookmark = aArguments->AsInt32(kArgIndexBookmark) ? true : false;
+ nsDependentCString tags = getSharedString(aArguments, kArgIndexTags);
+ int32_t openPageCount = aArguments->AsInt32(kArgIndexOpenPageCount);
+ bool matches = false;
+ if (HAS_BEHAVIOR(RESTRICT)) {
+ // Make sure we match all the filter requirements. If a given restriction
+ // is active, make sure the corresponding condition is not true.
+ matches = (!HAS_BEHAVIOR(HISTORY) || visitCount > 0) &&
+ (!HAS_BEHAVIOR(TYPED) || typed) &&
+ (!HAS_BEHAVIOR(BOOKMARK) || bookmark) &&
+ (!HAS_BEHAVIOR(TAG) || !tags.IsVoid()) &&
+ (!HAS_BEHAVIOR(OPENPAGE) || openPageCount > 0);
+ } else {
+ // Make sure that we match all the filter requirements and that the
+ // corresponding condition is true if at least a given restriction is active.
+ matches = (HAS_BEHAVIOR(HISTORY) && visitCount > 0) ||
+ (HAS_BEHAVIOR(TYPED) && typed) ||
+ (HAS_BEHAVIOR(BOOKMARK) && bookmark) ||
+ (HAS_BEHAVIOR(TAG) && !tags.IsVoid()) ||
+ (HAS_BEHAVIOR(OPENPAGE) && openPageCount > 0);
+ }
+
+ if (!matches) {
+ NS_ADDREF(*_result = new IntegerVariant(0));
+ return NS_OK;
+ }
+
+ // Obtain our search function.
+ searchFunctionPtr searchFunction = getSearchFunction(matchBehavior);
+
+ // Clean up our URI spec and prepare it for searching.
+ nsCString fixedUrlBuf;
+ nsDependentCSubstring fixedUrl =
+ fixupURISpec(url, matchBehavior, fixedUrlBuf);
+ // Limit the number of chars we search through.
+ const nsDependentCSubstring& trimmedUrl =
+ Substring(fixedUrl, 0, MAX_CHARS_TO_SEARCH_THROUGH);
+
+ nsDependentCString title = getSharedString(aArguments, kArgIndexTitle);
+ // Limit the number of chars we search through.
+ const nsDependentCSubstring& trimmedTitle =
+ Substring(title, 0, MAX_CHARS_TO_SEARCH_THROUGH);
+
+ // Determine if every token matches either the bookmark title, tags, page
+ // title, or page URL.
+ nsCWhitespaceTokenizer tokenizer(searchString);
+ while (matches && tokenizer.hasMoreTokens()) {
+ const nsDependentCSubstring &token = tokenizer.nextToken();
+
+ if (HAS_BEHAVIOR(TITLE) && HAS_BEHAVIOR(URL)) {
+ matches = (searchFunction(token, trimmedTitle) ||
+ searchFunction(token, tags)) &&
+ searchFunction(token, trimmedUrl);
+ }
+ else if (HAS_BEHAVIOR(TITLE)) {
+ matches = searchFunction(token, trimmedTitle) ||
+ searchFunction(token, tags);
+ }
+ else if (HAS_BEHAVIOR(URL)) {
+ matches = searchFunction(token, trimmedUrl);
+ }
+ else {
+ matches = searchFunction(token, trimmedTitle) ||
+ searchFunction(token, tags) ||
+ searchFunction(token, trimmedUrl);
+ }
+ }
+
+ NS_ADDREF(*_result = new IntegerVariant(matches ? 1 : 0));
+ return NS_OK;
+ #undef HAS_BEHAVIOR
+ }
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Frecency Calculation Function
+
+ /* static */
+ nsresult
+ CalculateFrecencyFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<CalculateFrecencyFunction> function =
+ new CalculateFrecencyFunction();
+
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("calculate_frecency"), 1, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ CalculateFrecencyFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ CalculateFrecencyFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ // Fetch arguments. Use default values if they were omitted.
+ uint32_t numEntries;
+ nsresult rv = aArguments->GetNumEntries(&numEntries);
+ NS_ENSURE_SUCCESS(rv, rv);
+ MOZ_ASSERT(numEntries == 1, "unexpected number of arguments");
+
+ int64_t pageId = aArguments->AsInt64(0);
+ MOZ_ASSERT(pageId > 0, "Should always pass a valid page id");
+ if (pageId <= 0) {
+ NS_ADDREF(*_result = new IntegerVariant(0));
+ return NS_OK;
+ }
+
+ int32_t typed = 0;
+ int32_t visitCount = 0;
+ bool hasBookmark = false;
+ int32_t isQuery = 0;
+ float pointsForSampledVisits = 0.0;
+ int32_t numSampledVisits = 0;
+ int32_t bonus = 0;
+
+ // This is a const version of the history object for thread-safety.
+ const nsNavHistory* history = nsNavHistory::GetConstHistoryService();
+ NS_ENSURE_STATE(history);
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+
+
+ // Fetch the page stats from the database.
+ {
+ RefPtr<mozIStorageStatement> getPageInfo = DB->GetStatement(
+ "SELECT typed, visit_count, foreign_count, "
+ "(substr(url, 0, 7) = 'place:') "
+ "FROM moz_places "
+ "WHERE id = :page_id "
+ );
+ NS_ENSURE_STATE(getPageInfo);
+ mozStorageStatementScoper infoScoper(getPageInfo);
+
+ rv = getPageInfo->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), pageId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult = false;
+ rv = getPageInfo->ExecuteStep(&hasResult);
+ NS_ENSURE_TRUE(NS_SUCCEEDED(rv) && hasResult, NS_ERROR_UNEXPECTED);
+
+ rv = getPageInfo->GetInt32(0, &typed);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = getPageInfo->GetInt32(1, &visitCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int32_t foreignCount = 0;
+ rv = getPageInfo->GetInt32(2, &foreignCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ hasBookmark = foreignCount > 0;
+ rv = getPageInfo->GetInt32(3, &isQuery);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (visitCount > 0) {
+ // Get a sample of the last visits to the page, to calculate its weight.
+ // In case of a temporary or permanent redirect, calculate the frecency
+ // as if the original page was visited.
+ nsCOMPtr<mozIStorageStatement> getVisits = DB->GetStatement(
+ NS_LITERAL_CSTRING(
+ "/* do not warn (bug 659740 - SQLite may ignore index if few visits exist) */"
+ "SELECT "
+ "ROUND((strftime('%s','now','localtime','utc') - v.visit_date/1000000)/86400), "
+ "IFNULL(r.visit_type, v.visit_type), "
+ "v.visit_date "
+ "FROM moz_historyvisits v "
+ "LEFT JOIN moz_historyvisits r ON r.id = v.from_visit AND v.visit_type BETWEEN "
+ ) + nsPrintfCString("%d AND %d ", nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT,
+ nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY) +
+ NS_LITERAL_CSTRING(
+ "WHERE v.place_id = :page_id "
+ "ORDER BY v.visit_date DESC "
+ )
+ );
+ NS_ENSURE_STATE(getVisits);
+ mozStorageStatementScoper visitsScoper(getVisits);
+ rv = getVisits->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), pageId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Fetch only a limited number of recent visits.
+ bool hasResult = false;
+ for (int32_t maxVisits = history->GetNumVisitsForFrecency();
+ numSampledVisits < maxVisits &&
+ NS_SUCCEEDED(getVisits->ExecuteStep(&hasResult)) && hasResult;
+ numSampledVisits++) {
+ int32_t visitType;
+ rv = getVisits->GetInt32(1, &visitType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bonus = history->GetFrecencyTransitionBonus(visitType, true);
+
+ // Add the bookmark visit bonus.
+ if (hasBookmark) {
+ bonus += history->GetFrecencyTransitionBonus(nsINavHistoryService::TRANSITION_BOOKMARK, true);
+ }
+
+ // If bonus was zero, we can skip the work to determine the weight.
+ if (bonus) {
+ int32_t ageInDays = getVisits->AsInt32(0);
+ int32_t weight = history->GetFrecencyAgedWeight(ageInDays);
+ pointsForSampledVisits += (float)(weight * (bonus / 100.0));
+ }
+ }
+ }
+
+ // If we sampled some visits for this page, use the calculated weight.
+ if (numSampledVisits) {
+ // We were unable to calculate points, maybe cause all the visits in the
+ // sample had a zero bonus. Though, we know the page has some past valid
+ // visit, or visit_count would be zero. Thus we set the frecency to
+ // -1, so they are still shown in autocomplete.
+ if (!pointsForSampledVisits) {
+ NS_ADDREF(*_result = new IntegerVariant(-1));
+ }
+ else {
+ // Estimate frecency using the sampled visits.
+ // Use ceilf() so that we don't round down to 0, which
+ // would cause us to completely ignore the place during autocomplete.
+ NS_ADDREF(*_result = new IntegerVariant((int32_t) ceilf(visitCount * ceilf(pointsForSampledVisits) / numSampledVisits)));
+ }
+ return NS_OK;
+ }
+
+ // Otherwise this page has no visits, it may be bookmarked.
+ if (!hasBookmark || isQuery) {
+ NS_ADDREF(*_result = new IntegerVariant(0));
+ return NS_OK;
+ }
+
+ // For unvisited bookmarks, produce a non-zero frecency, so that they show
+ // up in URL bar autocomplete.
+ visitCount = 1;
+
+ // Make it so something bookmarked and typed will have a higher frecency
+ // than something just typed or just bookmarked.
+ bonus += history->GetFrecencyTransitionBonus(nsINavHistoryService::TRANSITION_BOOKMARK, false);
+ if (typed) {
+ bonus += history->GetFrecencyTransitionBonus(nsINavHistoryService::TRANSITION_TYPED, false);
+ }
+
+ // Assume "now" as our ageInDays, so use the first bucket.
+ pointsForSampledVisits = history->GetFrecencyBucketWeight(1) * (bonus / (float)100.0);
+
+ // use ceilf() so that we don't round down to 0, which
+ // would cause us to completely ignore the place during autocomplete
+ NS_ADDREF(*_result = new IntegerVariant((int32_t) ceilf(visitCount * ceilf(pointsForSampledVisits))));
+
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// GUID Creation Function
+
+ /* static */
+ nsresult
+ GenerateGUIDFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<GenerateGUIDFunction> function = new GenerateGUIDFunction();
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("generate_guid"), 0, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ GenerateGUIDFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ GenerateGUIDFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ nsAutoCString guid;
+ nsresult rv = GenerateGUID(guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NS_ADDREF(*_result = new UTF8TextVariant(guid));
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// Get Unreversed Host Function
+
+ /* static */
+ nsresult
+ GetUnreversedHostFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<GetUnreversedHostFunction> function = new GetUnreversedHostFunction();
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("get_unreversed_host"), 1, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ GetUnreversedHostFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ GetUnreversedHostFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ // Must have non-null function arguments.
+ MOZ_ASSERT(aArguments);
+
+ nsAutoString src;
+ aArguments->GetString(0, src);
+
+ RefPtr<nsVariant> result = new nsVariant();
+
+ if (src.Length()>1) {
+ src.Truncate(src.Length() - 1);
+ nsAutoString dest;
+ ReverseString(src, dest);
+ result->SetAsAString(dest);
+ }
+ else {
+ result->SetAsAString(EmptyString());
+ }
+ result.forget(_result);
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// Fixup URL Function
+
+ /* static */
+ nsresult
+ FixupURLFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<FixupURLFunction> function = new FixupURLFunction();
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("fixup_url"), 1, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ FixupURLFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ FixupURLFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ // Must have non-null function arguments.
+ MOZ_ASSERT(aArguments);
+
+ nsAutoString src;
+ aArguments->GetString(0, src);
+
+ RefPtr<nsVariant> result = new nsVariant();
+
+ if (StringBeginsWith(src, NS_LITERAL_STRING("http://")))
+ src.Cut(0, 7);
+ else if (StringBeginsWith(src, NS_LITERAL_STRING("https://")))
+ src.Cut(0, 8);
+ else if (StringBeginsWith(src, NS_LITERAL_STRING("ftp://")))
+ src.Cut(0, 6);
+
+ // Remove common URL hostname prefixes
+ if (StringBeginsWith(src, NS_LITERAL_STRING("www."))) {
+ src.Cut(0, 4);
+ }
+
+ result->SetAsAString(src);
+ result.forget(_result);
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// Frecency Changed Notification Function
+
+ /* static */
+ nsresult
+ FrecencyNotificationFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<FrecencyNotificationFunction> function =
+ new FrecencyNotificationFunction();
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("notify_frecency"), 5, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ FrecencyNotificationFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ FrecencyNotificationFunction::OnFunctionCall(mozIStorageValueArray *aArgs,
+ nsIVariant **_result)
+ {
+ uint32_t numArgs;
+ nsresult rv = aArgs->GetNumEntries(&numArgs);
+ NS_ENSURE_SUCCESS(rv, rv);
+ MOZ_ASSERT(numArgs == 5);
+
+ int32_t newFrecency = aArgs->AsInt32(0);
+
+ nsAutoCString spec;
+ rv = aArgs->GetUTF8String(1, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString guid;
+ rv = aArgs->GetUTF8String(2, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hidden = static_cast<bool>(aArgs->AsInt32(3));
+ PRTime lastVisitDate = static_cast<PRTime>(aArgs->AsInt64(4));
+
+ const nsNavHistory* navHistory = nsNavHistory::GetConstHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ navHistory->DispatchFrecencyChangedNotification(spec, newFrecency, guid,
+ hidden, lastVisitDate);
+
+ RefPtr<nsVariant> result = new nsVariant();
+ rv = result->SetAsInt32(newFrecency);
+ NS_ENSURE_SUCCESS(rv, rv);
+ result.forget(_result);
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// Store Last Inserted Id Function
+
+ /* static */
+ nsresult
+ StoreLastInsertedIdFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<StoreLastInsertedIdFunction> function =
+ new StoreLastInsertedIdFunction();
+ nsresult rv = aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("store_last_inserted_id"), 2, function
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+ }
+
+ NS_IMPL_ISUPPORTS(
+ StoreLastInsertedIdFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ StoreLastInsertedIdFunction::OnFunctionCall(mozIStorageValueArray *aArgs,
+ nsIVariant **_result)
+ {
+ uint32_t numArgs;
+ nsresult rv = aArgs->GetNumEntries(&numArgs);
+ NS_ENSURE_SUCCESS(rv, rv);
+ MOZ_ASSERT(numArgs == 2);
+
+ nsAutoCString table;
+ rv = aArgs->GetUTF8String(0, table);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ int64_t lastInsertedId = aArgs->AsInt64(1);
+
+ MOZ_ASSERT(table.EqualsLiteral("moz_places") ||
+ table.EqualsLiteral("moz_historyvisits") ||
+ table.EqualsLiteral("moz_bookmarks"));
+
+ if (table.EqualsLiteral("moz_bookmarks")) {
+ nsNavBookmarks::StoreLastInsertedId(table, lastInsertedId);
+ } else {
+ nsNavHistory::StoreLastInsertedId(table, lastInsertedId);
+ }
+
+ RefPtr<nsVariant> result = new nsVariant();
+ rv = result->SetAsInt64(lastInsertedId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ result.forget(_result);
+ return NS_OK;
+ }
+
+////////////////////////////////////////////////////////////////////////////////
+//// Hash Function
+
+ /* static */
+ nsresult
+ HashFunction::create(mozIStorageConnection *aDBConn)
+ {
+ RefPtr<HashFunction> function = new HashFunction();
+ return aDBConn->CreateFunction(
+ NS_LITERAL_CSTRING("hash"), -1, function
+ );
+ }
+
+ NS_IMPL_ISUPPORTS(
+ HashFunction,
+ mozIStorageFunction
+ )
+
+ NS_IMETHODIMP
+ HashFunction::OnFunctionCall(mozIStorageValueArray *aArguments,
+ nsIVariant **_result)
+ {
+ // Must have non-null function arguments.
+ MOZ_ASSERT(aArguments);
+
+ // Fetch arguments. Use default values if they were omitted.
+ uint32_t numEntries;
+ nsresult rv = aArguments->GetNumEntries(&numEntries);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(numEntries >= 1 && numEntries <= 2, NS_ERROR_FAILURE);
+
+ nsString str;
+ aArguments->GetString(0, str);
+ nsAutoCString mode;
+ if (numEntries > 1) {
+ aArguments->GetUTF8String(1, mode);
+ }
+
+ RefPtr<nsVariant> result = new nsVariant();
+ if (mode.IsEmpty()) {
+ // URI-like strings (having a prefix before a colon), are handled specially,
+ // as a 48 bit hash, where first 16 bits are the prefix hash, while the
+ // other 32 are the string hash.
+ // The 16 bits have been decided based on the fact hashing all of the IANA
+ // known schemes, plus "places", does not generate collisions.
+ nsAString::const_iterator start, tip, end;
+ str.BeginReading(tip);
+ start = tip;
+ str.EndReading(end);
+ if (FindInReadable(NS_LITERAL_STRING(":"), tip, end)) {
+ const nsDependentSubstring& prefix = Substring(start, tip);
+ uint64_t prefixHash = static_cast<uint64_t>(HashString(prefix) & 0x0000FFFF);
+ // The second half of the url is more likely to be unique, so we add it.
+ uint32_t srcHash = HashString(str);
+ uint64_t hash = (prefixHash << 32) + srcHash;
+ result->SetAsInt64(hash);
+ } else {
+ uint32_t hash = HashString(str);
+ result->SetAsInt64(hash);
+ }
+ } else if (mode.Equals(NS_LITERAL_CSTRING("prefix_lo"))) {
+ // Keep only 16 bits.
+ uint64_t hash = static_cast<uint64_t>(HashString(str) & 0x0000FFFF) << 32;
+ result->SetAsInt64(hash);
+ } else if (mode.Equals(NS_LITERAL_CSTRING("prefix_hi"))) {
+ // Keep only 16 bits.
+ uint64_t hash = static_cast<uint64_t>(HashString(str) & 0x0000FFFF) << 32;
+ // Make this a prefix upper bound by filling the lowest 32 bits.
+ hash += 0xFFFFFFFF;
+ result->SetAsInt64(hash);
+ } else {
+ return NS_ERROR_FAILURE;
+ }
+
+ result.forget(_result);
+ return NS_OK;
+ }
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/SQLFunctions.h b/toolkit/components/places/SQLFunctions.h
new file mode 100644
index 000000000..bba159345
--- /dev/null
+++ b/toolkit/components/places/SQLFunctions.h
@@ -0,0 +1,394 @@
+/* vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_SQLFunctions_h_
+#define mozilla_places_SQLFunctions_h_
+
+/**
+ * This file contains functions that Places adds to the database handle that can
+ * be accessed by SQL queries.
+ *
+ * Keep the GUID-related parts of this file in sync with
+ * toolkit/downloads/SQLFunctions.[h|cpp]!
+ */
+
+#include "mozIStorageFunction.h"
+#include "mozilla/Attributes.h"
+
+class mozIStorageConnection;
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// AutoComplete Matching Function
+
+/**
+ * This function is used to determine if a given set of data should match an
+ * AutoComplete query.
+ *
+ * In SQL, you'd use it in the WHERE clause like so:
+ * WHERE AUTOCOMPLETE_MATCH(aSearchString, aURL, aTitle, aTags, aVisitCount,
+ * aTyped, aBookmark, aOpenPageCount, aMatchBehavior,
+ * aSearchBehavior)
+ *
+ * @param aSearchString
+ * The string to compare against.
+ * @param aURL
+ * The URL to test for an AutoComplete match.
+ * @param aTitle
+ * The title to test for an AutoComplete match.
+ * @param aTags
+ * The tags to test for an AutoComplete match.
+ * @param aVisitCount
+ * The number of visits aURL has.
+ * @param aTyped
+ * Indicates if aURL is a typed URL or not. Treated as a boolean.
+ * @param aBookmark
+ * Indicates if aURL is a bookmark or not. Treated as a boolean.
+ * @param aOpenPageCount
+ * The number of times aURL has been registered as being open. (See
+ * mozIPlacesAutoComplete::registerOpenPage.)
+ * @param aMatchBehavior
+ * The match behavior to use for this search.
+ * @param aSearchBehavior
+ * A bitfield dictating the search behavior.
+ */
+class MatchAutoCompleteFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+
+private:
+ ~MatchAutoCompleteFunction() {}
+
+ /**
+ * Argument Indexes
+ */
+ static const uint32_t kArgSearchString = 0;
+ static const uint32_t kArgIndexURL = 1;
+ static const uint32_t kArgIndexTitle = 2;
+ static const uint32_t kArgIndexTags = 3;
+ static const uint32_t kArgIndexVisitCount = 4;
+ static const uint32_t kArgIndexTyped = 5;
+ static const uint32_t kArgIndexBookmark = 6;
+ static const uint32_t kArgIndexOpenPageCount = 7;
+ static const uint32_t kArgIndexMatchBehavior = 8;
+ static const uint32_t kArgIndexSearchBehavior = 9;
+ static const uint32_t kArgIndexLength = 10;
+
+ /**
+ * Typedefs
+ */
+ typedef bool (*searchFunctionPtr)(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString);
+
+ typedef nsACString::const_char_iterator const_char_iterator;
+
+ /**
+ * Obtains the search function to match on.
+ *
+ * @param aBehavior
+ * The matching behavior to use defined by one of the
+ * mozIPlacesAutoComplete::MATCH_* values.
+ * @return a pointer to the function that will perform the proper search.
+ */
+ static searchFunctionPtr getSearchFunction(int32_t aBehavior);
+
+ /**
+ * Tests if aSourceString starts with aToken.
+ *
+ * @param aToken
+ * The string to search for.
+ * @param aSourceString
+ * The string to search.
+ * @return true if found, false otherwise.
+ */
+ static bool findBeginning(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString);
+
+ /**
+ * Tests if aSourceString starts with aToken in a case sensitive way.
+ *
+ * @param aToken
+ * The string to search for.
+ * @param aSourceString
+ * The string to search.
+ * @return true if found, false otherwise.
+ */
+ static bool findBeginningCaseSensitive(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString);
+
+ /**
+ * Searches aSourceString for aToken anywhere in the string in a case-
+ * insensitive way.
+ *
+ * @param aToken
+ * The string to search for.
+ * @param aSourceString
+ * The string to search.
+ * @return true if found, false otherwise.
+ */
+ static bool findAnywhere(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString);
+
+ /**
+ * Tests if aToken is found on a word boundary in aSourceString.
+ *
+ * @param aToken
+ * The string to search for.
+ * @param aSourceString
+ * The string to search.
+ * @return true if found, false otherwise.
+ */
+ static bool findOnBoundary(const nsDependentCSubstring &aToken,
+ const nsACString &aSourceString);
+
+
+ /**
+ * Fixes a URI's spec such that it is ready to be searched. This includes
+ * unescaping escaped characters and removing certain specs that we do not
+ * care to search for.
+ *
+ * @param aURISpec
+ * The spec of the URI to prepare for searching.
+ * @param aMatchBehavior
+ * The matching behavior to use defined by one of the
+ * mozIPlacesAutoComplete::MATCH_* values.
+ * @param aSpecBuf
+ * A string buffer that the returned slice can point into, if needed.
+ * @return the fixed up string.
+ */
+ static nsDependentCSubstring fixupURISpec(const nsACString &aURISpec,
+ int32_t aMatchBehavior,
+ nsACString &aSpecBuf);
+};
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Frecency Calculation Function
+
+/**
+ * This function is used to calculate frecency for a page.
+ *
+ * In SQL, you'd use it in when setting frecency like:
+ * SET frecency = CALCULATE_FRECENCY(place_id).
+ * Optional parameters must be passed in if the page is not yet in the database,
+ * otherwise they will be fetched from it automatically.
+ *
+ * @param pageId
+ * The id of the page. Pass -1 if the page is being added right now.
+ * @param [optional] typed
+ * Whether the page has been typed in. Default is false.
+ * @param [optional] fullVisitCount
+ * Count of all the visits (All types). Default is 0.
+ * @param [optional] isBookmarked
+ * Whether the page is bookmarked. Default is false.
+ */
+class CalculateFrecencyFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~CalculateFrecencyFunction() {}
+};
+
+/**
+ * SQL function to generate a GUID for a place or bookmark item. This is just
+ * a wrapper around GenerateGUID in Helpers.h.
+ *
+ * @return a guid for the item.
+ */
+class GenerateGUIDFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~GenerateGUIDFunction() {}
+};
+
+/**
+ * SQL function to unreverse the rev_host of a page.
+ *
+ * @param rev_host
+ * The rev_host value of the page.
+ *
+ * @return the unreversed host of the page.
+ */
+class GetUnreversedHostFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~GetUnreversedHostFunction() {}
+};
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Fixup URL Function
+
+/**
+ * Make a given URL more suitable for searches, by removing common prefixes
+ * such as "www."
+ *
+ * @param url
+ * A URL.
+ * @return
+ * The same URL, with redundant parts removed.
+ */
+class FixupURLFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~FixupURLFunction() {}
+};
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Frecency Changed Notification Function
+
+/**
+ * For a given place, posts a runnable to the main thread that calls
+ * onFrecencyChanged on nsNavHistory's nsINavHistoryObservers. The passed-in
+ * newFrecency value is returned unchanged.
+ *
+ * @param newFrecency
+ * The place's new frecency.
+ * @param url
+ * The place's URL.
+ * @param guid
+ * The place's GUID.
+ * @param hidden
+ * The place's hidden boolean.
+ * @param lastVisitDate
+ * The place's last visit date.
+ * @return newFrecency
+ */
+class FrecencyNotificationFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~FrecencyNotificationFunction() {}
+};
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Store Last Inserted Id Function
+
+/**
+ * Store the last inserted id for reference purpose.
+ *
+ * @param tableName
+ * The table name.
+ * @param id
+ * The last inserted id.
+ * @return null
+ */
+class StoreLastInsertedIdFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~StoreLastInsertedIdFunction() {}
+};
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// Hash Function
+
+/**
+ * Calculates hash for a given string using the mfbt AddToHash function.
+ *
+ * @param string
+ * A string.
+ * @return
+ * The hash for the string.
+ */
+class HashFunction final : public mozIStorageFunction
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_MOZISTORAGEFUNCTION
+
+ /**
+ * Registers the function with the specified database connection.
+ *
+ * @param aDBConn
+ * The database connection to register with.
+ */
+ static nsresult create(mozIStorageConnection *aDBConn);
+private:
+ ~HashFunction() {}
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_SQLFunctions_h_
diff --git a/toolkit/components/places/Shutdown.cpp b/toolkit/components/places/Shutdown.cpp
new file mode 100644
index 000000000..43586542b
--- /dev/null
+++ b/toolkit/components/places/Shutdown.cpp
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "Shutdown.h"
+#include "mozilla/Unused.h"
+
+namespace mozilla {
+namespace places {
+
+uint16_t PlacesShutdownBlocker::sCounter = 0;
+Atomic<bool> PlacesShutdownBlocker::sIsStarted(false);
+
+PlacesShutdownBlocker::PlacesShutdownBlocker(const nsString& aName)
+ : mName(aName)
+ , mState(NOT_STARTED)
+ , mCounter(sCounter++)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ // During tests, we can end up with the Database singleton being resurrected.
+ // Make sure that each instance of DatabaseShutdown has a unique name.
+ if (mCounter > 1) {
+ mName.AppendInt(mCounter);
+ }
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::GetName(nsAString& aName)
+{
+ aName = mName;
+ return NS_OK;
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::GetState(nsIPropertyBag** _state)
+{
+ NS_ENSURE_ARG_POINTER(_state);
+
+ nsCOMPtr<nsIWritablePropertyBag2> bag =
+ do_CreateInstance("@mozilla.org/hash-property-bag;1");
+ NS_ENSURE_TRUE(bag, NS_ERROR_OUT_OF_MEMORY);
+ bag.forget(_state);
+
+ // Put `mState` in field `progress`
+ RefPtr<nsVariant> progress = new nsVariant();
+ nsresult rv = progress->SetAsUint8(mState);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+ rv = static_cast<nsIWritablePropertyBag2*>(*_state)->SetPropertyAsInterface(
+ NS_LITERAL_STRING("progress"), progress);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+
+ // Put `mBarrier`'s state in field `barrier`, if possible
+ if (!mBarrier) {
+ return NS_OK;
+ }
+ nsCOMPtr<nsIPropertyBag> barrierState;
+ rv = mBarrier->GetState(getter_AddRefs(barrierState));
+ if (NS_FAILED(rv)) {
+ return NS_OK;
+ }
+
+ RefPtr<nsVariant> barrier = new nsVariant();
+ rv = barrier->SetAsInterface(NS_GET_IID(nsIPropertyBag), barrierState);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+ rv = static_cast<nsIWritablePropertyBag2*>(*_state)->SetPropertyAsInterface(
+ NS_LITERAL_STRING("Barrier"), barrier);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+
+ return NS_OK;
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+ MOZ_ASSERT(false, "should always be overridden");
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMPL_ISUPPORTS(
+ PlacesShutdownBlocker,
+ nsIAsyncShutdownBlocker
+)
+
+////////////////////////////////////////////////////////////////////////////////
+
+ClientsShutdownBlocker::ClientsShutdownBlocker()
+ : PlacesShutdownBlocker(NS_LITERAL_STRING("Places Clients shutdown"))
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ // Create a barrier that will be exposed to clients through GetClient(), so
+ // they can block Places shutdown.
+ nsCOMPtr<nsIAsyncShutdownService> asyncShutdown = services::GetAsyncShutdown();
+ MOZ_ASSERT(asyncShutdown);
+ if (asyncShutdown) {
+ nsCOMPtr<nsIAsyncShutdownBarrier> barrier;
+ MOZ_ALWAYS_SUCCEEDS(asyncShutdown->MakeBarrier(mName, getter_AddRefs(barrier)));
+ mBarrier = new nsMainThreadPtrHolder<nsIAsyncShutdownBarrier>(barrier);
+ }
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+ClientsShutdownBlocker::GetClient()
+{
+ nsCOMPtr<nsIAsyncShutdownClient> client;
+ if (mBarrier) {
+ MOZ_ALWAYS_SUCCEEDS(mBarrier->GetClient(getter_AddRefs(client)));
+ }
+ return client.forget();
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+ClientsShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ mParentClient = new nsMainThreadPtrHolder<nsIAsyncShutdownClient>(aParentClient);
+ mState = RECEIVED_BLOCK_SHUTDOWN;
+
+ if (NS_WARN_IF(!mBarrier)) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Wait until all the clients have removed their blockers.
+ MOZ_ALWAYS_SUCCEEDS(mBarrier->Wait(this));
+
+ mState = CALLED_WAIT_CLIENTS;
+ return NS_OK;
+}
+
+// nsIAsyncShutdownCompletionCallback
+NS_IMETHODIMP
+ClientsShutdownBlocker::Done()
+{
+ // At this point all the clients are done, we can stop blocking the shutdown
+ // phase.
+ mState = RECEIVED_DONE;
+
+ // mParentClient is nullptr in tests.
+ if (mParentClient) {
+ nsresult rv = mParentClient->RemoveBlocker(this);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+ mParentClient = nullptr;
+ }
+ mBarrier = nullptr;
+ return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS_INHERITED(
+ ClientsShutdownBlocker,
+ PlacesShutdownBlocker,
+ nsIAsyncShutdownCompletionCallback
+)
+
+////////////////////////////////////////////////////////////////////////////////
+
+ConnectionShutdownBlocker::ConnectionShutdownBlocker(Database* aDatabase)
+ : PlacesShutdownBlocker(NS_LITERAL_STRING("Places Connection shutdown"))
+ , mDatabase(aDatabase)
+{
+ // Do nothing.
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+ConnectionShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ mParentClient = new nsMainThreadPtrHolder<nsIAsyncShutdownClient>(aParentClient);
+ mState = RECEIVED_BLOCK_SHUTDOWN;
+ // Annotate that Database shutdown started.
+ sIsStarted = true;
+
+ // Fire internal database closing notification.
+ nsCOMPtr<nsIObserverService> os = services::GetObserverService();
+ MOZ_ASSERT(os);
+ if (os) {
+ Unused << os->NotifyObservers(nullptr, TOPIC_PLACES_WILL_CLOSE_CONNECTION, nullptr);
+ }
+ mState = NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION;
+
+ // At this stage, any use of this database is forbidden. Get rid of
+ // `gDatabase`. Note, however, that the database could be
+ // resurrected. This can happen in particular during tests.
+ MOZ_ASSERT(Database::gDatabase == nullptr || Database::gDatabase == mDatabase);
+ Database::gDatabase = nullptr;
+
+ // Database::Shutdown will invoke Complete once the connection is closed.
+ mDatabase->Shutdown();
+ mState = CALLED_STORAGESHUTDOWN;
+ return NS_OK;
+}
+
+// mozIStorageCompletionCallback
+NS_IMETHODIMP
+ConnectionShutdownBlocker::Complete(nsresult, nsISupports*)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ mState = RECEIVED_STORAGESHUTDOWN_COMPLETE;
+
+ // The connection is closed, the Database has no more use, so we can break
+ // possible cycles.
+ mDatabase = nullptr;
+
+ // Notify the connection has gone.
+ nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
+ MOZ_ASSERT(os);
+ if (os) {
+ MOZ_ALWAYS_SUCCEEDS(os->NotifyObservers(nullptr,
+ TOPIC_PLACES_CONNECTION_CLOSED,
+ nullptr));
+ }
+ mState = NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED;
+
+ // mParentClient is nullptr in tests
+ if (mParentClient) {
+ nsresult rv = mParentClient->RemoveBlocker(this);
+ if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+ mParentClient = nullptr;
+ }
+ return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS_INHERITED(
+ ConnectionShutdownBlocker,
+ PlacesShutdownBlocker,
+ mozIStorageCompletionCallback
+)
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/Shutdown.h b/toolkit/components/places/Shutdown.h
new file mode 100644
index 000000000..69023c608
--- /dev/null
+++ b/toolkit/components/places/Shutdown.h
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_Shutdown_h_
+#define mozilla_places_Shutdown_h_
+
+#include "nsIAsyncShutdown.h"
+#include "Database.h"
+#include "nsProxyRelease.h"
+
+namespace mozilla {
+namespace places {
+
+class Database;
+
+/**
+ * This is most of the code responsible for Places shutdown.
+ *
+ * PHASE 1 (Legacy clients shutdown)
+ * The shutdown procedure begins when the Database singleton receives
+ * profile-change-teardown (note that tests will instead notify nsNavHistory,
+ * that forwards the notification to the Database instance).
+ * Database::Observe first of all checks if initialization was completed
+ * properly, to avoid race conditions, then it notifies "places-shutdown" to
+ * legacy clients. Legacy clients are supposed to start and complete any
+ * shutdown critical work in the same tick, since we won't wait for them.
+
+ * PHASE 2 (Modern clients shutdown)
+ * Modern clients should instead register as a blocker by passing a promise to
+ * nsPIPlacesDatabase::shutdownClient (for example see sanitize.js), so they
+ * block Places shutdown until the promise is resolved.
+ * When profile-change-teardown is observed by async shutdown, it calls
+ * ClientsShutdownBlocker::BlockShutdown. This class is registered as a teardown
+ * phase blocker in Database::Init (see Database::mClientsShutdown).
+ * ClientsShutdownBlocker::BlockShudown waits for all the clients registered
+ * through nsPIPlacesDatabase::shutdownClient. When all the clients are done,
+ * its `Done` method is invoked, and it stops blocking the shutdown phase, so
+ * that it can continue.
+ *
+ * PHASE 3 (Connection shutdown)
+ * ConnectionBlocker is registered as a profile-before-change blocker in
+ * Database::Init (see Database::mConnectionShutdown).
+ * When profile-before-change is observer by async shutdown, it calls
+ * ConnectionShutdownBlocker::BlockShutdown.
+ * This is the last chance for any Places internal work, like privacy cleanups,
+ * before the connection is closed. This a places-will-close-connection
+ * notification is sent to legacy clients that must complete any operation in
+ * the same tick, since we won't wait for them.
+ * Then the control is passed to Database::Shutdown, that executes some sanity
+ * checks, clears cached statements and proceeds with asyncClose.
+ * Once the connection is definitely closed, Database will call back
+ * ConnectionBlocker::Complete. At this point a final
+ * places-connection-closed notification is sent, for testing purposes.
+ */
+
+/**
+ * A base AsyncShutdown blocker in charge of shutting down Places.
+ */
+class PlacesShutdownBlocker : public nsIAsyncShutdownBlocker
+{
+public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_NSIASYNCSHUTDOWNBLOCKER
+
+ explicit PlacesShutdownBlocker(const nsString& aName);
+
+ /**
+ * `true` if we have not started shutdown, i.e. if
+ * `BlockShutdown()` hasn't been called yet, false otherwise.
+ */
+ static bool IsStarted() {
+ return sIsStarted;
+ }
+
+ // The current state, used internally and for forensics/debugging purposes.
+ // Not all the states make sense for all the derived classes.
+ enum States {
+ NOT_STARTED,
+ // Execution of `BlockShutdown` in progress.
+ RECEIVED_BLOCK_SHUTDOWN,
+
+ // Values specific to ClientsShutdownBlocker
+ // a. Set while we are waiting for clients to do their job and unblock us.
+ CALLED_WAIT_CLIENTS,
+ // b. Set when all the clients are done.
+ RECEIVED_DONE,
+
+ // Values specific to ConnectionShutdownBlocker
+ // a. Set after we notified observers that Places is closing the connection.
+ NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION,
+ // b. Set after we pass control to Database::Shutdown, and wait for it to
+ // close the connection and call our `Complete` method when done.
+ CALLED_STORAGESHUTDOWN,
+ // c. Set when Database has closed the connection and passed control to
+ // us through `Complete`.
+ RECEIVED_STORAGESHUTDOWN_COMPLETE,
+ // d. We have notified observers that Places has closed the connection.
+ NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED,
+ };
+ States State() {
+ return mState;
+ }
+
+protected:
+ // The blocker name, also used as barrier name.
+ nsString mName;
+ // The current state, see States.
+ States mState;
+ // The barrier optionally used to wait for clients.
+ nsMainThreadPtrHandle<nsIAsyncShutdownBarrier> mBarrier;
+ // The parent object who registered this as a blocker.
+ nsMainThreadPtrHandle<nsIAsyncShutdownClient> mParentClient;
+
+ // As tests may resurrect a dead `Database`, we use a counter to
+ // give the instances of `PlacesShutdownBlocker` unique names.
+ uint16_t mCounter;
+ static uint16_t sCounter;
+
+ static Atomic<bool> sIsStarted;
+
+ virtual ~PlacesShutdownBlocker() {}
+};
+
+/**
+ * Blocker also used to wait for clients, through an owned barrier.
+ */
+class ClientsShutdownBlocker final : public PlacesShutdownBlocker
+ , public nsIAsyncShutdownCompletionCallback
+{
+public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_NSIASYNCSHUTDOWNCOMPLETIONCALLBACK
+
+ explicit ClientsShutdownBlocker();
+
+ NS_IMETHOD BlockShutdown(nsIAsyncShutdownClient* aParentClient) override;
+
+ already_AddRefed<nsIAsyncShutdownClient> GetClient();
+
+private:
+ ~ClientsShutdownBlocker() {}
+};
+
+/**
+ * Blocker used to wait when closing the database connection.
+ */
+class ConnectionShutdownBlocker final : public PlacesShutdownBlocker
+ , public mozIStorageCompletionCallback
+{
+public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_MOZISTORAGECOMPLETIONCALLBACK
+
+ NS_IMETHOD BlockShutdown(nsIAsyncShutdownClient* aParentClient) override;
+
+ explicit ConnectionShutdownBlocker(mozilla::places::Database* aDatabase);
+
+private:
+ ~ConnectionShutdownBlocker() {}
+
+ // The owning database.
+ // The cycle is broken in method Complete(), once the connection
+ // has been closed by mozStorage.
+ RefPtr<mozilla::places::Database> mDatabase;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_Shutdown_h_
diff --git a/toolkit/components/places/UnifiedComplete.js b/toolkit/components/places/UnifiedComplete.js
new file mode 100644
index 000000000..ad3d35aab
--- /dev/null
+++ b/toolkit/components/places/UnifiedComplete.js
@@ -0,0 +1,2149 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 expandtab
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Constants
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+// Match type constants.
+// These indicate what type of search function we should be using.
+const MATCH_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE;
+const MATCH_BOUNDARY_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY_ANYWHERE;
+const MATCH_BOUNDARY = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY;
+const MATCH_BEGINNING = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING;
+const MATCH_BEGINNING_CASE_SENSITIVE = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING_CASE_SENSITIVE;
+
+const PREF_BRANCH = "browser.urlbar.";
+
+// Prefs are defined as [pref name, default value].
+const PREF_ENABLED = [ "autocomplete.enabled", true ];
+const PREF_AUTOFILL = [ "autoFill", true ];
+const PREF_AUTOFILL_TYPED = [ "autoFill.typed", true ];
+const PREF_AUTOFILL_SEARCHENGINES = [ "autoFill.searchEngines", false ];
+const PREF_RESTYLESEARCHES = [ "restyleSearches", false ];
+const PREF_DELAY = [ "delay", 50 ];
+const PREF_BEHAVIOR = [ "matchBehavior", MATCH_BOUNDARY_ANYWHERE ];
+const PREF_FILTER_JS = [ "filter.javascript", true ];
+const PREF_MAXRESULTS = [ "maxRichResults", 25 ];
+const PREF_RESTRICT_HISTORY = [ "restrict.history", "^" ];
+const PREF_RESTRICT_BOOKMARKS = [ "restrict.bookmark", "*" ];
+const PREF_RESTRICT_TYPED = [ "restrict.typed", "~" ];
+const PREF_RESTRICT_TAG = [ "restrict.tag", "+" ];
+const PREF_RESTRICT_SWITCHTAB = [ "restrict.openpage", "%" ];
+const PREF_RESTRICT_SEARCHES = [ "restrict.searces", "$" ];
+const PREF_MATCH_TITLE = [ "match.title", "#" ];
+const PREF_MATCH_URL = [ "match.url", "@" ];
+
+const PREF_SUGGEST_HISTORY = [ "suggest.history", true ];
+const PREF_SUGGEST_BOOKMARK = [ "suggest.bookmark", true ];
+const PREF_SUGGEST_OPENPAGE = [ "suggest.openpage", true ];
+const PREF_SUGGEST_HISTORY_ONLYTYPED = [ "suggest.history.onlyTyped", false ];
+const PREF_SUGGEST_SEARCHES = [ "suggest.searches", false ];
+
+const PREF_MAX_CHARS_FOR_SUGGEST = [ "maxCharsForSearchSuggestions", 20];
+
+// AutoComplete query type constants.
+// Describes the various types of queries that we can process rows for.
+const QUERYTYPE_FILTERED = 0;
+const QUERYTYPE_AUTOFILL_HOST = 1;
+const QUERYTYPE_AUTOFILL_URL = 2;
+
+// This separator is used as an RTL-friendly way to split the title and tags.
+// It can also be used by an nsIAutoCompleteResult consumer to re-split the
+// "comment" back into the title and the tag.
+const TITLE_TAGS_SEPARATOR = " \u2013 ";
+
+// Telemetry probes.
+const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS";
+const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS";
+// The default frecency value used when inserting matches with unknown frecency.
+const FRECENCY_DEFAULT = 1000;
+
+// Remote matches are appended when local matches are below a given frecency
+// threshold (FRECENCY_DEFAULT) as soon as they arrive. However we'll
+// always try to have at least MINIMUM_LOCAL_MATCHES local matches.
+const MINIMUM_LOCAL_MATCHES = 6;
+
+// Extensions are allowed to add suggestions if they have registered a keyword
+// with the omnibox API. This is the maximum number of suggestions an extension
+// is allowed to add for a given search string.
+const MAXIMUM_ALLOWED_EXTENSION_MATCHES = 6;
+
+// A regex that matches "single word" hostnames for whitelisting purposes.
+// The hostname will already have been checked for general validity, so we
+// don't need to be exhaustive here, so allow dashes anywhere.
+const REGEXP_SINGLEWORD_HOST = new RegExp("^[a-z0-9-]+$", "i");
+
+// Regex used to match userContextId.
+const REGEXP_USER_CONTEXT_ID = /(?:^| )user-context-id:(\d+)/;
+
+// Regex used to match one or more whitespace.
+const REGEXP_SPACES = /\s+/;
+
+// Sqlite result row index constants.
+const QUERYINDEX_QUERYTYPE = 0;
+const QUERYINDEX_URL = 1;
+const QUERYINDEX_TITLE = 2;
+const QUERYINDEX_ICONURL = 3;
+const QUERYINDEX_BOOKMARKED = 4;
+const QUERYINDEX_BOOKMARKTITLE = 5;
+const QUERYINDEX_TAGS = 6;
+const QUERYINDEX_VISITCOUNT = 7;
+const QUERYINDEX_TYPED = 8;
+const QUERYINDEX_PLACEID = 9;
+const QUERYINDEX_SWITCHTAB = 10;
+const QUERYINDEX_FRECENCY = 11;
+
+// This SQL query fragment provides the following:
+// - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED)
+// - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE)
+// - the tags associated with a bookmarked entry (QUERYINDEX_TAGS)
+const SQL_BOOKMARK_TAGS_FRAGMENT =
+ `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked,
+ ( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL
+ ORDER BY lastModified DESC LIMIT 1
+ ) AS btitle,
+ ( SELECT GROUP_CONCAT(t.title, ', ')
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent
+ WHERE b.fk = h.id
+ ) AS tags`;
+
+// TODO bug 412736: in case of a frecency tie, we might break it with h.typed
+// and h.visit_count. That is slower though, so not doing it yet...
+// NB: as a slight performance optimization, we only evaluate the "btitle"
+// and "tags" queries for bookmarked entries.
+function defaultQuery(conditions = "") {
+ let query =
+ `SELECT :query_type, h.url, h.title, f.url, ${SQL_BOOKMARK_TAGS_FRAGMENT},
+ h.visit_count, h.typed, h.id, t.open_count, h.frecency
+ FROM moz_places h
+ LEFT JOIN moz_favicons f ON f.id = h.favicon_id
+ LEFT JOIN moz_openpages_temp t
+ ON t.url = h.url
+ AND t.userContextId = :userContextId
+ WHERE h.frecency <> 0
+ AND AUTOCOMPLETE_MATCH(:searchString, h.url,
+ CASE WHEN bookmarked THEN
+ IFNULL(btitle, h.title)
+ ELSE h.title END,
+ CASE WHEN bookmarked THEN
+ tags
+ ELSE '' END,
+ h.visit_count, h.typed,
+ bookmarked, t.open_count,
+ :matchBehavior, :searchBehavior)
+ ${conditions}
+ ORDER BY h.frecency DESC, h.id DESC
+ LIMIT :maxResults`;
+ return query;
+}
+
+const SQL_SWITCHTAB_QUERY =
+ `SELECT :query_type, t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ t.open_count, NULL
+ FROM moz_openpages_temp t
+ LEFT JOIN moz_places h ON h.url_hash = hash(t.url) AND h.url = t.url
+ WHERE h.id IS NULL
+ AND t.userContextId = :userContextId
+ AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL,
+ NULL, NULL, NULL, t.open_count,
+ :matchBehavior, :searchBehavior)
+ ORDER BY t.ROWID DESC
+ LIMIT :maxResults`;
+
+const SQL_ADAPTIVE_QUERY =
+ `/* do not warn (bug 487789) */
+ SELECT :query_type, h.url, h.title, f.url, ${SQL_BOOKMARK_TAGS_FRAGMENT},
+ h.visit_count, h.typed, h.id, t.open_count, h.frecency
+ FROM (
+ SELECT ROUND(MAX(use_count) * (1 + (input = :search_string)), 1) AS rank,
+ place_id
+ FROM moz_inputhistory
+ WHERE input BETWEEN :search_string AND :search_string || X'FFFF'
+ GROUP BY place_id
+ ) AS i
+ JOIN moz_places h ON h.id = i.place_id
+ LEFT JOIN moz_favicons f ON f.id = h.favicon_id
+ LEFT JOIN moz_openpages_temp t
+ ON t.url = h.url
+ AND t.userContextId = :userContextId
+ WHERE AUTOCOMPLETE_MATCH(NULL, h.url,
+ IFNULL(btitle, h.title), tags,
+ h.visit_count, h.typed, bookmarked,
+ t.open_count,
+ :matchBehavior, :searchBehavior)
+ ORDER BY rank DESC, h.frecency DESC`;
+
+
+function hostQuery(conditions = "") {
+ let query =
+ `/* do not warn (bug NA): not worth to index on (typed, frecency) */
+ SELECT :query_type, host || '/', IFNULL(prefix, '') || host || '/',
+ ( SELECT f.url FROM moz_favicons f
+ JOIN moz_places h ON h.favicon_id = f.id
+ WHERE rev_host = get_unreversed_host(host || '.') || '.'
+ OR rev_host = get_unreversed_host(host || '.') || '.www.'
+ ) AS favicon_url,
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, frecency
+ FROM moz_hosts
+ WHERE host BETWEEN :searchString AND :searchString || X'FFFF'
+ AND frecency <> 0
+ ${conditions}
+ ORDER BY frecency DESC
+ LIMIT 1`;
+ return query;
+}
+
+const SQL_HOST_QUERY = hostQuery();
+
+const SQL_TYPED_HOST_QUERY = hostQuery("AND typed = 1");
+
+function bookmarkedHostQuery(conditions = "") {
+ let query =
+ `/* do not warn (bug NA): not worth to index on (typed, frecency) */
+ SELECT :query_type, host || '/', IFNULL(prefix, '') || host || '/',
+ ( SELECT f.url FROM moz_favicons f
+ JOIN moz_places h ON h.favicon_id = f.id
+ WHERE rev_host = get_unreversed_host(host || '.') || '.'
+ OR rev_host = get_unreversed_host(host || '.') || '.www.'
+ ) AS favicon_url,
+ ( SELECT foreign_count > 0 FROM moz_places
+ WHERE rev_host = get_unreversed_host(host || '.') || '.'
+ OR rev_host = get_unreversed_host(host || '.') || '.www.'
+ ) AS bookmarked, NULL, NULL, NULL, NULL, NULL, NULL, frecency
+ FROM moz_hosts
+ WHERE host BETWEEN :searchString AND :searchString || X'FFFF'
+ AND bookmarked
+ AND frecency <> 0
+ ${conditions}
+ ORDER BY frecency DESC
+ LIMIT 1`;
+ return query;
+}
+
+const SQL_BOOKMARKED_HOST_QUERY = bookmarkedHostQuery();
+
+const SQL_BOOKMARKED_TYPED_HOST_QUERY = bookmarkedHostQuery("AND typed = 1");
+
+function urlQuery(conditions = "") {
+ return `/* do not warn (bug no): cannot use an index to sort */
+ SELECT :query_type, h.url, NULL, f.url AS favicon_url,
+ foreign_count > 0 AS bookmarked,
+ NULL, NULL, NULL, NULL, NULL, NULL, h.frecency
+ FROM moz_places h
+ LEFT JOIN moz_favicons f ON h.favicon_id = f.id
+ WHERE (rev_host = :revHost OR rev_host = :revHost || "www.")
+ AND h.frecency <> 0
+ AND fixup_url(h.url) BETWEEN :searchString AND :searchString || X'FFFF'
+ ${conditions}
+ ORDER BY h.frecency DESC, h.id DESC
+ LIMIT 1`;
+}
+
+const SQL_URL_QUERY = urlQuery();
+
+const SQL_TYPED_URL_QUERY = urlQuery("AND h.typed = 1");
+
+// TODO (bug 1045924): use foreign_count once available.
+const SQL_BOOKMARKED_URL_QUERY = urlQuery("AND bookmarked");
+
+const SQL_BOOKMARKED_TYPED_URL_QUERY = urlQuery("AND bookmarked AND h.typed = 1");
+
+// Getters
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch",
+ "resource://gre/modules/TelemetryStopwatch.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+ "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
+ "resource://gre/modules/PromiseUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSearchHandler",
+ "resource://gre/modules/ExtensionSearchHandler.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSearchAutocompleteProvider",
+ "resource://gre/modules/PlacesSearchAutocompleteProvider.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesRemoteTabsAutocompleteProvider",
+ "resource://gre/modules/PlacesRemoteTabsAutocompleteProvider.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
+ "resource://gre/modules/BrowserUtils.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "textURIService",
+ "@mozilla.org/intl/texttosuburi;1",
+ "nsITextToSubURI");
+
+/**
+ * Storage object for switch-to-tab entries.
+ * This takes care of caching and registering open pages, that will be reused
+ * by switch-to-tab queries. It has an internal cache, so that the Sqlite
+ * store is lazy initialized only on first use.
+ * It has a simple API:
+ * initDatabase(conn): initializes the temporary Sqlite entities to store data
+ * add(uri): adds a given nsIURI to the store
+ * delete(uri): removes a given nsIURI from the store
+ * shutdown(): stops storing data to Sqlite
+ */
+XPCOMUtils.defineLazyGetter(this, "SwitchToTabStorage", () => Object.seal({
+ _conn: null,
+ // Temporary queue used while the database connection is not available.
+ _queue: new Map(),
+ initDatabase: Task.async(function* (conn) {
+ // To reduce IO use an in-memory table for switch-to-tab tracking.
+ // Note: this should be kept up-to-date with the definition in
+ // nsPlacesTables.h.
+ yield conn.execute(
+ `CREATE TEMP TABLE moz_openpages_temp (
+ url TEXT,
+ userContextId INTEGER,
+ open_count INTEGER,
+ PRIMARY KEY (url, userContextId)
+ )`);
+
+ // Note: this should be kept up-to-date with the definition in
+ // nsPlacesTriggers.h.
+ yield conn.execute(
+ `CREATE TEMPORARY TRIGGER moz_openpages_temp_afterupdate_trigger
+ AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW
+ WHEN NEW.open_count = 0
+ BEGIN
+ DELETE FROM moz_openpages_temp
+ WHERE url = NEW.url
+ AND userContextId = NEW.userContextId;
+ END`);
+
+ this._conn = conn;
+
+ // Populate the table with the current cache contents...
+ for (let [userContextId, uris] of this._queue) {
+ for (let uri of uris) {
+ this.add(uri, userContextId);
+ }
+ }
+
+ // ...then clear it to avoid double additions.
+ this._queue.clear();
+ }),
+
+ add(uri, userContextId) {
+ if (!this._conn) {
+ if (!this._queue.has(userContextId)) {
+ this._queue.set(userContextId, new Set());
+ }
+ this._queue.get(userContextId).add(uri);
+ return;
+ }
+ this._conn.executeCached(
+ `INSERT OR REPLACE INTO moz_openpages_temp (url, userContextId, open_count)
+ VALUES ( :url,
+ :userContextId,
+ IFNULL( ( SELECT open_count + 1
+ FROM moz_openpages_temp
+ WHERE url = :url
+ AND userContextId = :userContextId ),
+ 1
+ )
+ )`
+ , { url: uri.spec, userContextId });
+ },
+
+ delete(uri, userContextId) {
+ if (!this._conn) {
+ // This should not happen.
+ if (!this._queue.has(userContextId)) {
+ throw new Error("Unknown userContextId!");
+ }
+
+ this._queue.get(userContextId).delete(uri);
+ if (this._queue.get(userContextId).size == 0) {
+ this._queue.delete(userContextId);
+ }
+ return;
+ }
+ this._conn.executeCached(
+ `UPDATE moz_openpages_temp
+ SET open_count = open_count - 1
+ WHERE url = :url
+ AND userContextId = :userContextId`
+ , { url: uri.spec, userContextId });
+ },
+
+ shutdown: function () {
+ this._conn = null;
+ this._queue.clear();
+ }
+}));
+
+/**
+ * This helper keeps track of preferences and keeps their values up-to-date.
+ */
+XPCOMUtils.defineLazyGetter(this, "Prefs", () => {
+ let prefs = new Preferences(PREF_BRANCH);
+ let types = ["History", "Bookmark", "Openpage", "Searches"];
+
+ function syncEnabledPref() {
+ loadSyncedPrefs();
+
+ let suggestPrefs = [
+ PREF_SUGGEST_HISTORY,
+ PREF_SUGGEST_BOOKMARK,
+ PREF_SUGGEST_OPENPAGE,
+ PREF_SUGGEST_SEARCHES,
+ ];
+
+ if (store.enabled) {
+ // If the autocomplete preference is active, set to default value all suggest
+ // preferences only if all of them are false.
+ if (types.every(type => store["suggest" + type] == false)) {
+ for (let type of suggestPrefs) {
+ prefs.set(...type);
+ }
+ }
+ } else {
+ // If the preference was deactivated, deactivate all suggest preferences.
+ for (let type of suggestPrefs) {
+ prefs.set(type[0], false);
+ }
+ }
+ }
+
+ function loadSyncedPrefs () {
+ store.enabled = prefs.get(...PREF_ENABLED);
+ store.suggestHistory = prefs.get(...PREF_SUGGEST_HISTORY);
+ store.suggestBookmark = prefs.get(...PREF_SUGGEST_BOOKMARK);
+ store.suggestOpenpage = prefs.get(...PREF_SUGGEST_OPENPAGE);
+ store.suggestTyped = prefs.get(...PREF_SUGGEST_HISTORY_ONLYTYPED);
+ store.suggestSearches = prefs.get(...PREF_SUGGEST_SEARCHES);
+ }
+
+ function loadPrefs(subject, topic, data) {
+ if (data) {
+ // Synchronize suggest.* prefs with autocomplete.enabled.
+ if (data == PREF_BRANCH + PREF_ENABLED[0]) {
+ syncEnabledPref();
+ } else if (data.startsWith(PREF_BRANCH + "suggest.")) {
+ loadSyncedPrefs();
+ prefs.set(PREF_ENABLED[0], types.some(type => store["suggest" + type]));
+ }
+ }
+
+ store.enabled = prefs.get(...PREF_ENABLED);
+ store.autofill = prefs.get(...PREF_AUTOFILL);
+ store.autofillTyped = prefs.get(...PREF_AUTOFILL_TYPED);
+ store.autofillSearchEngines = prefs.get(...PREF_AUTOFILL_SEARCHENGINES);
+ store.restyleSearches = prefs.get(...PREF_RESTYLESEARCHES);
+ store.delay = prefs.get(...PREF_DELAY);
+ store.matchBehavior = prefs.get(...PREF_BEHAVIOR);
+ store.filterJavaScript = prefs.get(...PREF_FILTER_JS);
+ store.maxRichResults = prefs.get(...PREF_MAXRESULTS);
+ store.restrictHistoryToken = prefs.get(...PREF_RESTRICT_HISTORY);
+ store.restrictBookmarkToken = prefs.get(...PREF_RESTRICT_BOOKMARKS);
+ store.restrictTypedToken = prefs.get(...PREF_RESTRICT_TYPED);
+ store.restrictTagToken = prefs.get(...PREF_RESTRICT_TAG);
+ store.restrictOpenPageToken = prefs.get(...PREF_RESTRICT_SWITCHTAB);
+ store.restrictSearchesToken = prefs.get(...PREF_RESTRICT_SEARCHES);
+ store.matchTitleToken = prefs.get(...PREF_MATCH_TITLE);
+ store.matchURLToken = prefs.get(...PREF_MATCH_URL);
+ store.suggestHistory = prefs.get(...PREF_SUGGEST_HISTORY);
+ store.suggestBookmark = prefs.get(...PREF_SUGGEST_BOOKMARK);
+ store.suggestOpenpage = prefs.get(...PREF_SUGGEST_OPENPAGE);
+ store.suggestTyped = prefs.get(...PREF_SUGGEST_HISTORY_ONLYTYPED);
+ store.suggestSearches = prefs.get(...PREF_SUGGEST_SEARCHES);
+ store.maxCharsForSearchSuggestions = prefs.get(...PREF_MAX_CHARS_FOR_SUGGEST);
+ store.keywordEnabled = true;
+ try {
+ store.keywordEnabled = Services.prefs.getBoolPref("keyword.enabled");
+ } catch (ex) {}
+
+ // If history is not set, onlyTyped value should be ignored.
+ if (!store.suggestHistory) {
+ store.suggestTyped = false;
+ }
+ store.defaultBehavior = types.concat("Typed").reduce((memo, type) => {
+ let prefValue = store["suggest" + type];
+ return memo | (prefValue &&
+ Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]);
+ }, 0);
+
+ // Further restrictions to apply for "empty searches" (i.e. searches for "").
+ // The empty behavior is typed history, if history is enabled. Otherwise,
+ // it is bookmarks, if they are enabled. If both history and bookmarks are disabled,
+ // it defaults to open pages.
+ store.emptySearchDefaultBehavior = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT;
+ if (store.suggestHistory) {
+ store.emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY |
+ Ci.mozIPlacesAutoComplete.BEHAVIOR_TYPED;
+ } else if (store.suggestBookmark) {
+ store.emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
+ } else {
+ store.emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE;
+ }
+
+ // Validate matchBehavior; default to MATCH_BOUNDARY_ANYWHERE.
+ if (store.matchBehavior != MATCH_ANYWHERE &&
+ store.matchBehavior != MATCH_BOUNDARY &&
+ store.matchBehavior != MATCH_BEGINNING) {
+ store.matchBehavior = MATCH_BOUNDARY_ANYWHERE;
+ }
+
+ store.tokenToBehaviorMap = new Map([
+ [ store.restrictHistoryToken, "history" ],
+ [ store.restrictBookmarkToken, "bookmark" ],
+ [ store.restrictTagToken, "tag" ],
+ [ store.restrictOpenPageToken, "openpage" ],
+ [ store.matchTitleToken, "title" ],
+ [ store.matchURLToken, "url" ],
+ [ store.restrictTypedToken, "typed" ],
+ [ store.restrictSearchesToken, "searches" ],
+ ]);
+ }
+
+ let store = {
+ _ignoreNotifications: false,
+ observe(subject, topic, data) {
+ // Avoid re-entrancy when flipping linked preferences.
+ if (this._ignoreNotifications)
+ return;
+ this._ignoreNotifications = true;
+ loadPrefs(subject, topic, data);
+ this._ignoreNotifications = false;
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference ])
+ };
+
+ // Synchronize suggest.* prefs with autocomplete.enabled at initialization
+ syncEnabledPref();
+
+ loadPrefs();
+ prefs.observe("", store);
+ Services.prefs.addObserver("keyword.enabled", store, true);
+
+ return Object.seal(store);
+});
+
+// Helper functions
+
+/**
+ * Used to unescape encoded URI strings and drop information that we do not
+ * care about.
+ *
+ * @param spec
+ * The text to unescape and modify.
+ * @return the modified spec.
+ */
+function fixupSearchText(spec) {
+ return textURIService.unEscapeURIForUI("UTF-8", stripPrefix(spec));
+}
+
+/**
+ * Generates the tokens used in searching from a given string.
+ *
+ * @param searchString
+ * The string to generate tokens from.
+ * @return an array of tokens.
+ * @note Calling split on an empty string will return an array containing one
+ * empty string. We don't want that, as it'll break our logic, so return
+ * an empty array then.
+ */
+function getUnfilteredSearchTokens(searchString) {
+ return searchString.length ? searchString.split(REGEXP_SPACES) : [];
+}
+
+/**
+ * Strip prefixes from the URI that we don't care about for searching.
+ *
+ * @param spec
+ * The text to modify.
+ * @return the modified spec.
+ */
+function stripPrefix(spec)
+{
+ ["http://", "https://", "ftp://"].some(scheme => {
+ // Strip protocol if not directly followed by a space
+ if (spec.startsWith(scheme) && spec[scheme.length] != " ") {
+ spec = spec.slice(scheme.length);
+ return true;
+ }
+ return false;
+ });
+
+ // Strip www. if not directly followed by a space
+ if (spec.startsWith("www.") && spec[4] != " ") {
+ spec = spec.slice(4);
+ }
+ return spec;
+}
+
+/**
+ * Strip http and trailing separators from a spec.
+ *
+ * @param spec
+ * The text to modify.
+ * @return the modified spec.
+ */
+function stripHttpAndTrim(spec) {
+ if (spec.startsWith("http://")) {
+ spec = spec.slice(7);
+ }
+ if (spec.endsWith("?")) {
+ spec = spec.slice(0, -1);
+ }
+ if (spec.endsWith("/")) {
+ spec = spec.slice(0, -1);
+ }
+ return spec;
+}
+
+/**
+ * Returns the key to be used for a URL in a map for the purposes of removing
+ * duplicate entries - any 2 URLs that should be considered the same should
+ * return the same key. For some moz-action URLs this will unwrap the params
+ * and return a key based on the wrapped URL.
+ */
+function makeKeyForURL(actionUrl) {
+ // At this stage we only consider moz-action URLs.
+ if (!actionUrl.startsWith("moz-action:")) {
+ return stripHttpAndTrim(actionUrl);
+ }
+ let [, type, params] = actionUrl.match(/^moz-action:([^,]+),(.*)$/);
+ try {
+ params = JSON.parse(params);
+ } catch (ex) {
+ // This is unexpected in this context, so just return the input.
+ return stripHttpAndTrim(actionUrl);
+ }
+ // For now we only handle these 2 action types and treat them as the same.
+ switch (type) {
+ case "remotetab":
+ case "switchtab":
+ if (params.url) {
+ return "moz-action:tab:" + stripHttpAndTrim(params.url);
+ }
+ break;
+ // TODO (bug 1222435) - "switchtab" should be handled as an "autofill"
+ // entry.
+ default:
+ // do nothing.
+ // TODO (bug 1222436) - extend this method so it can be used instead of
+ // the |placeId| that's also used to remove duplicate entries.
+ }
+ return stripHttpAndTrim(actionUrl);
+}
+
+/**
+ * Returns whether the passed in string looks like a url.
+ */
+function looksLikeUrl(str, ignoreAlphanumericHosts = false) {
+ // Single word not including special chars.
+ return !REGEXP_SPACES.test(str) &&
+ (["/", "@", ":", "["].some(c => str.includes(c)) ||
+ (ignoreAlphanumericHosts ? /(.*\..*){3,}/.test(str) : str.includes(".")));
+}
+
+/**
+ * Manages a single instance of an autocomplete search.
+ *
+ * The first three parameters all originate from the similarly named parameters
+ * of nsIAutoCompleteSearch.startSearch().
+ *
+ * @param searchString
+ * The search string.
+ * @param searchParam
+ * A space-delimited string of search parameters. The following
+ * parameters are supported:
+ * * enable-actions: Include "actions", such as switch-to-tab and search
+ * engine aliases, in the results.
+ * * disable-private-actions: The search is taking place in a private
+ * window outside of permanent private-browsing mode. The search
+ * should exclude privacy-sensitive results as appropriate.
+ * * private-window: The search is taking place in a private window,
+ * possibly in permanent private-browsing mode. The search
+ * should exclude privacy-sensitive results as appropriate.
+ * * user-context-id: The userContextId of the selected tab.
+ * @param autocompleteListener
+ * An nsIAutoCompleteObserver.
+ * @param resultListener
+ * An nsIAutoCompleteSimpleResultListener.
+ * @param autocompleteSearch
+ * An nsIAutoCompleteSearch.
+ * @param prohibitSearchSuggestions
+ * Whether search suggestions are allowed for this search.
+ */
+function Search(searchString, searchParam, autocompleteListener,
+ resultListener, autocompleteSearch, prohibitSearchSuggestions) {
+ // We want to store the original string for case sensitive searches.
+ this._originalSearchString = searchString;
+ this._trimmedOriginalSearchString = searchString.trim();
+ this._searchString = fixupSearchText(this._trimmedOriginalSearchString.toLowerCase());
+
+ this._matchBehavior = Prefs.matchBehavior;
+ // Set the default behavior for this search.
+ this._behavior = this._searchString ? Prefs.defaultBehavior
+ : Prefs.emptySearchDefaultBehavior;
+
+ let params = new Set(searchParam.split(" "));
+ this._enableActions = params.has("enable-actions");
+ this._disablePrivateActions = params.has("disable-private-actions");
+ this._inPrivateWindow = params.has("private-window");
+ this._prohibitAutoFill = params.has("prohibit-autofill");
+
+ let userContextId = searchParam.match(REGEXP_USER_CONTEXT_ID);
+ this._userContextId = userContextId ?
+ parseInt(userContextId[1], 10) :
+ Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+
+ this._searchTokens =
+ this.filterTokens(getUnfilteredSearchTokens(this._searchString));
+ // The protocol and the host are lowercased by nsIURI, so it's fine to
+ // lowercase the typed prefix, to add it back to the results later.
+ this._strippedPrefix = this._trimmedOriginalSearchString.slice(
+ 0, this._trimmedOriginalSearchString.length - this._searchString.length
+ ).toLowerCase();
+ // The URIs in the database are fixed-up, so we can match on a lowercased
+ // host, but the path must be matched in a case sensitive way.
+ let pathIndex =
+ this._trimmedOriginalSearchString.indexOf("/", this._strippedPrefix.length);
+ this._autofillUrlSearchString = fixupSearchText(
+ this._trimmedOriginalSearchString.slice(0, pathIndex).toLowerCase() +
+ this._trimmedOriginalSearchString.slice(pathIndex)
+ );
+
+ this._prohibitSearchSuggestions = prohibitSearchSuggestions;
+
+ this._listener = autocompleteListener;
+ this._autocompleteSearch = autocompleteSearch;
+
+ // Create a new result to add eventual matches. Note we need a result
+ // regardless having matches.
+ let result = Cc["@mozilla.org/autocomplete/simple-result;1"]
+ .createInstance(Ci.nsIAutoCompleteSimpleResult);
+ result.setSearchString(searchString);
+ result.setListener(resultListener);
+ // Will be set later, if needed.
+ result.setDefaultIndex(-1);
+ this._result = result;
+
+ // These are used to avoid adding duplicate entries to the results.
+ this._usedURLs = new Set();
+ this._usedPlaceIds = new Set();
+
+ // Resolved when all the remote matches have been fetched.
+ this._remoteMatchesPromises = [];
+
+ // The index to insert remote matches at.
+ this._remoteMatchesStartIndex = 0;
+ // The index to insert local matches at.
+
+ this._localMatchesStartIndex = 0;
+
+ // Counts the number of inserted local matches.
+ this._localMatchesCount = 0;
+ // Counts the number of inserted remote matches.
+ this._remoteMatchesCount = 0;
+ // Counts the number of inserted extension matches.
+ this._extensionMatchesCount = 0;
+}
+
+Search.prototype = {
+ /**
+ * Enables the desired AutoComplete behavior.
+ *
+ * @param type
+ * The behavior type to set.
+ */
+ setBehavior: function (type) {
+ type = type.toUpperCase();
+ this._behavior |=
+ Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type];
+
+ // Setting the "typed" behavior should also set the "history" behavior.
+ if (type == "TYPED") {
+ this.setBehavior("history");
+ }
+ },
+
+ /**
+ * Determines if the specified AutoComplete behavior is set.
+ *
+ * @param aType
+ * The behavior type to test for.
+ * @return true if the behavior is set, false otherwise.
+ */
+ hasBehavior: function (type) {
+ let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()];
+
+ if (this._disablePrivateActions &&
+ behavior == Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE) {
+ return false;
+ }
+
+ return this._behavior & behavior;
+ },
+
+ /**
+ * Used to delay the most complex queries, to save IO while the user is
+ * typing.
+ */
+ _sleepDeferred: null,
+ _sleep: function (aTimeMs) {
+ // Reuse a single instance to try shaving off some usless work before
+ // the first query.
+ if (!this._sleepTimer)
+ this._sleepTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._sleepDeferred = PromiseUtils.defer();
+ this._sleepTimer.initWithCallback(() => this._sleepDeferred.resolve(),
+ aTimeMs, Ci.nsITimer.TYPE_ONE_SHOT);
+ return this._sleepDeferred.promise;
+ },
+
+ /**
+ * Given an array of tokens, this function determines which query should be
+ * ran. It also removes any special search tokens.
+ *
+ * @param tokens
+ * An array of search tokens.
+ * @return the filtered list of tokens to search with.
+ */
+ filterTokens: function (tokens) {
+ let foundToken = false;
+ // Set the proper behavior while filtering tokens.
+ for (let i = tokens.length - 1; i >= 0; i--) {
+ let behavior = Prefs.tokenToBehaviorMap.get(tokens[i]);
+ // Don't remove the token if it didn't match, or if it's an action but
+ // actions are not enabled.
+ if (behavior && (behavior != "openpage" || this._enableActions)) {
+ // Don't use the suggest preferences if it is a token search and
+ // set the restrict bit to 1 (to intersect the search results).
+ if (!foundToken) {
+ foundToken = true;
+ // Do not take into account previous behavior (e.g.: history, bookmark)
+ this._behavior = 0;
+ this.setBehavior("restrict");
+ }
+ this.setBehavior(behavior);
+ tokens.splice(i, 1);
+ }
+ }
+
+ // Set the right JavaScript behavior based on our preference. Note that the
+ // preference is whether or not we should filter JavaScript, and the
+ // behavior is if we should search it or not.
+ if (!Prefs.filterJavaScript) {
+ this.setBehavior("javascript");
+ }
+
+ return tokens;
+ },
+
+ /**
+ * Stop this search.
+ * After invoking this method, we won't run any more searches or heuristics,
+ * and no new matches may be added to the current result.
+ */
+ stop() {
+ if (this._sleepTimer)
+ this._sleepTimer.cancel();
+ if (this._sleepDeferred) {
+ this._sleepDeferred.resolve();
+ this._sleepDeferred = null;
+ }
+ if (this._searchSuggestionController) {
+ this._searchSuggestionController.stop();
+ this._searchSuggestionController = null;
+ }
+ this.pending = false;
+ },
+
+ /**
+ * Whether this search is active.
+ */
+ pending: true,
+
+ /**
+ * Execute the search and populate results.
+ * @param conn
+ * The Sqlite connection.
+ */
+ execute: Task.async(function* (conn) {
+ // A search might be canceled before it starts.
+ if (!this.pending)
+ return;
+
+ TelemetryStopwatch.start(TELEMETRY_1ST_RESULT, this);
+ if (this._searchString)
+ TelemetryStopwatch.start(TELEMETRY_6_FIRST_RESULTS, this);
+
+ // Since we call the synchronous parseSubmissionURL function later, we must
+ // wait for the initialization of PlacesSearchAutocompleteProvider first.
+ yield PlacesSearchAutocompleteProvider.ensureInitialized();
+ if (!this.pending)
+ return;
+
+ // For any given search, we run many queries/heuristics:
+ // 1) by alias (as defined in SearchService)
+ // 2) inline completion from search engine resultDomains
+ // 3) inline completion for hosts (this._hostQuery) or urls (this._urlQuery)
+ // 4) directly typed in url (ie, can be navigated to as-is)
+ // 5) submission for the current search engine
+ // 6) Places keywords
+ // 7) adaptive learning (this._adaptiveQuery)
+ // 8) open pages not supported by history (this._switchToTabQuery)
+ // 9) query based on match behavior
+ //
+ // (6) only gets ran if we get any filtered tokens, since if there are no
+ // tokens, there is nothing to match. This is the *first* query we check if
+ // we want to run, but it gets queued to be run later.
+ //
+ // (1), (4), (5) only get run if actions are enabled. When actions are
+ // enabled, the first result is always a special result (resulting from one
+ // of the queries between (1) and (6) inclusive). As such, the UI is
+ // expected to auto-select the first result when actions are enabled. If the
+ // first result is an inline completion result, that will also be the
+ // default result and therefore be autofilled (this also happens if actions
+ // are not enabled).
+
+ // Get the final query, based on the tokens found in the search string.
+ let queries = [ this._adaptiveQuery ];
+
+ // "openpage" behavior is supported by the default query.
+ // _switchToTabQuery instead returns only pages not supported by history.
+ if (this.hasBehavior("openpage")) {
+ queries.push(this._switchToTabQuery);
+ }
+ queries.push(this._searchQuery);
+
+ // Add the first heuristic result, if any. Set _addingHeuristicFirstMatch
+ // to true so that when the result is added, "heuristic" can be included in
+ // its style.
+ this._addingHeuristicFirstMatch = true;
+ let hasHeuristic = yield this._matchFirstHeuristicResult(conn);
+ this._addingHeuristicFirstMatch = false;
+ if (!this.pending)
+ return;
+
+ // We sleep a little between adding the heuristicFirstMatch and matching
+ // any other searches so we aren't kicking off potentially expensive
+ // searches on every keystroke.
+ // Though, if there's no heuristic result, we start searching immediately,
+ // since autocomplete may be waiting for us.
+ if (hasHeuristic) {
+ yield this._sleep(Prefs.delay);
+ if (!this.pending)
+ return;
+ }
+
+ if (this._enableActions && this._searchTokens.length > 0) {
+ yield this._matchSearchSuggestions();
+ if (!this.pending)
+ return;
+ }
+
+ for (let [query, params] of queries) {
+ yield conn.executeCached(query, params, this._onResultRow.bind(this));
+ if (!this.pending)
+ return;
+ }
+
+ if (this._enableActions && this.hasBehavior("openpage")) {
+ yield this._matchRemoteTabs();
+ if (!this.pending)
+ return;
+ }
+
+ // If we do not have enough results, and our match type is
+ // MATCH_BOUNDARY_ANYWHERE, search again with MATCH_ANYWHERE to get more
+ // results.
+ if (this._matchBehavior == MATCH_BOUNDARY_ANYWHERE &&
+ this._localMatchesCount < Prefs.maxRichResults) {
+ this._matchBehavior = MATCH_ANYWHERE;
+ for (let [query, params] of [ this._adaptiveQuery,
+ this._searchQuery ]) {
+ yield conn.executeCached(query, params, this._onResultRow.bind(this));
+ if (!this.pending)
+ return;
+ }
+ }
+
+ // Only add extension suggestions if the first token is a registered keyword
+ // and the search string has characters after the first token.
+ if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) &&
+ this._originalSearchString.length > this._searchTokens[0].length) {
+ yield this._matchExtensionSuggestions();
+ if (!this.pending)
+ return;
+ } else if (ExtensionSearchHandler.hasActiveInputSession()) {
+ ExtensionSearchHandler.handleInputCancelled();
+ }
+
+ // Ensure to fill any remaining space. Suggestions which come from extensions are
+ // inserted at the beginning, so any suggestions
+ yield Promise.all(this._remoteMatchesPromises);
+ }),
+
+ *_matchFirstHeuristicResult(conn) {
+ // We always try to make the first result a special "heuristic" result. The
+ // heuristics below determine what type of result it will be, if any.
+
+ let hasSearchTerms = this._searchTokens.length > 0;
+
+ if (hasSearchTerms) {
+ // It may be a keyword registered by an extension.
+ let matched = yield this._matchExtensionHeuristicResult();
+ if (matched) {
+ return true;
+ }
+ }
+
+ if (this._enableActions && hasSearchTerms) {
+ // It may be a search engine with an alias - which works like a keyword.
+ let matched = yield this._matchSearchEngineAlias();
+ if (matched) {
+ return true;
+ }
+ }
+
+ if (this.pending && hasSearchTerms) {
+ // It may be a Places keyword.
+ let matched = yield this._matchPlacesKeyword();
+ if (matched) {
+ return true;
+ }
+ }
+
+ let shouldAutofill = this._shouldAutofill;
+ if (this.pending && shouldAutofill) {
+ // It may also look like a URL we know from the database.
+ let matched = yield this._matchKnownUrl(conn);
+ if (matched) {
+ return true;
+ }
+ }
+
+ if (this.pending && shouldAutofill) {
+ // Or it may look like a URL we know about from search engines.
+ let matched = yield this._matchSearchEngineUrl();
+ if (matched) {
+ return true;
+ }
+ }
+
+ if (this.pending && hasSearchTerms && this._enableActions) {
+ // If we don't have a result that matches what we know about, then
+ // we use a fallback for things we don't know about.
+
+ // We may not have auto-filled, but this may still look like a URL.
+ // However, even if the input is a valid URL, we may not want to use
+ // it as such. This can happen if the host would require whitelisting,
+ // but isn't in the whitelist.
+ let matched = yield this._matchUnknownUrl();
+ if (matched) {
+ // Since we can't tell if this is a real URL and
+ // whether the user wants to visit or search for it,
+ // we always provide an alternative searchengine match.
+ try {
+ new URL(this._originalSearchString);
+ } catch (ex) {
+ if (Prefs.keywordEnabled && !looksLikeUrl(this._originalSearchString, true)) {
+ this._addingHeuristicFirstMatch = false;
+ yield this._matchCurrentSearchEngine();
+ this._addingHeuristicFirstMatch = true;
+ }
+ }
+ return true;
+ }
+ }
+
+ if (this.pending && this._enableActions && this._originalSearchString) {
+ // When all else fails, and the search string is non-empty, we search
+ // using the current search engine.
+ let matched = yield this._matchCurrentSearchEngine();
+ if (matched) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ *_matchSearchSuggestions() {
+ // Limit the string sent for search suggestions to a maximum length.
+ let searchString = this._searchTokens.join(" ")
+ .substr(0, Prefs.maxCharsForSearchSuggestions);
+ // Avoid fetching suggestions if they are not required, private browsing
+ // mode is enabled, or the search string may expose sensitive information.
+ if (!this.hasBehavior("searches") || this._inPrivateWindow ||
+ this._prohibitSearchSuggestionsFor(searchString)) {
+ return;
+ }
+
+ this._searchSuggestionController =
+ PlacesSearchAutocompleteProvider.getSuggestionController(
+ searchString,
+ this._inPrivateWindow,
+ Prefs.maxRichResults,
+ this._userContextId
+ );
+ let promise = this._searchSuggestionController.fetchCompletePromise
+ .then(() => {
+ // The search has been canceled already.
+ if (!this._searchSuggestionController)
+ return;
+ if (this._searchSuggestionController.resultsCount >= 0 &&
+ this._searchSuggestionController.resultsCount < 2) {
+ // The original string is used to properly compare with the next search.
+ this._lastLowResultsSearchSuggestion = this._originalSearchString;
+ }
+ while (this.pending && this._remoteMatchesCount < Prefs.maxRichResults) {
+ let [match, suggestion] = this._searchSuggestionController.consume();
+ if (!suggestion)
+ break;
+ if (!looksLikeUrl(suggestion)) {
+ // Don't include the restrict token, if present.
+ let searchString = this._searchTokens.join(" ");
+ this._addSearchEngineMatch(match, searchString, suggestion);
+ }
+ }
+ });
+
+ if (this.hasBehavior("restrict")) {
+ // We're done if we're restricting to search suggestions.
+ yield promise;
+ this.stop();
+ } else {
+ this._remoteMatchesPromises.push(promise);
+ }
+ },
+
+ _prohibitSearchSuggestionsFor(searchString) {
+ if (this._prohibitSearchSuggestions)
+ return true;
+
+ // Suggestions for a single letter are unlikely to be useful.
+ if (searchString.length < 2)
+ return true;
+
+ // The first token may be a whitelisted host.
+ if (this._searchTokens.length == 1 &&
+ REGEXP_SINGLEWORD_HOST.test(this._searchTokens[0]) &&
+ Services.uriFixup.isDomainWhitelisted(this._searchTokens[0], -1)) {
+ return true;
+ }
+
+ // Disallow fetching search suggestions for strings looking like URLs, to
+ // avoid disclosing information about networks or passwords.
+ return this._searchTokens.some(looksLikeUrl);
+ },
+
+ _matchKnownUrl: function* (conn) {
+ // Hosts have no "/" in them.
+ let lastSlashIndex = this._searchString.lastIndexOf("/");
+ // Search only URLs if there's a slash in the search string...
+ if (lastSlashIndex != -1) {
+ // ...but not if it's exactly at the end of the search string.
+ if (lastSlashIndex < this._searchString.length - 1) {
+ // We don't want to execute this query right away because it needs to
+ // search the entire DB without an index, but we need to know if we have
+ // a result as it will influence other heuristics. So we guess by
+ // assuming that if we get a result from a *host* query and it *looks*
+ // like a URL, then we'll probably have a result.
+ let gotResult = false;
+ let [ query, params ] = this._urlQuery;
+ yield conn.executeCached(query, params, row => {
+ gotResult = true;
+ this._onResultRow(row);
+ });
+ return gotResult;
+ }
+ return false;
+ }
+
+ let gotResult = false;
+ let [ query, params ] = this._hostQuery;
+ yield conn.executeCached(query, params, row => {
+ gotResult = true;
+ this._onResultRow(row);
+ });
+ return gotResult;
+ },
+
+ _matchExtensionHeuristicResult: function* () {
+ if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) &&
+ this._originalSearchString.length > this._searchTokens[0].length) {
+ let description = ExtensionSearchHandler.getDescription(this._searchTokens[0]);
+ this._addExtensionMatch(this._originalSearchString, description);
+ return true;
+ }
+ return false;
+ },
+
+ _matchPlacesKeyword: function* () {
+ // The first word could be a keyword, so that's what we'll search.
+ let keyword = this._searchTokens[0];
+ let entry = yield PlacesUtils.keywords.fetch(this._searchTokens[0]);
+ if (!entry)
+ return false;
+
+ let searchString = this._trimmedOriginalSearchString.substr(keyword.length + 1);
+
+ let url = null, postData = null;
+ try {
+ [url, postData] =
+ yield BrowserUtils.parseUrlAndPostData(entry.url.href,
+ entry.postData,
+ searchString);
+ } catch (ex) {
+ // It's not possible to bind a param to this keyword.
+ return false;
+ }
+
+ let style = (this._enableActions ? "action " : "") + "keyword";
+ let actionURL = PlacesUtils.mozActionURI("keyword", {
+ url,
+ input: this._originalSearchString,
+ postData,
+ });
+ let value = this._enableActions ? actionURL : url;
+ // The title will end up being "host: queryString"
+ let comment = entry.url.host;
+
+ this._addMatch({ value, comment, style, frecency: FRECENCY_DEFAULT });
+ return true;
+ },
+
+ _matchSearchEngineUrl: function* () {
+ if (!Prefs.autofillSearchEngines)
+ return false;
+
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByToken(
+ this._searchString);
+ if (!match)
+ return false;
+
+ // The match doesn't contain a 'scheme://www.' prefix, but since we have
+ // stripped it from the search string, here we could still be matching
+ // 'https://www.g' to 'google.com'.
+ // There are a couple cases where we don't want to match though:
+ //
+ // * If the protocol differs we should not match. For example if the user
+ // searched https we should not return http.
+ try {
+ let prefixURI = NetUtil.newURI(this._strippedPrefix);
+ let finalURI = NetUtil.newURI(match.url);
+ if (prefixURI.scheme != finalURI.scheme)
+ return false;
+ } catch (e) {}
+
+ // * If the user typed "www." but the final url doesn't have it, we
+ // should not match as well, the two urls may point to different pages.
+ if (this._strippedPrefix.endsWith("www.") &&
+ !stripHttpAndTrim(match.url).startsWith("www."))
+ return false;
+
+ let value = this._strippedPrefix + match.token;
+
+ // In any case, we should never arrive here with a value that doesn't
+ // match the search string. If this happens there is some case we
+ // are not handling properly yet.
+ if (!value.startsWith(this._originalSearchString)) {
+ Components.utils.reportError(`Trying to inline complete in-the-middle
+ ${this._originalSearchString} to ${value}`);
+ return false;
+ }
+
+ this._result.setDefaultIndex(0);
+ this._addMatch({
+ value: value,
+ comment: match.engineName,
+ icon: match.iconUrl,
+ style: "priority-search",
+ finalCompleteValue: match.url,
+ frecency: FRECENCY_DEFAULT
+ });
+ return true;
+ },
+
+ _matchSearchEngineAlias: function* () {
+ if (this._searchTokens.length < 1)
+ return false;
+
+ let alias = this._searchTokens[0];
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByAlias(alias);
+ if (!match)
+ return false;
+
+ match.engineAlias = alias;
+ let query = this._trimmedOriginalSearchString.substr(alias.length + 1);
+
+ this._addSearchEngineMatch(match, query);
+ return true;
+ },
+
+ _matchCurrentSearchEngine: function* () {
+ let match = yield PlacesSearchAutocompleteProvider.getDefaultMatch();
+ if (!match)
+ return false;
+
+ let query = this._originalSearchString;
+ this._addSearchEngineMatch(match, query);
+ return true;
+ },
+
+ _addExtensionMatch(content, comment) {
+ if (this._extensionMatchesCount >= MAXIMUM_ALLOWED_EXTENSION_MATCHES) {
+ return;
+ }
+
+ this._addMatch({
+ value: PlacesUtils.mozActionURI("extension", {
+ content,
+ keyword: this._searchTokens[0]
+ }),
+ comment,
+ icon: "chrome://browser/content/extension.svg",
+ style: "action extension",
+ frecency: FRECENCY_DEFAULT,
+ extension: true,
+ });
+ },
+
+ _addSearchEngineMatch(match, query, suggestion) {
+ let actionURLParams = {
+ engineName: match.engineName,
+ input: suggestion || this._originalSearchString,
+ searchQuery: query,
+ };
+ if (suggestion)
+ actionURLParams.searchSuggestion = suggestion;
+ if (match.engineAlias) {
+ actionURLParams.alias = match.engineAlias;
+ }
+ let value = PlacesUtils.mozActionURI("searchengine", actionURLParams);
+
+ this._addMatch({
+ value: value,
+ comment: match.engineName,
+ icon: match.iconUrl,
+ style: "action searchengine",
+ frecency: FRECENCY_DEFAULT,
+ remote: !!suggestion
+ });
+ },
+
+ *_matchExtensionSuggestions() {
+ let promise = ExtensionSearchHandler.handleSearch(this._searchTokens[0], this._originalSearchString,
+ suggestions => {
+ suggestions.forEach(suggestion => {
+ let content = `${this._searchTokens[0]} ${suggestion.content}`;
+ this._addExtensionMatch(content, suggestion.description);
+ });
+ }
+ );
+ this._remoteMatchesPromises.push(promise);
+ },
+
+ *_matchRemoteTabs() {
+ let matches = yield PlacesRemoteTabsAutocompleteProvider.getMatches(this._originalSearchString);
+ for (let {url, title, icon, deviceName} of matches) {
+ // It's rare that Sync supplies the icon for the page (but if it does, it
+ // is a string URL)
+ if (!icon) {
+ try {
+ let favicon = yield PlacesUtils.promiseFaviconLinkUrl(url);
+ if (favicon) {
+ icon = favicon.spec;
+ }
+ } catch (ex) {} // no favicon for this URL.
+ } else {
+ icon = PlacesUtils.favicons
+ .getFaviconLinkForIcon(NetUtil.newURI(icon)).spec;
+ }
+
+ let match = {
+ // We include the deviceName in the action URL so we can render it in
+ // the URLBar.
+ value: PlacesUtils.mozActionURI("remotetab", { url, deviceName }),
+ comment: title || url,
+ style: "action remotetab",
+ // we want frecency > FRECENCY_DEFAULT so it doesn't get pushed out
+ // by "remote" matches.
+ frecency: FRECENCY_DEFAULT + 1,
+ icon,
+ }
+ this._addMatch(match);
+ }
+ },
+
+ // TODO (bug 1054814): Use visited URLs to inform which scheme to use, if the
+ // scheme isn't specificed.
+ _matchUnknownUrl: function* () {
+ let flags = Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
+ Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
+ let fixupInfo = null;
+ try {
+ fixupInfo = Services.uriFixup.getFixupURIInfo(this._originalSearchString,
+ flags);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_MALFORMED_URI && !Prefs.keywordEnabled) {
+ let value = PlacesUtils.mozActionURI("visiturl", {
+ url: this._originalSearchString,
+ input: this._originalSearchString,
+ });
+ this._addMatch({
+ value,
+ comment: this._originalSearchString,
+ style: "action visiturl",
+ frecency: 0,
+ });
+
+ return true;
+ }
+ return false;
+ }
+
+ // If the URI cannot be fixed or the preferred URI would do a keyword search,
+ // that basically means this isn't useful to us. Note that
+ // fixupInfo.keywordAsSent will never be true if the keyword.enabled pref
+ // is false or there are no engines, so in that case we will always return
+ // a "visit".
+ if (!fixupInfo.fixedURI || fixupInfo.keywordAsSent)
+ return false;
+
+ let uri = fixupInfo.fixedURI;
+ // Check the host, as "http:///" is a valid nsIURI, but not useful to us.
+ // But, some schemes are expected to have no host. So we check just against
+ // schemes we know should have a host. This allows new schemes to be
+ // implemented without us accidentally blocking access to them.
+ let hostExpected = new Set(["http", "https", "ftp", "chrome", "resource"]);
+ if (hostExpected.has(uri.scheme) && !uri.host)
+ return false;
+
+ // getFixupURIInfo() escaped the URI, so it may not be pretty. Embed the
+ // escaped URL in the action URI since that URL should be "canonical". But
+ // pass the pretty, unescaped URL as the match comment, since it's likely
+ // to be displayed to the user, and in any case the front-end should not
+ // rely on it being canonical.
+ let escapedURL = uri.spec;
+ let displayURL = textURIService.unEscapeURIForUI("UTF-8", uri.spec);
+
+ let value = PlacesUtils.mozActionURI("visiturl", {
+ url: escapedURL,
+ input: this._originalSearchString,
+ });
+
+ let match = {
+ value: value,
+ comment: displayURL,
+ style: "action visiturl",
+ frecency: 0,
+ };
+
+ try {
+ let favicon = yield PlacesUtils.promiseFaviconLinkUrl(uri);
+ if (favicon)
+ match.icon = favicon.spec;
+ } catch (e) {
+ // It's possible we don't have a favicon for this - and that's ok.
+ }
+
+ this._addMatch(match);
+ return true;
+ },
+
+ _onResultRow: function (row) {
+ if (this._localMatchesCount == 0) {
+ TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT, this);
+ }
+ let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE);
+ let match;
+ switch (queryType) {
+ case QUERYTYPE_AUTOFILL_HOST:
+ this._result.setDefaultIndex(0);
+ match = this._processHostRow(row);
+ break;
+ case QUERYTYPE_AUTOFILL_URL:
+ this._result.setDefaultIndex(0);
+ match = this._processUrlRow(row);
+ break;
+ case QUERYTYPE_FILTERED:
+ match = this._processRow(row);
+ break;
+ }
+ this._addMatch(match);
+ // If the search has been canceled by the user or by _addMatch, or we
+ // fetched enough results, we can stop the underlying Sqlite query.
+ if (!this.pending || this._localMatchesCount == Prefs.maxRichResults)
+ throw StopIteration;
+ },
+
+ _maybeRestyleSearchMatch: function (match) {
+ // Return if the URL does not represent a search result.
+ let parseResult =
+ PlacesSearchAutocompleteProvider.parseSubmissionURL(match.value);
+ if (!parseResult) {
+ return;
+ }
+
+ // Do not apply the special style if the user is doing a search from the
+ // location bar but the entered terms match an irrelevant portion of the
+ // URL. For example, "https://www.google.com/search?q=terms&client=firefox"
+ // when searching for "Firefox".
+ let terms = parseResult.terms.toLowerCase();
+ if (this._searchTokens.length > 0 &&
+ this._searchTokens.every(token => !terms.includes(token))) {
+ return;
+ }
+
+ // Turn the match into a searchengine action with a favicon.
+ match.value = PlacesUtils.mozActionURI("searchengine", {
+ engineName: parseResult.engineName,
+ input: parseResult.terms,
+ searchQuery: parseResult.terms,
+ });
+ match.comment = parseResult.engineName;
+ match.icon = match.icon || match.iconUrl;
+ match.style = "action searchengine favicon";
+ },
+
+ _addMatch(match) {
+ // A search could be canceled between a query start and its completion,
+ // in such a case ensure we won't notify any result for it.
+ if (!this.pending)
+ return;
+
+ // Must check both id and url, cause keywords dynamically modify the url.
+ let urlMapKey = makeKeyForURL(match.value);
+ if ((match.placeId && this._usedPlaceIds.has(match.placeId)) ||
+ this._usedURLs.has(urlMapKey)) {
+ return;
+ }
+
+ // Add this to our internal tracker to ensure duplicates do not end up in
+ // the result.
+ // Not all entries have a place id, thus we fallback to the url for them.
+ // We cannot use only the url since keywords entries are modified to
+ // include the search string, and would be returned multiple times. Ids
+ // are faster too.
+ if (match.placeId)
+ this._usedPlaceIds.add(match.placeId);
+ this._usedURLs.add(urlMapKey);
+
+ match.style = match.style || "favicon";
+
+ // Restyle past searches, unless they are bookmarks or special results.
+ if (Prefs.restyleSearches && match.style == "favicon") {
+ this._maybeRestyleSearchMatch(match);
+ }
+
+ if (this._addingHeuristicFirstMatch) {
+ match.style += " heuristic";
+ }
+
+ match.icon = match.icon || "";
+ match.finalCompleteValue = match.finalCompleteValue || "";
+
+ this._result.insertMatchAt(this._getInsertIndexForMatch(match),
+ match.value,
+ match.comment,
+ match.icon,
+ match.style,
+ match.finalCompleteValue);
+
+ if (this._result.matchCount == 6)
+ TelemetryStopwatch.finish(TELEMETRY_6_FIRST_RESULTS, this);
+
+ this.notifyResults(true);
+ },
+
+ _getInsertIndexForMatch(match) {
+ let index = 0;
+ if (match.remote) {
+ // Append after local matches.
+ index = this._remoteMatchesStartIndex + this._remoteMatchesCount;
+ this._remoteMatchesCount++;
+ } else if (match.extension) {
+ index = this._localMatchesStartIndex;
+ this._localMatchesStartIndex++;
+ this._remoteMatchesStartIndex++;
+ this._extensionMatchesCount++;
+ } else {
+ // This is a local match.
+ if (match.frecency > FRECENCY_DEFAULT ||
+ this._localMatchesCount < MINIMUM_LOCAL_MATCHES) {
+ // Append before remote matches.
+ index = this._remoteMatchesStartIndex;
+ this._remoteMatchesStartIndex++
+ } else {
+ // Append after remote matches.
+ index = this._localMatchesCount + this._remoteMatchesCount;
+ }
+ this._localMatchesCount++;
+ }
+ return index;
+ },
+
+ _processHostRow: function (row) {
+ let match = {};
+ let trimmedHost = row.getResultByIndex(QUERYINDEX_URL);
+ let untrimmedHost = row.getResultByIndex(QUERYINDEX_TITLE);
+ let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY);
+ let faviconUrl = row.getResultByIndex(QUERYINDEX_ICONURL);
+
+ // If the untrimmed value doesn't preserve the user's input just
+ // ignore it and complete to the found host.
+ if (untrimmedHost &&
+ !untrimmedHost.toLowerCase().includes(this._trimmedOriginalSearchString.toLowerCase())) {
+ untrimmedHost = null;
+ }
+
+ match.value = this._strippedPrefix + trimmedHost;
+ // Remove the trailing slash.
+ match.comment = stripHttpAndTrim(trimmedHost);
+ match.finalCompleteValue = untrimmedHost;
+ if (faviconUrl) {
+ match.icon = PlacesUtils.favicons
+ .getFaviconLinkForIcon(NetUtil.newURI(faviconUrl)).spec;
+ }
+ // Although this has a frecency, this query is executed before any other
+ // queries that would result in frecency matches.
+ match.frecency = frecency;
+ match.style = "autofill";
+ return match;
+ },
+
+ _processUrlRow: function (row) {
+ let match = {};
+ let value = row.getResultByIndex(QUERYINDEX_URL);
+ let url = fixupSearchText(value);
+ let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY);
+ let faviconUrl = row.getResultByIndex(QUERYINDEX_ICONURL);
+
+ let prefix = value.slice(0, value.length - stripPrefix(value).length);
+
+ // We must complete the URL up to the next separator (which is /, ? or #).
+ let separatorIndex = url.slice(this._searchString.length)
+ .search(/[\/\?\#]/);
+ if (separatorIndex != -1) {
+ separatorIndex += this._searchString.length;
+ if (url[separatorIndex] == "/") {
+ separatorIndex++; // Include the "/" separator
+ }
+ url = url.slice(0, separatorIndex);
+ }
+
+ // If the untrimmed value doesn't preserve the user's input just
+ // ignore it and complete to the found url.
+ let untrimmedURL = prefix + url;
+ if (untrimmedURL &&
+ !untrimmedURL.toLowerCase().includes(this._trimmedOriginalSearchString.toLowerCase())) {
+ untrimmedURL = null;
+ }
+
+ match.value = this._strippedPrefix + url;
+ match.comment = url;
+ match.finalCompleteValue = untrimmedURL;
+ if (faviconUrl) {
+ match.icon = PlacesUtils.favicons
+ .getFaviconLinkForIcon(NetUtil.newURI(faviconUrl)).spec;
+ }
+ // Although this has a frecency, this query is executed before any other
+ // queries that would result in frecency matches.
+ match.frecency = frecency;
+ match.style = "autofill";
+ return match;
+ },
+
+ _processRow: function (row) {
+ let match = {};
+ match.placeId = row.getResultByIndex(QUERYINDEX_PLACEID);
+ let escapedURL = row.getResultByIndex(QUERYINDEX_URL);
+ let openPageCount = row.getResultByIndex(QUERYINDEX_SWITCHTAB) || 0;
+ let historyTitle = row.getResultByIndex(QUERYINDEX_TITLE) || "";
+ let iconurl = row.getResultByIndex(QUERYINDEX_ICONURL) || "";
+ let bookmarked = row.getResultByIndex(QUERYINDEX_BOOKMARKED);
+ let bookmarkTitle = bookmarked ?
+ row.getResultByIndex(QUERYINDEX_BOOKMARKTITLE) : null;
+ let tags = row.getResultByIndex(QUERYINDEX_TAGS) || "";
+ let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY);
+
+ // If actions are enabled and the page is open, add only the switch-to-tab
+ // result. Otherwise, add the normal result.
+ let url = escapedURL;
+ let action = null;
+ if (this._enableActions && openPageCount > 0 && this.hasBehavior("openpage")) {
+ url = PlacesUtils.mozActionURI("switchtab", {url: escapedURL});
+ action = "switchtab";
+ }
+
+ // Always prefer the bookmark title unless it is empty
+ let title = bookmarkTitle || historyTitle;
+
+ // We will always prefer to show tags if we have them.
+ let showTags = !!tags;
+
+ // However, we'll act as if a page is not bookmarked if the user wants
+ // only history and not bookmarks and there are no tags.
+ if (this.hasBehavior("history") && !this.hasBehavior("bookmark") &&
+ !showTags) {
+ showTags = false;
+ match.style = "favicon";
+ }
+
+ // If we have tags and should show them, we need to add them to the title.
+ if (showTags) {
+ title += TITLE_TAGS_SEPARATOR + tags;
+ }
+
+ // We have to determine the right style to display. Tags show the tag icon,
+ // bookmarks get the bookmark icon, and keywords get the keyword icon. If
+ // the result does not fall into any of those, it just gets the favicon.
+ if (!match.style) {
+ // It is possible that we already have a style set (from a keyword
+ // search or because of the user's preferences), so only set it if we
+ // haven't already done so.
+ if (showTags) {
+ // If we're not suggesting bookmarks, then this shouldn't
+ // display as one.
+ match.style = this.hasBehavior("bookmark") ? "bookmark-tag" : "tag";
+ }
+ else if (bookmarked) {
+ match.style = "bookmark";
+ }
+ }
+
+ if (action)
+ match.style = "action " + action;
+
+ match.value = url;
+ match.comment = title;
+ if (iconurl) {
+ match.icon = PlacesUtils.favicons
+ .getFaviconLinkForIcon(NetUtil.newURI(iconurl)).spec;
+ }
+ match.frecency = frecency;
+
+ return match;
+ },
+
+ /**
+ * @return a string consisting of the search query to be used based on the
+ * previously set urlbar suggestion preferences.
+ */
+ get _suggestionPrefQuery() {
+ if (!this.hasBehavior("restrict") && this.hasBehavior("history") &&
+ this.hasBehavior("bookmark")) {
+ return this.hasBehavior("typed") ? defaultQuery("AND h.typed = 1")
+ : defaultQuery();
+ }
+ let conditions = [];
+ if (this.hasBehavior("history")) {
+ // Enforce ignoring the visit_count index, since the frecency one is much
+ // faster in this case. ANALYZE helps the query planner to figure out the
+ // faster path, but it may not have up-to-date information yet.
+ conditions.push("+h.visit_count > 0");
+ }
+ if (this.hasBehavior("typed")) {
+ conditions.push("h.typed = 1");
+ }
+ if (this.hasBehavior("bookmark")) {
+ conditions.push("bookmarked");
+ }
+ if (this.hasBehavior("tag")) {
+ conditions.push("tags NOTNULL");
+ }
+
+ return conditions.length ? defaultQuery("AND " + conditions.join(" AND "))
+ : defaultQuery();
+ },
+
+ /**
+ * Obtains the search query to be used based on the previously set search
+ * preferences (accessed by this.hasBehavior).
+ *
+ * @return an array consisting of the correctly optimized query to search the
+ * database with and an object containing the params to bound.
+ */
+ get _searchQuery() {
+ let query = this._suggestionPrefQuery;
+
+ return [
+ query,
+ {
+ parent: PlacesUtils.tagsFolderId,
+ query_type: QUERYTYPE_FILTERED,
+ matchBehavior: this._matchBehavior,
+ searchBehavior: this._behavior,
+ // We only want to search the tokens that we are left with - not the
+ // original search string.
+ searchString: this._searchTokens.join(" "),
+ userContextId: this._userContextId,
+ // Limit the query to the the maximum number of desired results.
+ // This way we can avoid doing more work than needed.
+ maxResults: Prefs.maxRichResults
+ }
+ ];
+ },
+
+ /**
+ * Obtains the query to search for switch-to-tab entries.
+ *
+ * @return an array consisting of the correctly optimized query to search the
+ * database with and an object containing the params to bound.
+ */
+ get _switchToTabQuery() {
+ return [
+ SQL_SWITCHTAB_QUERY,
+ {
+ query_type: QUERYTYPE_FILTERED,
+ matchBehavior: this._matchBehavior,
+ searchBehavior: this._behavior,
+ // We only want to search the tokens that we are left with - not the
+ // original search string.
+ searchString: this._searchTokens.join(" "),
+ userContextId: this._userContextId,
+ maxResults: Prefs.maxRichResults
+ }
+ ];
+ },
+
+ /**
+ * Obtains the query to search for adaptive results.
+ *
+ * @return an array consisting of the correctly optimized query to search the
+ * database with and an object containing the params to bound.
+ */
+ get _adaptiveQuery() {
+ return [
+ SQL_ADAPTIVE_QUERY,
+ {
+ parent: PlacesUtils.tagsFolderId,
+ search_string: this._searchString,
+ query_type: QUERYTYPE_FILTERED,
+ matchBehavior: this._matchBehavior,
+ searchBehavior: this._behavior,
+ userContextId: this._userContextId,
+ }
+ ];
+ },
+
+ /**
+ * Whether we should try to autoFill.
+ */
+ get _shouldAutofill() {
+ // First of all, check for the autoFill pref.
+ if (!Prefs.autofill)
+ return false;
+
+ if (this._searchTokens.length != 1)
+ return false;
+
+ // autoFill can only cope with history or bookmarks entries.
+ if (!this.hasBehavior("history") &&
+ !this.hasBehavior("bookmark"))
+ return false;
+
+ // autoFill doesn't search titles or tags.
+ if (this.hasBehavior("title") || this.hasBehavior("tag"))
+ return false;
+
+ // Don't try to autofill if the search term includes any whitespace.
+ // This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH
+ // tokenizer ends up trimming the search string and returning a value
+ // that doesn't match it, or is even shorter.
+ if (REGEXP_SPACES.test(this._originalSearchString))
+ return false;
+
+ if (this._searchString.length == 0)
+ return false;
+
+ if (this._prohibitAutoFill)
+ return false;
+
+ return true;
+ },
+
+ /**
+ * Obtains the query to search for autoFill host results.
+ *
+ * @return an array consisting of the correctly optimized query to search the
+ * database with and an object containing the params to bound.
+ */
+ get _hostQuery() {
+ let typed = Prefs.autofillTyped || this.hasBehavior("typed");
+ let bookmarked = this.hasBehavior("bookmark") && !this.hasBehavior("history");
+
+ let query = [];
+ if (bookmarked) {
+ query.push(typed ? SQL_BOOKMARKED_TYPED_HOST_QUERY
+ : SQL_BOOKMARKED_HOST_QUERY);
+ } else {
+ query.push(typed ? SQL_TYPED_HOST_QUERY
+ : SQL_HOST_QUERY);
+ }
+
+ query.push({
+ query_type: QUERYTYPE_AUTOFILL_HOST,
+ searchString: this._searchString.toLowerCase()
+ });
+
+ return query;
+ },
+
+ /**
+ * Obtains the query to search for autoFill url results.
+ *
+ * @return an array consisting of the correctly optimized query to search the
+ * database with and an object containing the params to bound.
+ */
+ get _urlQuery() {
+ // We expect this to be a full URL, not just a host. We want to extract the
+ // host and use that as a guess for whether we'll get a result from a URL
+ // query.
+ let slashIndex = this._autofillUrlSearchString.indexOf("/");
+ let revHost = this._autofillUrlSearchString.substring(0, slashIndex).toLowerCase()
+ .split("").reverse().join("") + ".";
+
+ let typed = Prefs.autofillTyped || this.hasBehavior("typed");
+ let bookmarked = this.hasBehavior("bookmark") && !this.hasBehavior("history");
+
+ let query = [];
+ if (bookmarked) {
+ query.push(typed ? SQL_BOOKMARKED_TYPED_URL_QUERY
+ : SQL_BOOKMARKED_URL_QUERY);
+ } else {
+ query.push(typed ? SQL_TYPED_URL_QUERY
+ : SQL_URL_QUERY);
+ }
+
+ query.push({
+ query_type: QUERYTYPE_AUTOFILL_URL,
+ searchString: this._autofillUrlSearchString,
+ revHost
+ });
+
+ return query;
+ },
+
+ /**
+ * Notifies the listener about results.
+ *
+ * @param searchOngoing
+ * Indicates whether the search is ongoing.
+ */
+ notifyResults: function (searchOngoing) {
+ let result = this._result;
+ let resultCode = result.matchCount ? "RESULT_SUCCESS" : "RESULT_NOMATCH";
+ if (searchOngoing) {
+ resultCode += "_ONGOING";
+ }
+ result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]);
+ this._listener.onSearchResult(this._autocompleteSearch, result);
+ },
+}
+
+// UnifiedComplete class
+// component @mozilla.org/autocomplete/search;1?name=unifiedcomplete
+
+function UnifiedComplete() {
+ // Make sure the preferences are initialized as soon as possible.
+ // If the value of browser.urlbar.autocomplete.enabled is set to false,
+ // then all the other suggest preferences for history, bookmarks and
+ // open pages should be set to false.
+ Prefs;
+}
+
+UnifiedComplete.prototype = {
+ // Database handling
+
+ /**
+ * Promise resolved when the database initialization has completed, or null
+ * if it has never been requested.
+ */
+ _promiseDatabase: null,
+
+ /**
+ * Gets a Sqlite database handle.
+ *
+ * @return {Promise}
+ * @resolves to the Sqlite database handle (according to Sqlite.jsm).
+ * @rejects javascript exception.
+ */
+ getDatabaseHandle: function () {
+ if (Prefs.enabled && !this._promiseDatabase) {
+ this._promiseDatabase = Task.spawn(function* () {
+ let conn = yield Sqlite.cloneStorageConnection({
+ connection: PlacesUtils.history.DBConnection,
+ readOnly: true
+ });
+
+ try {
+ Sqlite.shutdown.addBlocker("Places UnifiedComplete.js clone closing",
+ Task.async(function* () {
+ SwitchToTabStorage.shutdown();
+ yield conn.close();
+ }));
+ } catch (ex) {
+ // It's too late to block shutdown, just close the connection.
+ yield conn.close();
+ throw ex;
+ }
+
+ // Autocomplete often fallbacks to a table scan due to lack of text
+ // indices. A larger cache helps reducing IO and improving performance.
+ // The value used here is larger than the default Storage value defined
+ // as MAX_CACHE_SIZE_BYTES in storage/mozStorageConnection.cpp.
+ yield conn.execute("PRAGMA cache_size = -6144"); // 6MiB
+
+ yield SwitchToTabStorage.initDatabase(conn);
+
+ return conn;
+ }.bind(this)).then(null, ex => { dump("Couldn't get database handle: " + ex + "\n");
+ Cu.reportError(ex); });
+ }
+ return this._promiseDatabase;
+ },
+
+ // mozIPlacesAutoComplete
+
+ registerOpenPage(uri, userContextId) {
+ SwitchToTabStorage.add(uri, userContextId);
+ },
+
+ unregisterOpenPage(uri, userContextId) {
+ SwitchToTabStorage.delete(uri, userContextId);
+ },
+
+ // nsIAutoCompleteSearch
+
+ startSearch: function (searchString, searchParam, previousResult, listener) {
+ // Stop the search in case the controller has not taken care of it.
+ if (this._currentSearch) {
+ this.stopSearch();
+ }
+
+ // Note: We don't use previousResult to make sure ordering of results are
+ // consistent. See bug 412730 for more details.
+
+ // If the previous search didn't fetch enough search suggestions, it's
+ // unlikely a longer text would do.
+ let prohibitSearchSuggestions =
+ this._lastLowResultsSearchSuggestion &&
+ searchString.length > this._lastLowResultsSearchSuggestion.length &&
+ searchString.startsWith(this._lastLowResultsSearchSuggestion);
+
+ this._currentSearch = new Search(searchString, searchParam, listener,
+ this, this, prohibitSearchSuggestions);
+
+ // If we are not enabled, we need to return now. Notice we need an empty
+ // result regardless, so we still create the Search object.
+ if (!Prefs.enabled) {
+ this.finishSearch(true);
+ return;
+ }
+
+ let search = this._currentSearch;
+ this.getDatabaseHandle().then(conn => search.execute(conn))
+ .then(null, ex => {
+ dump(`Query failed: ${ex}\n`);
+ Cu.reportError(ex);
+ })
+ .then(() => {
+ if (search == this._currentSearch) {
+ this.finishSearch(true);
+ }
+ });
+ },
+
+ stopSearch: function () {
+ if (this._currentSearch) {
+ this._currentSearch.stop();
+ }
+ // Don't notify since we are canceling this search. This also means we
+ // won't fire onSearchComplete for this search.
+ this.finishSearch();
+ },
+
+ /**
+ * Properly cleans up when searching is completed.
+ *
+ * @param notify [optional]
+ * Indicates if we should notify the AutoComplete listener about our
+ * results or not.
+ */
+ finishSearch: function (notify=false) {
+ TelemetryStopwatch.cancel(TELEMETRY_1ST_RESULT, this);
+ TelemetryStopwatch.cancel(TELEMETRY_6_FIRST_RESULTS, this);
+ // Clear state now to avoid race conditions, see below.
+ let search = this._currentSearch;
+ if (!search)
+ return;
+ this._lastLowResultsSearchSuggestion = search._lastLowResultsSearchSuggestion;
+ delete this._currentSearch;
+
+ if (!notify)
+ return;
+
+ // There is a possible race condition here.
+ // When a search completes it calls finishSearch that notifies results
+ // here. When the controller gets the last result it fires
+ // onSearchComplete.
+ // If onSearchComplete immediately starts a new search it will set a new
+ // _currentSearch, and on return the execution will continue here, after
+ // notifyResults.
+ // Thus, ensure that notifyResults is the last call in this method,
+ // otherwise you might be touching the wrong search.
+ search.notifyResults(false);
+ },
+
+ // nsIAutoCompleteSimpleResultListener
+
+ onValueRemoved: function (result, spec, removeFromDB) {
+ if (removeFromDB) {
+ PlacesUtils.history.removePage(NetUtil.newURI(spec));
+ }
+ },
+
+ // nsIAutoCompleteSearchDescriptor
+
+ get searchType() {
+ return Ci.nsIAutoCompleteSearchDescriptor.SEARCH_TYPE_IMMEDIATE;
+ },
+
+ get clearingAutoFillSearchesAgain() {
+ return true;
+ },
+
+ // nsISupports
+
+ classID: Components.ID("f964a319-397a-4d21-8be6-5cdd1ee3e3ae"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(UnifiedComplete),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIAutoCompleteSearch,
+ Ci.nsIAutoCompleteSimpleResultListener,
+ Ci.nsIAutoCompleteSearchDescriptor,
+ Ci.mozIPlacesAutoComplete,
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference
+ ])
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([UnifiedComplete]);
diff --git a/toolkit/components/places/VisitInfo.cpp b/toolkit/components/places/VisitInfo.cpp
new file mode 100644
index 000000000..cd3ec2f79
--- /dev/null
+++ b/toolkit/components/places/VisitInfo.cpp
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "VisitInfo.h"
+#include "nsIURI.h"
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// VisitInfo
+
+VisitInfo::VisitInfo(int64_t aVisitId,
+ PRTime aVisitDate,
+ uint32_t aTransitionType,
+ already_AddRefed<nsIURI> aReferrer)
+: mVisitId(aVisitId)
+, mVisitDate(aVisitDate)
+, mTransitionType(aTransitionType)
+, mReferrer(aReferrer)
+{
+}
+
+VisitInfo::~VisitInfo()
+{
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// mozIVisitInfo
+
+NS_IMETHODIMP
+VisitInfo::GetVisitId(int64_t* _visitId)
+{
+ *_visitId = mVisitId;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+VisitInfo::GetVisitDate(PRTime* _visitDate)
+{
+ *_visitDate = mVisitDate;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+VisitInfo::GetTransitionType(uint32_t* _transitionType)
+{
+ *_transitionType = mTransitionType;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+VisitInfo::GetReferrerURI(nsIURI** _referrer)
+{
+ NS_IF_ADDREF(*_referrer = mReferrer);
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsISupports
+
+NS_IMPL_ISUPPORTS(
+ VisitInfo
+, mozIVisitInfo
+)
+
+} // namespace places
+} // namespace mozilla
diff --git a/toolkit/components/places/VisitInfo.h b/toolkit/components/places/VisitInfo.h
new file mode 100644
index 000000000..54b25c686
--- /dev/null
+++ b/toolkit/components/places/VisitInfo.h
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_places_VisitInfo_h__
+#define mozilla_places_VisitInfo_h__
+
+#include "mozIAsyncHistory.h"
+#include "nsAutoPtr.h"
+#include "mozilla/Attributes.h"
+
+class nsIURI;
+
+namespace mozilla {
+namespace places {
+
+class VisitInfo final : public mozIVisitInfo
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZIVISITINFO
+
+ VisitInfo(int64_t aVisitId, PRTime aVisitDate, uint32_t aTransitionType,
+ already_AddRefed<nsIURI> aReferrer);
+
+private:
+ ~VisitInfo();
+ const int64_t mVisitId;
+ const PRTime mVisitDate;
+ const uint32_t mTransitionType;
+ nsCOMPtr<nsIURI> mReferrer;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_VisitInfo_h__
diff --git a/toolkit/components/places/moz.build b/toolkit/components/places/moz.build
new file mode 100644
index 000000000..adac79cba
--- /dev/null
+++ b/toolkit/components/places/moz.build
@@ -0,0 +1,97 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+if CONFIG['MOZ_PLACES']:
+ TEST_DIRS += ['tests']
+
+XPIDL_SOURCES += [
+ 'nsINavHistoryService.idl',
+]
+
+XPIDL_MODULE = 'places'
+
+if CONFIG['MOZ_PLACES']:
+ XPIDL_SOURCES += [
+ 'mozIAsyncFavicons.idl',
+ 'mozIAsyncHistory.idl',
+ 'mozIAsyncLivemarks.idl',
+ 'mozIColorAnalyzer.idl',
+ 'mozIPlacesAutoComplete.idl',
+ 'mozIPlacesPendingOperation.idl',
+ 'nsIAnnotationService.idl',
+ 'nsIBrowserHistory.idl',
+ 'nsIFaviconService.idl',
+ 'nsINavBookmarksService.idl',
+ 'nsITaggingService.idl',
+ 'nsPIPlacesDatabase.idl',
+ ]
+
+ EXPORTS.mozilla.places = [
+ 'Database.h',
+ 'History.h',
+ ]
+
+ UNIFIED_SOURCES += [
+ 'Database.cpp',
+ 'FaviconHelpers.cpp',
+ 'Helpers.cpp',
+ 'History.cpp',
+ 'nsAnnoProtocolHandler.cpp',
+ 'nsAnnotationService.cpp',
+ 'nsFaviconService.cpp',
+ 'nsNavBookmarks.cpp',
+ 'nsNavHistory.cpp',
+ 'nsNavHistoryQuery.cpp',
+ 'nsNavHistoryResult.cpp',
+ 'nsPlacesModule.cpp',
+ 'PlaceInfo.cpp',
+ 'Shutdown.cpp',
+ 'SQLFunctions.cpp',
+ 'VisitInfo.cpp',
+ ]
+
+ LOCAL_INCLUDES += [
+ '../build',
+ ]
+
+ EXTRA_JS_MODULES += [
+ 'BookmarkHTMLUtils.jsm',
+ 'BookmarkJSONUtils.jsm',
+ 'Bookmarks.jsm',
+ 'ClusterLib.js',
+ 'ColorAnalyzer_worker.js',
+ 'ColorConversion.js',
+ 'ExtensionSearchHandler.jsm',
+ 'History.jsm',
+ 'PlacesBackups.jsm',
+ 'PlacesDBUtils.jsm',
+ 'PlacesRemoteTabsAutocompleteProvider.jsm',
+ 'PlacesSearchAutocompleteProvider.jsm',
+ 'PlacesSyncUtils.jsm',
+ 'PlacesTransactions.jsm',
+ 'PlacesUtils.jsm',
+ ]
+
+ EXTRA_COMPONENTS += [
+ 'ColorAnalyzer.js',
+ 'nsLivemarkService.js',
+ 'nsPlacesExpiration.js',
+ 'nsTaggingService.js',
+ 'PageIconProtocolHandler.js',
+ 'PlacesCategoriesStarter.js',
+ 'toolkitplaces.manifest',
+ 'UnifiedComplete.js',
+ ]
+
+ FINAL_LIBRARY = 'xul'
+
+include('/ipc/chromium/chromium-config.mozbuild')
+
+with Files('**'):
+ BUG_COMPONENT = ('Toolkit', 'Places')
+
+if CONFIG['GNU_CXX']:
+ CXXFLAGS += ['-Wno-error=shadow']
diff --git a/toolkit/components/places/mozIAsyncFavicons.idl b/toolkit/components/places/mozIAsyncFavicons.idl
new file mode 100644
index 000000000..f1be18278
--- /dev/null
+++ b/toolkit/components/places/mozIAsyncFavicons.idl
@@ -0,0 +1,174 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIFaviconDataCallback;
+interface nsIPrincipal;
+interface mozIPlacesPendingOperation;
+
+[scriptable, uuid(a9c81797-9133-4823-b55f-3646e67cfd41)]
+interface mozIAsyncFavicons : nsISupports
+{
+ /**
+ * Declares that a given page uses a favicon with the given URI and
+ * attempts to fetch and save the icon data by loading the favicon URI
+ * through an async network request.
+ *
+ * If the icon data already exists, we won't try to reload the icon unless
+ * aForceReload is true. Similarly, if the icon is in the failed favicon
+ * cache we won't do anything unless aForceReload is true, in which case
+ * we'll try to reload the favicon.
+ *
+ * This function will only save favicons for pages that are already stored in
+ * the database, like visited pages or bookmarks. For any other URIs, it
+ * will succeed but do nothing. This function will also ignore the error
+ * page favicon URI (see FAVICON_ERRORPAGE_URL below).
+ *
+ * Icons that fail to load will automatically be added to the failed favicon
+ * cache, and this function will not save favicons for non-bookmarked URIs
+ * when history is disabled.
+ *
+ * @note This function is identical to
+ * nsIFaviconService::setAndLoadFaviconForPage.
+ *
+ * @param aPageURI
+ * URI of the page whose favicon is being set.
+ * @param aFaviconURI
+ * URI of the favicon to associate with the page.
+ * @param aForceReload
+ * If aForceReload is false, we try to reload the favicon only if we
+ * don't have it or it has expired from the cache. Setting
+ * aForceReload to true causes us to reload the favicon even if we
+ * have a usable copy.
+ * @param aFaviconLoadType
+ * Set to FAVICON_LOAD_PRIVATE if the favicon is loaded from a private
+ * browsing window. Set to FAVICON_LOAD_NON_PRIVATE otherwise.
+ * @param aCallback
+ * Once we're done setting and/or fetching the favicon, we invoke this
+ * callback.
+ * @param aLoadingPrincipal
+ * Principal of the page whose favicon is being set. If this argument
+ * is omitted, the loadingPrincipal defaults to the nullPrincipal.
+ *
+ * @see nsIFaviconDataCallback in nsIFaviconService.idl.
+ */
+ mozIPlacesPendingOperation setAndFetchFaviconForPage(
+ in nsIURI aPageURI,
+ in nsIURI aFaviconURI,
+ in boolean aForceReload,
+ in unsigned long aFaviconLoadType,
+ [optional] in nsIFaviconDataCallback aCallback,
+ [optional] in nsIPrincipal aLoadingPrincipal);
+ /**
+ * Sets the data for a given favicon URI either by replacing existing data in
+ * the database or taking the place of otherwise fetched icon data when
+ * calling setAndFetchFaviconForPage later.
+ *
+ * Favicon data for favicon URIs that are not associated with a page URI via
+ * setAndFetchFaviconForPage will be stored in memory, but may be expired at
+ * any time, so you should make an effort to associate favicon URIs with page
+ * URIs as soon as possible.
+ *
+ * It's better to not use this function for chrome: icon URIs since you can
+ * reference the chrome image yourself. getFaviconLinkForIcon/Page will ignore
+ * any associated data if the favicon URI is "chrome:" and just return the
+ * same chrome URI.
+ *
+ * This function does NOT send out notifications that the data has changed.
+ * Pages using this favicons that are visible in history or bookmarks views
+ * will keep the old icon until they have been refreshed by other means.
+ *
+ * This function tries to optimize the favicon size, if it is bigger
+ * than a defined limit we will try to convert it to a 16x16 png image.
+ * If the conversion fails and favicon is still bigger than our max accepted
+ * size it won't be saved.
+ *
+ * @param aFaviconURI
+ * URI of the favicon whose data is being set.
+ * @param aData
+ * Binary contents of the favicon to save
+ * @param aDataLength
+ * Length of binary data
+ * @param aMimeType
+ * MIME type of the data to store. This is important so that we know
+ * what to report when the favicon is used. You should always set this
+ * param unless you are clearing an icon.
+ * @param aExpiration
+ * Time in microseconds since the epoch when this favicon expires.
+ * Until this time, we won't try to load it again.
+ * @throws NS_ERROR_FAILURE
+ * Thrown if the favicon is overbloated and won't be saved to the db.
+ */
+ void replaceFaviconData(in nsIURI aFaviconURI,
+ [const,array,size_is(aDataLen)] in octet aData,
+ in unsigned long aDataLen,
+ in AUTF8String aMimeType,
+ [optional] in PRTime aExpiration);
+
+ /**
+ * Same as replaceFaviconData but the data is provided by a string
+ * containing a data URL.
+ *
+ * @see replaceFaviconData
+ *
+ * @param aFaviconURI
+ * URI of the favicon whose data is being set.
+ * @param aDataURL
+ * string containing a data URL that represents the contents of
+ * the favicon to save
+ * @param aExpiration
+ * Time in microseconds since the epoch when this favicon expires.
+ * Until this time, we won't try to load it again.
+ * @param aLoadingPrincipal
+ * Principal of the page whose favicon is being set. If this argument
+ * is omitted, the loadingPrincipal defaults to the nullPrincipal.
+ * @throws NS_ERROR_FAILURE
+ * Thrown if the favicon is overbloated and won't be saved to the db.
+ */
+ void replaceFaviconDataFromDataURL(in nsIURI aFaviconURI,
+ in AString aDataURL,
+ [optional] in PRTime aExpiration,
+ [optional] in nsIPrincipal aLoadingPrincipal);
+
+ /**
+ * Retrieves the favicon URI associated to the given page, if any.
+ *
+ * @param aPageURI
+ * URI of the page whose favicon URI we're looking up.
+ * @param aCallback
+ * This callback is always invoked to notify the result of the lookup.
+ * The aURI parameter will be the favicon URI, or null when no favicon
+ * is associated with the page or an error occurred while fetching it.
+ *
+ * @note When the callback is invoked, aDataLen will be always 0, aData will
+ * be an empty array, and aMimeType will be an empty string, regardless
+ * of whether a favicon is associated with the page.
+ *
+ * @see nsIFaviconDataCallback in nsIFaviconService.idl.
+ */
+ void getFaviconURLForPage(in nsIURI aPageURI,
+ in nsIFaviconDataCallback aCallback);
+
+ /**
+ * Retrieves the favicon URI and data associated to the given page, if any.
+ *
+ * @param aPageURI
+ * URI of the page whose favicon URI and data we're looking up.
+ * @param aCallback
+ * This callback is always invoked to notify the result of the lookup. The aURI
+ * parameter will be the favicon URI, or null when no favicon is
+ * associated with the page or an error occurred while fetching it. If
+ * aURI is not null, the other parameters may contain the favicon data.
+ * However, if no favicon data is currently associated with the favicon
+ * URI, aDataLen will be 0, aData will be an empty array, and aMimeType
+ * will be an empty string.
+ *
+ * @see nsIFaviconDataCallback in nsIFaviconService.idl.
+ */
+ void getFaviconDataForPage(in nsIURI aPageURI,
+ in nsIFaviconDataCallback aCallback);
+};
diff --git a/toolkit/components/places/mozIAsyncHistory.idl b/toolkit/components/places/mozIAsyncHistory.idl
new file mode 100644
index 000000000..35c8cc3a6
--- /dev/null
+++ b/toolkit/components/places/mozIAsyncHistory.idl
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIVariant;
+
+[scriptable, uuid(41e4ccc9-f0c8-4cd7-9753-7a38514b8488)]
+interface mozIVisitInfo : nsISupports
+{
+ /**
+ * The machine-local (internal) id of the visit.
+ */
+ readonly attribute long long visitId;
+
+ /**
+ * The time the visit occurred.
+ */
+ readonly attribute PRTime visitDate;
+
+ /**
+ * The transition type used to get to this visit. One of the TRANSITION_TYPE
+ * constants on nsINavHistory.
+ *
+ * @see nsINavHistory.idl
+ */
+ readonly attribute unsigned long transitionType;
+
+ /**
+ * The referring URI of this visit. This may be null.
+ */
+ readonly attribute nsIURI referrerURI;
+};
+
+[scriptable, uuid(ad83e137-c92a-4b7b-b67e-0a318811f91e)]
+interface mozIPlaceInfo : nsISupports
+{
+ /**
+ * The machine-local (internal) id of the place.
+ */
+ readonly attribute long long placeId;
+
+ /**
+ * The globally unique id of the place.
+ */
+ readonly attribute ACString guid;
+
+ /**
+ * The URI of the place.
+ */
+ readonly attribute nsIURI uri;
+
+ /**
+ * The title associated with the place.
+ */
+ readonly attribute AString title;
+
+ /**
+ * The frecency of the place.
+ */
+ readonly attribute long long frecency;
+
+ /**
+ * An array of mozIVisitInfo objects for the place.
+ */
+ [implicit_jscontext]
+ readonly attribute jsval visits;
+};
+
+/**
+ * Shared Callback interface for mozIAsyncHistory methods. The semantics
+ * for each method are detailed in mozIAsyncHistory.
+ */
+[scriptable, uuid(1f266877-2859-418b-a11b-ec3ae4f4f93d)]
+interface mozIVisitInfoCallback : nsISupports
+{
+ /**
+ * Called when the given place could not be processed.
+ *
+ * @param aResultCode
+ * nsresult indicating the failure reason.
+ * @param aPlaceInfo
+ * The information that was given to the caller for the place.
+ */
+ void handleError(in nsresult aResultCode,
+ in mozIPlaceInfo aPlaceInfo);
+
+ /**
+ * Called for each place processed successfully.
+ *
+ * @param aPlaceInfo
+ * The current info stored for the place.
+ */
+ void handleResult(in mozIPlaceInfo aPlaceInfo);
+
+ /**
+ * Called when all records were processed.
+ */
+ void handleCompletion();
+
+};
+
+[scriptable, function, uuid(994092bf-936f-449b-8dd6-0941e024360d)]
+interface mozIVisitedStatusCallback : nsISupports
+{
+ /**
+ * Notifies whether a certain URI has been visited.
+ *
+ * @param aURI
+ * URI being notified about.
+ * @param aVisitedStatus
+ * The visited status of aURI.
+ */
+ void isVisited(in nsIURI aURI,
+ in boolean aVisitedStatus);
+};
+
+[scriptable, uuid(1643EFD2-A329-4733-A39D-17069C8D3B2D)]
+interface mozIAsyncHistory : nsISupports
+{
+ /**
+ * Gets the available information for the given array of places, each
+ * identified by either nsIURI or places GUID (string).
+ *
+ * The retrieved places info objects DO NOT include the visits data (the
+ * |visits| attribute is set to null).
+ *
+ * If a given place does not exist in the database, aCallback.handleError is
+ * called for it with NS_ERROR_NOT_AVAILABLE result code.
+ *
+ * @param aPlaceIdentifiers
+ * The place[s] for which to retrieve information, identified by either
+ * a single place GUID, a single URI, or a JS array of URIs and/or GUIDs.
+ * @param aCallback
+ * A mozIVisitInfoCallback object which consists of callbacks to be
+ * notified for successful or failed retrievals.
+ * If there's no information available for a given place, aCallback
+ * is called with a stub place info object, containing just the provided
+ * data (GUID or URI).
+ *
+ * @throws NS_ERROR_INVALID_ARG
+ * - Passing in NULL for aPlaceIdentifiers or aCallback.
+ * - Not providing at least one valid GUID or URI.
+ */
+ [implicit_jscontext]
+ void getPlacesInfo(in jsval aPlaceIdentifiers,
+ in mozIVisitInfoCallback aCallback);
+
+ /**
+ * Adds a set of visits for one or more mozIPlaceInfo objects, and updates
+ * each mozIPlaceInfo's title or guid.
+ *
+ * aCallback.handleResult is called for each visit added.
+ *
+ * @param aPlaceInfo
+ * The mozIPlaceInfo object[s] containing the information to store or
+ * update. This can be a single object, or an array of objects.
+ * @param [optional] aCallback
+ * A mozIVisitInfoCallback object which consists of callbacks to be
+ * notified for successful and/or failed changes.
+ *
+ * @throws NS_ERROR_INVALID_ARG
+ * - Passing in NULL for aPlaceInfo.
+ * - Not providing at least one valid guid, or uri for all
+ * mozIPlaceInfo object[s].
+ * - Not providing an array or nothing for the visits property of
+ * mozIPlaceInfo.
+ * - Not providing a visitDate and transitionType for each
+ * mozIVisitInfo.
+ * - Providing an invalid transitionType for a mozIVisitInfo.
+ */
+ [implicit_jscontext]
+ void updatePlaces(in jsval aPlaceInfo,
+ [optional] in mozIVisitInfoCallback aCallback);
+
+ /**
+ * Checks if a given URI has been visited.
+ *
+ * @param aURI
+ * The URI to check for.
+ * @param aCallback
+ * A mozIVisitStatusCallback object which receives the visited status.
+ */
+ void isURIVisited(in nsIURI aURI,
+ in mozIVisitedStatusCallback aCallback);
+};
diff --git a/toolkit/components/places/mozIAsyncLivemarks.idl b/toolkit/components/places/mozIAsyncLivemarks.idl
new file mode 100644
index 000000000..e84ecca8e
--- /dev/null
+++ b/toolkit/components/places/mozIAsyncLivemarks.idl
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+
+interface mozILivemarkInfo;
+interface mozILivemark;
+
+interface nsINavHistoryResultObserver;
+
+[scriptable, uuid(672387b7-a75d-4e8f-9b49-5c1dcbfff46b)]
+interface mozIAsyncLivemarks : nsISupports
+{
+ /**
+ * Creates a new livemark
+ *
+ * @param aLivemarkInfo
+ * mozILivemarkInfo object containing at least title, parentId,
+ * index and feedURI of the livemark to create.
+ *
+ * @return {Promise}
+ * @throws NS_ERROR_INVALID_ARG if the supplied information is insufficient
+ * for the creation.
+ */
+ jsval addLivemark(in jsval aLivemarkInfo);
+
+ /**
+ * Removes an existing livemark.
+ *
+ * @param aLivemarkInfo
+ * mozILivemarkInfo object containing either an id or a guid of the
+ * livemark to remove.
+ *
+ * @return {Promise}
+ * @throws NS_ERROR_INVALID_ARG if the id/guid is invalid.
+ */
+ jsval removeLivemark(in jsval aLivemarkInfo);
+
+ /**
+ * Gets an existing livemark.
+ *
+ * @param aLivemarkInfo
+ * mozILivemarkInfo object containing either an id or a guid of the
+ * livemark to retrieve.
+ *
+ * @return {Promise}
+ * @throws NS_ERROR_INVALID_ARG if the id/guid is invalid or an invalid
+ * callback is provided.
+ */
+ jsval getLivemark(in jsval aLivemarkInfo);
+
+ /**
+ * Reloads all livemarks if they are expired or if forced to do so.
+ *
+ * @param [optional]aForceUpdate
+ * If set to true forces a reload even if contents are still valid.
+ *
+ * @note The update process is asynchronous, observers registered through
+ * registerForUpdates will be notified of updated contents.
+ */
+ void reloadLivemarks([optional]in boolean aForceUpdate);
+};
+
+[scriptable, uuid(3a3c5e8f-ec4a-4086-ae0a-d16420d30c9f)]
+interface mozILivemarkInfo : nsISupports
+{
+ /**
+ * Id of the bookmarks folder representing this livemark.
+ *
+ * @deprecated Use guid instead.
+ */
+ readonly attribute long long id;
+
+ /**
+ * The globally unique identifier of this livemark.
+ */
+ readonly attribute ACString guid;
+
+ /**
+ * Title of this livemark.
+ */
+ readonly attribute AString title;
+
+ /**
+ * Id of the bookmarks parent folder containing this livemark.
+ *
+ * @deprecated Use parentGuid instead.
+ */
+ readonly attribute long long parentId;
+
+ /**
+ * Guid of the bookmarks parent folder containing this livemark.
+ */
+ readonly attribute long long parentGuid;
+
+ /**
+ * The position of this livemark in the bookmarks parent folder.
+ */
+ readonly attribute long index;
+
+ /**
+ * Time this livemark was created.
+ */
+ readonly attribute PRTime dateAdded;
+
+ /**
+ * Time this livemark's details were last modified. Doesn't track changes to
+ * the livemark contents.
+ */
+ readonly attribute PRTime lastModified;
+
+ /**
+ * The URI of the syndication feed associated with this livemark.
+ */
+ readonly attribute nsIURI feedURI;
+
+ /**
+ * The URI of the website associated with this livemark.
+ */
+ readonly attribute nsIURI siteURI;
+};
+
+[scriptable, uuid(9f6fdfae-db9a-4bd8-bde1-148758cf1b18)]
+interface mozILivemark : mozILivemarkInfo
+{
+ // Indicates the livemark is inactive.
+ const unsigned short STATUS_READY = 0;
+ // Indicates the livemark is fetching new contents.
+ const unsigned short STATUS_LOADING = 1;
+ // Indicates the livemark failed to fetch new contents.
+ const unsigned short STATUS_FAILED = 2;
+
+ /**
+ * Status of this livemark. One of the STATUS_* constants above.
+ */
+ readonly attribute unsigned short status;
+
+ /**
+ * Reload livemark contents if they are expired or if forced to do so.
+ *
+ * @param [optional]aForceUpdate
+ * If set to true forces a reload even if contents are still valid.
+ *
+ * @note The update process is asynchronous, it's possible to register a
+ * result observer to be notified of updated contents through
+ * registerForUpdates.
+ */
+ void reload([optional]in boolean aForceUpdate);
+
+ /**
+ * Returns an array of nsINavHistoryResultNode objects, representing children
+ * of this livemark. The nodes will have aContainerNode as parent.
+ *
+ * @param aContainerNode
+ * Object implementing nsINavHistoryContainerResultNode, to be used as
+ * parent of the livemark nodes.
+ */
+ jsval getNodesForContainer(in jsval aContainerNode);
+
+ /**
+ * Registers a container node for updates on this livemark.
+ * When the livemark contents change, an invalidateContainer(aContainerNode)
+ * request is sent to aResultObserver.
+ *
+ * @param aContainerNode
+ * Object implementing nsINavHistoryContainerResultNode, representing
+ * this livemark.
+ * @param aResultObserver
+ * The nsINavHistoryResultObserver that should be notified of changes
+ * to the livemark contents.
+ */
+ void registerForUpdates(in jsval aContainerNode,
+ in nsINavHistoryResultObserver aResultObserver);
+
+ /**
+ * Unregisters a previously registered container node.
+ *
+ * @param aContainerNode
+ * Object implementing nsINavHistoryContainerResultNode, representing
+ * this livemark.
+ *
+ * @note it's suggested to always unregister containers that are no more used,
+ * to free up the associated resources. A good time to do so is when
+ * the container gets closed.
+ */
+ void unregisterForUpdates(in jsval aContainerNode);
+};
diff --git a/toolkit/components/places/mozIColorAnalyzer.idl b/toolkit/components/places/mozIColorAnalyzer.idl
new file mode 100644
index 000000000..368958cbb
--- /dev/null
+++ b/toolkit/components/places/mozIColorAnalyzer.idl
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+
+[function, scriptable, uuid(e4089e21-71b6-40af-b546-33c21b90e874)]
+interface mozIRepresentativeColorCallback : nsISupports
+{
+ /**
+ * Will be called when color analysis finishes.
+ *
+ * @param success
+ * True if analysis was successful, false otherwise.
+ * Analysis can fail if the image is transparent, imageURI doesn't
+ * resolve to a valid image, or the image is too big.
+ *
+ * @param color
+ * The representative color as an integer in RGB form.
+ * e.g. 0xFF0102 == rgb(255,1,2)
+ * If success is false, color is not provided.
+ */
+ void onComplete(in boolean success, [optional] in unsigned long color);
+};
+
+[scriptable, uuid(d056186c-28a0-494e-aacc-9e433772b143)]
+interface mozIColorAnalyzer : nsISupports
+{
+ /**
+ * Given an image URI, find the most representative color for that image
+ * based on the frequency of each color. Preference is given to colors that
+ * are more interesting. Avoids the background color if it can be
+ * discerned. Ignores sufficiently transparent colors.
+ *
+ * This is intended to be used on favicon images. Larger images take longer
+ * to process, especially those with a larger number of unique colors. If
+ * imageURI points to an image that has more than 128^2 pixels, this method
+ * will fail before analyzing it for performance reasons.
+ *
+ * @param imageURI
+ * A URI pointing to the image - ideally a data: URI, but any scheme
+ * that will load when setting the src attribute of a DOM img element
+ * should work.
+ * @param callback
+ * Function to call when the representative color is found or an
+ * error occurs.
+ */
+ void findRepresentativeColor(in nsIURI imageURI,
+ in mozIRepresentativeColorCallback callback);
+};
diff --git a/toolkit/components/places/mozIPlacesAutoComplete.idl b/toolkit/components/places/mozIPlacesAutoComplete.idl
new file mode 100644
index 000000000..7f3247fdc
--- /dev/null
+++ b/toolkit/components/places/mozIPlacesAutoComplete.idl
@@ -0,0 +1,138 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 sts=2
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+
+/**
+ * This interface provides some constants used by the Places AutoComplete
+ * search provider as well as methods to track opened pages for AutoComplete
+ * purposes.
+ */
+[scriptable, uuid(61b6348a-09e1-4810-8057-f8cb3cec6ef8)]
+interface mozIPlacesAutoComplete : nsISupports
+{
+ //////////////////////////////////////////////////////////////////////////////
+ //// Matching Constants
+
+ /**
+ * Match anywhere in each searchable term.
+ */
+ const long MATCH_ANYWHERE = 0;
+
+ /**
+ * Match first on word boundaries, and if we do not get enough results, then
+ * match anywhere in each searchable term.
+ */
+ const long MATCH_BOUNDARY_ANYWHERE = 1;
+
+ /**
+ * Match on word boundaries in each searchable term.
+ */
+ const long MATCH_BOUNDARY = 2;
+
+ /**
+ * Match only the beginning of each search term.
+ */
+ const long MATCH_BEGINNING = 3;
+
+ /**
+ * Match anywhere in each searchable term without doing any transformation
+ * or stripping on the underlying data.
+ */
+ const long MATCH_ANYWHERE_UNMODIFIED = 4;
+
+ /**
+ * Match only the beginning of each search term using a case sensitive
+ * comparator.
+ */
+ const long MATCH_BEGINNING_CASE_SENSITIVE = 5;
+
+ //////////////////////////////////////////////////////////////////////////////
+ //// Search Behavior Constants
+
+ /**
+ * Search through history.
+ */
+ const long BEHAVIOR_HISTORY = 1 << 0;
+
+ /**
+ * Search though bookmarks.
+ */
+ const long BEHAVIOR_BOOKMARK = 1 << 1;
+
+ /**
+ * Search through tags.
+ */
+ const long BEHAVIOR_TAG = 1 << 2;
+
+ /**
+ * Search the title of pages.
+ */
+ const long BEHAVIOR_TITLE = 1 << 3;
+
+ /**
+ * Search the URL of pages.
+ */
+ const long BEHAVIOR_URL = 1 << 4;
+
+ /**
+ * Search for typed pages.
+ */
+ const long BEHAVIOR_TYPED = 1 << 5;
+
+ /**
+ * Search javascript: URLs.
+ */
+ const long BEHAVIOR_JAVASCRIPT = 1 << 6;
+
+ /**
+ * Search for pages that have been marked as being opened, such as a tab
+ * in a tabbrowser.
+ */
+ const long BEHAVIOR_OPENPAGE = 1 << 7;
+
+ /**
+ * Use intersection between history, typed, bookmark, tag and openpage
+ * instead of union, when the restrict bit is set.
+ */
+ const long BEHAVIOR_RESTRICT = 1 << 8;
+
+ /**
+ * Include search suggestions from the currently selected search provider.
+ */
+ const long BEHAVIOR_SEARCHES = 1 << 9;
+
+ /**
+ * Mark a page as being currently open.
+ *
+ * @note Pages will not be automatically unregistered when Private Browsing
+ * mode is entered or exited. Therefore, consumers MUST unregister or
+ * register themselves.
+ *
+ * @param aURI
+ * The URI to register as an open page.
+ * @param aUserContextId
+ * The Container Id of the tab.
+ */
+ void registerOpenPage(in nsIURI aURI, in uint32_t aUserContextId);
+
+ /**
+ * Mark a page as no longer being open (either by closing the window or tab,
+ * or by navigating away from that page).
+ *
+ * @note Pages will not be automatically unregistered when Private Browsing
+ * mode is entered or exited. Therefore, consumers MUST unregister or
+ * register themselves.
+ *
+ * @param aURI
+ * The URI to unregister as an open page.
+ * @param aUserContextId
+ * The Container Id of the tab.
+ */
+ void unregisterOpenPage(in nsIURI aURI, in uint32_t aUserContextId);
+};
diff --git a/toolkit/components/places/mozIPlacesPendingOperation.idl b/toolkit/components/places/mozIPlacesPendingOperation.idl
new file mode 100644
index 000000000..678a90870
--- /dev/null
+++ b/toolkit/components/places/mozIPlacesPendingOperation.idl
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(ebd31374-3808-40e4-9e73-303bf70467c3)]
+interface mozIPlacesPendingOperation : nsISupports {
+ /**
+ * Cancels a pending operation, if possible. This will only fail if you try
+ * to cancel more than once.
+ */
+ void cancel();
+};
diff --git a/toolkit/components/places/nsAnnoProtocolHandler.cpp b/toolkit/components/places/nsAnnoProtocolHandler.cpp
new file mode 100644
index 000000000..b98942e33
--- /dev/null
+++ b/toolkit/components/places/nsAnnoProtocolHandler.cpp
@@ -0,0 +1,367 @@
+//* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Implementation of moz-anno: URLs for accessing favicons. The urls are sent
+ * to the favicon service. If the favicon service doesn't have the
+ * data, a stream containing the default favicon will be returned.
+ *
+ * The reference to annotations ("moz-anno") is a leftover from previous
+ * iterations of this component. As of now the moz-anno protocol is independent
+ * of annotations.
+ */
+
+#include "nsAnnoProtocolHandler.h"
+#include "nsFaviconService.h"
+#include "nsIChannel.h"
+#include "nsIInputStreamChannel.h"
+#include "nsILoadGroup.h"
+#include "nsIStandardURL.h"
+#include "nsIStringStream.h"
+#include "nsISupportsUtils.h"
+#include "nsIURI.h"
+#include "nsNetUtil.h"
+#include "nsIOutputStream.h"
+#include "nsContentUtils.h"
+#include "nsServiceManagerUtils.h"
+#include "nsStringStream.h"
+#include "mozilla/storage.h"
+#include "nsIPipe.h"
+#include "Helpers.h"
+
+using namespace mozilla;
+using namespace mozilla::places;
+
+////////////////////////////////////////////////////////////////////////////////
+//// Global Functions
+
+/**
+ * Creates a channel to obtain the default favicon.
+ */
+static
+nsresult
+GetDefaultIcon(nsILoadInfo *aLoadInfo, nsIChannel **aChannel)
+{
+ nsCOMPtr<nsIURI> defaultIconURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(defaultIconURI),
+ NS_LITERAL_CSTRING(FAVICON_DEFAULT_URL));
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_NewChannelInternal(aChannel, defaultIconURI, aLoadInfo);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// faviconAsyncLoader
+
+namespace {
+
+/**
+ * An instance of this class is passed to the favicon service as the callback
+ * for getting favicon data from the database. We'll get this data back in
+ * HandleResult, and on HandleCompletion, we'll close our output stream which
+ * will close the original channel for the favicon request.
+ *
+ * However, if an error occurs at any point, we do not set mReturnDefaultIcon to
+ * false, so we will open up another channel to get the default favicon, and
+ * pass that along to our output stream in HandleCompletion. If anything
+ * happens at that point, the world must be against us, so we return nothing.
+ */
+class faviconAsyncLoader : public AsyncStatementCallback
+ , public nsIRequestObserver
+{
+public:
+ NS_DECL_ISUPPORTS_INHERITED
+
+ faviconAsyncLoader(nsIChannel *aChannel, nsIOutputStream *aOutputStream) :
+ mChannel(aChannel)
+ , mOutputStream(aOutputStream)
+ , mReturnDefaultIcon(true)
+ {
+ NS_ASSERTION(aChannel,
+ "Not providing a channel will result in crashes!");
+ NS_ASSERTION(aOutputStream,
+ "Not providing an output stream will result in crashes!");
+ }
+
+ //////////////////////////////////////////////////////////////////////////////
+ //// mozIStorageStatementCallback
+
+ NS_IMETHOD HandleResult(mozIStorageResultSet *aResultSet) override
+ {
+ // We will only get one row back in total, so we do not need to loop.
+ nsCOMPtr<mozIStorageRow> row;
+ nsresult rv = aResultSet->GetNextRow(getter_AddRefs(row));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We do not allow favicons without a MIME type, so we'll return the default
+ // icon.
+ nsAutoCString mimeType;
+ (void)row->GetUTF8String(1, mimeType);
+ NS_ENSURE_FALSE(mimeType.IsEmpty(), NS_OK);
+
+ // Set our mimeType now that we know it.
+ rv = mChannel->SetContentType(mimeType);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Obtain the binary blob that contains our favicon data.
+ uint8_t *favicon;
+ uint32_t size = 0;
+ rv = row->GetBlob(0, &size, &favicon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint32_t totalWritten = 0;
+ do {
+ uint32_t bytesWritten;
+ rv = mOutputStream->Write(
+ &(reinterpret_cast<const char *>(favicon)[totalWritten]),
+ size - totalWritten,
+ &bytesWritten
+ );
+ if (NS_FAILED(rv) || !bytesWritten)
+ break;
+ totalWritten += bytesWritten;
+ } while (size != totalWritten);
+ NS_ASSERTION(NS_FAILED(rv) || size == totalWritten,
+ "Failed to write all of our data out to the stream!");
+
+ // Free our favicon array.
+ free(favicon);
+
+ // Handle an error to write if it occurred, but only after we've freed our
+ // favicon.
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // At this point, we should have written out all of our data to our stream.
+ // HandleCompletion will close the output stream, so we are done here.
+ mReturnDefaultIcon = false;
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason) override
+ {
+ if (!mReturnDefaultIcon)
+ return mOutputStream->Close();
+
+ // We need to return our default icon, so we'll open up a new channel to get
+ // that data, and push it to our output stream. If at any point we get an
+ // error, we can't do anything, so we'll just close our output stream.
+ nsCOMPtr<nsIStreamListener> listener;
+ nsresult rv = NS_NewSimpleStreamListener(getter_AddRefs(listener),
+ mOutputStream, this);
+ NS_ENSURE_SUCCESS(rv, mOutputStream->Close());
+
+ // we should pass the loadInfo of the original channel along
+ // to the new channel. Note that mChannel can not be null,
+ // constructor checks that.
+ nsCOMPtr<nsILoadInfo> loadInfo = mChannel->GetLoadInfo();
+ nsCOMPtr<nsIChannel> newChannel;
+ rv = GetDefaultIcon(loadInfo, getter_AddRefs(newChannel));
+ NS_ENSURE_SUCCESS(rv, mOutputStream->Close());
+
+ rv = newChannel->AsyncOpen2(listener);
+ NS_ENSURE_SUCCESS(rv, mOutputStream->Close());
+
+ return NS_OK;
+ }
+
+ //////////////////////////////////////////////////////////////////////////////
+ //// nsIRequestObserver
+
+ NS_IMETHOD OnStartRequest(nsIRequest *, nsISupports *) override
+ {
+ return NS_OK;
+ }
+
+ NS_IMETHOD OnStopRequest(nsIRequest *, nsISupports *, nsresult aStatusCode) override
+ {
+ // We always need to close our output stream, regardless of the status code.
+ (void)mOutputStream->Close();
+
+ // But, we'll warn about it not being successful if it wasn't.
+ NS_WARNING_ASSERTION(
+ NS_SUCCEEDED(aStatusCode),
+ "Got an error when trying to load our default favicon!");
+
+ return NS_OK;
+ }
+
+protected:
+ virtual ~faviconAsyncLoader() {}
+
+private:
+ nsCOMPtr<nsIChannel> mChannel;
+ nsCOMPtr<nsIOutputStream> mOutputStream;
+ bool mReturnDefaultIcon;
+};
+
+NS_IMPL_ISUPPORTS_INHERITED(
+ faviconAsyncLoader,
+ AsyncStatementCallback,
+ nsIRequestObserver
+)
+
+} // namespace
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsAnnoProtocolHandler
+
+NS_IMPL_ISUPPORTS(nsAnnoProtocolHandler, nsIProtocolHandler)
+
+// nsAnnoProtocolHandler::GetScheme
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::GetScheme(nsACString& aScheme)
+{
+ aScheme.AssignLiteral("moz-anno");
+ return NS_OK;
+}
+
+
+// nsAnnoProtocolHandler::GetDefaultPort
+//
+// There is no default port for annotation URLs
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::GetDefaultPort(int32_t *aDefaultPort)
+{
+ *aDefaultPort = -1;
+ return NS_OK;
+}
+
+
+// nsAnnoProtocolHandler::GetProtocolFlags
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::GetProtocolFlags(uint32_t *aProtocolFlags)
+{
+ *aProtocolFlags = (URI_NORELATIVE | URI_NOAUTH | URI_DANGEROUS_TO_LOAD |
+ URI_IS_LOCAL_RESOURCE);
+ return NS_OK;
+}
+
+
+// nsAnnoProtocolHandler::NewURI
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::NewURI(const nsACString& aSpec,
+ const char *aOriginCharset,
+ nsIURI *aBaseURI, nsIURI **_retval)
+{
+ nsCOMPtr <nsIURI> uri = do_CreateInstance(NS_SIMPLEURI_CONTRACTID);
+ if (!uri)
+ return NS_ERROR_OUT_OF_MEMORY;
+ nsresult rv = uri->SetSpec(aSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *_retval = nullptr;
+ uri.swap(*_retval);
+ return NS_OK;
+}
+
+
+// nsAnnoProtocolHandler::NewChannel
+//
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::NewChannel2(nsIURI* aURI,
+ nsILoadInfo* aLoadInfo,
+ nsIChannel** _retval)
+{
+ NS_ENSURE_ARG_POINTER(aURI);
+
+ // annotation info
+ nsCOMPtr<nsIURI> annoURI;
+ nsAutoCString annoName;
+ nsresult rv = ParseAnnoURI(aURI, getter_AddRefs(annoURI), annoName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Only favicon annotation are supported.
+ if (!annoName.EqualsLiteral(FAVICON_ANNOTATION_NAME))
+ return NS_ERROR_INVALID_ARG;
+
+ return NewFaviconChannel(aURI, annoURI, aLoadInfo, _retval);
+}
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::NewChannel(nsIURI *aURI, nsIChannel **_retval)
+{
+ return NewChannel2(aURI, nullptr, _retval);
+}
+
+
+// nsAnnoProtocolHandler::AllowPort
+//
+// Don't override any bans on bad ports.
+
+NS_IMETHODIMP
+nsAnnoProtocolHandler::AllowPort(int32_t port, const char *scheme,
+ bool *_retval)
+{
+ *_retval = false;
+ return NS_OK;
+}
+
+
+// nsAnnoProtocolHandler::ParseAnnoURI
+//
+// Splits an annotation URL into its URI and name parts
+
+nsresult
+nsAnnoProtocolHandler::ParseAnnoURI(nsIURI* aURI,
+ nsIURI** aResultURI, nsCString& aName)
+{
+ nsresult rv;
+ nsAutoCString path;
+ rv = aURI->GetPath(path);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ int32_t firstColon = path.FindChar(':');
+ if (firstColon <= 0)
+ return NS_ERROR_MALFORMED_URI;
+
+ rv = NS_NewURI(aResultURI, Substring(path, firstColon + 1));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aName = Substring(path, 0, firstColon);
+ return NS_OK;
+}
+
+nsresult
+nsAnnoProtocolHandler::NewFaviconChannel(nsIURI *aURI, nsIURI *aAnnotationURI,
+ nsILoadInfo* aLoadInfo, nsIChannel **_channel)
+{
+ // Create our pipe. This will give us our input stream and output stream
+ // that will be written to when we get data from the database.
+ nsCOMPtr<nsIInputStream> inputStream;
+ nsCOMPtr<nsIOutputStream> outputStream;
+ nsresult rv = NS_NewPipe(getter_AddRefs(inputStream),
+ getter_AddRefs(outputStream),
+ 0, nsIFaviconService::MAX_FAVICON_BUFFER_SIZE,
+ true, true);
+ NS_ENSURE_SUCCESS(rv, GetDefaultIcon(aLoadInfo, _channel));
+
+ // Create our channel. We'll call SetContentType with the right type when
+ // we know what it actually is.
+ nsCOMPtr<nsIChannel> channel;
+ rv = NS_NewInputStreamChannelInternal(getter_AddRefs(channel),
+ aURI,
+ inputStream,
+ EmptyCString(), // aContentType
+ EmptyCString(), // aContentCharset
+ aLoadInfo);
+ NS_ENSURE_SUCCESS(rv, GetDefaultIcon(aLoadInfo, _channel));
+
+ // Now we go ahead and get our data asynchronously for the favicon.
+ nsCOMPtr<mozIStorageStatementCallback> callback =
+ new faviconAsyncLoader(channel, outputStream);
+ NS_ENSURE_TRUE(callback, GetDefaultIcon(aLoadInfo, _channel));
+ nsFaviconService* faviconService = nsFaviconService::GetFaviconService();
+ NS_ENSURE_TRUE(faviconService, GetDefaultIcon(aLoadInfo, _channel));
+
+ rv = faviconService->GetFaviconDataAsync(aAnnotationURI, callback);
+ NS_ENSURE_SUCCESS(rv, GetDefaultIcon(aLoadInfo, _channel));
+
+ channel.forget(_channel);
+ return NS_OK;
+}
diff --git a/toolkit/components/places/nsAnnoProtocolHandler.h b/toolkit/components/places/nsAnnoProtocolHandler.h
new file mode 100644
index 000000000..8e543c7c5
--- /dev/null
+++ b/toolkit/components/places/nsAnnoProtocolHandler.h
@@ -0,0 +1,54 @@
+//* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsAnnoProtocolHandler_h___
+#define nsAnnoProtocolHandler_h___
+
+#include "nsCOMPtr.h"
+#include "nsIProtocolHandler.h"
+#include "nsIURI.h"
+#include "nsString.h"
+#include "nsWeakReference.h"
+#include "mozilla/Attributes.h"
+
+// {e8b8bdb7-c96c-4d82-9c6f-2b3c585ec7ea}
+#define NS_ANNOPROTOCOLHANDLER_CID \
+{ 0xe8b8bdb7, 0xc96c, 0x4d82, { 0x9c, 0x6f, 0x2b, 0x3c, 0x58, 0x5e, 0xc7, 0xea } }
+
+class nsAnnoProtocolHandler final : public nsIProtocolHandler, public nsSupportsWeakReference
+{
+public:
+ nsAnnoProtocolHandler() {}
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIPROTOCOLHANDLER
+
+private:
+ ~nsAnnoProtocolHandler() {}
+
+protected:
+ nsresult ParseAnnoURI(nsIURI* aURI, nsIURI** aResultURI, nsCString& aName);
+
+ /**
+ * Obtains a new channel to be used to get a favicon from the database. This
+ * method is asynchronous.
+ *
+ * @param aURI
+ * The URI the channel will be created for. This is the URI that is
+ * set as the original URI on the channel.
+ * @param aAnnotationURI
+ * The URI that holds the data needed to get the favicon from the
+ * database.
+ * @param aLoadInfo
+ * The loadinfo that requested the resource load.
+ * @returns (via _channel) the channel that will obtain the favicon data.
+ */
+ nsresult NewFaviconChannel(nsIURI *aURI,
+ nsIURI *aAnnotationURI,
+ nsILoadInfo *aLoadInfo,
+ nsIChannel **_channel);
+};
+
+#endif /* nsAnnoProtocolHandler_h___ */
diff --git a/toolkit/components/places/nsAnnotationService.cpp b/toolkit/components/places/nsAnnotationService.cpp
new file mode 100644
index 000000000..9d62bd34a
--- /dev/null
+++ b/toolkit/components/places/nsAnnotationService.cpp
@@ -0,0 +1,1990 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/ArrayUtils.h"
+
+#include "nsAnnotationService.h"
+#include "nsNavHistory.h"
+#include "nsPlacesTables.h"
+#include "nsPlacesIndexes.h"
+#include "nsPlacesMacros.h"
+#include "Helpers.h"
+
+#include "nsNetUtil.h"
+#include "nsIVariant.h"
+#include "nsString.h"
+#include "nsVariant.h"
+#include "mozilla/storage.h"
+
+#include "GeckoProfiler.h"
+
+#include "nsNetCID.h"
+
+using namespace mozilla;
+using namespace mozilla::places;
+
+#define ENSURE_ANNO_TYPE(_type, _statement) \
+ PR_BEGIN_MACRO \
+ int32_t type = _statement->AsInt32(kAnnoIndex_Type); \
+ NS_ENSURE_TRUE(type == nsIAnnotationService::_type, NS_ERROR_INVALID_ARG); \
+ PR_END_MACRO
+
+#define NOTIFY_ANNOS_OBSERVERS(_notification) \
+ PR_BEGIN_MACRO \
+ for (int32_t i = 0; i < mObservers.Count(); i++) \
+ mObservers[i]->_notification; \
+ PR_END_MACRO
+
+const int32_t nsAnnotationService::kAnnoIndex_ID = 0;
+const int32_t nsAnnotationService::kAnnoIndex_PageOrItem = 1;
+const int32_t nsAnnotationService::kAnnoIndex_NameID = 2;
+const int32_t nsAnnotationService::kAnnoIndex_Content = 3;
+const int32_t nsAnnotationService::kAnnoIndex_Flags = 4;
+const int32_t nsAnnotationService::kAnnoIndex_Expiration = 5;
+const int32_t nsAnnotationService::kAnnoIndex_Type = 6;
+const int32_t nsAnnotationService::kAnnoIndex_DateAdded = 7;
+const int32_t nsAnnotationService::kAnnoIndex_LastModified = 8;
+
+namespace mozilla {
+namespace places {
+
+////////////////////////////////////////////////////////////////////////////////
+//// AnnotatedResult
+
+AnnotatedResult::AnnotatedResult(const nsCString& aGUID,
+ nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aAnnotationName,
+ nsIVariant* aAnnotationValue)
+: mGUID(aGUID)
+, mURI(aURI)
+, mItemId(aItemId)
+, mAnnotationName(aAnnotationName)
+, mAnnotationValue(aAnnotationValue)
+{
+}
+
+AnnotatedResult::~AnnotatedResult()
+{
+}
+
+NS_IMETHODIMP
+AnnotatedResult::GetGuid(nsACString& _guid)
+{
+ _guid = mGUID;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AnnotatedResult::GetUri(nsIURI** _uri)
+{
+ NS_IF_ADDREF(*_uri = mURI);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AnnotatedResult::GetItemId(int64_t* _itemId)
+{
+ *_itemId = mItemId;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AnnotatedResult::GetAnnotationName(nsACString& _annotationName)
+{
+ _annotationName = mAnnotationName;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AnnotatedResult::GetAnnotationValue(nsIVariant** _annotationValue)
+{
+ NS_IF_ADDREF(*_annotationValue = mAnnotationValue);
+ return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS(AnnotatedResult, mozIAnnotatedResult)
+
+} // namespace places
+} // namespace mozilla
+
+PLACES_FACTORY_SINGLETON_IMPLEMENTATION(nsAnnotationService, gAnnotationService)
+
+NS_IMPL_ISUPPORTS(nsAnnotationService
+, nsIAnnotationService
+, nsIObserver
+, nsISupportsWeakReference
+)
+
+
+nsAnnotationService::nsAnnotationService()
+ : mHasSessionAnnotations(false)
+{
+ NS_ASSERTION(!gAnnotationService,
+ "Attempting to create two instances of the service!");
+ gAnnotationService = this;
+}
+
+
+nsAnnotationService::~nsAnnotationService()
+{
+ NS_ASSERTION(gAnnotationService == this,
+ "Deleting a non-singleton instance of the service");
+ if (gAnnotationService == this)
+ gAnnotationService = nullptr;
+}
+
+
+nsresult
+nsAnnotationService::Init()
+{
+ mDB = Database::GetDatabase();
+ NS_ENSURE_STATE(mDB);
+
+ nsCOMPtr<nsIObserverService> obsSvc = mozilla::services::GetObserverService();
+ if (obsSvc) {
+ (void)obsSvc->AddObserver(this, TOPIC_PLACES_SHUTDOWN, true);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsAnnotationService::SetAnnotationStringInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ const nsAString& aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartSetAnnotation(aURI, aItemId, aName, aFlags, aExpiration,
+ nsIAnnotationService::TYPE_STRING,
+ statement);
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindStringByName(NS_LITERAL_CSTRING("content"), aValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetPageAnnotation(nsIURI* aURI,
+ const nsACString& aName,
+ nsIVariant* aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG(aValue);
+
+ uint16_t dataType;
+ nsresult rv = aValue->GetDataType(&dataType);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ switch (dataType) {
+ case nsIDataType::VTYPE_INT8:
+ case nsIDataType::VTYPE_UINT8:
+ case nsIDataType::VTYPE_INT16:
+ case nsIDataType::VTYPE_UINT16:
+ case nsIDataType::VTYPE_INT32:
+ case nsIDataType::VTYPE_UINT32:
+ case nsIDataType::VTYPE_BOOL: {
+ int32_t valueInt;
+ rv = aValue->GetAsInt32(&valueInt);
+ if (NS_SUCCEEDED(rv)) {
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetPageAnnotationInt32(aURI, aName, valueInt, aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ // Fall through int64_t case otherwise.
+ MOZ_FALLTHROUGH;
+ }
+ case nsIDataType::VTYPE_INT64:
+ case nsIDataType::VTYPE_UINT64: {
+ int64_t valueLong;
+ rv = aValue->GetAsInt64(&valueLong);
+ if (NS_SUCCEEDED(rv)) {
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetPageAnnotationInt64(aURI, aName, valueLong, aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ // Fall through double case otherwise.
+ MOZ_FALLTHROUGH;
+ }
+ case nsIDataType::VTYPE_FLOAT:
+ case nsIDataType::VTYPE_DOUBLE: {
+ double valueDouble;
+ rv = aValue->GetAsDouble(&valueDouble);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetPageAnnotationDouble(aURI, aName, valueDouble, aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ case nsIDataType::VTYPE_CHAR:
+ case nsIDataType::VTYPE_WCHAR:
+ case nsIDataType::VTYPE_DOMSTRING:
+ case nsIDataType::VTYPE_CHAR_STR:
+ case nsIDataType::VTYPE_WCHAR_STR:
+ case nsIDataType::VTYPE_STRING_SIZE_IS:
+ case nsIDataType::VTYPE_WSTRING_SIZE_IS:
+ case nsIDataType::VTYPE_UTF8STRING:
+ case nsIDataType::VTYPE_CSTRING:
+ case nsIDataType::VTYPE_ASTRING: {
+ nsAutoString stringValue;
+ rv = aValue->GetAsAString(stringValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetPageAnnotationString(aURI, aName, stringValue, aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ }
+
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetItemAnnotation(int64_t aItemId,
+ const nsACString& aName,
+ nsIVariant* aValue,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aSource)
+{
+ PROFILER_LABEL("AnnotationService", "SetItemAnnotation",
+ js::ProfileEntry::Category::OTHER);
+
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG(aValue);
+
+ if (aExpiration == EXPIRE_WITH_HISTORY)
+ return NS_ERROR_INVALID_ARG;
+
+ uint16_t dataType;
+ nsresult rv = aValue->GetDataType(&dataType);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ switch (dataType) {
+ case nsIDataType::VTYPE_INT8:
+ case nsIDataType::VTYPE_UINT8:
+ case nsIDataType::VTYPE_INT16:
+ case nsIDataType::VTYPE_UINT16:
+ case nsIDataType::VTYPE_INT32:
+ case nsIDataType::VTYPE_UINT32:
+ case nsIDataType::VTYPE_BOOL: {
+ int32_t valueInt;
+ rv = aValue->GetAsInt32(&valueInt);
+ if (NS_SUCCEEDED(rv)) {
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetItemAnnotationInt32(aItemId, aName, valueInt, aFlags, aExpiration, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ // Fall through int64_t case otherwise.
+ MOZ_FALLTHROUGH;
+ }
+ case nsIDataType::VTYPE_INT64:
+ case nsIDataType::VTYPE_UINT64: {
+ int64_t valueLong;
+ rv = aValue->GetAsInt64(&valueLong);
+ if (NS_SUCCEEDED(rv)) {
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetItemAnnotationInt64(aItemId, aName, valueLong, aFlags, aExpiration, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ // Fall through double case otherwise.
+ MOZ_FALLTHROUGH;
+ }
+ case nsIDataType::VTYPE_FLOAT:
+ case nsIDataType::VTYPE_DOUBLE: {
+ double valueDouble;
+ rv = aValue->GetAsDouble(&valueDouble);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetItemAnnotationDouble(aItemId, aName, valueDouble, aFlags, aExpiration, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ case nsIDataType::VTYPE_CHAR:
+ case nsIDataType::VTYPE_WCHAR:
+ case nsIDataType::VTYPE_DOMSTRING:
+ case nsIDataType::VTYPE_CHAR_STR:
+ case nsIDataType::VTYPE_WCHAR_STR:
+ case nsIDataType::VTYPE_STRING_SIZE_IS:
+ case nsIDataType::VTYPE_WSTRING_SIZE_IS:
+ case nsIDataType::VTYPE_UTF8STRING:
+ case nsIDataType::VTYPE_CSTRING:
+ case nsIDataType::VTYPE_ASTRING: {
+ nsAutoString stringValue;
+ rv = aValue->GetAsAString(stringValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetItemAnnotationString(aItemId, aName, stringValue, aFlags, aExpiration, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+ }
+
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetPageAnnotationString(nsIURI* aURI,
+ const nsACString& aName,
+ const nsAString& aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv = SetAnnotationStringInternal(aURI, 0, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationSet(aURI, aName));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetItemAnnotationString(int64_t aItemId,
+ const nsACString& aName,
+ const nsAString& aValue,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ if (aExpiration == EXPIRE_WITH_HISTORY)
+ return NS_ERROR_INVALID_ARG;
+
+ nsresult rv = SetAnnotationStringInternal(nullptr, aItemId, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationSet(aItemId, aName, aSource));
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::SetAnnotationInt32Internal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int32_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartSetAnnotation(aURI, aItemId, aName, aFlags, aExpiration,
+ nsIAnnotationService::TYPE_INT32,
+ statement);
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindInt32ByName(NS_LITERAL_CSTRING("content"), aValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetPageAnnotationInt32(nsIURI* aURI,
+ const nsACString& aName,
+ int32_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv = SetAnnotationInt32Internal(aURI, 0, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationSet(aURI, aName));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetItemAnnotationInt32(int64_t aItemId,
+ const nsACString& aName,
+ int32_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ if (aExpiration == EXPIRE_WITH_HISTORY)
+ return NS_ERROR_INVALID_ARG;
+
+ nsresult rv = SetAnnotationInt32Internal(nullptr, aItemId, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationSet(aItemId, aName, aSource));
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::SetAnnotationInt64Internal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int64_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartSetAnnotation(aURI, aItemId, aName, aFlags, aExpiration,
+ nsIAnnotationService::TYPE_INT64,
+ statement);
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("content"), aValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetPageAnnotationInt64(nsIURI* aURI,
+ const nsACString& aName,
+ int64_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv = SetAnnotationInt64Internal(aURI, 0, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationSet(aURI, aName));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetItemAnnotationInt64(int64_t aItemId,
+ const nsACString& aName,
+ int64_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ if (aExpiration == EXPIRE_WITH_HISTORY)
+ return NS_ERROR_INVALID_ARG;
+
+ nsresult rv = SetAnnotationInt64Internal(nullptr, aItemId, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationSet(aItemId, aName, aSource));
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::SetAnnotationDoubleInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ double aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartSetAnnotation(aURI, aItemId, aName, aFlags, aExpiration,
+ nsIAnnotationService::TYPE_DOUBLE,
+ statement);
+ NS_ENSURE_SUCCESS(rv, rv);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindDoubleByName(NS_LITERAL_CSTRING("content"), aValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetPageAnnotationDouble(nsIURI* aURI,
+ const nsACString& aName,
+ double aValue,
+ int32_t aFlags,
+ uint16_t aExpiration)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv = SetAnnotationDoubleInternal(aURI, 0, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationSet(aURI, aName));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::SetItemAnnotationDouble(int64_t aItemId,
+ const nsACString& aName,
+ double aValue,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ if (aExpiration == EXPIRE_WITH_HISTORY)
+ return NS_ERROR_INVALID_ARG;
+
+ nsresult rv = SetAnnotationDoubleInternal(nullptr, aItemId, aName, aValue,
+ aFlags, aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationSet(aItemId, aName, aSource));
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationString(nsIURI* aURI,
+ const nsACString& aName,
+ nsAString& _retval)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_STRING, statement);
+ rv = statement->GetString(kAnnoIndex_Content, _retval);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationString(int64_t aItemId,
+ const nsACString& aName,
+ nsAString& _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_STRING, statement);
+ rv = statement->GetString(kAnnoIndex_Content, _retval);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotation(nsIURI* aURI,
+ const nsACString& aName,
+ nsIVariant** _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+
+ nsCOMPtr<nsIWritableVariant> value = new nsVariant();
+ int32_t type = statement->AsInt32(kAnnoIndex_Type);
+ switch (type) {
+ case nsIAnnotationService::TYPE_INT32:
+ case nsIAnnotationService::TYPE_INT64:
+ case nsIAnnotationService::TYPE_DOUBLE: {
+ rv = value->SetAsDouble(statement->AsDouble(kAnnoIndex_Content));
+ break;
+ }
+ case nsIAnnotationService::TYPE_STRING: {
+ nsAutoString valueString;
+ rv = statement->GetString(kAnnoIndex_Content, valueString);
+ if (NS_SUCCEEDED(rv))
+ rv = value->SetAsAString(valueString);
+ break;
+ }
+ default: {
+ rv = NS_ERROR_UNEXPECTED;
+ break;
+ }
+ }
+
+ if (NS_SUCCEEDED(rv)) {
+ value.forget(_retval);
+ }
+
+ return rv;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotation(int64_t aItemId,
+ const nsACString& aName,
+ nsIVariant** _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+
+ nsCOMPtr<nsIWritableVariant> value = new nsVariant();
+ int32_t type = statement->AsInt32(kAnnoIndex_Type);
+ switch (type) {
+ case nsIAnnotationService::TYPE_INT32:
+ case nsIAnnotationService::TYPE_INT64:
+ case nsIAnnotationService::TYPE_DOUBLE: {
+ rv = value->SetAsDouble(statement->AsDouble(kAnnoIndex_Content));
+ break;
+ }
+ case nsIAnnotationService::TYPE_STRING: {
+ nsAutoString valueString;
+ rv = statement->GetString(kAnnoIndex_Content, valueString);
+ if (NS_SUCCEEDED(rv))
+ rv = value->SetAsAString(valueString);
+ break;
+ }
+ default: {
+ rv = NS_ERROR_UNEXPECTED;
+ break;
+ }
+ }
+
+ if (NS_SUCCEEDED(rv)) {
+ value.forget(_retval);
+ }
+
+ return rv;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationInt32(nsIURI* aURI,
+ const nsACString& aName,
+ int32_t* _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_INT32, statement);
+ *_retval = statement->AsInt32(kAnnoIndex_Content);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationInt32(int64_t aItemId,
+ const nsACString& aName,
+ int32_t* _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_INT32, statement);
+ *_retval = statement->AsInt32(kAnnoIndex_Content);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationInt64(nsIURI* aURI,
+ const nsACString& aName,
+ int64_t* _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_INT64, statement);
+ *_retval = statement->AsInt64(kAnnoIndex_Content);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationInt64(int64_t aItemId,
+ const nsACString& aName,
+ int64_t* _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_INT64, statement);
+ *_retval = statement->AsInt64(kAnnoIndex_Content);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationType(nsIURI* aURI,
+ const nsACString& aName,
+ uint16_t* _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ *_retval = statement->AsInt32(kAnnoIndex_Type);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationType(int64_t aItemId,
+ const nsACString& aName,
+ uint16_t* _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ *_retval = statement->AsInt32(kAnnoIndex_Type);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationDouble(nsIURI* aURI,
+ const nsACString& aName,
+ double* _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_DOUBLE, statement);
+ *_retval = statement->AsDouble(kAnnoIndex_Content);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationDouble(int64_t aItemId,
+ const nsACString& aName,
+ double* _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ ENSURE_ANNO_TYPE(TYPE_DOUBLE, statement);
+ *_retval = statement->AsDouble(kAnnoIndex_Content);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationInfo(nsIURI* aURI,
+ const nsACString& aName,
+ int32_t* _flags,
+ uint16_t* _expiration,
+ uint16_t* _storageType)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_flags);
+ NS_ENSURE_ARG_POINTER(_expiration);
+ NS_ENSURE_ARG_POINTER(_storageType);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(aURI, 0, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ *_flags = statement->AsInt32(kAnnoIndex_Flags);
+ *_expiration = (uint16_t)statement->AsInt32(kAnnoIndex_Expiration);
+ int32_t type = (uint16_t)statement->AsInt32(kAnnoIndex_Type);
+ if (type == 0) {
+ // For annotations created before explicit typing,
+ // we can't determine type, just return as string type.
+ *_storageType = nsIAnnotationService::TYPE_STRING;
+ }
+ else
+ *_storageType = type;
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationInfo(int64_t aItemId,
+ const nsACString& aName,
+ int32_t* _flags,
+ uint16_t* _expiration,
+ uint16_t* _storageType)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_flags);
+ NS_ENSURE_ARG_POINTER(_expiration);
+ NS_ENSURE_ARG_POINTER(_storageType);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ nsresult rv = StartGetAnnotation(nullptr, aItemId, aName, statement);
+ if (NS_FAILED(rv))
+ return rv;
+
+ mozStorageStatementScoper scoper(statement);
+ *_flags = statement->AsInt32(kAnnoIndex_Flags);
+ *_expiration = (uint16_t)statement->AsInt32(kAnnoIndex_Expiration);
+ int32_t type = (uint16_t)statement->AsInt32(kAnnoIndex_Type);
+ if (type == 0) {
+ // For annotations created before explicit typing,
+ // we can't determine type, just return as string type.
+ *_storageType = nsIAnnotationService::TYPE_STRING;
+ }
+ else {
+ *_storageType = type;
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPagesWithAnnotation(const nsACString& aName,
+ uint32_t* _resultCount,
+ nsIURI*** _results)
+{
+ NS_ENSURE_TRUE(!aName.IsEmpty(), NS_ERROR_INVALID_ARG);
+ NS_ENSURE_ARG_POINTER(_resultCount);
+ NS_ENSURE_ARG_POINTER(_results);
+
+ *_resultCount = 0;
+ *_results = nullptr;
+ nsCOMArray<nsIURI> results;
+
+ nsresult rv = GetPagesWithAnnotationCOMArray(aName, &results);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Convert to raw array.
+ if (results.Count() == 0)
+ return NS_OK;
+
+ *_resultCount = results.Count();
+ results.Forget(_results);
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::GetPagesWithAnnotationCOMArray(const nsACString& aName,
+ nsCOMArray<nsIURI>* _results)
+{
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT h.url "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_annos a ON n.id = a.anno_attribute_id "
+ "JOIN moz_places h ON h.id = a.place_id "
+ "WHERE n.name = :anno_name"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(rv = stmt->ExecuteStep(&hasMore)) &&
+ hasMore) {
+ nsAutoCString uristring;
+ rv = stmt->GetUTF8String(0, uristring);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // convert to a URI, in case of some invalid URI, just ignore this row
+ // so we can mostly continue.
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewURI(getter_AddRefs(uri), uristring);
+ if (NS_FAILED(rv))
+ continue;
+
+ bool added = _results->AppendObject(uri);
+ NS_ENSURE_TRUE(added, NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemsWithAnnotation(const nsACString& aName,
+ uint32_t* _resultCount,
+ int64_t** _results)
+{
+ NS_ENSURE_TRUE(!aName.IsEmpty(), NS_ERROR_INVALID_ARG);
+ NS_ENSURE_ARG_POINTER(_resultCount);
+ NS_ENSURE_ARG_POINTER(_results);
+
+ *_resultCount = 0;
+ *_results = nullptr;
+ nsTArray<int64_t> results;
+
+ nsresult rv = GetItemsWithAnnotationTArray(aName, &results);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Convert to raw array.
+ if (results.Length() == 0)
+ return NS_OK;
+
+ *_results = static_cast<int64_t*>
+ (moz_xmalloc(results.Length() * sizeof(int64_t)));
+ NS_ENSURE_TRUE(*_results, NS_ERROR_OUT_OF_MEMORY);
+
+ *_resultCount = results.Length();
+ for (uint32_t i = 0; i < *_resultCount; i ++) {
+ (*_results)[i] = results[i];
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetAnnotationsWithName(const nsACString& aName,
+ uint32_t* _count,
+ mozIAnnotatedResult*** _annotations)
+{
+ NS_ENSURE_ARG(!aName.IsEmpty());
+ NS_ENSURE_ARG_POINTER(_annotations);
+
+ *_count = 0;
+ *_annotations = nullptr;
+ nsCOMArray<mozIAnnotatedResult> annotations;
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT h.guid, h.url, -1, a.type, a.content "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_annos a ON n.id = a.anno_attribute_id "
+ "JOIN moz_places h ON h.id = a.place_id "
+ "WHERE n.name = :anno_name "
+ "UNION ALL "
+ "SELECT b.guid, h.url, b.id, a.type, a.content "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_items_annos a ON n.id = a.anno_attribute_id "
+ "JOIN moz_bookmarks b ON b.id = a.item_id "
+ "LEFT JOIN moz_places h ON h.id = b.fk "
+ "WHERE n.name = :anno_name "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"),
+ aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(rv = stmt->ExecuteStep(&hasMore)) && hasMore) {
+ nsAutoCString guid;
+ rv = stmt->GetUTF8String(0, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIURI> uri;
+ bool uriIsNull = false;
+ rv = stmt->GetIsNull(1, &uriIsNull);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!uriIsNull) {
+ nsAutoCString url;
+ rv = stmt->GetUTF8String(1, url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = NS_NewURI(getter_AddRefs(uri), url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ int64_t itemId = stmt->AsInt64(2);
+ int32_t type = stmt->AsInt32(3);
+
+ nsCOMPtr<nsIWritableVariant> variant = new nsVariant();
+ switch (type) {
+ case nsIAnnotationService::TYPE_INT32: {
+ rv = variant->SetAsInt32(stmt->AsInt32(4));
+ break;
+ }
+ case nsIAnnotationService::TYPE_INT64: {
+ rv = variant->SetAsInt64(stmt->AsInt64(4));
+ break;
+ }
+ case nsIAnnotationService::TYPE_DOUBLE: {
+ rv = variant->SetAsDouble(stmt->AsDouble(4));
+ break;
+ }
+ case nsIAnnotationService::TYPE_STRING: {
+ nsAutoString valueString;
+ rv = stmt->GetString(4, valueString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = variant->SetAsAString(valueString);
+ break;
+ }
+ default:
+ MOZ_ASSERT(false, "Unsupported annotation type");
+ // Move to the next result.
+ continue;
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIAnnotatedResult> anno = new AnnotatedResult(guid, uri, itemId,
+ aName, variant);
+ NS_ENSURE_TRUE(annotations.AppendObject(anno), NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ // Convert to raw array.
+ if (annotations.Count() == 0)
+ return NS_OK;
+
+ *_count = annotations.Count();
+ annotations.Forget(_annotations);
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::GetItemsWithAnnotationTArray(const nsACString& aName,
+ nsTArray<int64_t>* _results)
+{
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT a.item_id "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_items_annos a ON n.id = a.anno_attribute_id "
+ "WHERE n.name = :anno_name"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) &&
+ hasMore) {
+ if (!_results->AppendElement(stmt->AsInt64(0)))
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetPageAnnotationNames(nsIURI* aURI,
+ uint32_t* _count,
+ nsIVariant*** _result)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_count);
+ NS_ENSURE_ARG_POINTER(_result);
+
+ *_count = 0;
+ *_result = nullptr;
+
+ nsTArray<nsCString> names;
+ nsresult rv = GetAnnotationNamesTArray(aURI, 0, &names);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (names.Length() == 0)
+ return NS_OK;
+
+ *_result = static_cast<nsIVariant**>
+ (moz_xmalloc(sizeof(nsIVariant*) * names.Length()));
+ NS_ENSURE_TRUE(*_result, NS_ERROR_OUT_OF_MEMORY);
+
+ for (uint32_t i = 0; i < names.Length(); i ++) {
+ nsCOMPtr<nsIWritableVariant> var = new nsVariant();
+ if (!var) {
+ // need to release all the variants we've already created
+ for (uint32_t j = 0; j < i; j ++)
+ NS_RELEASE((*_result)[j]);
+ free(*_result);
+ *_result = nullptr;
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ var->SetAsAUTF8String(names[i]);
+ NS_ADDREF((*_result)[i] = var);
+ }
+ *_count = names.Length();
+
+ return NS_OK;
+}
+
+
+nsresult
+nsAnnotationService::GetAnnotationNamesTArray(nsIURI* aURI,
+ int64_t aItemId,
+ nsTArray<nsCString>* _result)
+{
+ _result->Clear();
+
+ bool isItemAnnotation = (aItemId > 0);
+ nsCOMPtr<mozIStorageStatement> statement;
+ if (isItemAnnotation) {
+ statement = mDB->GetStatement(
+ "SELECT n.name "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_items_annos a ON a.anno_attribute_id = n.id "
+ "WHERE a.item_id = :item_id"
+ );
+ }
+ else {
+ statement = mDB->GetStatement(
+ "SELECT n.name "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_annos a ON a.anno_attribute_id = n.id "
+ "JOIN moz_places h ON h.id = a.place_id "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url"
+ );
+ }
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsresult rv;
+ if (isItemAnnotation)
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ else
+ rv = URIBinder::Bind(statement, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult = false;
+ while (NS_SUCCEEDED(statement->ExecuteStep(&hasResult)) &&
+ hasResult) {
+ nsAutoCString name;
+ rv = statement->GetUTF8String(0, name);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!_result->AppendElement(name))
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::GetItemAnnotationNames(int64_t aItemId,
+ uint32_t* _count,
+ nsIVariant*** _result)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_count);
+ NS_ENSURE_ARG_POINTER(_result);
+
+ *_count = 0;
+ *_result = nullptr;
+
+ nsTArray<nsCString> names;
+ nsresult rv = GetAnnotationNamesTArray(nullptr, aItemId, &names);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (names.Length() == 0)
+ return NS_OK;
+
+ *_result = static_cast<nsIVariant**>
+ (moz_xmalloc(sizeof(nsIVariant*) * names.Length()));
+ NS_ENSURE_TRUE(*_result, NS_ERROR_OUT_OF_MEMORY);
+
+ for (uint32_t i = 0; i < names.Length(); i ++) {
+ nsCOMPtr<nsIWritableVariant> var = new nsVariant();
+ if (!var) {
+ // need to release all the variants we've already created
+ for (uint32_t j = 0; j < i; j ++)
+ NS_RELEASE((*_result)[j]);
+ free(*_result);
+ *_result = nullptr;
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ var->SetAsAUTF8String(names[i]);
+ NS_ADDREF((*_result)[i] = var);
+ }
+ *_count = names.Length();
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::PageHasAnnotation(nsIURI* aURI,
+ const nsACString& aName,
+ bool* _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsresult rv = HasAnnotationInternal(aURI, 0, aName, _retval);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::ItemHasAnnotation(int64_t aItemId,
+ const nsACString& aName,
+ bool* _retval)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsresult rv = HasAnnotationInternal(nullptr, aItemId, aName, _retval);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+/**
+ * @note We don't remove anything from the moz_anno_attributes table. If we
+ * delete the last item of a given name, that item really should go away.
+ * It will be cleaned up by expiration.
+ */
+nsresult
+nsAnnotationService::RemoveAnnotationInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName)
+{
+ bool isItemAnnotation = (aItemId > 0);
+ nsCOMPtr<mozIStorageStatement> statement;
+ if (isItemAnnotation) {
+ statement = mDB->GetStatement(
+ "DELETE FROM moz_items_annos "
+ "WHERE item_id = :item_id "
+ "AND anno_attribute_id = "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name)"
+ );
+ }
+ else {
+ statement = mDB->GetStatement(
+ "DELETE FROM moz_annos "
+ "WHERE place_id = "
+ "(SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url) "
+ "AND anno_attribute_id = "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name)"
+ );
+ }
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsresult rv;
+ if (isItemAnnotation)
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ else
+ rv = URIBinder::Bind(statement, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::RemovePageAnnotation(nsIURI* aURI,
+ const nsACString& aName)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv = RemoveAnnotationInternal(aURI, 0, aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationRemoved(aURI, aName));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::RemoveItemAnnotation(int64_t aItemId,
+ const nsACString& aName,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ nsresult rv = RemoveAnnotationInternal(nullptr, aItemId, aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationRemoved(aItemId, aName, aSource));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::RemovePageAnnotations(nsIURI* aURI)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // Should this be precompiled or a getter?
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(
+ "DELETE FROM moz_annos WHERE place_id = "
+ "(SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)"
+ );
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsresult rv = URIBinder::Bind(statement, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Update observers
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationRemoved(aURI, EmptyCString()));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::RemoveItemAnnotations(int64_t aItemId,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ // Should this be precompiled or a getter?
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(
+ "DELETE FROM moz_items_annos WHERE item_id = :item_id"
+ );
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsresult rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationRemoved(aItemId, EmptyCString(),
+ aSource));
+
+ return NS_OK;
+}
+
+
+/**
+ * @note If we use annotations for some standard items like GeckoFlags, it
+ * might be a good idea to blacklist these standard annotations from this
+ * copy function.
+ */
+NS_IMETHODIMP
+nsAnnotationService::CopyPageAnnotations(nsIURI* aSourceURI,
+ nsIURI* aDestURI,
+ bool aOverwriteDest)
+{
+ NS_ENSURE_ARG(aSourceURI);
+ NS_ENSURE_ARG(aDestURI);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ nsCOMPtr<mozIStorageStatement> sourceStmt = mDB->GetStatement(
+ "SELECT h.id, n.id, n.name, a2.id "
+ "FROM moz_places h "
+ "JOIN moz_annos a ON a.place_id = h.id "
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+ "LEFT JOIN moz_annos a2 ON a2.place_id = "
+ "(SELECT id FROM moz_places WHERE url_hash = hash(:dest_url) AND url = :dest_url) "
+ "AND a2.anno_attribute_id = n.id "
+ "WHERE url = :source_url"
+ );
+ NS_ENSURE_STATE(sourceStmt);
+ mozStorageStatementScoper sourceScoper(sourceStmt);
+
+ nsresult rv = URIBinder::Bind(sourceStmt, NS_LITERAL_CSTRING("source_url"), aSourceURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = URIBinder::Bind(sourceStmt, NS_LITERAL_CSTRING("dest_url"), aDestURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStorageStatement> copyStmt = mDB->GetStatement(
+ "INSERT INTO moz_annos "
+ "(place_id, anno_attribute_id, content, flags, expiration, "
+ "type, dateAdded, lastModified) "
+ "SELECT (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url), "
+ "anno_attribute_id, content, flags, expiration, type, "
+ ":date, :date "
+ "FROM moz_annos "
+ "WHERE place_id = :page_id "
+ "AND anno_attribute_id = :name_id"
+ );
+ NS_ENSURE_STATE(copyStmt);
+ mozStorageStatementScoper copyScoper(copyStmt);
+
+ bool hasResult;
+ while (NS_SUCCEEDED(sourceStmt->ExecuteStep(&hasResult)) && hasResult) {
+ int64_t sourcePlaceId = sourceStmt->AsInt64(0);
+ int64_t annoNameID = sourceStmt->AsInt64(1);
+ nsAutoCString annoName;
+ rv = sourceStmt->GetUTF8String(2, annoName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int64_t annoExistsOnDest = sourceStmt->AsInt64(3);
+
+ if (annoExistsOnDest) {
+ if (!aOverwriteDest)
+ continue;
+ rv = RemovePageAnnotation(aDestURI, annoName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Copy the annotation.
+ mozStorageStatementScoper scoper(copyStmt);
+ rv = URIBinder::Bind(copyStmt, NS_LITERAL_CSTRING("page_url"), aDestURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), sourcePlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("name_id"), annoNameID);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("date"), PR_Now());
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = copyStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnPageAnnotationSet(aDestURI, annoName));
+ }
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::CopyItemAnnotations(int64_t aSourceItemId,
+ int64_t aDestItemId,
+ bool aOverwriteDest,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aSourceItemId, 1);
+ NS_ENSURE_ARG_MIN(aDestItemId, 1);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ nsCOMPtr<mozIStorageStatement> sourceStmt = mDB->GetStatement(
+ "SELECT n.id, n.name, a2.id "
+ "FROM moz_bookmarks b "
+ "JOIN moz_items_annos a ON a.item_id = b.id "
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
+ "LEFT JOIN moz_items_annos a2 ON a2.item_id = :dest_item_id "
+ "AND a2.anno_attribute_id = n.id "
+ "WHERE b.id = :source_item_id"
+ );
+ NS_ENSURE_STATE(sourceStmt);
+ mozStorageStatementScoper sourceScoper(sourceStmt);
+
+ nsresult rv = sourceStmt->BindInt64ByName(NS_LITERAL_CSTRING("source_item_id"), aSourceItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = sourceStmt->BindInt64ByName(NS_LITERAL_CSTRING("dest_item_id"), aDestItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStorageStatement> copyStmt = mDB->GetStatement(
+ "INSERT OR REPLACE INTO moz_items_annos "
+ "(item_id, anno_attribute_id, content, flags, expiration, "
+ "type, dateAdded, lastModified) "
+ "SELECT :dest_item_id, anno_attribute_id, content, flags, expiration, "
+ "type, :date, :date "
+ "FROM moz_items_annos "
+ "WHERE item_id = :source_item_id "
+ "AND anno_attribute_id = :name_id"
+ );
+ NS_ENSURE_STATE(copyStmt);
+ mozStorageStatementScoper copyScoper(copyStmt);
+
+ bool hasResult;
+ while (NS_SUCCEEDED(sourceStmt->ExecuteStep(&hasResult)) && hasResult) {
+ int64_t annoNameID = sourceStmt->AsInt64(0);
+ nsAutoCString annoName;
+ rv = sourceStmt->GetUTF8String(1, annoName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int64_t annoExistsOnDest = sourceStmt->AsInt64(2);
+
+ if (annoExistsOnDest) {
+ if (!aOverwriteDest)
+ continue;
+ rv = RemoveItemAnnotation(aDestItemId, annoName, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Copy the annotation.
+ mozStorageStatementScoper scoper(copyStmt);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("dest_item_id"), aDestItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("source_item_id"), aSourceItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("name_id"), annoNameID);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = copyStmt->BindInt64ByName(NS_LITERAL_CSTRING("date"), PR_Now());
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = copyStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_ANNOS_OBSERVERS(OnItemAnnotationSet(aDestItemId, annoName, aSource));
+ }
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::AddObserver(nsIAnnotationObserver* aObserver)
+{
+ NS_ENSURE_ARG(aObserver);
+
+ if (mObservers.IndexOfObject(aObserver) >= 0)
+ return NS_ERROR_INVALID_ARG; // Already registered.
+ if (!mObservers.AppendObject(aObserver))
+ return NS_ERROR_OUT_OF_MEMORY;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsAnnotationService::RemoveObserver(nsIAnnotationObserver* aObserver)
+{
+ NS_ENSURE_ARG(aObserver);
+
+ if (!mObservers.RemoveObject(aObserver))
+ return NS_ERROR_INVALID_ARG;
+ return NS_OK;
+}
+
+nsresult
+nsAnnotationService::HasAnnotationInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ bool* _hasAnno)
+{
+ bool isItemAnnotation = (aItemId > 0);
+ nsCOMPtr<mozIStorageStatement> stmt;
+ if (isItemAnnotation) {
+ stmt = mDB->GetStatement(
+ "SELECT b.id, "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name) AS nameid, "
+ "a.id, a.dateAdded "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_items_annos a ON a.item_id = b.id "
+ "AND a.anno_attribute_id = nameid "
+ "WHERE b.id = :item_id"
+ );
+ }
+ else {
+ stmt = mDB->GetStatement(
+ "SELECT h.id, "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name) AS nameid, "
+ "a.id, a.dateAdded "
+ "FROM moz_places h "
+ "LEFT JOIN moz_annos a ON a.place_id = h.id "
+ "AND a.anno_attribute_id = nameid "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url"
+ );
+ }
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper checkAnnoScoper(stmt);
+
+ nsresult rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (isItemAnnotation)
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ else
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasResult) {
+ // We are trying to get an annotation on an invalid bookmarks or
+ // history entry.
+ // Here we preserve the old behavior, returning that we don't have the
+ // annotation, ignoring the fact itemId is invalid.
+ // Otherwise we should return NS_ERROR_INVALID_ARG, but this will somehow
+ // break the API. In future we could want to be pickier.
+ *_hasAnno = false;
+ }
+ else {
+ int64_t annotationId = stmt->AsInt64(2);
+ *_hasAnno = (annotationId > 0);
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * This loads the statement and steps it once so you can get data out of it.
+ *
+ * @note You have to reset the statement when you're done if this succeeds.
+ * @throws NS_ERROR_NOT_AVAILABLE if the annotation is not found.
+ */
+
+nsresult
+nsAnnotationService::StartGetAnnotation(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ nsCOMPtr<mozIStorageStatement>& aStatement)
+{
+ bool isItemAnnotation = (aItemId > 0);
+
+ if (isItemAnnotation) {
+ aStatement = mDB->GetStatement(
+ "SELECT a.id, a.item_id, :anno_name, a.content, a.flags, "
+ "a.expiration, a.type "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_items_annos a ON a.anno_attribute_id = n.id "
+ "WHERE a.item_id = :item_id "
+ "AND n.name = :anno_name"
+ );
+ }
+ else {
+ aStatement = mDB->GetStatement(
+ "SELECT a.id, a.place_id, :anno_name, a.content, a.flags, "
+ "a.expiration, a.type "
+ "FROM moz_anno_attributes n "
+ "JOIN moz_annos a ON n.id = a.anno_attribute_id "
+ "JOIN moz_places h ON h.id = a.place_id "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url "
+ "AND n.name = :anno_name"
+ );
+ }
+ NS_ENSURE_STATE(aStatement);
+ mozStorageStatementScoper getAnnoScoper(aStatement);
+
+ nsresult rv;
+ if (isItemAnnotation)
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ else
+ rv = URIBinder::Bind(aStatement, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = aStatement->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult = false;
+ rv = aStatement->ExecuteStep(&hasResult);
+ if (NS_FAILED(rv) || !hasResult)
+ return NS_ERROR_NOT_AVAILABLE;
+
+ // on success, DON'T reset the statement, the caller needs to read from it,
+ // and it is the caller's job to reset it.
+ getAnnoScoper.Abandon();
+
+ return NS_OK;
+}
+
+
+/**
+ * This does most of the setup work needed to set an annotation, except for
+ * binding the the actual value and executing the statement.
+ * It will either update an existing annotation or insert a new one.
+ *
+ * @note The aStatement RESULT IS NOT ADDREFED. This is just one of the class
+ * vars, which control its scope. DO NOT RELEASE.
+ * The caller must take care of resetting the statement if this succeeds.
+ */
+nsresult
+nsAnnotationService::StartSetAnnotation(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aType,
+ nsCOMPtr<mozIStorageStatement>& aStatement)
+{
+ bool isItemAnnotation = (aItemId > 0);
+
+ if (aExpiration == EXPIRE_SESSION) {
+ mHasSessionAnnotations = true;
+ }
+
+ // Ensure the annotation name exists.
+ nsCOMPtr<mozIStorageStatement> addNameStmt = mDB->GetStatement(
+ "INSERT OR IGNORE INTO moz_anno_attributes (name) VALUES (:anno_name)"
+ );
+ NS_ENSURE_STATE(addNameStmt);
+ mozStorageStatementScoper scoper(addNameStmt);
+
+ nsresult rv = addNameStmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = addNameStmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We have to check 2 things:
+ // - if the annotation already exists we should update it.
+ // - we should not allow setting annotations on invalid URIs or itemIds.
+ // This query will tell us:
+ // - whether the item or page exists.
+ // - whether the annotation already exists.
+ // - the nameID associated with the annotation name.
+ // - the id and dateAdded of the old annotation, if it exists.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ if (isItemAnnotation) {
+ stmt = mDB->GetStatement(
+ "SELECT b.id, "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name) AS nameid, "
+ "a.id, a.dateAdded "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_items_annos a ON a.item_id = b.id "
+ "AND a.anno_attribute_id = nameid "
+ "WHERE b.id = :item_id"
+ );
+ }
+ else {
+ stmt = mDB->GetStatement(
+ "SELECT h.id, "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name) AS nameid, "
+ "a.id, a.dateAdded "
+ "FROM moz_places h "
+ "LEFT JOIN moz_annos a ON a.place_id = h.id "
+ "AND a.anno_attribute_id = nameid "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url"
+ );
+ }
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper checkAnnoScoper(stmt);
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), aName);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (isItemAnnotation)
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ else
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasResult) {
+ // We are trying to create an annotation on an invalid bookmark
+ // or history entry.
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ int64_t fkId = stmt->AsInt64(0);
+ int64_t nameID = stmt->AsInt64(1);
+ int64_t oldAnnoId = stmt->AsInt64(2);
+ int64_t oldAnnoDate = stmt->AsInt64(3);
+
+ if (isItemAnnotation) {
+ aStatement = mDB->GetStatement(
+ "INSERT OR REPLACE INTO moz_items_annos "
+ "(id, item_id, anno_attribute_id, content, flags, "
+ "expiration, type, dateAdded, lastModified) "
+ "VALUES (:id, :fk, :name_id, :content, :flags, "
+ ":expiration, :type, :date_added, :last_modified)"
+ );
+ }
+ else {
+ aStatement = mDB->GetStatement(
+ "INSERT OR REPLACE INTO moz_annos "
+ "(id, place_id, anno_attribute_id, content, flags, "
+ "expiration, type, dateAdded, lastModified) "
+ "VALUES (:id, :fk, :name_id, :content, :flags, "
+ ":expiration, :type, :date_added, :last_modified)"
+ );
+ }
+ NS_ENSURE_STATE(aStatement);
+ mozStorageStatementScoper setAnnoScoper(aStatement);
+
+ // Don't replace existing annotations.
+ if (oldAnnoId > 0) {
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("id"), oldAnnoId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("date_added"), oldAnnoDate);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ rv = aStatement->BindNullByName(NS_LITERAL_CSTRING("id"));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("date_added"), RoundedPRNow());
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("fk"), fkId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("name_id"), nameID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = aStatement->BindInt32ByName(NS_LITERAL_CSTRING("flags"), aFlags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt32ByName(NS_LITERAL_CSTRING("expiration"), aExpiration);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt32ByName(NS_LITERAL_CSTRING("type"), aType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aStatement->BindInt64ByName(NS_LITERAL_CSTRING("last_modified"), RoundedPRNow());
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // On success, leave the statement open, the caller will set the value
+ // and execute the statement.
+ setAnnoScoper.Abandon();
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+nsAnnotationService::Observe(nsISupports *aSubject,
+ const char *aTopic,
+ const char16_t *aData)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ if (strcmp(aTopic, TOPIC_PLACES_SHUTDOWN) == 0) {
+ // Remove all session annotations, if any.
+ if (mHasSessionAnnotations) {
+ nsCOMPtr<mozIStorageAsyncStatement> pageAnnoStmt = mDB->GetAsyncStatement(
+ "DELETE FROM moz_annos WHERE expiration = :expire_session"
+ );
+ NS_ENSURE_STATE(pageAnnoStmt);
+ nsresult rv = pageAnnoStmt->BindInt32ByName(NS_LITERAL_CSTRING("expire_session"),
+ EXPIRE_SESSION);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStorageAsyncStatement> itemAnnoStmt = mDB->GetAsyncStatement(
+ "DELETE FROM moz_items_annos WHERE expiration = :expire_session"
+ );
+ NS_ENSURE_STATE(itemAnnoStmt);
+ rv = itemAnnoStmt->BindInt32ByName(NS_LITERAL_CSTRING("expire_session"),
+ EXPIRE_SESSION);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozIStorageBaseStatement *stmts[] = {
+ pageAnnoStmt.get()
+ , itemAnnoStmt.get()
+ };
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = mDB->MainConn()->ExecuteAsync(stmts, ArrayLength(stmts), nullptr,
+ getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return NS_OK;
+}
diff --git a/toolkit/components/places/nsAnnotationService.h b/toolkit/components/places/nsAnnotationService.h
new file mode 100644
index 000000000..f1b4921d8
--- /dev/null
+++ b/toolkit/components/places/nsAnnotationService.h
@@ -0,0 +1,161 @@
+//* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsAnnotationService_h___
+#define nsAnnotationService_h___
+
+#include "nsIAnnotationService.h"
+#include "nsTArray.h"
+#include "nsCOMArray.h"
+#include "nsCOMPtr.h"
+#include "nsServiceManagerUtils.h"
+#include "nsWeakReference.h"
+#include "nsToolkitCompsCID.h"
+#include "Database.h"
+#include "nsString.h"
+#include "mozilla/Attributes.h"
+
+namespace mozilla {
+namespace places {
+
+class AnnotatedResult final : public mozIAnnotatedResult
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZIANNOTATEDRESULT
+
+ AnnotatedResult(const nsCString& aGUID, nsIURI* aURI, int64_t aItemd,
+ const nsACString& aAnnotationName,
+ nsIVariant* aAnnotationValue);
+
+private:
+ ~AnnotatedResult();
+
+ const nsCString mGUID;
+ nsCOMPtr<nsIURI> mURI;
+ const int64_t mItemId;
+ const nsCString mAnnotationName;
+ nsCOMPtr<nsIVariant> mAnnotationValue;
+};
+
+} // namespace places
+} // namespace mozilla
+
+class nsAnnotationService final : public nsIAnnotationService
+ , public nsIObserver
+ , public nsSupportsWeakReference
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIANNOTATIONSERVICE
+ NS_DECL_NSIOBSERVER
+
+ nsAnnotationService();
+
+ /**
+ * Obtains the service's object.
+ */
+ static already_AddRefed<nsAnnotationService> GetSingleton();
+
+ /**
+ * Initializes the service's object. This should only be called once.
+ */
+ nsresult Init();
+
+ /**
+ * Returns a cached pointer to the annotation service for consumers in the
+ * places directory.
+ */
+ static nsAnnotationService* GetAnnotationService()
+ {
+ if (!gAnnotationService) {
+ nsCOMPtr<nsIAnnotationService> serv =
+ do_GetService(NS_ANNOTATIONSERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(serv, nullptr);
+ NS_ASSERTION(gAnnotationService,
+ "Should have static instance pointer now");
+ }
+ return gAnnotationService;
+ }
+
+private:
+ ~nsAnnotationService();
+
+protected:
+ RefPtr<mozilla::places::Database> mDB;
+
+ nsCOMArray<nsIAnnotationObserver> mObservers;
+ bool mHasSessionAnnotations;
+
+ static nsAnnotationService* gAnnotationService;
+
+ static const int kAnnoIndex_ID;
+ static const int kAnnoIndex_PageOrItem;
+ static const int kAnnoIndex_NameID;
+ static const int kAnnoIndex_Content;
+ static const int kAnnoIndex_Flags;
+ static const int kAnnoIndex_Expiration;
+ static const int kAnnoIndex_Type;
+ static const int kAnnoIndex_DateAdded;
+ static const int kAnnoIndex_LastModified;
+
+ nsresult HasAnnotationInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ bool* _hasAnno);
+
+ nsresult StartGetAnnotation(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ nsCOMPtr<mozIStorageStatement>& aStatement);
+
+ nsresult StartSetAnnotation(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int32_t aFlags,
+ uint16_t aExpiration,
+ uint16_t aType,
+ nsCOMPtr<mozIStorageStatement>& aStatement);
+
+ nsresult SetAnnotationStringInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ const nsAString& aValue,
+ int32_t aFlags,
+ uint16_t aExpiration);
+ nsresult SetAnnotationInt32Internal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int32_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration);
+ nsresult SetAnnotationInt64Internal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ int64_t aValue,
+ int32_t aFlags,
+ uint16_t aExpiration);
+ nsresult SetAnnotationDoubleInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName,
+ double aValue,
+ int32_t aFlags,
+ uint16_t aExpiration);
+
+ nsresult RemoveAnnotationInternal(nsIURI* aURI,
+ int64_t aItemId,
+ const nsACString& aName);
+
+public:
+ nsresult GetPagesWithAnnotationCOMArray(const nsACString& aName,
+ nsCOMArray<nsIURI>* _results);
+ nsresult GetItemsWithAnnotationTArray(const nsACString& aName,
+ nsTArray<int64_t>* _result);
+ nsresult GetAnnotationNamesTArray(nsIURI* aURI,
+ int64_t aItemId,
+ nsTArray<nsCString>* _result);
+};
+
+#endif /* nsAnnotationService_h___ */
diff --git a/toolkit/components/places/nsFaviconService.cpp b/toolkit/components/places/nsFaviconService.cpp
new file mode 100644
index 000000000..42526b285
--- /dev/null
+++ b/toolkit/components/places/nsFaviconService.cpp
@@ -0,0 +1,716 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This is the favicon service, which stores favicons for web pages with your
+ * history as you browse. It is also used to save the favicons for bookmarks.
+ *
+ * DANGER: The history query system makes assumptions about the favicon storage
+ * so that icons can be quickly generated for history/bookmark result sets. If
+ * you change the database layout at all, you will have to update both services.
+ */
+
+#include "nsFaviconService.h"
+
+#include "nsNavHistory.h"
+#include "nsPlacesMacros.h"
+#include "Helpers.h"
+
+#include "nsNetUtil.h"
+#include "nsReadableUtils.h"
+#include "nsStreamUtils.h"
+#include "nsStringStream.h"
+#include "plbase64.h"
+#include "nsIClassInfoImpl.h"
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/LoadInfo.h"
+#include "mozilla/Preferences.h"
+#include "nsILoadInfo.h"
+#include "nsIContentPolicy.h"
+#include "nsContentUtils.h"
+#include "nsNullPrincipal.h"
+
+// For large favicons optimization.
+#include "imgITools.h"
+#include "imgIContainer.h"
+
+// The target dimension, in pixels, for favicons we optimize.
+#define OPTIMIZED_FAVICON_DIMENSION 32
+
+#define MAX_FAILED_FAVICONS 256
+#define FAVICON_CACHE_REDUCE_COUNT 64
+
+#define UNASSOCIATED_FAVICONS_LENGTH 32
+
+// When replaceFaviconData is called, we store the icons in an in-memory cache
+// instead of in storage. Icons in the cache are expired according to this
+// interval.
+#define UNASSOCIATED_ICON_EXPIRY_INTERVAL 60000
+
+// The MIME type of the default favicon and favicons created by
+// OptimizeFaviconImage.
+#define DEFAULT_MIME_TYPE "image/png"
+
+using namespace mozilla;
+using namespace mozilla::places;
+
+/**
+ * Used to notify a topic to system observers on async execute completion.
+ * Will throw on error.
+ */
+class ExpireFaviconsStatementCallbackNotifier : public AsyncStatementCallback
+{
+public:
+ ExpireFaviconsStatementCallbackNotifier();
+ NS_IMETHOD HandleCompletion(uint16_t aReason);
+};
+
+
+PLACES_FACTORY_SINGLETON_IMPLEMENTATION(nsFaviconService, gFaviconService)
+
+NS_IMPL_CLASSINFO(nsFaviconService, nullptr, 0, NS_FAVICONSERVICE_CID)
+NS_IMPL_ISUPPORTS_CI(
+ nsFaviconService
+, nsIFaviconService
+, mozIAsyncFavicons
+, nsITimerCallback
+)
+
+nsFaviconService::nsFaviconService()
+ : mFailedFaviconSerial(0)
+ , mFailedFavicons(MAX_FAILED_FAVICONS / 2)
+ , mUnassociatedIcons(UNASSOCIATED_FAVICONS_LENGTH)
+{
+ NS_ASSERTION(!gFaviconService,
+ "Attempting to create two instances of the service!");
+ gFaviconService = this;
+}
+
+
+nsFaviconService::~nsFaviconService()
+{
+ NS_ASSERTION(gFaviconService == this,
+ "Deleting a non-singleton instance of the service");
+ if (gFaviconService == this)
+ gFaviconService = nullptr;
+}
+
+
+nsresult
+nsFaviconService::Init()
+{
+ mDB = Database::GetDatabase();
+ NS_ENSURE_STATE(mDB);
+
+ mExpireUnassociatedIconsTimer = do_CreateInstance("@mozilla.org/timer;1");
+ NS_ENSURE_STATE(mExpireUnassociatedIconsTimer);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFaviconService::ExpireAllFavicons()
+{
+ nsCOMPtr<mozIStorageAsyncStatement> unlinkIconsStmt = mDB->GetAsyncStatement(
+ "UPDATE moz_places "
+ "SET favicon_id = NULL "
+ "WHERE favicon_id NOT NULL"
+ );
+ NS_ENSURE_STATE(unlinkIconsStmt);
+ nsCOMPtr<mozIStorageAsyncStatement> removeIconsStmt = mDB->GetAsyncStatement(
+ "DELETE FROM moz_favicons WHERE id NOT IN ("
+ "SELECT favicon_id FROM moz_places WHERE favicon_id NOT NULL "
+ ")"
+ );
+ NS_ENSURE_STATE(removeIconsStmt);
+
+ mozIStorageBaseStatement* stmts[] = {
+ unlinkIconsStmt.get()
+ , removeIconsStmt.get()
+ };
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ RefPtr<ExpireFaviconsStatementCallbackNotifier> callback =
+ new ExpireFaviconsStatementCallbackNotifier();
+ nsresult rv = mDB->MainConn()->ExecuteAsync(
+ stmts, ArrayLength(stmts), callback, getter_AddRefs(ps)
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsITimerCallback
+
+NS_IMETHODIMP
+nsFaviconService::Notify(nsITimer* timer)
+{
+ if (timer != mExpireUnassociatedIconsTimer.get()) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ PRTime now = PR_Now();
+ for (auto iter = mUnassociatedIcons.Iter(); !iter.Done(); iter.Next()) {
+ UnassociatedIconHashKey* iconKey = iter.Get();
+ if (now - iconKey->created >= UNASSOCIATED_ICON_EXPIRY_INTERVAL) {
+ iter.Remove();
+ }
+ }
+
+ // Re-init the expiry timer if the cache isn't empty.
+ if (mUnassociatedIcons.Count() > 0) {
+ mExpireUnassociatedIconsTimer->InitWithCallback(
+ this, UNASSOCIATED_ICON_EXPIRY_INTERVAL, nsITimer::TYPE_ONE_SHOT);
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIFaviconService
+
+NS_IMETHODIMP
+nsFaviconService::GetDefaultFavicon(nsIURI** _retval)
+{
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ // not found, use default
+ if (!mDefaultIcon) {
+ nsresult rv = NS_NewURI(getter_AddRefs(mDefaultIcon),
+ NS_LITERAL_CSTRING(FAVICON_DEFAULT_URL));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ return mDefaultIcon->Clone(_retval);
+}
+
+void
+nsFaviconService::SendFaviconNotifications(nsIURI* aPageURI,
+ nsIURI* aFaviconURI,
+ const nsACString& aGUID)
+{
+ nsAutoCString faviconSpec;
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ if (history && NS_SUCCEEDED(aFaviconURI->GetSpec(faviconSpec))) {
+ history->SendPageChangedNotification(aPageURI,
+ nsINavHistoryObserver::ATTRIBUTE_FAVICON,
+ NS_ConvertUTF8toUTF16(faviconSpec),
+ aGUID);
+ }
+}
+
+NS_IMETHODIMP
+nsFaviconService::SetAndFetchFaviconForPage(nsIURI* aPageURI,
+ nsIURI* aFaviconURI,
+ bool aForceReload,
+ uint32_t aFaviconLoadType,
+ nsIFaviconDataCallback* aCallback,
+ nsIPrincipal* aLoadingPrincipal,
+ mozIPlacesPendingOperation **_canceler)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aPageURI);
+ NS_ENSURE_ARG(aFaviconURI);
+ NS_ENSURE_ARG_POINTER(_canceler);
+
+ // If a favicon is in the failed cache, only load it during a forced reload.
+ bool previouslyFailed;
+ nsresult rv = IsFailedFavicon(aFaviconURI, &previouslyFailed);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (previouslyFailed) {
+ if (aForceReload)
+ RemoveFailedFavicon(aFaviconURI);
+ else
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPrincipal> loadingPrincipal = aLoadingPrincipal;
+ MOZ_ASSERT(loadingPrincipal, "please provide aLoadingPrincipal for this favicon");
+ if (!loadingPrincipal) {
+ // Let's default to the nullPrincipal if no loadingPrincipal is provided.
+ const char16_t* params[] = {
+ u"nsFaviconService::setAndFetchFaviconForPage()",
+ u"nsFaviconService::setAndFetchFaviconForPage(..., [optional aLoadingPrincipal])"
+ };
+ nsContentUtils::ReportToConsole(nsIScriptError::warningFlag,
+ NS_LITERAL_CSTRING("Security by Default"),
+ nullptr, // aDocument
+ nsContentUtils::eNECKO_PROPERTIES,
+ "APIDeprecationWarning",
+ params, ArrayLength(params));
+ loadingPrincipal = nsNullPrincipal::Create();
+ }
+ NS_ENSURE_TRUE(loadingPrincipal, NS_ERROR_FAILURE);
+
+ // Check if the icon already exists and fetch it from the network, if needed.
+ // Finally associate the icon to the requested page if not yet associated.
+ bool loadPrivate = aFaviconLoadType == nsIFaviconService::FAVICON_LOAD_PRIVATE;
+
+ PageData page;
+ rv = aPageURI->GetSpec(page.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // URIs can arguably miss a host.
+ (void)GetReversedHostname(aPageURI, page.revHost);
+ bool canAddToHistory;
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(navHistory, NS_ERROR_OUT_OF_MEMORY);
+ rv = navHistory->CanAddURI(aPageURI, &canAddToHistory);
+ NS_ENSURE_SUCCESS(rv, rv);
+ page.canAddToHistory = !!canAddToHistory && !loadPrivate;
+
+ IconData icon;
+ UnassociatedIconHashKey* iconKey = mUnassociatedIcons.GetEntry(aFaviconURI);
+ if (iconKey) {
+ icon = iconKey->iconData;
+ mUnassociatedIcons.RemoveEntry(iconKey);
+ } else {
+ icon.fetchMode = aForceReload ? FETCH_ALWAYS : FETCH_IF_MISSING;
+ rv = aFaviconURI->GetSpec(icon.spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // If the page url points to an image, the icon's url will be the same.
+ // In future evaluate to store a resample of the image. For now avoid that
+ // for database size concerns.
+ // Don't store favicons for error pages too.
+ if (icon.spec.Equals(page.spec) ||
+ icon.spec.Equals(FAVICON_ERRORPAGE_URL)) {
+ return NS_OK;
+ }
+
+ RefPtr<AsyncFetchAndSetIconForPage> event =
+ new AsyncFetchAndSetIconForPage(icon, page, loadPrivate,
+ aCallback, aLoadingPrincipal);
+
+ // Get the target thread and start the work.
+ // DB will be updated and observers notified when data has finished loading.
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ DB->DispatchToAsyncThread(event);
+
+ // Return this event to the caller to allow aborting an eventual fetch.
+ event.forget(_canceler);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFaviconService::ReplaceFaviconData(nsIURI* aFaviconURI,
+ const uint8_t* aData,
+ uint32_t aDataLen,
+ const nsACString& aMimeType,
+ PRTime aExpiration)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aFaviconURI);
+ NS_ENSURE_ARG(aData);
+ NS_ENSURE_TRUE(aDataLen > 0, NS_ERROR_INVALID_ARG);
+ NS_ENSURE_TRUE(aMimeType.Length() > 0, NS_ERROR_INVALID_ARG);
+ if (aExpiration == 0) {
+ aExpiration = PR_Now() + MAX_FAVICON_EXPIRATION;
+ }
+
+ UnassociatedIconHashKey* iconKey = mUnassociatedIcons.PutEntry(aFaviconURI);
+ if (!iconKey) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ iconKey->created = PR_Now();
+
+ // If the cache contains unassociated icons, an expiry timer should already exist, otherwise
+ // there may be a timer left hanging around, so make sure we fire a new one.
+ int32_t unassociatedCount = mUnassociatedIcons.Count();
+ if (unassociatedCount == 1) {
+ mExpireUnassociatedIconsTimer->Cancel();
+ mExpireUnassociatedIconsTimer->InitWithCallback(
+ this, UNASSOCIATED_ICON_EXPIRY_INTERVAL, nsITimer::TYPE_ONE_SHOT);
+ }
+
+ IconData* iconData = &(iconKey->iconData);
+ iconData->expiration = aExpiration;
+ iconData->status = ICON_STATUS_CACHED;
+ iconData->fetchMode = FETCH_NEVER;
+ nsresult rv = aFaviconURI->GetSpec(iconData->spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If the page provided a large image for the favicon (eg, a highres image
+ // or a multiresolution .ico file), we don't want to store more data than
+ // needed.
+ if (aDataLen > MAX_FAVICON_FILESIZE) {
+ rv = OptimizeFaviconImage(aData, aDataLen, aMimeType, iconData->data, iconData->mimeType);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (iconData->data.Length() > nsIFaviconService::MAX_FAVICON_BUFFER_SIZE) {
+ // We cannot optimize this favicon size and we are over the maximum size
+ // allowed, so we will not save data to the db to avoid bloating it.
+ mUnassociatedIcons.RemoveEntry(aFaviconURI);
+ return NS_ERROR_FAILURE;
+ }
+ } else {
+ iconData->mimeType.Assign(aMimeType);
+ iconData->data.Assign(TO_CHARBUFFER(aData), aDataLen);
+ }
+
+ // If the database contains an icon at the given url, we will update the
+ // database immediately so that the associated pages are kept in sync.
+ // Otherwise, do nothing and let the icon be picked up from the memory hash.
+ RefPtr<AsyncReplaceFaviconData> event = new AsyncReplaceFaviconData(*iconData);
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ DB->DispatchToAsyncThread(event);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFaviconService::ReplaceFaviconDataFromDataURL(nsIURI* aFaviconURI,
+ const nsAString& aDataURL,
+ PRTime aExpiration,
+ nsIPrincipal* aLoadingPrincipal)
+{
+ NS_ENSURE_ARG(aFaviconURI);
+ NS_ENSURE_TRUE(aDataURL.Length() > 0, NS_ERROR_INVALID_ARG);
+ if (aExpiration == 0) {
+ aExpiration = PR_Now() + MAX_FAVICON_EXPIRATION;
+ }
+
+ nsCOMPtr<nsIURI> dataURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(dataURI), aDataURL);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Use the data: protocol handler to convert the data.
+ nsCOMPtr<nsIIOService> ioService = do_GetIOService(&rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIProtocolHandler> protocolHandler;
+ rv = ioService->GetProtocolHandler("data", getter_AddRefs(protocolHandler));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIPrincipal> loadingPrincipal = aLoadingPrincipal;
+ MOZ_ASSERT(loadingPrincipal, "please provide aLoadingPrincipal for this favicon");
+ if (!loadingPrincipal) {
+ // Let's default to the nullPrincipal if no loadingPrincipal is provided.
+ const char16_t* params[] = {
+ u"nsFaviconService::ReplaceFaviconDataFromDataURL()",
+ u"nsFaviconService::ReplaceFaviconDataFromDataURL(..., [optional aLoadingPrincipal])"
+ };
+ nsContentUtils::ReportToConsole(nsIScriptError::warningFlag,
+ NS_LITERAL_CSTRING("Security by Default"),
+ nullptr, // aDocument
+ nsContentUtils::eNECKO_PROPERTIES,
+ "APIDeprecationWarning",
+ params, ArrayLength(params));
+
+ loadingPrincipal = nsNullPrincipal::Create();
+ }
+ NS_ENSURE_TRUE(loadingPrincipal, NS_ERROR_FAILURE);
+
+ nsCOMPtr<nsILoadInfo> loadInfo =
+ new mozilla::LoadInfo(loadingPrincipal,
+ nullptr, // aTriggeringPrincipal
+ nullptr, // aLoadingNode
+ nsILoadInfo::SEC_ALLOW_CROSS_ORIGIN_DATA_INHERITS |
+ nsILoadInfo::SEC_ALLOW_CHROME |
+ nsILoadInfo::SEC_DISALLOW_SCRIPT,
+ nsIContentPolicy::TYPE_INTERNAL_IMAGE_FAVICON);
+
+ nsCOMPtr<nsIChannel> channel;
+ rv = protocolHandler->NewChannel2(dataURI, loadInfo, getter_AddRefs(channel));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Blocking stream is OK for data URIs.
+ nsCOMPtr<nsIInputStream> stream;
+ rv = channel->Open2(getter_AddRefs(stream));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint64_t available64;
+ rv = stream->Available(&available64);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (available64 == 0 || available64 > UINT32_MAX / sizeof(uint8_t))
+ return NS_ERROR_FILE_TOO_BIG;
+ uint32_t available = (uint32_t)available64;
+
+ // Read all the decoded data.
+ uint8_t* buffer = static_cast<uint8_t*>
+ (moz_xmalloc(sizeof(uint8_t) * available));
+ if (!buffer)
+ return NS_ERROR_OUT_OF_MEMORY;
+ uint32_t numRead;
+ rv = stream->Read(TO_CHARBUFFER(buffer), available, &numRead);
+ if (NS_FAILED(rv) || numRead != available) {
+ free(buffer);
+ return rv;
+ }
+
+ nsAutoCString mimeType;
+ rv = channel->GetContentType(mimeType);
+ if (NS_FAILED(rv)) {
+ free(buffer);
+ return rv;
+ }
+
+ // ReplaceFaviconData can now do the dirty work.
+ rv = ReplaceFaviconData(aFaviconURI, buffer, available, mimeType, aExpiration);
+ free(buffer);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFaviconService::GetFaviconURLForPage(nsIURI *aPageURI,
+ nsIFaviconDataCallback* aCallback)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aPageURI);
+ NS_ENSURE_ARG(aCallback);
+
+ nsAutoCString pageSpec;
+ nsresult rv = aPageURI->GetSpec(pageSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ RefPtr<AsyncGetFaviconURLForPage> event =
+ new AsyncGetFaviconURLForPage(pageSpec, aCallback);
+
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ DB->DispatchToAsyncThread(event);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsFaviconService::GetFaviconDataForPage(nsIURI* aPageURI,
+ nsIFaviconDataCallback* aCallback)
+{
+ MOZ_ASSERT(NS_IsMainThread());
+ NS_ENSURE_ARG(aPageURI);
+ NS_ENSURE_ARG(aCallback);
+
+ nsAutoCString pageSpec;
+ nsresult rv = aPageURI->GetSpec(pageSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ RefPtr<AsyncGetFaviconDataForPage> event =
+ new AsyncGetFaviconDataForPage(pageSpec, aCallback);
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ DB->DispatchToAsyncThread(event);
+
+ return NS_OK;
+}
+
+nsresult
+nsFaviconService::GetFaviconLinkForIcon(nsIURI* aFaviconURI,
+ nsIURI** aOutputURI)
+{
+ NS_ENSURE_ARG(aFaviconURI);
+ NS_ENSURE_ARG_POINTER(aOutputURI);
+
+ nsAutoCString spec;
+ if (aFaviconURI) {
+ nsresult rv = aFaviconURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ return GetFaviconLinkForIconString(spec, aOutputURI);
+}
+
+
+NS_IMETHODIMP
+nsFaviconService::AddFailedFavicon(nsIURI* aFaviconURI)
+{
+ NS_ENSURE_ARG(aFaviconURI);
+
+ nsAutoCString spec;
+ nsresult rv = aFaviconURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mFailedFavicons.Put(spec, mFailedFaviconSerial);
+ mFailedFaviconSerial ++;
+
+ if (mFailedFavicons.Count() > MAX_FAILED_FAVICONS) {
+ // need to expire some entries, delete the FAVICON_CACHE_REDUCE_COUNT number
+ // of items that are the oldest
+ uint32_t threshold = mFailedFaviconSerial -
+ MAX_FAILED_FAVICONS + FAVICON_CACHE_REDUCE_COUNT;
+ for (auto iter = mFailedFavicons.Iter(); !iter.Done(); iter.Next()) {
+ if (iter.Data() < threshold) {
+ iter.Remove();
+ }
+ }
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsFaviconService::RemoveFailedFavicon(nsIURI* aFaviconURI)
+{
+ NS_ENSURE_ARG(aFaviconURI);
+
+ nsAutoCString spec;
+ nsresult rv = aFaviconURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // we silently do nothing and succeed if the icon is not in the cache
+ mFailedFavicons.Remove(spec);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsFaviconService::IsFailedFavicon(nsIURI* aFaviconURI, bool* _retval)
+{
+ NS_ENSURE_ARG(aFaviconURI);
+ nsAutoCString spec;
+ nsresult rv = aFaviconURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint32_t serial;
+ *_retval = mFailedFavicons.Get(spec, &serial);
+ return NS_OK;
+}
+
+
+// nsFaviconService::GetFaviconLinkForIconString
+//
+// This computes a favicon URL with string input and using the cached
+// default one to minimize parsing.
+
+nsresult
+nsFaviconService::GetFaviconLinkForIconString(const nsCString& aSpec,
+ nsIURI** aOutput)
+{
+ if (aSpec.IsEmpty()) {
+ // default icon for empty strings
+ if (! mDefaultIcon) {
+ nsresult rv = NS_NewURI(getter_AddRefs(mDefaultIcon),
+ NS_LITERAL_CSTRING(FAVICON_DEFAULT_URL));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ return mDefaultIcon->Clone(aOutput);
+ }
+
+ if (StringBeginsWith(aSpec, NS_LITERAL_CSTRING("chrome:"))) {
+ // pass through for chrome URLs, since they can be referenced without
+ // this service
+ return NS_NewURI(aOutput, aSpec);
+ }
+
+ nsAutoCString annoUri;
+ annoUri.AssignLiteral("moz-anno:" FAVICON_ANNOTATION_NAME ":");
+ annoUri += aSpec;
+ return NS_NewURI(aOutput, annoUri);
+}
+
+
+// nsFaviconService::GetFaviconSpecForIconString
+//
+// This computes a favicon spec for when you don't want a URI object (as in
+// the tree view implementation), sparing all parsing and normalization.
+void
+nsFaviconService::GetFaviconSpecForIconString(const nsCString& aSpec,
+ nsACString& aOutput)
+{
+ if (aSpec.IsEmpty()) {
+ aOutput.AssignLiteral(FAVICON_DEFAULT_URL);
+ } else if (StringBeginsWith(aSpec, NS_LITERAL_CSTRING("chrome:"))) {
+ aOutput = aSpec;
+ } else {
+ aOutput.AssignLiteral("moz-anno:" FAVICON_ANNOTATION_NAME ":");
+ aOutput += aSpec;
+ }
+}
+
+
+// nsFaviconService::OptimizeFaviconImage
+//
+// Given a blob of data (a image file already read into a buffer), optimize
+// its size by recompressing it as a 16x16 PNG.
+nsresult
+nsFaviconService::OptimizeFaviconImage(const uint8_t* aData, uint32_t aDataLen,
+ const nsACString& aMimeType,
+ nsACString& aNewData,
+ nsACString& aNewMimeType)
+{
+ nsresult rv;
+
+ nsCOMPtr<imgITools> imgtool = do_CreateInstance("@mozilla.org/image/tools;1");
+
+ nsCOMPtr<nsIInputStream> stream;
+ rv = NS_NewByteInputStream(getter_AddRefs(stream),
+ reinterpret_cast<const char*>(aData), aDataLen,
+ NS_ASSIGNMENT_DEPEND);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // decode image
+ nsCOMPtr<imgIContainer> container;
+ rv = imgtool->DecodeImageData(stream, aMimeType, getter_AddRefs(container));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aNewMimeType.AssignLiteral(DEFAULT_MIME_TYPE);
+
+ // scale and recompress
+ nsCOMPtr<nsIInputStream> iconStream;
+ rv = imgtool->EncodeScaledImage(container, aNewMimeType,
+ OPTIMIZED_FAVICON_DIMENSION,
+ OPTIMIZED_FAVICON_DIMENSION,
+ EmptyString(),
+ getter_AddRefs(iconStream));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Read the stream into a new buffer.
+ rv = NS_ConsumeStream(iconStream, UINT32_MAX, aNewData);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+nsFaviconService::GetFaviconDataAsync(nsIURI* aFaviconURI,
+ mozIStorageStatementCallback *aCallback)
+{
+ NS_ASSERTION(aCallback, "Doesn't make sense to call this without a callback");
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
+ "SELECT f.data, f.mime_type FROM moz_favicons f WHERE url = :icon_url"
+ );
+ NS_ENSURE_STATE(stmt);
+
+ // Ignore the ref part of the URI before querying the database because
+ // we may have added a media fragment for rendering purposes.
+
+ nsAutoCString faviconURI;
+ aFaviconURI->GetSpecIgnoringRef(faviconURI);
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("icon_url"), faviconURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> pendingStatement;
+ return stmt->ExecuteAsync(aCallback, getter_AddRefs(pendingStatement));
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// ExpireFaviconsStatementCallbackNotifier
+
+ExpireFaviconsStatementCallbackNotifier::ExpireFaviconsStatementCallbackNotifier()
+{
+}
+
+
+NS_IMETHODIMP
+ExpireFaviconsStatementCallbackNotifier::HandleCompletion(uint16_t aReason)
+{
+ // We should dispatch only if expiration has been successful.
+ if (aReason != mozIStorageStatementCallback::REASON_FINISHED)
+ return NS_OK;
+
+ nsCOMPtr<nsIObserverService> observerService =
+ mozilla::services::GetObserverService();
+ if (observerService) {
+ (void)observerService->NotifyObservers(nullptr,
+ NS_PLACES_FAVICONS_EXPIRED_TOPIC_ID,
+ nullptr);
+ }
+
+ return NS_OK;
+}
diff --git a/toolkit/components/places/nsFaviconService.h b/toolkit/components/places/nsFaviconService.h
new file mode 100644
index 000000000..b2fcdbeaa
--- /dev/null
+++ b/toolkit/components/places/nsFaviconService.h
@@ -0,0 +1,147 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsFaviconService_h_
+#define nsFaviconService_h_
+
+#include "nsIFaviconService.h"
+#include "mozIAsyncFavicons.h"
+
+#include "nsCOMPtr.h"
+#include "nsString.h"
+#include "nsDataHashtable.h"
+#include "nsServiceManagerUtils.h"
+#include "nsTHashtable.h"
+#include "nsToolkitCompsCID.h"
+#include "nsURIHashKey.h"
+#include "nsITimer.h"
+#include "Database.h"
+#include "mozilla/storage.h"
+#include "mozilla/Attributes.h"
+
+#include "FaviconHelpers.h"
+
+// Favicons bigger than this (in bytes) will not be stored in the database. We
+// expect that most 32x32 PNG favicons will be no larger due to compression.
+#define MAX_FAVICON_FILESIZE 3072 /* 3 KiB */
+
+// forward class definitions
+class mozIStorageStatementCallback;
+
+class UnassociatedIconHashKey : public nsURIHashKey
+{
+public:
+ explicit UnassociatedIconHashKey(const nsIURI* aURI)
+ : nsURIHashKey(aURI)
+ {
+ }
+ UnassociatedIconHashKey(const UnassociatedIconHashKey& aOther)
+ : nsURIHashKey(aOther)
+ {
+ NS_NOTREACHED("Do not call me!");
+ }
+ mozilla::places::IconData iconData;
+ PRTime created;
+};
+
+class nsFaviconService final : public nsIFaviconService
+ , public mozIAsyncFavicons
+ , public nsITimerCallback
+{
+public:
+ nsFaviconService();
+
+ /**
+ * Obtains the service's object.
+ */
+ static already_AddRefed<nsFaviconService> GetSingleton();
+
+ /**
+ * Initializes the service's object. This should only be called once.
+ */
+ nsresult Init();
+
+ /**
+ * Returns a cached pointer to the favicon service for consumers in the
+ * places directory.
+ */
+ static nsFaviconService* GetFaviconService()
+ {
+ if (!gFaviconService) {
+ nsCOMPtr<nsIFaviconService> serv =
+ do_GetService(NS_FAVICONSERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(serv, nullptr);
+ NS_ASSERTION(gFaviconService, "Should have static instance pointer now");
+ }
+ return gFaviconService;
+ }
+
+ // addition to API for strings to prevent excessive parsing of URIs
+ nsresult GetFaviconLinkForIconString(const nsCString& aIcon, nsIURI** aOutput);
+ void GetFaviconSpecForIconString(const nsCString& aIcon, nsACString& aOutput);
+
+ nsresult OptimizeFaviconImage(const uint8_t* aData, uint32_t aDataLen,
+ const nsACString& aMimeType,
+ nsACString& aNewData, nsACString& aNewMimeType);
+
+ /**
+ * Obtains the favicon data asynchronously.
+ *
+ * @param aFaviconURI
+ * The URI representing the favicon we are looking for.
+ * @param aCallback
+ * The callback where results or errors will be dispatch to. In the
+ * returned result, the favicon binary data will be at index 0, and the
+ * mime type will be at index 1.
+ */
+ nsresult GetFaviconDataAsync(nsIURI* aFaviconURI,
+ mozIStorageStatementCallback* aCallback);
+
+ /**
+ * Call to send out favicon changed notifications. Should only be called
+ * when there is data loaded for the favicon.
+ * @param aPageURI
+ * The URI of the page to notify about.
+ * @param aFaviconURI
+ * The moz-anno:favicon URI of the icon.
+ * @param aGUID
+ * The unique ID associated with the page.
+ */
+ void SendFaviconNotifications(nsIURI* aPageURI, nsIURI* aFaviconURI,
+ const nsACString& aGUID);
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIFAVICONSERVICE
+ NS_DECL_MOZIASYNCFAVICONS
+ NS_DECL_NSITIMERCALLBACK
+
+private:
+ ~nsFaviconService();
+
+ RefPtr<mozilla::places::Database> mDB;
+
+ nsCOMPtr<nsITimer> mExpireUnassociatedIconsTimer;
+
+ static nsFaviconService* gFaviconService;
+
+ /**
+ * A cached URI for the default icon. We return this a lot, and don't want to
+ * re-parse and normalize our unchanging string many times. Important: do
+ * not return this directly; use Clone() since callers may change the object
+ * they get back. May be null, in which case it needs initialization.
+ */
+ nsCOMPtr<nsIURI> mDefaultIcon;
+
+ uint32_t mFailedFaviconSerial;
+ nsDataHashtable<nsCStringHashKey, uint32_t> mFailedFavicons;
+
+ // This class needs access to the icons cache.
+ friend class mozilla::places::AsyncReplaceFaviconData;
+ nsTHashtable<UnassociatedIconHashKey> mUnassociatedIcons;
+};
+
+#define FAVICON_ANNOTATION_NAME "favicon"
+
+#endif // nsFaviconService_h_
diff --git a/toolkit/components/places/nsIAnnotationService.idl b/toolkit/components/places/nsIAnnotationService.idl
new file mode 100644
index 000000000..bdd417ece
--- /dev/null
+++ b/toolkit/components/places/nsIAnnotationService.idl
@@ -0,0 +1,422 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIVariant;
+interface mozIAnnotatedResult;
+
+[scriptable, uuid(63fe98e0-6889-4c2c-ac9f-703e4bc25027)]
+interface nsIAnnotationObserver : nsISupports
+{
+ /**
+ * Called when an annotation value is set. It could be a new annotation,
+ * or it could be a new value for an existing annotation.
+ */
+ void onPageAnnotationSet(in nsIURI aPage,
+ in AUTF8String aName);
+ void onItemAnnotationSet(in long long aItemId,
+ in AUTF8String aName,
+ in unsigned short aSource);
+
+ /**
+ * Called when an annotation is deleted. If aName is empty, then ALL
+ * annotations for the given URI have been deleted. This is not called when
+ * annotations are expired (normally happens when the app exits).
+ */
+ void onPageAnnotationRemoved(in nsIURI aURI,
+ in AUTF8String aName);
+ void onItemAnnotationRemoved(in long long aItemId,
+ in AUTF8String aName,
+ in unsigned short aSource);
+};
+
+[scriptable, uuid(D4CDAAB1-8EEC-47A8-B420-AD7CB333056A)]
+interface nsIAnnotationService : nsISupports
+{
+ /**
+ * Valid values for aExpiration, which sets the expiration policy for your
+ * annotation. The times for the days, weeks and months policies are
+ * measured since the last visit date of the page in question. These
+ * will not expire so long as the user keeps visiting the page from time
+ * to time.
+ */
+
+ // For temporary data that can be discarded when the user exits.
+ // Removed at application exit.
+ const unsigned short EXPIRE_SESSION = 0;
+
+ // NOTE: 1 is skipped due to its temporary use as EXPIRE_NEVER in bug #319455.
+
+ // For general page settings, things the user is interested in seeing
+ // if they come back to this page some time in the near future.
+ // Removed at 30 days.
+ const unsigned short EXPIRE_WEEKS = 2;
+
+ // Something that the user will be interested in seeing in their
+ // history like favicons. If they haven't visited a page in a couple
+ // of months, they probably aren't interested in many other annotations,
+ // the positions of things, or other stuff you create, so put that in
+ // the weeks policy.
+ // Removed at 180 days.
+ const unsigned short EXPIRE_MONTHS = 3;
+
+ // For annotations that only live as long as the URI is in the database.
+ // A page annotation will expire if the page has no visits
+ // and is not bookmarked.
+ // An item annotation will expire when the item is deleted.
+ const unsigned short EXPIRE_NEVER = 4;
+
+ // For annotations that only live as long as the URI has visits.
+ // Valid only for page annotations.
+ const unsigned short EXPIRE_WITH_HISTORY = 5;
+
+ // For short-lived temporary data that you still want to outlast a session.
+ // Removed at 7 days.
+ const unsigned short EXPIRE_DAYS = 6;
+
+ // type constants
+ const unsigned short TYPE_INT32 = 1;
+ const unsigned short TYPE_DOUBLE = 2;
+ const unsigned short TYPE_STRING = 3;
+ const unsigned short TYPE_INT64 = 5;
+
+ /**
+ * Sets an annotation, overwriting any previous annotation with the same
+ * URL/name. IT IS YOUR JOB TO NAMESPACE YOUR ANNOTATION NAMES.
+ * Use the form "namespace/value", so your name would be like
+ * "bills_extension/page_state" or "history/thumbnail".
+ *
+ * Do not use characters that are not valid in URLs such as spaces, ":",
+ * commas, or most other symbols. You should stick to ASCII letters and
+ * numbers plus "_", "-", and "/".
+ *
+ * aExpiration is one of EXPIRE_* above. aFlags should be 0 for now, some
+ * flags will be defined in the future.
+ *
+ * NOTE: ALL PAGE ANNOTATIONS WILL GET DELETED WHEN THE PAGE IS REMOVED FROM
+ * HISTORY IF THE PAGE IS NOT BOOKMARKED. This means that if you create an
+ * annotation on an unvisited URI, it will get deleted when the browser
+ * shuts down. Otherwise, URIs can exist in history as annotations but the
+ * user has no way of knowing it, potentially violating their privacy
+ * expectations about actions such as "Clear history".
+ * If there is an important annotation that the user or extension wants to
+ * keep, you should add a bookmark for the page and use an EXPIRE_NEVER
+ * annotation. This will ensure the annotation exists until the item is
+ * removed by the user.
+ * See EXPIRE_* constants above for further information.
+ *
+ * For item annotations, aSource should be a change source constant from
+ * nsINavBookmarksService::SOURCE_*, and defaults to SOURCE_DEFAULT if
+ * omitted.
+ *
+ * The annotation "favicon" is special. Favicons are stored in the favicon
+ * service, but are special cased in the protocol handler so they look like
+ * annotations. Do not set favicons using this service, it will not work.
+ *
+ * Only C++ consumers may use the type-specific methods.
+ *
+ * @throws NS_ERROR_ILLEGAL_VALUE if the page or the bookmark doesn't exist.
+ */
+ void setPageAnnotation(in nsIURI aURI,
+ in AUTF8String aName,
+ in nsIVariant aValue,
+ in long aFlags,
+ in unsigned short aExpiration);
+ void setItemAnnotation(in long long aItemId,
+ in AUTF8String aName,
+ in nsIVariant aValue,
+ in long aFlags,
+ in unsigned short aExpiration,
+ [optional] in unsigned short aSource);
+
+ /**
+ * @throws NS_ERROR_ILLEGAL_VALUE if the page or the bookmark doesn't exist.
+ */
+ [noscript] void setPageAnnotationString(in nsIURI aURI,
+ in AUTF8String aName,
+ in AString aValue,
+ in long aFlags,
+ in unsigned short aExpiration);
+ [noscript] void setItemAnnotationString(in long long aItemId,
+ in AUTF8String aName,
+ in AString aValue,
+ in long aFlags,
+ in unsigned short aExpiration,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Sets an annotation just like setAnnotationString, but takes an Int32 as
+ * input.
+ *
+ * @throws NS_ERROR_ILLEGAL_VALUE if the page or the bookmark doesn't exist.
+ */
+ [noscript] void setPageAnnotationInt32(in nsIURI aURI,
+ in AUTF8String aName,
+ in long aValue,
+ in long aFlags,
+ in unsigned short aExpiration);
+ [noscript] void setItemAnnotationInt32(in long long aItemId,
+ in AUTF8String aName,
+ in long aValue,
+ in long aFlags,
+ in unsigned short aExpiration,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Sets an annotation just like setAnnotationString, but takes an Int64 as
+ * input.
+ *
+ * @throws NS_ERROR_ILLEGAL_VALUE if the page or the bookmark doesn't exist.
+ */
+ [noscript] void setPageAnnotationInt64(in nsIURI aURI,
+ in AUTF8String aName,
+ in long long aValue,
+ in long aFlags,
+ in unsigned short aExpiration);
+ [noscript] void setItemAnnotationInt64(in long long aItemId,
+ in AUTF8String aName,
+ in long long aValue,
+ in long aFlags,
+ in unsigned short aExpiration,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Sets an annotation just like setAnnotationString, but takes a double as
+ * input.
+ *
+ * @throws NS_ERROR_ILLEGAL_VALUE if the page or the bookmark doesn't exist.
+ */
+ [noscript] void setPageAnnotationDouble(in nsIURI aURI,
+ in AUTF8String aName,
+ in double aValue,
+ in long aFlags,
+ in unsigned short aExpiration);
+ [noscript] void setItemAnnotationDouble(in long long aItemId,
+ in AUTF8String aName,
+ in double aValue,
+ in long aFlags,
+ in unsigned short aExpiration,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Retrieves the value of a given annotation. Throws an error if the
+ * annotation does not exist. C++ consumers may use the type-specific
+ * methods.
+ *
+ * The type-specific methods throw if the given annotation is set in
+ * a different type.
+ */
+ nsIVariant getPageAnnotation(in nsIURI aURI,
+ in AUTF8String aName);
+ nsIVariant getItemAnnotation(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * @see getPageAnnotation
+ */
+ [noscript] AString getPageAnnotationString(in nsIURI aURI,
+ in AUTF8String aName);
+ [noscript] AString getItemAnnotationString(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * @see getPageAnnotation
+ */
+ [noscript] long getPageAnnotationInt32(in nsIURI aURI,
+ in AUTF8String aName);
+ [noscript] long getItemAnnotationInt32(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * @see getPageAnnotation
+ */
+ [noscript] long long getPageAnnotationInt64(in nsIURI aURI,
+ in AUTF8String aName);
+ [noscript] long long getItemAnnotationInt64(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * @see getPageAnnotation
+ */
+ [noscript] double getPageAnnotationDouble(in nsIURI aURI,
+ in AUTF8String aName);
+ [noscript] double getItemAnnotationDouble(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * Retrieves info about an existing annotation.
+ *
+ * aType will be one of TYPE_* constansts above
+ *
+ * example JS:
+ * var flags = {}, exp = {}, type = {};
+ * annotator.getAnnotationInfo(myURI, "foo", flags, exp, type);
+ * // now you can use 'exp.value' and 'flags.value'
+ */
+ void getPageAnnotationInfo(in nsIURI aURI,
+ in AUTF8String aName,
+ out int32_t aFlags,
+ out unsigned short aExpiration,
+ out unsigned short aType);
+ void getItemAnnotationInfo(in long long aItemId,
+ in AUTF8String aName,
+ out long aFlags,
+ out unsigned short aExpiration,
+ out unsigned short aType);
+
+ /**
+ * Retrieves the type of an existing annotation
+ * Use getAnnotationInfo if you need this along with the mime-type etc.
+ *
+ * @param aURI
+ * the uri on which the annotation is set
+ * @param aName
+ * the annotation name
+ * @return one of the TYPE_* constants above
+ * @throws if the annotation is not set
+ */
+ uint16_t getPageAnnotationType(in nsIURI aURI,
+ in AUTF8String aName);
+ uint16_t getItemAnnotationType(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * Returns a list of all URIs having a given annotation.
+ */
+ void getPagesWithAnnotation(
+ in AUTF8String name,
+ [optional] out unsigned long resultCount,
+ [retval, array, size_is(resultCount)] out nsIURI results);
+ void getItemsWithAnnotation(
+ in AUTF8String name,
+ [optional] out unsigned long resultCount,
+ [retval, array, size_is(resultCount)] out long long results);
+
+ /**
+ * Returns a list of mozIAnnotation(s), having a given annotation name.
+ *
+ * @param name
+ * The annotation to search for.
+ * @return list of mozIAnnotation objects.
+ */
+ void getAnnotationsWithName(
+ in AUTF8String name,
+ [optional] out unsigned long count,
+ [retval, array, size_is(count)] out mozIAnnotatedResult results);
+
+ /**
+ * Get the names of all annotations for this URI.
+ *
+ * example JS:
+ * var annotations = annotator.getPageAnnotations(myURI, {});
+ */
+ void getPageAnnotationNames(
+ in nsIURI aURI,
+ [optional] out unsigned long count,
+ [retval, array, size_is(count)] out nsIVariant result);
+ void getItemAnnotationNames(
+ in long long aItemId,
+ [optional] out unsigned long count,
+ [retval, array, size_is(count)] out nsIVariant result);
+
+ /**
+ * Test for annotation existence.
+ */
+ boolean pageHasAnnotation(in nsIURI aURI,
+ in AUTF8String aName);
+ boolean itemHasAnnotation(in long long aItemId,
+ in AUTF8String aName);
+
+ /**
+ * Removes a specific annotation. Succeeds even if the annotation is
+ * not found.
+ */
+ void removePageAnnotation(in nsIURI aURI,
+ in AUTF8String aName);
+ void removeItemAnnotation(in long long aItemId,
+ in AUTF8String aName,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Removes all annotations for the given page/item.
+ * We may want some other similar functions to get annotations with given
+ * flags (once we have flags defined).
+ */
+ void removePageAnnotations(in nsIURI aURI);
+ void removeItemAnnotations(in long long aItemId,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Copies all annotations from the source to the destination URI/item. If
+ * the destination already has an annotation with the same name as one on
+ * the source, it will be overwritten if aOverwriteDest is set. Otherwise,
+ * the original annotation will be preferred.
+ *
+ * All the source annotations will stay as-is. If you don't want them
+ * any more, use removePageAnnotations on that URI.
+ */
+ void copyPageAnnotations(in nsIURI aSourceURI,
+ in nsIURI aDestURI,
+ in boolean aOverwriteDest);
+ void copyItemAnnotations(in long long aSourceItemId,
+ in long long aDestItemId,
+ in boolean aOverwriteDest,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Adds an annotation observer. The annotation service will keep an owning
+ * reference to the observer object.
+ */
+ void addObserver(in nsIAnnotationObserver aObserver);
+
+
+ /**
+ * Removes an annotaton observer previously registered by addObserver.
+ */
+ void removeObserver(in nsIAnnotationObserver aObserver);
+};
+
+/**
+ * Represents a place annotated with a given annotation. If a place has
+ * multiple annotations, it can be represented by multiple
+ * mozIAnnotatedResult(s).
+ */
+[scriptable, uuid(81fd0188-db6a-492e-80b6-f6414913b396)]
+interface mozIAnnotatedResult : nsISupports
+{
+ /**
+ * The globally unique identifier of the place with this annotation.
+ *
+ * @note if itemId is valid this is the guid of the bookmark, otherwise
+ * of the page.
+ */
+ readonly attribute AUTF8String guid;
+
+ /**
+ * The URI of the place with this annotation, if available, null otherwise.
+ */
+ readonly attribute nsIURI uri;
+
+ /**
+ * The bookmark id of the place with this annotation, if available,
+ * -1 otherwise.
+ *
+ * @note if itemId is -1, it doesn't mean the page is not bookmarked, just
+ * that this annotation is relative to the page, not to the bookmark.
+ */
+ readonly attribute long long itemId;
+
+ /**
+ * Name of the annotation.
+ */
+ readonly attribute AUTF8String annotationName;
+
+ /**
+ * Value of the annotation.
+ */
+ readonly attribute nsIVariant annotationValue;
+};
diff --git a/toolkit/components/places/nsIBrowserHistory.idl b/toolkit/components/places/nsIBrowserHistory.idl
new file mode 100644
index 000000000..8f3265972
--- /dev/null
+++ b/toolkit/components/places/nsIBrowserHistory.idl
@@ -0,0 +1,70 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * browser-specific interface to global history
+ */
+
+#include "nsISupports.idl"
+#include "nsIGlobalHistory2.idl"
+
+[scriptable, uuid(20d31479-38de-49f4-9300-566d6e834c66)]
+interface nsIBrowserHistory : nsISupports
+{
+ /**
+ * Removes a page from global history.
+ *
+ * @note It is preferrable to use this one rather then RemovePages when
+ * removing less than 10 pages, since it won't start a full batch
+ * operation.
+ */
+ void removePage(in nsIURI aURI);
+
+ /**
+ * Removes a list of pages from global history.
+ *
+ * @param aURIs
+ * Array of URIs to be removed.
+ * @param aLength
+ * Length of the array.
+ *
+ * @note the removal happens in a batch.
+ */
+ void removePages([array, size_is(aLength)] in nsIURI aURIs,
+ in unsigned long aLength);
+
+ /**
+ * Removes all global history information about pages for a given host.
+ *
+ * @param aHost
+ * Hostname to be removed.
+ * An empty host name means local files and anything else with no
+ * hostname. You can also pass in the localized "(local files)"
+ * title given to you from a history query to remove all
+ * history information from local files.
+ * @param aEntireDomain
+ * If true, will also delete pages from sub hosts (so if
+ * passed in "microsoft.com" will delete "www.microsoft.com",
+ * "msdn.microsoft.com", etc.).
+ *
+ * @note The removal happens in a batch.
+ */
+ void removePagesFromHost(in AUTF8String aHost,
+ in boolean aEntireDomain);
+
+ /**
+ * Removes all pages for a given timeframe.
+ * Limits are included: aBeginTime <= timeframe <= aEndTime
+ *
+ * @param aBeginTime
+ * Microseconds from epoch, representing the initial time.
+ * @param aEndTime
+ * Microseconds from epoch, representing the final time.
+ *
+ * @note The removal happens in a batch.
+ */
+ void removePagesByTimeframe(in PRTime aBeginTime,
+ in PRTime aEndTime);
+};
diff --git a/toolkit/components/places/nsIFaviconService.idl b/toolkit/components/places/nsIFaviconService.idl
new file mode 100644
index 000000000..25339d64b
--- /dev/null
+++ b/toolkit/components/places/nsIFaviconService.idl
@@ -0,0 +1,145 @@
+/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+
+[scriptable, uuid(e81e0b0c-b9f1-4c2e-8f3c-b809933cf73c)]
+interface nsIFaviconService : nsISupports
+{
+ // The favicon is being loaded from a private browsing window
+ const unsigned long FAVICON_LOAD_PRIVATE = 1;
+ // The favicon is being loaded from a non-private browsing window
+ const unsigned long FAVICON_LOAD_NON_PRIVATE = 2;
+
+ /**
+ * The limit in bytes of the size of favicons in memory and passed via the
+ * favicon protocol.
+ */
+ const unsigned long MAX_FAVICON_BUFFER_SIZE = 10240;
+
+ /**
+ * For a given icon URI, this will return a URI that will result in the image.
+ * In most cases, this is an annotation URI. For chrome URIs, this will do
+ * nothing but returning the input URI.
+ *
+ * No validity checking is done. If you pass an icon URI that we've never
+ * seen, you'll get back a URI that references an invalid icon. The moz-anno
+ * protocol handler's special case for "favicon" annotations will resolve
+ * invalid icons to the default icon, although without caching.
+ * For invalid chrome URIs, you'll get a broken image.
+ *
+ * @param aFaviconURI
+ * The URI of an icon in the favicon service.
+ * @return A URI that will give you the icon image. This is NOT the URI of
+ * the icon as set on the page, but a URI that will give you the
+ * data out of the favicon service. For a normal page with a
+ * favicon we've stored, this will be an annotation URI which will
+ * then cause the corresponding favicon data to be loaded async from
+ * this service. For pages where we don't have a favicon, this will
+ * be a chrome URI of the default icon. For chrome URIs, the
+ * output will be the same as the input.
+ */
+ nsIURI getFaviconLinkForIcon(in nsIURI aFaviconURI);
+
+ /**
+ * Expire all known favicons from the database.
+ *
+ * @note This is an async method.
+ * On successful completion a "places-favicons-expired" notification is
+ * dispatched through observer's service.
+ */
+ void expireAllFavicons();
+
+ /**
+ * Adds a given favicon's URI to the failed favicon cache.
+ *
+ * The lifespan of the favicon cache is up to the caching system. This cache
+ * will also be written when setAndLoadFaviconForPage hits an error while
+ * fetching an icon.
+ *
+ * @param aFaviconURI
+ * The URI of an icon in the favicon service.
+ */
+ void addFailedFavicon(in nsIURI aFaviconURI);
+
+ /**
+ * Removes the given favicon from the failed favicon cache. If the icon is
+ * not in the cache, it will silently succeed.
+ *
+ * @param aFaviconURI
+ * The URI of an icon in the favicon service.
+ */
+ void removeFailedFavicon(in nsIURI aFaviconURI);
+
+ /**
+ * Checks to see if a favicon is in the failed favicon cache.
+ * A positive return value means the icon is in the failed cache and you
+ * probably shouldn't try to load it. A false return value means that it's
+ * worth trying to load it.
+ * This allows you to avoid trying to load "foo.com/favicon.ico" for every
+ * page on a site that doesn't have a favicon.
+ *
+ * @param aFaviconURI
+ * The URI of an icon in the favicon service.
+ */
+ boolean isFailedFavicon(in nsIURI aFaviconURI);
+
+ /**
+ * The default favicon URI
+ */
+ readonly attribute nsIURI defaultFavicon;
+};
+
+[scriptable, function, uuid(c85e5c82-b70f-4621-9528-beb2aa47fb44)]
+interface nsIFaviconDataCallback : nsISupports
+{
+ /**
+ * Called when the required favicon's information is available.
+ *
+ * It's up to the invoking method to state if the callback is always invoked,
+ * or called on success only. Check the method documentation to ensure that.
+ *
+ * The caller will receive the most information we can gather on the icon,
+ * but it's not guaranteed that all of them will be set. For some method
+ * we could not know the favicon's data (it could just be too expensive to
+ * get it, or the method does not require we actually have any data).
+ * It's up to the caller to check aDataLen > 0 before using any data-related
+ * information like mime-type or data itself.
+ *
+ * @param aFaviconURI
+ * Receives the "favicon URI" (not the "favicon link URI") associated
+ * to the requested page. This can be null if there is no associated
+ * favicon URI, or the callback is notifying a failure.
+ * @param aDataLen
+ * Size of the icon data in bytes. Notice that a value of 0 does not
+ * necessarily mean that we don't have an icon.
+ * @param aData
+ * Icon data, or an empty array if aDataLen is 0.
+ * @param aMimeType
+ * Mime type of the icon, or an empty string if aDataLen is 0.
+ *
+ * @note If you want to open a network channel to access the favicon, it's
+ * recommended that you call the getFaviconLinkForIcon method to convert
+ * the "favicon URI" into a "favicon link URI".
+ */
+ void onComplete(in nsIURI aFaviconURI,
+ in unsigned long aDataLen,
+ [const,array,size_is(aDataLen)] in octet aData,
+ in AUTF8String aMimeType);
+};
+
+%{C++
+
+/**
+ * Notification sent when all favicons are expired.
+ */
+#define NS_PLACES_FAVICONS_EXPIRED_TOPIC_ID "places-favicons-expired"
+
+#define FAVICON_DEFAULT_URL "chrome://mozapps/skin/places/defaultFavicon.png"
+#define FAVICON_ERRORPAGE_URL "chrome://global/skin/icons/warning-16.png"
+
+%}
diff --git a/toolkit/components/places/nsINavBookmarksService.idl b/toolkit/components/places/nsINavBookmarksService.idl
new file mode 100644
index 000000000..e9e49a4f4
--- /dev/null
+++ b/toolkit/components/places/nsINavBookmarksService.idl
@@ -0,0 +1,697 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+interface nsIURI;
+interface nsITransaction;
+interface nsINavHistoryBatchCallback;
+
+/**
+ * Observer for bookmarks changes.
+ */
+[scriptable, uuid(c06b4e7d-15b1-4d4f-bdf7-147d2be9084a)]
+interface nsINavBookmarkObserver : nsISupports
+{
+ /*
+ * This observer should not be called for items that are tags.
+ */
+ readonly attribute boolean skipTags;
+
+ /*
+ * This observer should not be called for descendants when the parent is removed.
+ * For example when revmoing a folder containing bookmarks.
+ */
+ readonly attribute boolean skipDescendantsOnItemRemoval;
+
+ /**
+ * Notifies that a batch transaction has started.
+ * Other notifications will be sent during the batch, but the observer is
+ * guaranteed that onEndUpdateBatch() will be called at its completion.
+ * During a batch the observer should do its best to reduce the work done to
+ * handle notifications, since multiple changes are going to happen in a short
+ * timeframe.
+ */
+ void onBeginUpdateBatch();
+
+ /**
+ * Notifies that a batch transaction has ended.
+ */
+ void onEndUpdateBatch();
+
+ /**
+ * Notifies that an item (any type) was added. Called after the actual
+ * addition took place.
+ * When a new item is created, all the items following it in the same folder
+ * will have their index shifted down, but no additional notifications will
+ * be sent.
+ *
+ * @param aItemId
+ * The id of the item that was added.
+ * @param aParentId
+ * The id of the folder to which the item was added.
+ * @param aIndex
+ * The item's index in the folder.
+ * @param aItemType
+ * The type of the added item (see TYPE_* constants below).
+ * @param aURI
+ * The URI of the added item if it was TYPE_BOOKMARK, null otherwise.
+ * @param aTitle
+ * The title of the added item.
+ * @param aDateAdded
+ * The stored date added value, in microseconds from the epoch.
+ * @param aGuid
+ * The unique ID associated with the item.
+ * @param aParentGuid
+ * The unique ID associated with the item's parent.
+ * @param aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*,
+ * passed to the method that notifies the observer.
+ */
+ void onItemAdded(in long long aItemId,
+ in long long aParentId,
+ in long aIndex,
+ in unsigned short aItemType,
+ in nsIURI aURI,
+ in AUTF8String aTitle,
+ in PRTime aDateAdded,
+ in ACString aGuid,
+ in ACString aParentGuid,
+ in unsigned short aSource);
+
+ /**
+ * Notifies that an item was removed. Called after the actual remove took
+ * place.
+ * When an item is removed, all the items following it in the same folder
+ * will have their index shifted down, but no additional notifications will
+ * be sent.
+ *
+ * @param aItemId
+ * The id of the item that was removed.
+ * @param aParentId
+ * The id of the folder from which the item was removed.
+ * @param aIndex
+ * The bookmark's index in the folder.
+ * @param aItemType
+ * The type of the item to be removed (see TYPE_* constants below).
+ * @param aURI
+ * The URI of the added item if it was TYPE_BOOKMARK, null otherwise.
+ * @param aGuid
+ * The unique ID associated with the item.
+ * @param aParentGuid
+ * The unique ID associated with the item's parent.
+ * @param aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*,
+ * passed to the method that notifies the observer.
+ */
+ void onItemRemoved(in long long aItemId,
+ in long long aParentId,
+ in long aIndex,
+ in unsigned short aItemType,
+ in nsIURI aURI,
+ in ACString aGuid,
+ in ACString aParentGuid,
+ in unsigned short aSource);
+
+ /**
+ * Notifies that an item's information has changed. This will be called
+ * whenever any attributes like "title" are changed.
+ *
+ * @param aItemId
+ * The id of the item that was changed.
+ * @param aProperty
+ * The property which changed. Can be null for the removal of all of
+ * the annotations, in this case aIsAnnotationProperty is true.
+ * @param aIsAnnotationProperty
+ * Whether or not aProperty is the name of an annotation. If true
+ * aNewValue is always an empty string.
+ * @param aNewValue
+ * For certain properties, this is set to the new value of the
+ * property (see the list below).
+ * @param aLastModified
+ * The updated last-modified value.
+ * @param aItemType
+ * The type of the item to be removed (see TYPE_* constants below).
+ * @param aParentId
+ * The id of the folder containing the item.
+ * @param aGuid
+ * The unique ID associated with the item.
+ * @param aParentGuid
+ * The unique ID associated with the item's parent.
+ * @param aOldValue
+ * For certain properties, this is set to the new value of the
+ * property (see the list below).
+ * @param aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*,
+ * passed to the method that notifies the observer.
+ *
+ * @note List of values that may be associated with properties:
+ * aProperty | aNewValue
+ * =====================================================================
+ * cleartime | Empty string (all visits to this item were removed).
+ * title | The new title.
+ * favicon | The "moz-anno" URL of the new favicon.
+ * uri | new URL.
+ * tags | Empty string (tags for this item changed)
+ * dateAdded | PRTime (as string) when the item was first added.
+ * lastModified | PRTime (as string) when the item was last modified.
+ *
+ * aProperty | aOldValue
+ * =====================================================================
+ * cleartime | Empty string (currently unused).
+ * title | Empty string (currently unused).
+ * favicon | Empty string (currently unused).
+ * uri | old URL.
+ * tags | Empty string (currently unused).
+ * dateAdded | Empty string (currently unused).
+ * lastModified | Empty string (currently unused).
+ */
+ void onItemChanged(in long long aItemId,
+ in ACString aProperty,
+ in boolean aIsAnnotationProperty,
+ in AUTF8String aNewValue,
+ in PRTime aLastModified,
+ in unsigned short aItemType,
+ in long long aParentId,
+ in ACString aGuid,
+ in ACString aParentGuid,
+ in AUTF8String aOldValue,
+ in unsigned short aSource);
+
+ /**
+ * Notifies that the item was visited. Can be invoked only for TYPE_BOOKMARK
+ * items.
+ *
+ * @param aItemId
+ * The id of the bookmark that was visited.
+ * @param aVisitId
+ * The id of the visit.
+ * @param aTime
+ * The time of the visit.
+ * @param aTransitionType
+ * The transition for the visit. See nsINavHistoryService::TRANSITION_*
+ * constants for a list of possible values.
+ * @param aURI
+ * The nsIURI for this bookmark.
+ * @param aParentId
+ * The id of the folder containing the item.
+ * @param aGuid
+ * The unique ID associated with the item.
+ * @param aParentGuid
+ * The unique ID associated with the item's parent.
+ *
+ * @see onItemChanged with property = "cleartime" for when all visits to an
+ * item are removed.
+ *
+ * @note The reported time is the time of the visit that was added, which may
+ * be well in the past since the visit time can be specified. This
+ * means that the visit the observer is told about may not be the most
+ * recent visit for that page.
+ */
+ void onItemVisited(in long long aItemId,
+ in long long aVisitId,
+ in PRTime aTime,
+ in unsigned long aTransitionType,
+ in nsIURI aURI,
+ in long long aParentId,
+ in ACString aGuid,
+ in ACString aParentGuid);
+
+ /**
+ * Notifies that an item has been moved.
+ *
+ * @param aItemId
+ * The id of the item that was moved.
+ * @param aOldParentId
+ * The id of the old parent.
+ * @param aOldIndex
+ * The old index inside the old parent.
+ * @param aNewParentId
+ * The id of the new parent.
+ * @param aNewIndex
+ * The index inside the new parent.
+ * @param aItemType
+ * The type of the item to be removed (see TYPE_* constants below).
+ * @param aGuid
+ * The unique ID associated with the item.
+ * @param aOldParentGuid
+ * The unique ID associated with the old item's parent.
+ * @param aNewParentGuid
+ * The unique ID associated with the new item's parent.
+ * @param aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*,
+ * passed to the method that notifies the observer.
+ */
+ void onItemMoved(in long long aItemId,
+ in long long aOldParentId,
+ in long aOldIndex,
+ in long long aNewParentId,
+ in long aNewIndex,
+ in unsigned short aItemType,
+ in ACString aGuid,
+ in ACString aOldParentGuid,
+ in ACString aNewParentGuid,
+ in unsigned short aSource);
+};
+
+/**
+ * The BookmarksService interface provides methods for managing bookmarked
+ * history items. Bookmarks consist of a set of user-customizable
+ * folders. A URI in history can be contained in one or more such folders.
+ */
+
+[scriptable, uuid(24533891-afa6-4663-b72d-3143d03f1b04)]
+interface nsINavBookmarksService : nsISupports
+{
+ /**
+ * The item ID of the Places root.
+ */
+ readonly attribute long long placesRoot;
+
+ /**
+ * The item ID of the bookmarks menu folder.
+ */
+ readonly attribute long long bookmarksMenuFolder;
+
+ /**
+ * The item ID of the top-level folder that contain the tag "folders".
+ */
+ readonly attribute long long tagsFolder;
+
+ /**
+ * The item ID of the unfiled-bookmarks folder.
+ */
+ readonly attribute long long unfiledBookmarksFolder;
+
+ /**
+ * The item ID of the personal toolbar folder.
+ */
+ readonly attribute long long toolbarFolder;
+
+ /**
+ * The item ID of the mobile bookmarks folder.
+ */
+ readonly attribute long long mobileFolder;
+
+ /**
+ * This value should be used for APIs that allow passing in an index
+ * where an index is not known, or not required to be specified.
+ * e.g.: When appending an item to a folder.
+ */
+ const short DEFAULT_INDEX = -1;
+
+ const unsigned short TYPE_BOOKMARK = 1;
+ const unsigned short TYPE_FOLDER = 2;
+ const unsigned short TYPE_SEPARATOR = 3;
+ // Dynamic containers are deprecated and unsupported.
+ // This const exists just to avoid reusing the value.
+ const unsigned short TYPE_DYNAMIC_CONTAINER = 4;
+
+ // Change source constants. These are used to distinguish changes made by
+ // Sync and bookmarks import from other Places consumers, though they can
+ // be extended to support other callers. Sources are passed as optional
+ // parameters to methods used by Sync, and forwarded to observers.
+ const unsigned short SOURCE_DEFAULT = 0;
+ const unsigned short SOURCE_SYNC = 1;
+ const unsigned short SOURCE_IMPORT = 2;
+ const unsigned short SOURCE_IMPORT_REPLACE = 3;
+
+ /**
+ * Inserts a child bookmark into the given folder.
+ *
+ * @param aParentId
+ * The id of the parent folder
+ * @param aURI
+ * The URI to insert
+ * @param aIndex
+ * The index to insert at, or DEFAULT_INDEX to append
+ * @param aTitle
+ * The title for the new bookmark
+ * @param [optional] aGuid
+ * The GUID to be set for the new item. If not set, a new GUID is
+ * generated. Unless you've a very sound reason, such as an undo
+ * manager implementation, do not pass this argument.
+ * @param [optional] aSource
+ * The change source. This is forwarded to all bookmark observers,
+ * allowing them to distinguish between insertions from different
+ * callers. Defaults to SOURCE_DEFAULT if omitted.
+ * @return The ID of the newly-created bookmark.
+ *
+ * @note aTitle will be truncated to TITLE_LENGTH_MAX and
+ * aURI will be truncated to URI_LENGTH_MAX.
+ * @throws if aGuid is malformed.
+ */
+ long long insertBookmark(in long long aParentId, in nsIURI aURI,
+ in long aIndex, in AUTF8String aTitle,
+ [optional] in ACString aGuid,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Removes a child item. Used to delete a bookmark or separator.
+ * @param aItemId
+ * The child item to remove
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ */
+ void removeItem(in long long aItemId, [optional] in unsigned short aSource);
+
+ /**
+ * Creates a new child folder and inserts it under the given parent.
+ * @param aParentFolder
+ * The id of the parent folder
+ * @param aName
+ * The name of the new folder
+ * @param aIndex
+ * The index to insert at, or DEFAULT_INDEX to append
+ * @param [optional] aGuid
+ * The GUID to be set for the new item. If not set, a new GUID is
+ * generated. Unless you've a very sound reason, such as an undo
+ * manager implementation, do not pass this argument.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ * @return The ID of the newly-inserted folder.
+ * @throws if aGuid is malformed.
+ */
+ long long createFolder(in long long aParentFolder, in AUTF8String name,
+ in long index,
+ [optional] in ACString aGuid,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Gets an undo-able transaction for removing a folder from the bookmarks
+ * tree.
+ * @param aItemId
+ * The id of the folder to remove.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ * @return An object implementing nsITransaction that can be used to undo
+ * or redo the action.
+ *
+ * This method exists because complex delete->undo operations rely on
+ * recreated folders to have the same ID they had before they were deleted,
+ * so that any other items deleted in different transactions can be
+ * re-inserted correctly. This provides a safe encapsulation of this
+ * functionality without exposing the ability to recreate folders with
+ * specific IDs (potentially dangerous if abused by other code!) in the
+ * public API.
+ */
+ nsITransaction getRemoveFolderTransaction(in long long aItemId,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Convenience function for container services. Removes
+ * all children of the given folder.
+ * @param aItemId
+ * The id of the folder to remove children from.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ */
+ void removeFolderChildren(in long long aItemId,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Moves an item to a different container, preserving its contents.
+ * @param aItemId
+ * The id of the item to move
+ * @param aNewParentId
+ * The id of the new parent
+ * @param aIndex
+ * The index under aNewParent, or DEFAULT_INDEX to append
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ *
+ * NOTE: When moving down in the same container we take into account the
+ * removal of the original item. If you want to move from index X to
+ * index Y > X you must use moveItem(id, folder, Y + 1)
+ */
+ void moveItem(in long long aItemId,
+ in long long aNewParentId,
+ in long aIndex,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Inserts a bookmark separator into the given folder at the given index.
+ * The separator can be removed using removeChildAt().
+ * @param aParentId
+ * The id of the parent folder
+ * @param aIndex
+ * The separator's index under folder, or DEFAULT_INDEX to append
+ * @param [optional] aGuid
+ * The GUID to be set for the new item. If not set, a new GUID is
+ * generated. Unless you've a very sound reason, such as an undo
+ * manager implementation, do not pass this argument.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ * @return The ID of the new separator.
+ * @throws if aGuid is malformed.
+ */
+ long long insertSeparator(in long long aParentId, in long aIndex,
+ [optional] in ACString aGuid,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get the itemId given the containing folder and the index.
+ * @param aParentId
+ * The id of the diret parent folder of the item
+ * @param aIndex
+ * The index of the item within the parent folder.
+ * Pass DEFAULT_INDEX for the last item.
+ * @return The ID of the found item, -1 if the item does not exists.
+ */
+ long long getIdForItemAt(in long long aParentId, in long aIndex);
+
+ /**
+ * Set the title for an item.
+ * @param aItemId
+ * The id of the item whose title should be updated.
+ * @param aTitle
+ * The new title for the bookmark.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ *
+ * @note aTitle will be truncated to TITLE_LENGTH_MAX.
+ */
+ void setItemTitle(in long long aItemId, in AUTF8String aTitle,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get the title for an item.
+ *
+ * If no item title is available it will return a void string (null in JS).
+ *
+ * @param aItemId
+ * The id of the item whose title should be retrieved
+ * @return The title of the item.
+ */
+ AUTF8String getItemTitle(in long long aItemId);
+
+ /**
+ * Set the date added time for an item.
+ *
+ * @param aItemId
+ * the id of the item whose date added time should be updated.
+ * @param aDateAdded
+ * the new date added value in microseconds. Note that it is rounded
+ * down to milliseconds precision.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ */
+ void setItemDateAdded(in long long aItemId,
+ in PRTime aDateAdded,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get the date added time for an item.
+ *
+ * @param aItemId
+ * the id of the item whose date added time should be retrieved.
+ *
+ * @return the date added value in microseconds.
+ */
+ PRTime getItemDateAdded(in long long aItemId);
+
+ /**
+ * Set the last modified time for an item.
+ *
+ * @param aItemId
+ * the id of the item whose last modified time should be updated.
+ * @param aLastModified
+ * the new last modified value in microseconds. Note that it is
+ * rounded down to milliseconds precision.
+ * @param [optional] aSource
+ * The change source, forwarded to all bookmark observers. Defaults
+ * to SOURCE_DEFAULT.
+ *
+ * @note This is the only method that will send an itemChanged notification
+ * for the property. lastModified will still be updated in
+ * any other method that changes an item property, but we will send
+ * the corresponding itemChanged notification instead.
+ */
+ void setItemLastModified(in long long aItemId,
+ in PRTime aLastModified,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get the last modified time for an item.
+ *
+ * @param aItemId
+ * the id of the item whose last modified time should be retrieved.
+ *
+ * @return the date added value in microseconds.
+ *
+ * @note When an item is added lastModified is set to the same value as
+ * dateAdded.
+ */
+ PRTime getItemLastModified(in long long aItemId);
+
+ /**
+ * Get the URI for a bookmark item.
+ */
+ nsIURI getBookmarkURI(in long long aItemId);
+
+ /**
+ * Get the index for an item.
+ */
+ long getItemIndex(in long long aItemId);
+
+ /**
+ * Changes the index for a item. This method does not change the indices of
+ * any other items in the same folder, so ensure that the new index does not
+ * already exist, or change the index of other items accordingly, otherwise
+ * the indices will become corrupted.
+ *
+ * WARNING: This is API is intended for scenarios such as folder sorting,
+ * where the caller manages the indices of *all* items in the folder.
+ * You must always ensure each index is unique after a reordering.
+ *
+ * @param aItemId The id of the item to modify
+ * @param aNewIndex The new index
+ * @param aSource The optional change source, forwarded to all bookmark
+ * observers. Defaults to SOURCE_DEFAULT.
+ *
+ * @throws If aNewIndex is out of bounds.
+ */
+ void setItemIndex(in long long aItemId,
+ in long aNewIndex,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get an item's type (bookmark, separator, folder).
+ * The type is one of the TYPE_* constants defined above.
+ */
+ unsigned short getItemType(in long long aItemId);
+
+ /**
+ * Returns true if the given URI is in any bookmark folder. If you want the
+ * results to be redirect-aware, use getBookmarkedURIFor()
+ */
+ boolean isBookmarked(in nsIURI aURI);
+
+ /**
+ * Used to see if the given URI is bookmarked, or any page that redirected to
+ * it is bookmarked. For example, if I bookmark "mozilla.org" by manually
+ * typing it in, and follow the bookmark, I will get redirected to
+ * "www.mozilla.org". Logically, this new page is also bookmarked. This
+ * function, if given "www.mozilla.org", will return the URI of the bookmark,
+ * in this case "mozilla.org".
+ *
+ * If there is no bookmarked page found, it will return NULL.
+ *
+ * @note The function will only return bookmarks in the first 2 levels of
+ * redirection (1 -> 2 -> aURI).
+ */
+ nsIURI getBookmarkedURIFor(in nsIURI aURI);
+
+ /**
+ * Change the bookmarked URI for a bookmark.
+ * This changes which "place" the bookmark points at,
+ * which means all annotations, etc are carried along.
+ */
+ void changeBookmarkURI(in long long aItemId,
+ in nsIURI aNewURI,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Get the parent folder's id for an item.
+ */
+ long long getFolderIdForItem(in long long aItemId);
+
+ /**
+ * Returns the list of bookmark ids that contain the given URI.
+ */
+ void getBookmarkIdsForURI(in nsIURI aURI, [optional] out unsigned long count,
+ [array, retval, size_is(count)] out long long bookmarks);
+
+ /**
+ * Associates the given keyword with the given bookmark.
+ *
+ * Use an empty keyword to clear the keyword associated with the URI.
+ * In both of these cases, succeeds but does nothing if the URL/keyword is not found.
+ *
+ * @deprecated Use PlacesUtils.keywords.insert() API instead.
+ */
+ void setKeywordForBookmark(in long long aItemId,
+ in AString aKeyword,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Retrieves the keyword for the given bookmark. Will be void string
+ * (null in JS) if no such keyword is found.
+ *
+ * @deprecated Use PlacesUtils.keywords.fetch() API instead.
+ */
+ AString getKeywordForBookmark(in long long aItemId);
+
+ /**
+ * Returns the URI associated with the given keyword. Empty if no such
+ * keyword is found.
+ *
+ * @deprecated Use PlacesUtils.keywords.fetch() API instead.
+ */
+ nsIURI getURIForKeyword(in AString keyword);
+
+ /**
+ * Adds a bookmark observer. If ownsWeak is false, the bookmark service will
+ * keep an owning reference to the observer. If ownsWeak is true, then
+ * aObserver must implement nsISupportsWeakReference, and the bookmark
+ * service will keep a weak reference to the observer.
+ */
+ void addObserver(in nsINavBookmarkObserver observer, in boolean ownsWeak);
+
+ /**
+ * Removes a bookmark observer.
+ */
+ void removeObserver(in nsINavBookmarkObserver observer);
+
+ /**
+ * Gets an array of registered nsINavBookmarkObserver objects.
+ */
+ void getObservers([optional] out unsigned long count,
+ [retval, array, size_is(count)] out nsINavBookmarkObserver observers);
+
+ /**
+ * Runs the passed callback inside of a database transaction.
+ * Use this when a lot of things are about to change, for example
+ * adding or deleting a large number of bookmark items. Calls can
+ * be nested. Observers are notified when batches begin and end, via
+ * nsINavBookmarkObserver.onBeginUpdateBatch/onEndUpdateBatch.
+ *
+ * @param aCallback
+ * nsINavHistoryBatchCallback interface to call.
+ * @param aUserData
+ * Opaque parameter passed to nsINavBookmarksBatchCallback
+ */
+ void runInBatchMode(in nsINavHistoryBatchCallback aCallback,
+ in nsISupports aUserData);
+};
diff --git a/toolkit/components/places/nsINavHistoryService.idl b/toolkit/components/places/nsINavHistoryService.idl
new file mode 100644
index 000000000..3fd851870
--- /dev/null
+++ b/toolkit/components/places/nsINavHistoryService.idl
@@ -0,0 +1,1451 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Using Places services after quit-application is not reliable, so make
+ * sure to do any shutdown work on quit-application, or history
+ * synchronization could fail, losing latest changes.
+ */
+
+#include "nsISupports.idl"
+
+interface nsIArray;
+interface nsIURI;
+interface nsIVariant;
+interface nsIFile;
+
+interface nsINavHistoryContainerResultNode;
+interface nsINavHistoryQueryResultNode;
+interface nsINavHistoryQuery;
+interface nsINavHistoryQueryOptions;
+interface nsINavHistoryResult;
+interface nsINavHistoryBatchCallback;
+
+[scriptable, uuid(91d104bb-17ef-404b-9f9a-d9ed8de6824c)]
+interface nsINavHistoryResultNode : nsISupports
+{
+ /**
+ * Indentifies the parent result node in the result set. This is null for
+ * top level nodes.
+ */
+ readonly attribute nsINavHistoryContainerResultNode parent;
+
+ /**
+ * The history-result to which this node belongs.
+ */
+ readonly attribute nsINavHistoryResult parentResult;
+
+ /**
+ * URI of the resource in question. For visits and URLs, this is the URL of
+ * the page. For folders and queries, this is the place: URI of the
+ * corresponding folder or query. This may be empty for other types of
+ * objects like host containers.
+ */
+ readonly attribute AUTF8String uri;
+
+ /**
+ * Identifies the type of this node. This node can then be QI-ed to the
+ * corresponding specialized result node interface.
+ */
+ const unsigned long RESULT_TYPE_URI = 0; // nsINavHistoryResultNode
+
+ // Visit nodes are deprecated and unsupported.
+ // This line exists just to avoid reusing the value:
+ // const unsigned long RESULT_TYPE_VISIT = 1;
+
+ // Full visit nodes are deprecated and unsupported.
+ // This line exists just to avoid reusing the value:
+ // const unsigned long RESULT_TYPE_FULL_VISIT = 2;
+
+ // Dynamic containers are deprecated and unsupported.
+ // This const exists just to avoid reusing the value:
+ // const unsigned long RESULT_TYPE_DYNAMIC_CONTAINER = 4; // nsINavHistoryContainerResultNode
+
+ const unsigned long RESULT_TYPE_QUERY = 5; // nsINavHistoryQueryResultNode
+ const unsigned long RESULT_TYPE_FOLDER = 6; // nsINavHistoryQueryResultNode
+ const unsigned long RESULT_TYPE_SEPARATOR = 7; // nsINavHistoryResultNode
+ const unsigned long RESULT_TYPE_FOLDER_SHORTCUT = 9; // nsINavHistoryQueryResultNode
+ readonly attribute unsigned long type;
+
+ /**
+ * Title of the web page, or of the node's query (day, host, folder, etc)
+ */
+ readonly attribute AUTF8String title;
+
+ /**
+ * Total number of times the URI has ever been accessed. For hosts, this
+ * is the total of the children under it, NOT the total times the host has
+ * been accessed (this would require an additional query, so is not given
+ * by default when most of the time it is never needed).
+ */
+ readonly attribute unsigned long accessCount;
+
+ /**
+ * This is the time the user accessed the page.
+ *
+ * If this is a visit, it is the exact time that the page visit occurred.
+ *
+ * If this is a URI, it is the most recent time that the URI was visited.
+ * Even if you ask for all URIs for a given date range long ago, this might
+ * contain today's date if the URI was visited today.
+ *
+ * For hosts, or other node types with children, this is the most recent
+ * access time for any of the children.
+ *
+ * For days queries this is the respective endTime - a maximum possible
+ * visit time to fit in the day range.
+ */
+ readonly attribute PRTime time;
+
+ /**
+ * This URI can be used as an image source URI and will give you the favicon
+ * for the page. It is *not* the URI of the favicon, but rather something
+ * that will resolve to the actual image.
+ *
+ * In most cases, this is an annotation URI that will query the favicon
+ * service. If the entry has no favicon, this is the chrome URI of the
+ * default favicon. If the favicon originally lived in chrome, this will
+ * be the original chrome URI of the icon.
+ */
+ readonly attribute AUTF8String icon;
+
+ /**
+ * This is the number of levels between this node and the top of the
+ * hierarchy. The members of result.children have indentLevel = 0, their
+ * children have indentLevel = 1, etc. The indent level of the root node is
+ * set to -1.
+ */
+ readonly attribute long indentLevel;
+
+ /**
+ * When this item is in a bookmark folder (parent is of type folder), this is
+ * the index into that folder of this node. These indices start at 0 and
+ * increase in the order that they appear in the bookmark folder. For items
+ * that are not in a bookmark folder, this value is -1.
+ */
+ readonly attribute long bookmarkIndex;
+
+ /**
+ * If the node is an item (bookmark, folder or a separator) this value is the
+ * row ID of that bookmark in the database. For other nodes, this value is
+ * set to -1.
+ */
+ readonly attribute long long itemId;
+
+ /**
+ * If the node is an item (bookmark, folder or a separator) this value is the
+ * time that the item was created. For other nodes, this value is 0.
+ */
+ readonly attribute PRTime dateAdded;
+
+ /**
+ * If the node is an item (bookmark, folder or a separator) this value is the
+ * time that the item was last modified. For other nodes, this value is 0.
+ *
+ * @note When an item is added lastModified is set to the same value as
+ * dateAdded.
+ */
+ readonly attribute PRTime lastModified;
+
+ /**
+ * For uri nodes, this is a sorted list of the tags, delimited with commans,
+ * for the uri represented by this node. Otherwise this is an empty string.
+ */
+ readonly attribute AString tags;
+
+ /**
+ * The unique ID associated with the page. It my return an empty string
+ * if the result node is a non-URI node.
+ */
+ readonly attribute ACString pageGuid;
+
+ /**
+ * The unique ID associated with the bookmark. It returns an empty string
+ * if the result node is not associated with a bookmark, a folder or a
+ * separator.
+ */
+ readonly attribute ACString bookmarkGuid;
+
+ /**
+ * The unique ID associated with the history visit. For node types other than
+ * history visit nodes, this value is -1.
+ */
+ readonly attribute long long visitId;
+
+ /**
+ * The unique ID associated with visit node which was the referrer of this
+ * history visit. For node types other than history visit nodes, or visits
+ * without any known referrer, this value is -1.
+ */
+ readonly attribute long long fromVisitId;
+
+ /**
+ * The transition type associated with this visit. For node types other than
+ * history visit nodes, this value is 0.
+ */
+ readonly attribute unsigned long visitType;
+};
+
+
+/**
+ * Base class for container results. This includes all types of groupings.
+ * Bookmark folders and places queries will be QueryResultNodes which extends
+ * these items.
+ */
+[scriptable, uuid(3E9CC95F-0D93-45F1-894F-908EEB9866D7)]
+interface nsINavHistoryContainerResultNode : nsINavHistoryResultNode
+{
+
+ /**
+ * Set this to allow descent into the container. When closed, attempting
+ * to call getChildren or childCount will result in an error. You should
+ * set this to false when you are done reading.
+ *
+ * For HOST and DAY groupings, doing this is free since the children have
+ * been precomputed. For queries and bookmark folders, being open means they
+ * will keep themselves up-to-date by listening for updates and re-querying
+ * as needed.
+ */
+ attribute boolean containerOpen;
+
+ /**
+ * Indicates whether the container is closed, loading, or opened. Loading
+ * implies that the container has been opened asynchronously and has not yet
+ * fully opened.
+ */
+ readonly attribute unsigned short state;
+ const unsigned short STATE_CLOSED = 0;
+ const unsigned short STATE_LOADING = 1;
+ const unsigned short STATE_OPENED = 2;
+
+ /**
+ * This indicates whether this node "may" have children, and can be used
+ * when the container is open or closed. When the container is closed, it
+ * will give you an exact answer if the node can easily be populated (for
+ * example, a bookmark folder). If not (for example, a complex history query),
+ * it will return true. When the container is open, it will always be
+ * accurate. It is intended to be used to see if we should draw the "+" next
+ * to a tree item.
+ */
+ readonly attribute boolean hasChildren;
+
+ /**
+ * This gives you the children of the nodes. It is preferrable to use this
+ * interface over the array one, since it avoids creating an nsIArray object
+ * and the interface is already the correct type.
+ *
+ * @throws NS_ERROR_NOT_AVAILABLE if containerOpen is false.
+ */
+ readonly attribute unsigned long childCount;
+ nsINavHistoryResultNode getChild(in unsigned long aIndex);
+
+ /**
+ * Get the index of a direct child in this container.
+ *
+ * @param aNode
+ * a result node.
+ *
+ * @return aNode's index in this container.
+ * @throws NS_ERROR_NOT_AVAILABLE if containerOpen is false.
+ * @throws NS_ERROR_INVALID_ARG if aNode isn't a direct child of this
+ * container.
+ */
+ unsigned long getChildIndex(in nsINavHistoryResultNode aNode);
+
+ /**
+ * Look for a node in the container by some of its details. Does not search
+ * closed containers.
+ *
+ * @param aURI
+ * the node's uri attribute value
+ * @param aTime
+ * the node's time attribute value.
+ * @param aItemId
+ * the node's itemId attribute value.
+ * @param aRecursive
+ * whether or not to search recursively.
+ *
+ * @throws NS_ERROR_NOT_AVAILABLE if this container is closed.
+ * @return a result node that matches the given details if any, null
+ * otherwise.
+ */
+ nsINavHistoryResultNode findNodeByDetails(in AUTF8String aURIString,
+ in PRTime aTime,
+ in long long aItemId,
+ in boolean aRecursive);
+};
+
+
+/**
+ * Used for places queries and as a base for bookmark folders.
+ *
+ * Note that if you request places to *not* be expanded in the options that
+ * generated this node, this item will report it has no children and never try
+ * to populate itself.
+ */
+[scriptable, uuid(62817759-4FEE-44A3-B58C-3E2F5AFC9D0A)]
+interface nsINavHistoryQueryResultNode : nsINavHistoryContainerResultNode
+{
+ /**
+ * Get the queries which build this node's children.
+ * Only valid for RESULT_TYPE_QUERY nodes.
+ */
+ void getQueries([optional] out unsigned long queryCount,
+ [retval,array,size_is(queryCount)] out nsINavHistoryQuery queries);
+
+ /**
+ * Get the options which group this node's children.
+ * Only valid for RESULT_TYPE_QUERY nodes.
+ */
+ readonly attribute nsINavHistoryQueryOptions queryOptions;
+
+ /**
+ * For both simple folder queries and folder shortcut queries, this is set to
+ * the concrete itemId of the folder (i.e. for folder shortcuts it's the
+ * target folder id). Otherwise, this is set to -1.
+ */
+ readonly attribute long long folderItemId;
+
+ /**
+ * For both simple folder queries and folder shortcut queries, this is set to
+ * the concrete guid of the folder (i.e. for folder shortcuts it's the target
+ * folder guid). Otherwise, this is set to an empty string.
+ */
+ readonly attribute ACString targetFolderGuid;
+};
+
+
+/**
+ * Allows clients to observe what is happening to a result as it updates itself
+ * according to history and bookmark system events. Register this observer on a
+ * result using nsINavHistoryResult::addObserver.
+ */
+[scriptable, uuid(f62d8b6b-3c4e-4a9f-a897-db605d0b7a0f)]
+interface nsINavHistoryResultObserver : nsISupports
+{
+ /**
+ * Called when 'aItem' is inserted into 'aParent' at index 'aNewIndex'.
+ * The item previously at index (if any) and everything below it will have
+ * been shifted down by one. The item may be a container or a leaf.
+ */
+ void nodeInserted(in nsINavHistoryContainerResultNode aParent,
+ in nsINavHistoryResultNode aNode,
+ in unsigned long aNewIndex);
+
+ /**
+ * Called whan 'aItem' is removed from 'aParent' at 'aOldIndex'. The item
+ * may be a container or a leaf. This function will be called after the item
+ * has been removed from its parent list, but before anything else (including
+ * NULLing out the item's parent) has happened.
+ */
+ void nodeRemoved(in nsINavHistoryContainerResultNode aParent,
+ in nsINavHistoryResultNode aItem,
+ in unsigned long aOldIndex);
+
+ /**
+ * Called whan 'aItem' is moved from 'aOldParent' at 'aOldIndex' to
+ * aNewParent at aNewIndex. The item may be a container or a leaf.
+ *
+ * XXX: at the moment, this method is called only when an item is moved
+ * within the same container. When an item is moved between containers,
+ * a new node is created for the item, and the itemRemoved/itemAdded methods
+ * are used.
+ */
+ void nodeMoved(in nsINavHistoryResultNode aNode,
+ in nsINavHistoryContainerResultNode aOldParent,
+ in unsigned long aOldIndex,
+ in nsINavHistoryContainerResultNode aNewParent,
+ in unsigned long aNewIndex);
+
+ /**
+ * Called right after aNode's title has changed.
+ *
+ * @param aNode
+ * a result node
+ * @param aNewTitle
+ * the new title
+ */
+ void nodeTitleChanged(in nsINavHistoryResultNode aNode,
+ in AUTF8String aNewTitle);
+
+ /**
+ * Called right after aNode's uri property has changed.
+ *
+ * @param aNode
+ * a result node
+ * @param aNewURI
+ * the new uri
+ */
+ void nodeURIChanged(in nsINavHistoryResultNode aNode,
+ in AUTF8String aNewURI);
+
+ /**
+ * Called right after aNode's icon property has changed.
+ *
+ * @param aNode
+ * a result node
+ *
+ * @note: The new icon is accessible through aNode.icon.
+ */
+ void nodeIconChanged(in nsINavHistoryResultNode aNode);
+
+ /**
+ * Called right after aNode's time property or accessCount property, or both,
+ * have changed.
+ *
+ * @param aNode
+ * a uri result node
+ * @param aNewVisitDate
+ * the new visit date
+ * @param aNewAccessCount
+ * the new access-count
+ */
+ void nodeHistoryDetailsChanged(in nsINavHistoryResultNode aNode,
+ in PRTime aNewVisitDate,
+ in unsigned long aNewAccessCount);
+
+ /**
+ * Called when the tags set on the uri represented by aNode have changed.
+ *
+ * @param aNode
+ * a uri result node
+ *
+ * @note: The new tags list is accessible through aNode.tags.
+ */
+ void nodeTagsChanged(in nsINavHistoryResultNode aNode);
+
+ /**
+ * Called right after the aNode's keyword property has changed.
+ *
+ * @param aNode
+ * a uri result node
+ * @param aNewKeyword
+ * the new keyword
+ */
+ void nodeKeywordChanged(in nsINavHistoryResultNode aNode,
+ in AUTF8String aNewKeyword);
+
+ /**
+ * Called right after an annotation of aNode's has changed (set, altered, or
+ * unset).
+ *
+ * @param aNode
+ * a result node
+ * @param aAnnoName
+ * the name of the annotation that changed
+ */
+ void nodeAnnotationChanged(in nsINavHistoryResultNode aNode,
+ in AUTF8String aAnnoName);
+
+ /**
+ * Called right after aNode's dateAdded property has changed.
+ *
+ * @param aNode
+ * a result node
+ * @param aNewValue
+ * the new value of the dateAdded property
+ */
+ void nodeDateAddedChanged(in nsINavHistoryResultNode aNode,
+ in PRTime aNewValue);
+
+ /**
+ * Called right after aNode's dateModified property has changed.
+ *
+ * @param aNode
+ * a result node
+ * @param aNewValue
+ * the new value of the dateModified property
+ */
+ void nodeLastModifiedChanged(in nsINavHistoryResultNode aNode,
+ in PRTime aNewValue);
+
+ /**
+ * Called after a container changes state.
+ *
+ * @param aContainerNode
+ * The container that has changed state.
+ * @param aOldState
+ * The state that aContainerNode has transitioned out of.
+ * @param aNewState
+ * The state that aContainerNode has transitioned into.
+ */
+ void containerStateChanged(in nsINavHistoryContainerResultNode aContainerNode,
+ in unsigned long aOldState,
+ in unsigned long aNewState);
+
+ /**
+ * Called when something significant has happened within the container. The
+ * contents of the container should be re-built.
+ *
+ * @param aContainerNode
+ * the container node to invalidate
+ */
+ void invalidateContainer(in nsINavHistoryContainerResultNode aContainerNode);
+
+ /**
+ * This is called to indicate to the UI that the sort has changed to the
+ * given mode. For trees, for example, this would update the column headers
+ * to reflect the sorting. For many other types of views, this won't be
+ * applicable.
+ *
+ * @param sortingMode One of nsINavHistoryQueryOptions.SORT_BY_* that
+ * indicates the new sorting mode.
+ *
+ * This only is expected to update the sorting UI. invalidateAll() will also
+ * get called if the sorting changes to update everything.
+ */
+ void sortingChanged(in unsigned short sortingMode);
+
+ /**
+ * This is called to indicate that a batch operation is about to start or end.
+ * The observer could want to disable some events or updates during batches,
+ * since multiple operations are packed in a short time.
+ * For example treeviews could temporarily suppress select notifications.
+ *
+ * @param aToggleMode
+ * true if a batch is starting, false if it's ending.
+ */
+ void batching(in boolean aToggleMode);
+
+ /**
+ * Called by the result when this observer is added.
+ */
+ attribute nsINavHistoryResult result;
+};
+
+
+/**
+ * TODO: Bug 517719.
+ *
+ * A predefined view adaptor for interfacing results with an nsITree. This
+ * object will remove itself from its associated result when the tree has been
+ * detached. This prevents circular references. Users should be aware of this,
+ * if you want to re-use the same viewer, you will need to keep your own
+ * reference to it and re-initialize it when the tree changes. If you use this
+ * object, attach it to a result, never attach it to a tree, and forget about
+ * it, it will leak!
+ */
+[scriptable, uuid(f8b518c0-1faf-11df-8a39-0800200c9a66)]
+interface nsINavHistoryResultTreeViewer : nsINavHistoryResultObserver
+{
+ /**
+ * This allows you to get at the real node for a given row index. This is
+ * only valid when a tree is attached.
+ */
+ nsINavHistoryResultNode nodeForTreeIndex(in unsigned long aIndex);
+
+ /**
+ * Reverse of nodeForFlatIndex, returns the row index for a given result node.
+ * Returns INDEX_INVISIBLE if the item is not visible (for example, its
+ * parent is collapsed). This is only valid when a tree is attached. The
+ * the result will always be INDEX_INVISIBLE if not.
+ *
+ * Note: This sounds sort of obvious, but it got me: aNode must be a node
+ * retrieved from the same result that this viewer is for. If you
+ * execute another query and get a node from a _different_ result, this
+ * function will always return the index of that node in the tree that
+ * is attached to that result.
+ */
+ const unsigned long INDEX_INVISIBLE = 0xffffffff;
+ unsigned long treeIndexForNode(in nsINavHistoryResultNode aNode);
+};
+
+
+/**
+ * The result of a history/bookmark query.
+ */
+[scriptable, uuid(c2229ce3-2159-4001-859c-7013c52f7619)]
+interface nsINavHistoryResult : nsISupports
+{
+ /**
+ * Sorts all nodes recursively by the given parameter, one of
+ * nsINavHistoryQueryOptions.SORT_BY_* This will update the corresponding
+ * options for this result, so that re-using the current options/queries will
+ * always give you the current view.
+ */
+ attribute unsigned short sortingMode;
+
+ /**
+ * The annotation to use in SORT_BY_ANNOTATION_* sorting modes, set this
+ * before setting the sortingMode attribute.
+ */
+ attribute AUTF8String sortingAnnotation;
+
+ /**
+ * Whether or not notifications on result changes are suppressed.
+ * Initially set to false.
+ *
+ * Use this to avoid flickering and to improve performance when you
+ * do temporary changes to the result structure (e.g. when searching for a
+ * node recursively).
+ */
+ attribute boolean suppressNotifications;
+
+ /**
+ * Adds an observer for changes done in the result.
+ *
+ * @param aObserver
+ * a result observer.
+ * @param aOwnsWeak
+ * If false, the result will keep an owning reference to the observer,
+ * which must be removed using removeObserver.
+ * If true, the result will keep a weak reference to the observer, which
+ * must implement nsISupportsWeakReference.
+ *
+ * @see nsINavHistoryResultObserver
+ */
+ void addObserver(in nsINavHistoryResultObserver aObserver, in boolean aOwnsWeak);
+
+ /**
+ * Removes an observer that was added by addObserver.
+ *
+ * @param aObserver
+ * a result observer that was added by addObserver.
+ */
+ void removeObserver(in nsINavHistoryResultObserver aObserver);
+
+ /**
+ * This is the root of the results. Remember that you need to open all
+ * containers for their contents to be valid.
+ *
+ * When a result goes out of scope it will continue to observe changes till
+ * it is cycle collected. While the result waits to be collected it will stay
+ * in memory, and continue to update itself, potentially causing unwanted
+ * additional work. When you close the root node the result will stop
+ * observing changes, so it is good practice to close the root node when you
+ * are done with a result, since that will avoid unwanted performance hits.
+ */
+ readonly attribute nsINavHistoryContainerResultNode root;
+};
+
+
+/**
+ * Similar to nsIRDFObserver for history. Note that we don't pass the data
+ * source since that is always the global history.
+ *
+ * DANGER! If you are in the middle of a batch transaction, there may be a
+ * database transaction active. You can still access the DB, but be careful.
+ */
+[scriptable, uuid(0f0f45b0-13a1-44ae-a0ab-c6046ec6d4da)]
+interface nsINavHistoryObserver : nsISupports
+{
+ /**
+ * Notifies you that a bunch of things are about to change, don't do any
+ * heavy-duty processing until onEndUpdateBatch is called.
+ */
+ void onBeginUpdateBatch();
+
+ /**
+ * Notifies you that we are done doing a bunch of things and you should go
+ * ahead and update UI, etc.
+ */
+ void onEndUpdateBatch();
+
+ /**
+ * Called everytime a URI is visited.
+ *
+ * @note TRANSITION_EMBED visits (corresponding to images in a page, for
+ * example) are not displayed in history results. Most observers can
+ * ignore TRANSITION_EMBED visit notifications (which will comprise the
+ * majority of visit notifications) to save work.
+ *
+ * @param aVisitId
+ * Id of the visit that was just created.
+ * @param aTime
+ * Time of the visit.
+ * @param aSessionId
+ * No longer supported and always set to 0.
+ * @param aReferrerVisitId
+ * The id of the visit the user came from, defaults to 0 for no referrer.
+ * @param aTransitionType
+ * One of nsINavHistory.TRANSITION_*
+ * @param aGuid
+ * The unique id associated with the page.
+ * @param aHidden
+ * Whether the visited page is marked as hidden.
+ * @param aVisitCount
+ * Number of visits (included this one) for this URI.
+ * @param aTyped
+ * Whether the URI has been typed or not.
+ * TODO (Bug 1271801): This will become a count, rather than a boolean.
+ * For future compatibility, always compare it with "> 0".
+ */
+ void onVisit(in nsIURI aURI,
+ in long long aVisitId,
+ in PRTime aTime,
+ in long long aSessionId,
+ in long long aReferrerVisitId,
+ in unsigned long aTransitionType,
+ in ACString aGuid,
+ in boolean aHidden,
+ in unsigned long aVisitCount,
+ in unsigned long aTyped);
+
+ /**
+ * Called whenever either the "real" title or the custom title of the page
+ * changed. BOTH TITLES ARE ALWAYS INCLUDED in this notification, even though
+ * only one will change at a time. Often, consumers will want to display the
+ * user title if it is available, and fall back to the page title (the one
+ * specified in the <title> tag of the page).
+ *
+ * Note that there is a difference between an empty title and a NULL title.
+ * An empty string means that somebody specifically set the title to be
+ * nothing. NULL means nobody set it. From C++: use IsVoid() and SetIsVoid()
+ * to see whether an empty string is "null" or not (it will always be an
+ * empty string in either case).
+ *
+ * @param aURI
+ * The URI of the page.
+ * @param aPageTitle
+ * The new title of the page.
+ * @param aGUID
+ * The unique ID associated with the page.
+ */
+ void onTitleChanged(in nsIURI aURI,
+ in AString aPageTitle,
+ in ACString aGUID);
+
+ /**
+ * Called when an individual page's frecency has changed.
+ *
+ * This is not called for pages whose frecencies change as the result of some
+ * large operation where some large or unknown number of frecencies change at
+ * once. Use onManyFrecenciesChanged to detect such changes.
+ *
+ * @param aURI
+ * The page's URI.
+ * @param aNewFrecency
+ * The page's new frecency.
+ * @param aGUID
+ * The page's GUID.
+ * @param aHidden
+ * True if the page is marked as hidden.
+ * @param aVisitDate
+ * The page's last visit date.
+ */
+ void onFrecencyChanged(in nsIURI aURI,
+ in long aNewFrecency,
+ in ACString aGUID,
+ in boolean aHidden,
+ in PRTime aVisitDate);
+
+ /**
+ * Called when the frecencies of many pages have changed at once.
+ *
+ * onFrecencyChanged is not called for each of those pages.
+ */
+ void onManyFrecenciesChanged();
+
+ /**
+ * Removed by the user.
+ */
+ const unsigned short REASON_DELETED = 0;
+ /**
+ * Removed by automatic expiration.
+ */
+ const unsigned short REASON_EXPIRED = 1;
+
+ /**
+ * This page and all of its visits are being deleted. Note: the page may not
+ * necessarily have actually existed for this function to be called.
+ *
+ * Delete notifications are only 99.99% accurate. Batch delete operations
+ * must be done in two steps, so first come notifications, then a bulk
+ * delete. If there is some error in the middle (for example, out of memory)
+ * then you'll get a notification and it won't get deleted. There's no easy
+ * way around this.
+ *
+ * @param aURI
+ * The URI that was deleted.
+ * @param aGUID
+ * The unique ID associated with the page.
+ * @param aReason
+ * Indicates the reason for the removal. see REASON_* constants.
+ */
+ void onDeleteURI(in nsIURI aURI,
+ in ACString aGUID,
+ in unsigned short aReason);
+
+ /**
+ * Notification that all of history is being deleted.
+ */
+ void onClearHistory();
+
+ /**
+ * onPageChanged attribute indicating that favicon has been updated.
+ * aNewValue parameter will be set to the new favicon URI string.
+ */
+ const unsigned long ATTRIBUTE_FAVICON = 3;
+
+ /**
+ * An attribute of this page changed.
+ *
+ * @param aURI
+ * The URI of the page on which an attribute changed.
+ * @param aChangedAttribute
+ * The attribute whose value changed. See ATTRIBUTE_* constants.
+ * @param aNewValue
+ * The attribute's new value.
+ * @param aGUID
+ * The unique ID associated with the page.
+ */
+ void onPageChanged(in nsIURI aURI,
+ in unsigned long aChangedAttribute,
+ in AString aNewValue,
+ in ACString aGUID);
+
+ /**
+ * Called when some visits of an history entry are expired.
+ *
+ * @param aURI
+ * The page whose visits have been expired.
+ * @param aVisitTime
+ * The largest visit time in microseconds that has been expired. We
+ * guarantee that we don't have any visit older than this date.
+ * @param aGUID
+ * The unique ID associated with the page.
+ *
+ * @note: when all visits for a page are expired and also the full page entry
+ * is expired, you will only get an onDeleteURI notification. If a
+ * page entry is removed, then you can be sure that we don't have
+ * anymore visits for it.
+ * @param aReason
+ * Indicates the reason for the removal. see REASON_* constants.
+ * @param aTransitionType
+ * If it's a valid TRANSITION_* value, all visits of the specified type
+ * have been removed.
+ */
+ void onDeleteVisits(in nsIURI aURI,
+ in PRTime aVisitTime,
+ in ACString aGUID,
+ in unsigned short aReason,
+ in unsigned long aTransitionType);
+};
+
+
+/**
+ * This object encapsulates all the query parameters you're likely to need
+ * when building up history UI. All parameters are ANDed together.
+ *
+ * This is not intended to be a super-general query mechanism. This was designed
+ * so that most queries can be done in only one SQL query. This is important
+ * because, if the user has their profile on a networked drive, query latency
+ * can be non-negligible.
+ */
+
+[scriptable, uuid(dc87ae79-22f1-4dcf-975b-852b01d210cb)]
+interface nsINavHistoryQuery : nsISupports
+{
+ /**
+ * Time range for results (INCLUSIVE). The *TimeReference is one of the
+ * constants TIME_RELATIVE_* which indicates how to interpret the
+ * corresponding time value.
+ * TIME_RELATIVE_EPOCH (default):
+ * The time is relative to Jan 1 1970 GMT, (this is a normal PRTime)
+ * TIME_RELATIVE_TODAY:
+ * The time is relative to this morning at midnight. Normally used for
+ * queries relative to today. For example, a "past week" query would be
+ * today-6 days -> today+1 day
+ * TIME_RELATIVE_NOW:
+ * The time is relative to right now.
+ *
+ * Note: PRTime is in MICROseconds since 1 Jan 1970. Javascript date objects
+ * are expressed in MILLIseconds since 1 Jan 1970.
+ *
+ * As a special case, a 0 time relative to TIME_RELATIVE_EPOCH indicates that
+ * the time is not part of the query. This is the default, so an empty query
+ * will match any time. The has* functions return whether the corresponding
+ * time is considered.
+ *
+ * You can read absolute*Time to get the time value that the currently loaded
+ * reference points + offset resolve to.
+ */
+ const unsigned long TIME_RELATIVE_EPOCH = 0;
+ const unsigned long TIME_RELATIVE_TODAY = 1;
+ const unsigned long TIME_RELATIVE_NOW = 2;
+
+ attribute PRTime beginTime;
+ attribute unsigned long beginTimeReference;
+ readonly attribute boolean hasBeginTime;
+ readonly attribute PRTime absoluteBeginTime;
+
+ attribute PRTime endTime;
+ attribute unsigned long endTimeReference;
+ readonly attribute boolean hasEndTime;
+ readonly attribute PRTime absoluteEndTime;
+
+ /**
+ * Text search terms.
+ */
+ attribute AString searchTerms;
+ readonly attribute boolean hasSearchTerms;
+
+ /**
+ * Set lower or upper limits for how many times an item has been
+ * visited. The default is -1, and in that case all items are
+ * matched regardless of their visit count.
+ */
+ attribute long minVisits;
+ attribute long maxVisits;
+
+ /**
+ * When the set of transitions is nonempty, results are limited to pages which
+ * have at least one visit for each of the transition types.
+ * @note: For searching on more than one transition this can be very slow.
+ *
+ * Limit results to the specified list of transition types.
+ */
+ void setTransitions([const,array, size_is(count)] in unsigned long transitions,
+ in unsigned long count);
+
+ /**
+ * Get the transitions set for this query.
+ */
+ void getTransitions([optional] out unsigned long count,
+ [retval,array,size_is(count)] out unsigned long transitions);
+
+ /**
+ * Get the count of the set query transitions.
+ */
+ readonly attribute unsigned long transitionCount;
+
+ /**
+ * When set, returns only bookmarked items, when unset, returns anything. Setting this
+ * is equivalent to listing all bookmark folders in the 'folders' parameter.
+ */
+ attribute boolean onlyBookmarked;
+
+ /**
+ * This controls the meaning of 'domain', and whether it is an exact match
+ * 'domainIsHost' = true, or hierarchical (= false).
+ */
+ attribute boolean domainIsHost;
+
+ /**
+ * This is the host or domain name (controlled by domainIsHost). When
+ * domainIsHost, domain only does exact matching on host names. Otherwise,
+ * it will return anything whose host name ends in 'domain'.
+ *
+ * This one is a little different than most. Setting it to an empty string
+ * is a real query and will match any URI that has no host name (local files
+ * and such). Set this to NULL (in C++ use SetIsVoid) if you don't want
+ * domain matching.
+ */
+ attribute AUTF8String domain;
+ readonly attribute boolean hasDomain;
+
+ /**
+ * This is a URI to match, to, for example, find out every time you visited
+ * a given URI. This is an exact match.
+ */
+ attribute nsIURI uri;
+ readonly attribute boolean hasUri;
+
+ /**
+ * Test for existence or non-existence of a given annotation. We don't
+ * currently support >1 annotation name per query. If 'annotationIsNot' is
+ * true, we test for the non-existence of the specified annotation.
+ *
+ * Testing for not annotation will do the same thing as a normal query and
+ * remove everything that doesn't have that annotation. Asking for things
+ * that DO have a given annotation is a little different. It also includes
+ * things that have never been visited. This allows place queries to be
+ * returned as well as anything else that may have been tagged with an
+ * annotation. This will only work for RESULTS_AS_URI since there will be
+ * no visits for these items.
+ */
+ attribute boolean annotationIsNot;
+ attribute AUTF8String annotation;
+ readonly attribute boolean hasAnnotation;
+
+ /**
+ * Limit results to items that are tagged with all of the given tags. This
+ * attribute must be set to an array of strings. When called as a getter it
+ * will return an array of strings sorted ascending in lexicographical order.
+ * The array may be empty in either case. Duplicate tags may be specified
+ * when setting the attribute, but the getter returns only unique tags.
+ *
+ * To search for items that are tagged with any given tags rather than all,
+ * multiple queries may be passed to nsINavHistoryService.executeQueries().
+ */
+ attribute nsIVariant tags;
+
+ /**
+ * If 'tagsAreNot' is true, the results are instead limited to items that
+ * are not tagged with any of the given tags. This attribute is used in
+ * conjunction with the 'tags' attribute.
+ */
+ attribute boolean tagsAreNot;
+
+ /**
+ * Limit results to items that are in all of the given folders.
+ */
+ void getFolders([optional] out unsigned long count,
+ [retval,array,size_is(count)] out long long folders);
+ readonly attribute unsigned long folderCount;
+
+ /**
+ * For the special result type RESULTS_AS_TAG_CONTENTS we can define only
+ * one folder that must be a tag folder. This is not recursive so results
+ * will be returned from the first level of that folder.
+ */
+ void setFolders([const,array, size_is(folderCount)] in long long folders,
+ in unsigned long folderCount);
+
+ /**
+ * Creates a new query item with the same parameters of this one.
+ */
+ nsINavHistoryQuery clone();
+};
+
+/**
+ * This object represents the global options for executing a query.
+ */
+[scriptable, uuid(8198dfa7-8061-4766-95cb-fa86b3c00a47)]
+interface nsINavHistoryQueryOptions : nsISupports
+{
+ /**
+ * You can ask for the results to be pre-sorted. Since the DB has indices
+ * of many items, it can produce sorted results almost for free. These should
+ * be self-explanatory.
+ *
+ * Note: re-sorting is slower, as is sorting by title or when you have a
+ * host name.
+ *
+ * For bookmark items, SORT_BY_NONE means sort by the natural bookmark order.
+ */
+ const unsigned short SORT_BY_NONE = 0;
+ const unsigned short SORT_BY_TITLE_ASCENDING = 1;
+ const unsigned short SORT_BY_TITLE_DESCENDING = 2;
+ const unsigned short SORT_BY_DATE_ASCENDING = 3;
+ const unsigned short SORT_BY_DATE_DESCENDING = 4;
+ const unsigned short SORT_BY_URI_ASCENDING = 5;
+ const unsigned short SORT_BY_URI_DESCENDING = 6;
+ const unsigned short SORT_BY_VISITCOUNT_ASCENDING = 7;
+ const unsigned short SORT_BY_VISITCOUNT_DESCENDING = 8;
+ const unsigned short SORT_BY_KEYWORD_ASCENDING = 9;
+ const unsigned short SORT_BY_KEYWORD_DESCENDING = 10;
+ const unsigned short SORT_BY_DATEADDED_ASCENDING = 11;
+ const unsigned short SORT_BY_DATEADDED_DESCENDING = 12;
+ const unsigned short SORT_BY_LASTMODIFIED_ASCENDING = 13;
+ const unsigned short SORT_BY_LASTMODIFIED_DESCENDING = 14;
+ const unsigned short SORT_BY_TAGS_ASCENDING = 17;
+ const unsigned short SORT_BY_TAGS_DESCENDING = 18;
+ const unsigned short SORT_BY_ANNOTATION_ASCENDING = 19;
+ const unsigned short SORT_BY_ANNOTATION_DESCENDING = 20;
+ const unsigned short SORT_BY_FRECENCY_ASCENDING = 21;
+ const unsigned short SORT_BY_FRECENCY_DESCENDING = 22;
+
+ /**
+ * "URI" results, one for each URI visited in the range. Individual result
+ * nodes will be of type "URI".
+ */
+ const unsigned short RESULTS_AS_URI = 0;
+
+ /**
+ * "Visit" results, with one for each time a page was visited (this will
+ * often give you multiple results for one URI). Individual result nodes will
+ * have type "Visit"
+ *
+ * @note This result type is only supported by QUERY_TYPE_HISTORY.
+ */
+ const unsigned short RESULTS_AS_VISIT = 1;
+
+ /**
+ * This is identical to RESULT_TYPE_VISIT except that individual result nodes
+ * will have type "FullVisit". This is used for the attributes that are not
+ * commonly accessed to save space in the common case (the lists can be very
+ * long).
+ *
+ * @note Not yet implemented. See bug 409662.
+ * @note This result type is only supported by QUERY_TYPE_HISTORY.
+ */
+ const unsigned short RESULTS_AS_FULL_VISIT = 2;
+
+ /**
+ * This returns query nodes for each predefined date range where we
+ * had visits. The node contains information how to load its content:
+ * - visits for the given date range will be loaded.
+ *
+ * @note This result type is only supported by QUERY_TYPE_HISTORY.
+ */
+ const unsigned short RESULTS_AS_DATE_QUERY = 3;
+
+ /**
+ * This returns nsINavHistoryQueryResultNode nodes for each site where we
+ * have visits. The node contains information how to load its content:
+ * - last visit for each url in the given host will be loaded.
+ *
+ * @note This result type is only supported by QUERY_TYPE_HISTORY.
+ */
+ const unsigned short RESULTS_AS_SITE_QUERY = 4;
+
+ /**
+ * This returns nsINavHistoryQueryResultNode nodes for each day where we
+ * have visits. The node contains information how to load its content:
+ * - list of hosts visited in the given period will be loaded.
+ *
+ * @note This result type is only supported by QUERY_TYPE_HISTORY.
+ */
+ const unsigned short RESULTS_AS_DATE_SITE_QUERY = 5;
+
+ /**
+ * This returns nsINavHistoryQueryResultNode nodes for each tag.
+ * The node contains information how to load its content:
+ * - list of bookmarks with the given tag will be loaded.
+ *
+ * @note Setting this resultType will force queryType to QUERY_TYPE_BOOKMARKS.
+ */
+ const unsigned short RESULTS_AS_TAG_QUERY = 6;
+
+ /**
+ * This is a container with an URI result type that contains the last
+ * modified bookmarks for the given tag.
+ * Tag folder id must be defined in the query.
+ *
+ * @note Setting this resultType will force queryType to QUERY_TYPE_BOOKMARKS.
+ */
+ const unsigned short RESULTS_AS_TAG_CONTENTS = 7;
+
+ /**
+ * The sorting mode to be used for this query.
+ * mode is one of SORT_BY_*
+ */
+ attribute unsigned short sortingMode;
+
+ /**
+ * The annotation to use in SORT_BY_ANNOTATION_* sorting modes.
+ */
+ attribute AUTF8String sortingAnnotation;
+
+ /**
+ * Sets the result type. One of RESULT_TYPE_* which includes how URIs are
+ * represented.
+ */
+ attribute unsigned short resultType;
+
+ /**
+ * This option excludes all URIs and separators from a bookmarks query.
+ * This would be used if you just wanted a list of bookmark folders and
+ * queries (such as the left pane of the places page).
+ * Defaults to false.
+ */
+ attribute boolean excludeItems;
+
+ /**
+ * Set to true to exclude queries ("place:" URIs) from the query results.
+ * Simple folder queries (bookmark folder symlinks) will still be included.
+ * Defaults to false.
+ */
+ attribute boolean excludeQueries;
+
+ /**
+ * DO NOT USE THIS API. IT'LL BE REMOVED IN BUG 1072833.
+ *
+ * Set to true to exclude live bookmarks from the query results.
+ */
+ attribute boolean excludeReadOnlyFolders;
+
+ /**
+ * When set, allows items with "place:" URIs to appear as containers,
+ * with the container's contents filled in from the stored query.
+ * If not set, these will appear as normal items. Doesn't do anything if
+ * excludeQueries is set. Defaults to false.
+ *
+ * Note that this has no effect on folder links, which are place: URIs
+ * returned by nsINavBookmarkService.GetFolderURI. These are always expanded
+ * and will appear as bookmark folders.
+ */
+ attribute boolean expandQueries;
+
+ /**
+ * Some pages in history are marked "hidden" and thus don't appear by default
+ * in queries. These include automatic framed visits and redirects. Setting
+ * this attribute will return all pages, even hidden ones. Does nothing for
+ * bookmark queries. Defaults to false.
+ */
+ attribute boolean includeHidden;
+
+ /**
+ * This is the maximum number of results that you want. The query is exeucted,
+ * the results are sorted, and then the top 'maxResults' results are taken
+ * and returned. Set to 0 (the default) to get all results.
+ *
+ * THIS DOES NOT WORK IN CONJUNCTION WITH SORTING BY TITLE. This is because
+ * sorting by title requires us to sort after using locale-sensetive sorting
+ * (as opposed to letting the database do it for us).
+ *
+ * Instead, we get the result ordered by date, pick the maxResult most recent
+ * ones, and THEN sort by title.
+ */
+ attribute unsigned long maxResults;
+
+ const unsigned short QUERY_TYPE_HISTORY = 0;
+ const unsigned short QUERY_TYPE_BOOKMARKS = 1;
+ /* Unified queries are not yet implemented. See bug 378798 */
+ const unsigned short QUERY_TYPE_UNIFIED = 2;
+
+ /**
+ * The type of search to use when querying the DB; This attribute is only
+ * honored by query nodes. It is silently ignored for simple folder queries.
+ */
+ attribute unsigned short queryType;
+
+ /**
+ * When this is true, the root container node generated by these options and
+ * its descendant containers will be opened asynchronously if they support it.
+ * This is false by default.
+ *
+ * @note Currently only bookmark folder containers support being opened
+ * asynchronously.
+ */
+ attribute boolean asyncEnabled;
+
+ /**
+ * Creates a new options item with the same parameters of this one.
+ */
+ nsINavHistoryQueryOptions clone();
+};
+
+[scriptable, uuid(8a1f527e-c9d7-4a51-bf0c-d86f0379b701)]
+interface nsINavHistoryService : nsISupports
+{
+ /**
+ * System Notifications:
+ *
+ * places-init-complete - Sent once the History service is completely
+ * initialized successfully.
+ * places-database-locked - Sent if initialization of the History service
+ * failed due to the inability to open the places.sqlite
+ * for access reasons.
+ */
+
+ /**
+ * This transition type means the user followed a link and got a new toplevel
+ * window.
+ */
+ const unsigned long TRANSITION_LINK = 1;
+
+ /**
+ * This transition type means that the user typed the page's URL in the
+ * URL bar or selected it from URL bar autocomplete results, clicked on
+ * it from a history query (from the History sidebar, History menu,
+ * or history query in the personal toolbar or Places organizer.
+ */
+ const unsigned long TRANSITION_TYPED = 2;
+
+ /**
+ * This transition is set when the user followed a bookmark to get to the
+ * page.
+ */
+ const unsigned long TRANSITION_BOOKMARK = 3;
+
+ /**
+ * This transition type is set when some inner content is loaded. This is
+ * true of all images on a page, and the contents of the iframe. It is also
+ * true of any content in a frame if the user did not explicitly follow
+ * a link to get there.
+ */
+ const unsigned long TRANSITION_EMBED = 4;
+
+ /**
+ * Set when the transition was a permanent redirect.
+ */
+ const unsigned long TRANSITION_REDIRECT_PERMANENT = 5;
+
+ /**
+ * Set when the transition was a temporary redirect.
+ */
+ const unsigned long TRANSITION_REDIRECT_TEMPORARY = 6;
+
+ /**
+ * Set when the transition is a download.
+ */
+ const unsigned long TRANSITION_DOWNLOAD = 7;
+
+ /**
+ * This transition type means the user followed a link and got a visit in
+ * a frame.
+ */
+ const unsigned long TRANSITION_FRAMED_LINK = 8;
+
+ /**
+ * This transition type means the page has been reloaded.
+ */
+ const unsigned long TRANSITION_RELOAD = 9;
+
+ /**
+ * Set when database is coherent
+ */
+ const unsigned short DATABASE_STATUS_OK = 0;
+
+ /**
+ * Set when database did not exist and we created a new one
+ */
+ const unsigned short DATABASE_STATUS_CREATE = 1;
+
+ /**
+ * Set when database was corrupt and we replaced it
+ */
+ const unsigned short DATABASE_STATUS_CORRUPT = 2;
+
+ /**
+ * Set when database schema has been upgraded
+ */
+ const unsigned short DATABASE_STATUS_UPGRADED = 3;
+
+ /**
+ * Returns the current database status
+ */
+ readonly attribute unsigned short databaseStatus;
+
+ /**
+ * True if there is any history. This can be used in UI to determine whether
+ * the "clear history" button should be enabled or not. This is much better
+ * than using BrowserHistory.count since that can be very slow if there is
+ * a lot of history (it must enumerate each item). This is pretty fast.
+ */
+ readonly attribute boolean hasHistoryEntries;
+
+ /**
+ * Gets the original title of the page.
+ * @deprecated use mozIAsyncHistory.getPlacesInfo instead.
+ */
+ AString getPageTitle(in nsIURI aURI);
+
+ /**
+ * This is just like markPageAsTyped (in nsIBrowserHistory, also implemented
+ * by the history service), but for bookmarks. It declares that the given URI
+ * is being opened as a result of following a bookmark. If this URI is loaded
+ * soon after this message has been received, that transition will be marked
+ * as following a bookmark.
+ */
+ void markPageAsFollowedBookmark(in nsIURI aURI);
+
+ /**
+ * Designates the url as having been explicitly typed in by the user.
+ *
+ * @param aURI
+ * URI of the page to be marked.
+ */
+ void markPageAsTyped(in nsIURI aURI);
+
+ /**
+ * Designates the url as coming from a link explicitly followed by
+ * the user (for example by clicking on it).
+ *
+ * @param aURI
+ * URI of the page to be marked.
+ */
+ void markPageAsFollowedLink(in nsIURI aURI);
+
+ /**
+ * Returns true if this URI would be added to the history. You don't have to
+ * worry about calling this, adding a visit will always check before
+ * actually adding the page. This function is public because some components
+ * may want to check if this page would go in the history (i.e. for
+ * annotations).
+ */
+ boolean canAddURI(in nsIURI aURI);
+
+ /**
+ * This returns a new query object that you can pass to executeQuer[y/ies].
+ * It will be initialized to all empty (so using it will give you all history).
+ */
+ nsINavHistoryQuery getNewQuery();
+
+ /**
+ * This returns a new options object that you can pass to executeQuer[y/ies]
+ * after setting the desired options.
+ */
+ nsINavHistoryQueryOptions getNewQueryOptions();
+
+ /**
+ * Executes a single query.
+ */
+ nsINavHistoryResult executeQuery(in nsINavHistoryQuery aQuery,
+ in nsINavHistoryQueryOptions options);
+
+ /**
+ * Executes an array of queries. All of the query objects are ORed
+ * together. Within a query, all the terms are ANDed together as in
+ * executeQuery. See executeQuery()
+ */
+ nsINavHistoryResult executeQueries(
+ [array,size_is(aQueryCount)] in nsINavHistoryQuery aQueries,
+ in unsigned long aQueryCount,
+ in nsINavHistoryQueryOptions options);
+
+ /**
+ * Converts a query URI-like string to an array of actual query objects for
+ * use to executeQueries(). The output query array may be empty if there is
+ * no information. However, there will always be an options structure returned
+ * (if nothing is defined, it will just have the default values).
+ */
+ void queryStringToQueries(in AUTF8String aQueryString,
+ [array, size_is(aResultCount)] out nsINavHistoryQuery aQueries,
+ out unsigned long aResultCount,
+ out nsINavHistoryQueryOptions options);
+
+ /**
+ * Converts a query into an equivalent string that can be persisted. Inverse
+ * of queryStringToQueries()
+ */
+ AUTF8String queriesToQueryString(
+ [array, size_is(aQueryCount)] in nsINavHistoryQuery aQueries,
+ in unsigned long aQueryCount,
+ in nsINavHistoryQueryOptions options);
+
+ /**
+ * Adds a history observer. If ownsWeak is false, the history service will
+ * keep an owning reference to the observer. If ownsWeak is true, then
+ * aObserver must implement nsISupportsWeakReference, and the history service
+ * will keep a weak reference to the observer.
+ */
+ void addObserver(in nsINavHistoryObserver observer, in boolean ownsWeak);
+
+ /**
+ * Removes a history observer.
+ */
+ void removeObserver(in nsINavHistoryObserver observer);
+
+ /**
+ * Gets an array of registered nsINavHistoryObserver objects.
+ */
+ void getObservers([optional] out unsigned long count,
+ [retval, array, size_is(count)] out nsINavHistoryObserver observers);
+
+ /**
+ * Runs the passed callback in batch mode. Use this when a lot of things
+ * are about to change. Calls can be nested, observers will only be
+ * notified when all batches begin/end.
+ *
+ * @param aCallback
+ * nsINavHistoryBatchCallback interface to call.
+ * @param aUserData
+ * Opaque parameter passed to nsINavBookmarksBatchCallback
+ */
+ void runInBatchMode(in nsINavHistoryBatchCallback aCallback,
+ in nsISupports aClosure);
+
+ /**
+ * True if history is disabled. currently,
+ * history is disabled if the places.history.enabled pref is false.
+ */
+ readonly attribute boolean historyDisabled;
+
+ /**
+ * Clear all TRANSITION_EMBED visits.
+ */
+ void clearEmbedVisits();
+};
+
+/**
+ * @see runInBatchMode of nsINavHistoryService/nsINavBookmarksService
+ */
+[scriptable, function, uuid(5a5a9154-95ac-4e3d-90df-558816297407)]
+interface nsINavHistoryBatchCallback : nsISupports {
+ void runBatched(in nsISupports aUserData);
+};
diff --git a/toolkit/components/places/nsITaggingService.idl b/toolkit/components/places/nsITaggingService.idl
new file mode 100644
index 000000000..f3731feb6
--- /dev/null
+++ b/toolkit/components/places/nsITaggingService.idl
@@ -0,0 +1,95 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIVariant;
+
+[scriptable, uuid(9759bd0e-78e2-4421-9ed1-c676e1af3513)]
+interface nsITaggingService : nsISupports
+{
+
+ /**
+ * Defines the maximal length of a tag. Related to the bug 407821
+ * (https://bugzilla.mozilla.org/show_bug.cgi?id=407821)
+ */
+ const unsigned long MAX_TAG_LENGTH = 100;
+
+ /**
+ * Tags a URL with the given set of tags. Current tags set for the URL
+ * persist. Tags in aTags which are already set for the given URL are
+ * ignored.
+ *
+ * @param aURI
+ * the URL to tag.
+ * @param aTags
+ * Array of tags to set for the given URL. Each element within the
+ * array can be either a tag name (non-empty string) or a concrete
+ * itemId of a tag container.
+ * @param [optional] aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*.
+ * Defaults to SOURCE_DEFAULT if omitted.
+ */
+ void tagURI(in nsIURI aURI,
+ in nsIVariant aTags,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Removes tags from a URL. Tags from aTags which are not set for the
+ * given URL are ignored.
+ *
+ * @param aURI
+ * the URL to un-tag.
+ * @param aTags
+ * Array of tags to unset. Pass null to remove all tags from the given
+ * url. Each element within the array can be either a tag name
+ * (non-empty string) or a concrete itemId of a tag container.
+ * @param [optional] aSource
+ * A change source constant from nsINavBookmarksService::SOURCE_*.
+ * Defaults to SOURCE_DEFAULT if omitted.
+ */
+ void untagURI(in nsIURI aURI,
+ in nsIVariant aTags,
+ [optional] in unsigned short aSource);
+
+ /**
+ * Retrieves all URLs tagged with the given tag.
+ *
+ * @param aTag
+ * tag name
+ * @returns Array of uris tagged with aTag.
+ */
+ nsIVariant getURIsForTag(in AString aTag);
+
+ /**
+ * Retrieves all tags set for the given URL.
+ *
+ * @param aURI
+ * a URL.
+ * @returns array of tags (sorted by name).
+ */
+ void getTagsForURI(in nsIURI aURI,
+ [optional] out unsigned long length,
+ [retval, array, size_is(length)] out wstring aTags);
+
+ /**
+ * Retrieves all tags used to tag URIs in the data-base (sorted by name).
+ */
+ readonly attribute nsIVariant allTags;
+
+ /**
+ * Whether any tags exist.
+ *
+ * @note This is faster than allTags.length, since doesn't need to sort tags.
+ */
+ readonly attribute boolean hasTags;
+};
+
+%{C++
+
+#define TAGGING_SERVICE_CID "@mozilla.org/browser/tagging-service;1"
+
+%}
diff --git a/toolkit/components/places/nsLivemarkService.js b/toolkit/components/places/nsLivemarkService.js
new file mode 100644
index 000000000..eeca7e139
--- /dev/null
+++ b/toolkit/components/places/nsLivemarkService.js
@@ -0,0 +1,891 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+// Modules and services.
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "asyncHistory", function () {
+ // Lazily add an history observer when it's actually needed.
+ PlacesUtils.history.addObserver(PlacesUtils.livemarks, true);
+ return PlacesUtils.asyncHistory;
+});
+
+// Constants
+
+// Delay between reloads of consecute livemarks.
+const RELOAD_DELAY_MS = 500;
+// Expire livemarks after this time.
+const EXPIRE_TIME_MS = 3600000; // 1 hour.
+// Expire livemarks after this time on error.
+const ONERROR_EXPIRE_TIME_MS = 300000; // 5 minutes.
+
+// Livemarks cache.
+
+XPCOMUtils.defineLazyGetter(this, "CACHE_SQL", () => {
+ function getAnnoSQLFragment(aAnnoParam) {
+ return `SELECT a.content
+ FROM moz_items_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ WHERE a.item_id = b.id
+ AND n.name = ${aAnnoParam}`;
+ }
+
+ return `SELECT b.id, b.title, b.parent As parentId, b.position AS 'index',
+ b.guid, b.dateAdded, b.lastModified, p.guid AS parentGuid,
+ ( ${getAnnoSQLFragment(":feedURI_anno")} ) AS feedURI,
+ ( ${getAnnoSQLFragment(":siteURI_anno")} ) AS siteURI
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ JOIN moz_items_annos a ON a.item_id = b.id
+ JOIN moz_anno_attributes n ON a.anno_attribute_id = n.id
+ WHERE b.type = :folder_type
+ AND n.name = :feedURI_anno`;
+});
+
+XPCOMUtils.defineLazyGetter(this, "gLivemarksCachePromised",
+ Task.async(function* () {
+ let livemarksMap = new Map();
+ let conn = yield PlacesUtils.promiseDBConnection();
+ let rows = yield conn.executeCached(CACHE_SQL,
+ { folder_type: Ci.nsINavBookmarksService.TYPE_FOLDER,
+ feedURI_anno: PlacesUtils.LMANNO_FEEDURI,
+ siteURI_anno: PlacesUtils.LMANNO_SITEURI });
+ for (let row of rows) {
+ let siteURI = row.getResultByName("siteURI");
+ let livemark = new Livemark({
+ id: row.getResultByName("id"),
+ guid: row.getResultByName("guid"),
+ title: row.getResultByName("title"),
+ parentId: row.getResultByName("parentId"),
+ parentGuid: row.getResultByName("parentGuid"),
+ index: row.getResultByName("index"),
+ dateAdded: row.getResultByName("dateAdded"),
+ lastModified: row.getResultByName("lastModified"),
+ feedURI: NetUtil.newURI(row.getResultByName("feedURI")),
+ siteURI: siteURI ? NetUtil.newURI(siteURI) : null
+ });
+ livemarksMap.set(livemark.guid, livemark);
+ }
+ return livemarksMap;
+ })
+);
+
+/**
+ * Convert a Date object to a PRTime (microseconds).
+ *
+ * @param date
+ * the Date object to convert.
+ * @return microseconds from the epoch.
+ */
+function toPRTime(date) {
+ return date * 1000;
+}
+
+/**
+ * Convert a PRTime to a Date object.
+ *
+ * @param time
+ * microseconds from the epoch.
+ * @return a Date object or undefined if time was not defined.
+ */
+function toDate(time) {
+ return time ? new Date(parseInt(time / 1000)) : undefined;
+}
+
+// LivemarkService
+
+function LivemarkService() {
+ // Cleanup on shutdown.
+ Services.obs.addObserver(this, PlacesUtils.TOPIC_SHUTDOWN, true);
+
+ // Observe bookmarks but don't init the service just for that.
+ PlacesUtils.addLazyBookmarkObserver(this, true);
+}
+
+LivemarkService.prototype = {
+ // This is just an helper for code readability.
+ _promiseLivemarksMap: () => gLivemarksCachePromised,
+
+ _reloading: false,
+ _startReloadTimer(livemarksMap, forceUpdate, reloaded) {
+ if (this._reloadTimer) {
+ this._reloadTimer.cancel();
+ }
+ else {
+ this._reloadTimer = Cc["@mozilla.org/timer;1"]
+ .createInstance(Ci.nsITimer);
+ }
+
+ this._reloading = true;
+ this._reloadTimer.initWithCallback(() => {
+ // Find first livemark to be reloaded.
+ for (let [ guid, livemark ] of livemarksMap) {
+ if (!reloaded.has(guid)) {
+ reloaded.add(guid);
+ livemark.reload(forceUpdate);
+ this._startReloadTimer(livemarksMap, forceUpdate, reloaded);
+ return;
+ }
+ }
+ // All livemarks have been reloaded.
+ this._reloading = false;
+ }, RELOAD_DELAY_MS, Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ // nsIObserver
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == PlacesUtils.TOPIC_SHUTDOWN) {
+ if (this._reloadTimer) {
+ this._reloading = false;
+ this._reloadTimer.cancel();
+ delete this._reloadTimer;
+ }
+
+ // Stop any ongoing network fetch.
+ this._promiseLivemarksMap().then(livemarksMap => {
+ for (let livemark of livemarksMap.values()) {
+ livemark.terminate();
+ }
+ });
+ }
+ },
+
+ // mozIAsyncLivemarks
+
+ addLivemark(aLivemarkInfo) {
+ if (!aLivemarkInfo) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+ let hasParentId = "parentId" in aLivemarkInfo;
+ let hasParentGuid = "parentGuid" in aLivemarkInfo;
+ let hasIndex = "index" in aLivemarkInfo;
+ // Must provide at least non-null parent guid/id, index and feedURI.
+ if ((!hasParentId && !hasParentGuid) ||
+ (hasParentId && aLivemarkInfo.parentId < 1) ||
+ (hasParentGuid &&!/^[a-zA-Z0-9\-_]{12}$/.test(aLivemarkInfo.parentGuid)) ||
+ (hasIndex && aLivemarkInfo.index < Ci.nsINavBookmarksService.DEFAULT_INDEX) ||
+ !(aLivemarkInfo.feedURI instanceof Ci.nsIURI) ||
+ (aLivemarkInfo.siteURI && !(aLivemarkInfo.siteURI instanceof Ci.nsIURI)) ||
+ (aLivemarkInfo.guid && !/^[a-zA-Z0-9\-_]{12}$/.test(aLivemarkInfo.guid))) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return Task.spawn(function* () {
+ if (!aLivemarkInfo.parentGuid)
+ aLivemarkInfo.parentGuid = yield PlacesUtils.promiseItemGuid(aLivemarkInfo.parentId);
+
+ let livemarksMap = yield this._promiseLivemarksMap();
+
+ // Disallow adding a livemark inside another livemark.
+ if (livemarksMap.has(aLivemarkInfo.parentGuid)) {
+ throw new Components.Exception("Cannot create a livemark inside a livemark", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Create a new livemark.
+ let folder = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: aLivemarkInfo.parentGuid,
+ title: aLivemarkInfo.title,
+ index: aLivemarkInfo.index,
+ guid: aLivemarkInfo.guid,
+ dateAdded: toDate(aLivemarkInfo.dateAdded) || toDate(aLivemarkInfo.lastModified),
+ source: aLivemarkInfo.source,
+ });
+
+ // Set feed and site URI annotations.
+ let id = yield PlacesUtils.promiseItemId(folder.guid);
+
+ // Create the internal Livemark object.
+ let livemark = new Livemark({ id
+ , title: folder.title
+ , parentGuid: folder.parentGuid
+ , parentId: yield PlacesUtils.promiseItemId(folder.parentGuid)
+ , index: folder.index
+ , feedURI: aLivemarkInfo.feedURI
+ , siteURI: aLivemarkInfo.siteURI
+ , guid: folder.guid
+ , dateAdded: toPRTime(folder.dateAdded)
+ , lastModified: toPRTime(folder.lastModified)
+ });
+
+ livemark.writeFeedURI(aLivemarkInfo.feedURI, aLivemarkInfo.source);
+ if (aLivemarkInfo.siteURI) {
+ livemark.writeSiteURI(aLivemarkInfo.siteURI, aLivemarkInfo.source);
+ }
+
+ if (aLivemarkInfo.lastModified) {
+ yield PlacesUtils.bookmarks.update({ guid: folder.guid,
+ lastModified: toDate(aLivemarkInfo.lastModified),
+ source: aLivemarkInfo.source });
+ livemark.lastModified = aLivemarkInfo.lastModified;
+ }
+
+ livemarksMap.set(folder.guid, livemark);
+
+ return livemark;
+ }.bind(this));
+ },
+
+ removeLivemark(aLivemarkInfo) {
+ if (!aLivemarkInfo) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+ // Accept either a guid or an id.
+ let hasGuid = "guid" in aLivemarkInfo;
+ let hasId = "id" in aLivemarkInfo;
+ if ((hasGuid && !/^[a-zA-Z0-9\-_]{12}$/.test(aLivemarkInfo.guid)) ||
+ (hasId && aLivemarkInfo.id < 1) ||
+ (!hasId && !hasGuid)) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return Task.spawn(function* () {
+ if (!aLivemarkInfo.guid)
+ aLivemarkInfo.guid = yield PlacesUtils.promiseItemGuid(aLivemarkInfo.id);
+
+ let livemarksMap = yield this._promiseLivemarksMap();
+ if (!livemarksMap.has(aLivemarkInfo.guid))
+ throw new Components.Exception("Invalid livemark", Cr.NS_ERROR_INVALID_ARG);
+
+ yield PlacesUtils.bookmarks.remove(aLivemarkInfo.guid,
+ { source: aLivemarkInfo.source });
+ }.bind(this));
+ },
+
+ reloadLivemarks(aForceUpdate) {
+ // Check if there's a currently running reload, to save some useless work.
+ let notWorthRestarting =
+ this._forceUpdate || // We're already forceUpdating.
+ !aForceUpdate; // The caller didn't request a forced update.
+ if (this._reloading && notWorthRestarting) {
+ // Ignore this call.
+ return;
+ }
+
+ this._promiseLivemarksMap().then(livemarksMap => {
+ this._forceUpdate = !!aForceUpdate;
+ // Livemarks reloads happen on a timer for performance reasons.
+ this._startReloadTimer(livemarksMap, this._forceUpdate, new Set());
+ });
+ },
+
+ getLivemark(aLivemarkInfo) {
+ if (!aLivemarkInfo) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+ // Accept either a guid or an id.
+ let hasGuid = "guid" in aLivemarkInfo;
+ let hasId = "id" in aLivemarkInfo;
+ if ((hasGuid && !/^[a-zA-Z0-9\-_]{12}$/.test(aLivemarkInfo.guid)) ||
+ (hasId && aLivemarkInfo.id < 1) ||
+ (!hasId && !hasGuid)) {
+ throw new Components.Exception("Invalid arguments", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ return Task.spawn(function*() {
+ if (!aLivemarkInfo.guid)
+ aLivemarkInfo.guid = yield PlacesUtils.promiseItemGuid(aLivemarkInfo.id);
+
+ let livemarksMap = yield this._promiseLivemarksMap();
+ if (!livemarksMap.has(aLivemarkInfo.guid))
+ throw new Components.Exception("Invalid livemark", Cr.NS_ERROR_INVALID_ARG);
+
+ return livemarksMap.get(aLivemarkInfo.guid);
+ }.bind(this));
+ },
+
+ // nsINavBookmarkObserver
+
+ onBeginUpdateBatch() {},
+ onEndUpdateBatch() {},
+ onItemVisited() {},
+ onItemAdded() {},
+
+ onItemChanged(id, property, isAnno, value, lastModified, itemType, parentId,
+ guid, parentGuid) {
+ if (itemType != Ci.nsINavBookmarksService.TYPE_FOLDER)
+ return;
+
+ this._promiseLivemarksMap().then(livemarksMap => {
+ if (livemarksMap.has(guid)) {
+ let livemark = livemarksMap.get(guid);
+ if (property == "title") {
+ livemark.title = value;
+ }
+ livemark.lastModified = lastModified;
+ }
+ });
+ },
+
+ onItemMoved(id, parentId, oldIndex, newParentId, newIndex, itemType, guid,
+ oldParentGuid, newParentGuid) {
+ if (itemType != Ci.nsINavBookmarksService.TYPE_FOLDER)
+ return;
+
+ this._promiseLivemarksMap().then(livemarksMap => {
+ if (livemarksMap.has(guid)) {
+ let livemark = livemarksMap.get(guid);
+ livemark.parentId = newParentId;
+ livemark.parentGuid = newParentGuid;
+ livemark.index = newIndex;
+ }
+ });
+ },
+
+ onItemRemoved(id, parentId, index, itemType, uri, guid, parentGuid) {
+ if (itemType != Ci.nsINavBookmarksService.TYPE_FOLDER)
+ return;
+
+ this._promiseLivemarksMap().then(livemarksMap => {
+ if (livemarksMap.has(guid)) {
+ let livemark = livemarksMap.get(guid);
+ livemark.terminate();
+ livemarksMap.delete(guid);
+ }
+ });
+ },
+
+ // nsINavHistoryObserver
+
+ onPageChanged() {},
+ onTitleChanged() {},
+ onDeleteVisits() {},
+
+ onClearHistory() {
+ this._promiseLivemarksMap().then(livemarksMap => {
+ for (let livemark of livemarksMap.values()) {
+ livemark.updateURIVisitedStatus(null, false);
+ }
+ });
+ },
+
+ onDeleteURI(aURI) {
+ this._promiseLivemarksMap().then(livemarksMap => {
+ for (let livemark of livemarksMap.values()) {
+ livemark.updateURIVisitedStatus(aURI, false);
+ }
+ });
+ },
+
+ onVisit(aURI) {
+ this._promiseLivemarksMap().then(livemarksMap => {
+ for (let livemark of livemarksMap.values()) {
+ livemark.updateURIVisitedStatus(aURI, true);
+ }
+ });
+ },
+
+ // nsISupports
+
+ classID: Components.ID("{dca61eb5-c7cd-4df1-b0fb-d0722baba251}"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(LivemarkService),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.mozIAsyncLivemarks
+ , Ci.nsINavBookmarkObserver
+ , Ci.nsINavHistoryObserver
+ , Ci.nsIObserver
+ , Ci.nsISupportsWeakReference
+ ])
+};
+
+// Livemark
+
+/**
+ * Object used internally to represent a livemark.
+ *
+ * @param aLivemarkInfo
+ * Object containing information on the livemark. If the livemark is
+ * not included in the object, a new livemark will be created.
+ *
+ * @note terminate() must be invoked before getting rid of this object.
+ */
+function Livemark(aLivemarkInfo)
+{
+ this.id = aLivemarkInfo.id;
+ this.guid = aLivemarkInfo.guid;
+ this.feedURI = aLivemarkInfo.feedURI;
+ this.siteURI = aLivemarkInfo.siteURI || null;
+ this.title = aLivemarkInfo.title;
+ this.parentId = aLivemarkInfo.parentId;
+ this.parentGuid = aLivemarkInfo.parentGuid;
+ this.index = aLivemarkInfo.index;
+ this.dateAdded = aLivemarkInfo.dateAdded;
+ this.lastModified = aLivemarkInfo.lastModified;
+
+ this._status = Ci.mozILivemark.STATUS_READY;
+
+ // Hash of resultObservers, hashed by container.
+ this._resultObservers = new Map();
+
+ // Sorted array of objects representing livemark children in the form
+ // { uri, title, visited }.
+ this._children = [];
+
+ // Keeps a separate array of nodes for each requesting container, hashed by
+ // the container itself.
+ this._nodes = new Map();
+
+ this.loadGroup = null;
+ this.expireTime = 0;
+}
+
+Livemark.prototype = {
+ get status() {
+ return this._status;
+ },
+ set status(val) {
+ if (this._status != val) {
+ this._status = val;
+ this._invalidateRegisteredContainers();
+ }
+ return this._status;
+ },
+
+ writeFeedURI(aFeedURI, aSource) {
+ PlacesUtils.annotations
+ .setItemAnnotation(this.id, PlacesUtils.LMANNO_FEEDURI,
+ aFeedURI.spec,
+ 0, PlacesUtils.annotations.EXPIRE_NEVER,
+ aSource);
+ this.feedURI = aFeedURI;
+ },
+
+ writeSiteURI(aSiteURI, aSource) {
+ if (!aSiteURI) {
+ PlacesUtils.annotations.removeItemAnnotation(this.id,
+ PlacesUtils.LMANNO_SITEURI,
+ aSource)
+ this.siteURI = null;
+ return;
+ }
+
+ // Security check the site URI against the feed URI principal.
+ let secMan = Services.scriptSecurityManager;
+ let feedPrincipal = secMan.createCodebasePrincipal(this.feedURI, {});
+ try {
+ secMan.checkLoadURIWithPrincipal(feedPrincipal, aSiteURI,
+ Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL);
+ }
+ catch (ex) {
+ return;
+ }
+
+ PlacesUtils.annotations
+ .setItemAnnotation(this.id, PlacesUtils.LMANNO_SITEURI,
+ aSiteURI.spec,
+ 0, PlacesUtils.annotations.EXPIRE_NEVER,
+ aSource);
+ this.siteURI = aSiteURI;
+ },
+
+ /**
+ * Tries to updates the livemark if needed.
+ * The update process is asynchronous.
+ *
+ * @param [optional] aForceUpdate
+ * If true will try to update the livemark even if its contents have
+ * not yet expired.
+ */
+ updateChildren(aForceUpdate) {
+ // Check if the livemark is already updating.
+ if (this.status == Ci.mozILivemark.STATUS_LOADING)
+ return;
+
+ // Check the TTL/expiration on this, to check if there is no need to update
+ // this livemark.
+ if (!aForceUpdate && this.children.length && this.expireTime > Date.now())
+ return;
+
+ this.status = Ci.mozILivemark.STATUS_LOADING;
+
+ // Setting the status notifies observers that may remove the livemark.
+ if (this._terminated)
+ return;
+
+ try {
+ // Create a load group for the request. This will allow us to
+ // automatically keep track of redirects, so we can always
+ // cancel the channel.
+ let loadgroup = Cc["@mozilla.org/network/load-group;1"].
+ createInstance(Ci.nsILoadGroup);
+ // Creating a CodeBasePrincipal and using it as the loadingPrincipal
+ // is *not* desired and is only tolerated within this file.
+ // TODO: Find the right OriginAttributes and pass something other
+ // than {} to .createCodeBasePrincipal().
+ let channel = NetUtil.newChannel({
+ uri: this.feedURI,
+ loadingPrincipal: Services.scriptSecurityManager.createCodebasePrincipal(this.feedURI, {}),
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_XMLHTTPREQUEST
+ }).QueryInterface(Ci.nsIHttpChannel);
+ channel.loadGroup = loadgroup;
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BACKGROUND |
+ Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.requestMethod = "GET";
+ channel.setRequestHeader("X-Moz", "livebookmarks", false);
+
+ // Stream the result to the feed parser with this listener
+ let listener = new LivemarkLoadListener(this);
+ channel.notificationCallbacks = listener;
+ channel.asyncOpen2(listener);
+
+ this.loadGroup = loadgroup;
+ }
+ catch (ex) {
+ this.status = Ci.mozILivemark.STATUS_FAILED;
+ }
+ },
+
+ reload(aForceUpdate) {
+ this.updateChildren(aForceUpdate);
+ },
+
+ get children() {
+ return this._children;
+ },
+ set children(val) {
+ this._children = val;
+
+ // Discard the previous cached nodes, new ones should be generated.
+ for (let container of this._resultObservers.keys()) {
+ this._nodes.delete(container);
+ }
+
+ // Update visited status for each entry.
+ for (let child of this._children) {
+ asyncHistory.isURIVisited(child.uri, (aURI, aIsVisited) => {
+ this.updateURIVisitedStatus(aURI, aIsVisited);
+ });
+ }
+
+ return this._children;
+ },
+
+ _isURIVisited(aURI) {
+ return this.children.some(child => child.uri.equals(aURI) && child.visited);
+ },
+
+ getNodesForContainer(aContainerNode) {
+ if (this._nodes.has(aContainerNode)) {
+ return this._nodes.get(aContainerNode);
+ }
+
+ let livemark = this;
+ let nodes = [];
+ let now = Date.now() * 1000;
+ for (let child of this.children) {
+ // Workaround for bug 449811.
+ let localChild = child;
+ let node = {
+ // The QueryInterface is needed cause aContainerNode is a jsval.
+ // This is required to avoid issues with scriptable wrappers that would
+ // not allow the view to correctly set expandos.
+ get parent() {
+ return aContainerNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ },
+ get parentResult() {
+ return this.parent.parentResult;
+ },
+ get uri() {
+ return localChild.uri.spec;
+ },
+ get type() {
+ return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
+ },
+ get title() {
+ return localChild.title;
+ },
+ get accessCount() {
+ return Number(livemark._isURIVisited(NetUtil.newURI(this.uri)));
+ },
+ get time() {
+ return 0;
+ },
+ get icon() {
+ return "";
+ },
+ get indentLevel() {
+ return this.parent.indentLevel + 1;
+ },
+ get bookmarkIndex() {
+ return -1;
+ },
+ get itemId() {
+ return -1;
+ },
+ get dateAdded() {
+ return now;
+ },
+ get lastModified() {
+ return now;
+ },
+ get tags() {
+ return PlacesUtils.tagging.getTagsForURI(NetUtil.newURI(this.uri)).join(", ");
+ },
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryResultNode])
+ };
+ nodes.push(node);
+ }
+ this._nodes.set(aContainerNode, nodes);
+ return nodes;
+ },
+
+ registerForUpdates(aContainerNode, aResultObserver) {
+ this._resultObservers.set(aContainerNode, aResultObserver);
+ },
+
+ unregisterForUpdates(aContainerNode) {
+ this._resultObservers.delete(aContainerNode);
+ this._nodes.delete(aContainerNode);
+ },
+
+ _invalidateRegisteredContainers() {
+ for (let [ container, observer ] of this._resultObservers) {
+ observer.invalidateContainer(container);
+ }
+ },
+
+ /**
+ * Updates the visited status of nodes observing this livemark.
+ *
+ * @param aURI
+ * If provided will update nodes having the given uri,
+ * otherwise any node.
+ * @param aVisitedStatus
+ * Whether the nodes should be set as visited.
+ */
+ updateURIVisitedStatus(aURI, aVisitedStatus) {
+ for (let child of this.children) {
+ if (!aURI || child.uri.equals(aURI)) {
+ child.visited = aVisitedStatus;
+ }
+ }
+
+ for (let [ container, observer ] of this._resultObservers) {
+ if (this._nodes.has(container)) {
+ let nodes = this._nodes.get(container);
+ for (let node of nodes) {
+ // Workaround for bug 449811.
+ let localObserver = observer;
+ let localNode = node;
+ if (!aURI || node.uri == aURI.spec) {
+ Services.tm.mainThread.dispatch(() => {
+ localObserver.nodeHistoryDetailsChanged(localNode, 0, aVisitedStatus);
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * Terminates the livemark entry, cancelling any ongoing load.
+ * Must be invoked before destroying the entry.
+ */
+ terminate() {
+ // Avoid handling any updateChildren request from now on.
+ this._terminated = true;
+ this.abort();
+ },
+
+ /**
+ * Aborts the livemark loading if needed.
+ */
+ abort() {
+ this.status = Ci.mozILivemark.STATUS_FAILED;
+ if (this.loadGroup) {
+ this.loadGroup.cancel(Cr.NS_BINDING_ABORTED);
+ this.loadGroup = null;
+ }
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.mozILivemark
+ ])
+}
+
+// LivemarkLoadListener
+
+/**
+ * Object used internally to handle loading a livemark's contents.
+ *
+ * @param aLivemark
+ * The Livemark that is loading.
+ */
+function LivemarkLoadListener(aLivemark) {
+ this._livemark = aLivemark;
+ this._processor = null;
+ this._isAborted = false;
+ this._ttl = EXPIRE_TIME_MS;
+}
+
+LivemarkLoadListener.prototype = {
+ abort(aException) {
+ if (!this._isAborted) {
+ this._isAborted = true;
+ this._livemark.abort();
+ this._setResourceTTL(ONERROR_EXPIRE_TIME_MS);
+ }
+ },
+
+ // nsIFeedResultListener
+ handleResult(aResult) {
+ if (this._isAborted) {
+ return;
+ }
+
+ try {
+ // We need this to make sure the item links are safe
+ let feedPrincipal =
+ Services.scriptSecurityManager
+ .createCodebasePrincipal(this._livemark.feedURI, {});
+
+ // Enforce well-formedness because the existing code does
+ if (!aResult || !aResult.doc || aResult.bozo) {
+ throw new Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ let feed = aResult.doc.QueryInterface(Ci.nsIFeed);
+ let siteURI = this._livemark.siteURI;
+ if (feed.link && (!siteURI || !feed.link.equals(siteURI))) {
+ siteURI = feed.link;
+ this._livemark.writeSiteURI(siteURI);
+ }
+
+ // Insert feed items.
+ let livemarkChildren = [];
+ for (let i = 0; i < feed.items.length; ++i) {
+ let entry = feed.items.queryElementAt(i, Ci.nsIFeedEntry);
+ let uri = entry.link || siteURI;
+ if (!uri) {
+ continue;
+ }
+
+ try {
+ Services.scriptSecurityManager
+ .checkLoadURIWithPrincipal(feedPrincipal, uri,
+ Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL);
+ }
+ catch (ex) {
+ continue;
+ }
+
+ let title = entry.title ? entry.title.plainText() : "";
+ livemarkChildren.push({ uri: uri, title: title, visited: false });
+ }
+
+ this._livemark.children = livemarkChildren;
+ }
+ catch (ex) {
+ this.abort(ex);
+ }
+ finally {
+ this._processor.listener = null;
+ this._processor = null;
+ }
+ },
+
+ onDataAvailable(aRequest, aContext, aInputStream, aSourceOffset, aCount) {
+ if (this._processor) {
+ this._processor.onDataAvailable(aRequest, aContext, aInputStream,
+ aSourceOffset, aCount);
+ }
+ },
+
+ onStartRequest(aRequest, aContext) {
+ if (this._isAborted) {
+ throw new Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let channel = aRequest.QueryInterface(Ci.nsIChannel);
+ try {
+ // Parse feed data as it comes in
+ this._processor = Cc["@mozilla.org/feed-processor;1"].
+ createInstance(Ci.nsIFeedProcessor);
+ this._processor.listener = this;
+ this._processor.parseAsync(null, channel.URI);
+ this._processor.onStartRequest(aRequest, aContext);
+ }
+ catch (ex) {
+ Components.utils.reportError("Livemark Service: feed processor received an invalid channel for " + channel.URI.spec);
+ this.abort(ex);
+ }
+ },
+
+ onStopRequest(aRequest, aContext, aStatus) {
+ if (!Components.isSuccessCode(aStatus)) {
+ this.abort();
+ return;
+ }
+
+ // Set an expiration on the livemark, to reloading the data in future.
+ try {
+ if (this._processor) {
+ this._processor.onStopRequest(aRequest, aContext, aStatus);
+ }
+
+ // Calculate a new ttl
+ let channel = aRequest.QueryInterface(Ci.nsICachingChannel);
+ if (channel) {
+ let entryInfo = channel.cacheToken.QueryInterface(Ci.nsICacheEntry);
+ if (entryInfo) {
+ // nsICacheEntry returns value as seconds.
+ let expireTime = entryInfo.expirationTime * 1000;
+ let nowTime = Date.now();
+ // Note, expireTime can be 0, see bug 383538.
+ if (expireTime > nowTime) {
+ this._setResourceTTL(Math.max((expireTime - nowTime),
+ EXPIRE_TIME_MS));
+ return;
+ }
+ }
+ }
+ this._setResourceTTL(EXPIRE_TIME_MS);
+ }
+ catch (ex) {
+ this.abort(ex);
+ }
+ finally {
+ if (this._livemark.status == Ci.mozILivemark.STATUS_LOADING) {
+ this._livemark.status = Ci.mozILivemark.STATUS_READY;
+ }
+ this._livemark.locked = false;
+ this._livemark.loadGroup = null;
+ }
+ },
+
+ _setResourceTTL(aMilliseconds) {
+ this._livemark.expireTime = Date.now() + aMilliseconds;
+ },
+
+ // nsIInterfaceRequestor
+ getInterface(aIID) {
+ return this.QueryInterface(aIID);
+ },
+
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIFeedResultListener
+ , Ci.nsIStreamListener
+ , Ci.nsIRequestObserver
+ , Ci.nsIInterfaceRequestor
+ ])
+}
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([LivemarkService]);
diff --git a/toolkit/components/places/nsMaybeWeakPtr.h b/toolkit/components/places/nsMaybeWeakPtr.h
new file mode 100644
index 000000000..ce52e5090
--- /dev/null
+++ b/toolkit/components/places/nsMaybeWeakPtr.h
@@ -0,0 +1,145 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsMaybeWeakPtr_h_
+#define nsMaybeWeakPtr_h_
+
+#include "mozilla/Attributes.h"
+#include "nsCOMPtr.h"
+#include "nsWeakReference.h"
+#include "nsTArray.h"
+#include "nsCycleCollectionNoteChild.h"
+
+// nsMaybeWeakPtr is a helper object to hold a strong-or-weak reference
+// to the template class. It's pretty minimal, but sufficient.
+
+template<class T>
+class nsMaybeWeakPtr
+{
+public:
+ MOZ_IMPLICIT nsMaybeWeakPtr(nsISupports* aRef) : mPtr(aRef) {}
+ MOZ_IMPLICIT nsMaybeWeakPtr(const nsCOMPtr<nsIWeakReference>& aRef) : mPtr(aRef) {}
+ MOZ_IMPLICIT nsMaybeWeakPtr(const nsCOMPtr<T>& aRef) : mPtr(aRef) {}
+
+ bool operator==(const nsMaybeWeakPtr<T> &other) const {
+ return mPtr == other.mPtr;
+ }
+
+ nsISupports* GetRawValue() const { return mPtr.get(); }
+
+ const nsCOMPtr<T> GetValue() const;
+
+private:
+ nsCOMPtr<nsISupports> mPtr;
+};
+
+// nsMaybeWeakPtrArray is an array of MaybeWeakPtr objects, that knows how to
+// grab a weak reference to a given object if requested. It only allows a
+// given object to appear in the array once.
+
+template<class T>
+class nsMaybeWeakPtrArray : public nsTArray<nsMaybeWeakPtr<T>>
+{
+ typedef nsTArray<nsMaybeWeakPtr<T>> MaybeWeakArray;
+
+public:
+ nsresult AppendWeakElement(T* aElement, bool aOwnsWeak)
+ {
+ nsCOMPtr<nsISupports> ref;
+ if (aOwnsWeak) {
+ ref = do_GetWeakReference(aElement);
+ } else {
+ ref = aElement;
+ }
+
+ if (MaybeWeakArray::Contains(ref.get())) {
+ return NS_ERROR_INVALID_ARG;
+ }
+ if (!MaybeWeakArray::AppendElement(ref)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ return NS_OK;
+ }
+
+ nsresult RemoveWeakElement(T* aElement)
+ {
+ if (MaybeWeakArray::RemoveElement(aElement)) {
+ return NS_OK;
+ }
+
+ // Don't use do_GetWeakReference; it should only be called if we know
+ // the object supports weak references.
+ nsCOMPtr<nsISupportsWeakReference> supWeakRef = do_QueryInterface(aElement);
+ NS_ENSURE_TRUE(supWeakRef, NS_ERROR_INVALID_ARG);
+
+ nsCOMPtr<nsIWeakReference> weakRef;
+ nsresult rv = supWeakRef->GetWeakReference(getter_AddRefs(weakRef));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (MaybeWeakArray::RemoveElement(weakRef)) {
+ return NS_OK;
+ }
+
+ return NS_ERROR_INVALID_ARG;
+ }
+};
+
+template<class T>
+const nsCOMPtr<T>
+nsMaybeWeakPtr<T>::GetValue() const
+{
+ if (!mPtr) {
+ return nullptr;
+ }
+
+ nsresult rv;
+ nsCOMPtr<T> ref = do_QueryInterface(mPtr, &rv);
+ if (NS_SUCCEEDED(rv)) {
+ return ref;
+ }
+
+ nsCOMPtr<nsIWeakReference> weakRef = do_QueryInterface(mPtr);
+ if (weakRef) {
+ ref = do_QueryReferent(weakRef, &rv);
+ if (NS_SUCCEEDED(rv)) {
+ return ref;
+ }
+ }
+
+ return nullptr;
+}
+
+template <typename T>
+inline void
+ImplCycleCollectionUnlink(nsMaybeWeakPtrArray<T>& aField)
+{
+ aField.Clear();
+}
+
+template <typename E>
+inline void
+ImplCycleCollectionTraverse(nsCycleCollectionTraversalCallback& aCallback,
+ nsMaybeWeakPtrArray<E>& aField,
+ const char* aName,
+ uint32_t aFlags = 0)
+{
+ aFlags |= CycleCollectionEdgeNameArrayFlag;
+ size_t length = aField.Length();
+ for (size_t i = 0; i < length; ++i) {
+ CycleCollectionNoteChild(aCallback, aField[i].GetRawValue(), aName, aFlags);
+ }
+}
+
+// Call a method on each element in the array, but only if the element is
+// non-null.
+
+#define ENUMERATE_WEAKARRAY(array, type, method) \
+ for (uint32_t array_idx = 0; array_idx < array.Length(); ++array_idx) { \
+ const nsCOMPtr<type> &e = array.ElementAt(array_idx).GetValue(); \
+ if (e) \
+ e->method; \
+ }
+
+#endif
diff --git a/toolkit/components/places/nsNavBookmarks.cpp b/toolkit/components/places/nsNavBookmarks.cpp
new file mode 100644
index 000000000..74707be99
--- /dev/null
+++ b/toolkit/components/places/nsNavBookmarks.cpp
@@ -0,0 +1,2926 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsNavBookmarks.h"
+
+#include "nsNavHistory.h"
+#include "nsAnnotationService.h"
+#include "nsPlacesMacros.h"
+#include "Helpers.h"
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsNetUtil.h"
+#include "nsUnicharUtils.h"
+#include "nsPrintfCString.h"
+#include "prprf.h"
+#include "mozilla/storage.h"
+
+#include "GeckoProfiler.h"
+
+using namespace mozilla;
+
+// These columns sit to the right of the kGetInfoIndex_* columns.
+const int32_t nsNavBookmarks::kGetChildrenIndex_Guid = 18;
+const int32_t nsNavBookmarks::kGetChildrenIndex_Position = 19;
+const int32_t nsNavBookmarks::kGetChildrenIndex_Type = 20;
+const int32_t nsNavBookmarks::kGetChildrenIndex_PlaceID = 21;
+
+using namespace mozilla::places;
+
+PLACES_FACTORY_SINGLETON_IMPLEMENTATION(nsNavBookmarks, gBookmarksService)
+
+#define BOOKMARKS_ANNO_PREFIX "bookmarks/"
+#define BOOKMARKS_TOOLBAR_FOLDER_ANNO NS_LITERAL_CSTRING(BOOKMARKS_ANNO_PREFIX "toolbarFolder")
+#define FEED_URI_ANNO NS_LITERAL_CSTRING("livemark/feedURI")
+
+
+namespace {
+
+#define SKIP_TAGS(condition) ((condition) ? SkipTags : DontSkip)
+
+bool DontSkip(nsCOMPtr<nsINavBookmarkObserver> obs) { return false; }
+bool SkipTags(nsCOMPtr<nsINavBookmarkObserver> obs) {
+ bool skipTags = false;
+ (void) obs->GetSkipTags(&skipTags);
+ return skipTags;
+}
+bool SkipDescendants(nsCOMPtr<nsINavBookmarkObserver> obs) {
+ bool skipDescendantsOnItemRemoval = false;
+ (void) obs->GetSkipTags(&skipDescendantsOnItemRemoval);
+ return skipDescendantsOnItemRemoval;
+}
+
+template<typename Method, typename DataType>
+class AsyncGetBookmarksForURI : public AsyncStatementCallback
+{
+public:
+ AsyncGetBookmarksForURI(nsNavBookmarks* aBookmarksSvc,
+ Method aCallback,
+ const DataType& aData)
+ : mBookmarksSvc(aBookmarksSvc)
+ , mCallback(aCallback)
+ , mData(aData)
+ {
+ }
+
+ void Init()
+ {
+ RefPtr<Database> DB = Database::GetDatabase();
+ if (DB) {
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = DB->GetAsyncStatement(
+ "/* do not warn (bug 1175249) */ "
+ "SELECT b.id, b.guid, b.parent, b.lastModified, t.guid, t.parent "
+ "FROM moz_bookmarks b "
+ "JOIN moz_bookmarks t on t.id = b.parent "
+ "WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url) "
+ "ORDER BY b.lastModified DESC, b.id DESC "
+ );
+ if (stmt) {
+ (void)URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"),
+ mData.bookmark.url);
+ nsCOMPtr<mozIStoragePendingStatement> pendingStmt;
+ (void)stmt->ExecuteAsync(this, getter_AddRefs(pendingStmt));
+ }
+ }
+ }
+
+ NS_IMETHOD HandleResult(mozIStorageResultSet* aResultSet)
+ {
+ nsCOMPtr<mozIStorageRow> row;
+ while (NS_SUCCEEDED(aResultSet->GetNextRow(getter_AddRefs(row))) && row) {
+ // Skip tags, for the use-cases of this async getter they are useless.
+ int64_t grandParentId, tagsFolderId;
+ nsresult rv = row->GetInt64(5, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = mBookmarksSvc->GetTagsFolder(&tagsFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (grandParentId == tagsFolderId) {
+ continue;
+ }
+
+ mData.bookmark.grandParentId = grandParentId;
+ rv = row->GetInt64(0, &mData.bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = row->GetUTF8String(1, mData.bookmark.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = row->GetInt64(2, &mData.bookmark.parentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // lastModified (3) should not be set for the use-cases of this getter.
+ rv = row->GetUTF8String(4, mData.bookmark.parentGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (mCallback) {
+ ((*mBookmarksSvc).*mCallback)(mData);
+ }
+ }
+ return NS_OK;
+ }
+
+private:
+ RefPtr<nsNavBookmarks> mBookmarksSvc;
+ Method mCallback;
+ DataType mData;
+};
+
+} // namespace
+
+
+nsNavBookmarks::nsNavBookmarks()
+ : mItemCount(0)
+ , mRoot(0)
+ , mMenuRoot(0)
+ , mTagsRoot(0)
+ , mUnfiledRoot(0)
+ , mToolbarRoot(0)
+ , mMobileRoot(0)
+ , mCanNotify(false)
+ , mCacheObservers("bookmark-observers")
+ , mBatching(false)
+{
+ NS_ASSERTION(!gBookmarksService,
+ "Attempting to create two instances of the service!");
+ gBookmarksService = this;
+}
+
+
+nsNavBookmarks::~nsNavBookmarks()
+{
+ NS_ASSERTION(gBookmarksService == this,
+ "Deleting a non-singleton instance of the service");
+ if (gBookmarksService == this)
+ gBookmarksService = nullptr;
+}
+
+
+NS_IMPL_ISUPPORTS(nsNavBookmarks
+, nsINavBookmarksService
+, nsINavHistoryObserver
+, nsIAnnotationObserver
+, nsIObserver
+, nsISupportsWeakReference
+)
+
+
+Atomic<int64_t> nsNavBookmarks::sLastInsertedItemId(0);
+
+
+void // static
+nsNavBookmarks::StoreLastInsertedId(const nsACString& aTable,
+ const int64_t aLastInsertedId) {
+ MOZ_ASSERT(aTable.EqualsLiteral("moz_bookmarks"));
+ sLastInsertedItemId = aLastInsertedId;
+}
+
+
+nsresult
+nsNavBookmarks::Init()
+{
+ mDB = Database::GetDatabase();
+ NS_ENSURE_STATE(mDB);
+
+ nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
+ if (os) {
+ (void)os->AddObserver(this, TOPIC_PLACES_SHUTDOWN, true);
+ (void)os->AddObserver(this, TOPIC_PLACES_CONNECTION_CLOSED, true);
+ }
+
+ nsresult rv = ReadRoots();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mCanNotify = true;
+
+ // Observe annotations.
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ NS_ENSURE_TRUE(annosvc, NS_ERROR_OUT_OF_MEMORY);
+ annosvc->AddObserver(this);
+
+ // Allows us to notify on title changes. MUST BE LAST so it is impossible
+ // to fail after this call, or the history service will have a reference to
+ // us and we won't go away.
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(history);
+ history->AddObserver(this, true);
+
+ // DO NOT PUT STUFF HERE that can fail. See observer comment above.
+
+ return NS_OK;
+}
+
+nsresult
+nsNavBookmarks::ReadRoots()
+{
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mDB->MainConn()->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT guid, id FROM moz_bookmarks WHERE guid IN ( "
+ "'root________', 'menu________', 'toolbar_____', "
+ "'tags________', 'unfiled_____', 'mobile______' )"
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && hasResult) {
+ nsAutoCString guid;
+ rv = stmt->GetUTF8String(0, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int64_t id;
+ rv = stmt->GetInt64(1, &id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (guid.EqualsLiteral("root________")) {
+ mRoot = id;
+ }
+ else if (guid.EqualsLiteral("menu________")) {
+ mMenuRoot = id;
+ }
+ else if (guid.EqualsLiteral("toolbar_____")) {
+ mToolbarRoot = id;
+ }
+ else if (guid.EqualsLiteral("tags________")) {
+ mTagsRoot = id;
+ }
+ else if (guid.EqualsLiteral("unfiled_____")) {
+ mUnfiledRoot = id;
+ }
+ else if (guid.EqualsLiteral("mobile______")) {
+ mMobileRoot = id;
+ }
+ }
+
+ if (!mRoot || !mMenuRoot || !mToolbarRoot || !mTagsRoot || !mUnfiledRoot ||
+ !mMobileRoot)
+ return NS_ERROR_FAILURE;
+
+ return NS_OK;
+}
+
+// nsNavBookmarks::IsBookmarkedInDatabase
+//
+// This checks to see if the specified place_id is actually bookmarked.
+
+nsresult
+nsNavBookmarks::IsBookmarkedInDatabase(int64_t aPlaceId,
+ bool* aIsBookmarked)
+{
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT 1 FROM moz_bookmarks WHERE fk = :page_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), aPlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->ExecuteStep(aIsBookmarked);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::AdjustIndices(int64_t aFolderId,
+ int32_t aStartIndex,
+ int32_t aEndIndex,
+ int32_t aDelta)
+{
+ NS_ASSERTION(aStartIndex >= 0 && aEndIndex <= INT32_MAX &&
+ aStartIndex <= aEndIndex, "Bad indices");
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET position = position + :delta "
+ "WHERE parent = :parent "
+ "AND position BETWEEN :from_index AND :to_index"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("delta"), aDelta);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("from_index"), aStartIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("to_index"), aEndIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetPlacesRoot(int64_t* aRoot)
+{
+ *aRoot = mRoot;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetBookmarksMenuFolder(int64_t* aRoot)
+{
+ *aRoot = mMenuRoot;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetToolbarFolder(int64_t* aFolderId)
+{
+ *aFolderId = mToolbarRoot;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetTagsFolder(int64_t* aRoot)
+{
+ *aRoot = mTagsRoot;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetUnfiledBookmarksFolder(int64_t* aRoot)
+{
+ *aRoot = mUnfiledRoot;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetMobileFolder(int64_t* aRoot)
+{
+ *aRoot = mMobileRoot;
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::InsertBookmarkInDB(int64_t aPlaceId,
+ enum ItemType aItemType,
+ int64_t aParentId,
+ int32_t aIndex,
+ const nsACString& aTitle,
+ PRTime aDateAdded,
+ PRTime aLastModified,
+ const nsACString& aParentGuid,
+ int64_t aGrandParentId,
+ nsIURI* aURI,
+ uint16_t aSource,
+ int64_t* _itemId,
+ nsACString& _guid)
+{
+ // Check for a valid itemId.
+ MOZ_ASSERT(_itemId && (*_itemId == -1 || *_itemId > 0));
+ // Check for a valid placeId.
+ MOZ_ASSERT(aPlaceId && (aPlaceId == -1 || aPlaceId > 0));
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "INSERT INTO moz_bookmarks "
+ "(id, fk, type, parent, position, title, "
+ "dateAdded, lastModified, guid) "
+ "VALUES (:item_id, :page_id, :item_type, :parent, :item_index, "
+ ":item_title, :date_added, :last_modified, "
+ ":item_guid)"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv;
+ if (*_itemId != -1)
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), *_itemId);
+ else
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("item_id"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (aPlaceId != -1)
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), aPlaceId);
+ else
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("page_id"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_type"), aItemType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_index"), aIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Support NULL titles.
+ if (aTitle.IsVoid())
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("item_title"));
+ else
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("item_title"), aTitle);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("date_added"), aDateAdded);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (aLastModified) {
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("last_modified"),
+ aLastModified);
+ }
+ else {
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("last_modified"), aDateAdded);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Could use IsEmpty because our callers check for GUID validity,
+ // but it doesn't hurt.
+ if (_guid.Length() == 12) {
+ MOZ_ASSERT(IsValidGUID(_guid));
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("item_guid"), _guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ nsAutoCString guid;
+ rv = GenerateGUID(guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("item_guid"), guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ _guid.Assign(guid);
+ }
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (*_itemId == -1) {
+ *_itemId = sLastInsertedItemId;
+ }
+
+ if (aParentId > 0) {
+ // Update last modified date of the ancestors.
+ // TODO (bug 408991): Doing this for all ancestors would be slow without a
+ // nested tree, so for now update only the parent.
+ rv = SetItemDateInternal(LAST_MODIFIED, aParentId, aDateAdded);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Add a cache entry since we know everything about this bookmark.
+ BookmarkData bookmark;
+ bookmark.id = *_itemId;
+ bookmark.guid.Assign(_guid);
+ if (aTitle.IsVoid()) {
+ bookmark.title.SetIsVoid(true);
+ }
+ else {
+ bookmark.title.Assign(aTitle);
+ }
+ bookmark.position = aIndex;
+ bookmark.placeId = aPlaceId;
+ bookmark.parentId = aParentId;
+ bookmark.type = aItemType;
+ bookmark.dateAdded = aDateAdded;
+ if (aLastModified)
+ bookmark.lastModified = aLastModified;
+ else
+ bookmark.lastModified = aDateAdded;
+ if (aURI) {
+ rv = aURI->GetSpec(bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ bookmark.parentGuid = aParentGuid;
+ bookmark.grandParentId = aGrandParentId;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavBookmarks::InsertBookmark(int64_t aFolder,
+ nsIURI* aURI,
+ int32_t aIndex,
+ const nsACString& aTitle,
+ const nsACString& aGUID,
+ uint16_t aSource,
+ int64_t* aNewBookmarkId)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(aNewBookmarkId);
+ NS_ENSURE_ARG_MIN(aIndex, nsINavBookmarksService::DEFAULT_INDEX);
+
+ if (!aGUID.IsEmpty() && !IsValidGUID(aGUID))
+ return NS_ERROR_INVALID_ARG;
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ int64_t placeId;
+ nsAutoCString placeGuid;
+ nsresult rv = history->GetOrCreateIdForPage(aURI, &placeId, placeGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Get the correct index for insertion. This also ensures the parent exists.
+ int32_t index, folderCount;
+ int64_t grandParentId;
+ nsAutoCString folderGuid;
+ rv = FetchFolderInfo(aFolder, &folderCount, folderGuid, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aIndex == nsINavBookmarksService::DEFAULT_INDEX ||
+ aIndex >= folderCount) {
+ index = folderCount;
+ }
+ else {
+ index = aIndex;
+ // Create space for the insertion.
+ rv = AdjustIndices(aFolder, index, INT32_MAX, 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ *aNewBookmarkId = -1;
+ PRTime dateAdded = RoundedPRNow();
+ nsAutoCString guid(aGUID);
+ nsCString title;
+ TruncateTitle(aTitle, title);
+
+ rv = InsertBookmarkInDB(placeId, BOOKMARK, aFolder, index, title, dateAdded,
+ 0, folderGuid, grandParentId, aURI, aSource,
+ aNewBookmarkId, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If not a tag, recalculate frecency for this entry, since it changed.
+ if (grandParentId != mTagsRoot) {
+ rv = history->UpdateFrecency(placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ SKIP_TAGS(grandParentId == mTagsRoot),
+ OnItemAdded(*aNewBookmarkId, aFolder, index,
+ TYPE_BOOKMARK, aURI, title, dateAdded,
+ guid, folderGuid, aSource));
+
+ // If the bookmark has been added to a tag container, notify all
+ // bookmark-folder result nodes which contain a bookmark for the new
+ // bookmark's url.
+ if (grandParentId == mTagsRoot) {
+ // Notify a tags change to all bookmarks for this URI.
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(aURI, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ // Check that bookmarks doesn't include the current tag itemId.
+ MOZ_ASSERT(bookmarks[i].id != *aNewBookmarkId);
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ DontSkip,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("tags"),
+ false,
+ EmptyCString(),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::RemoveItem(int64_t aItemId, uint16_t aSource)
+{
+ PROFILER_LABEL("nsNavBookmarks", "RemoveItem",
+ js::ProfileEntry::Category::OTHER);
+
+ NS_ENSURE_ARG(!IsRoot(aItemId));
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ // First, if not a tag, remove item annotations.
+ if (bookmark.parentId != mTagsRoot &&
+ bookmark.grandParentId != mTagsRoot) {
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ NS_ENSURE_TRUE(annosvc, NS_ERROR_OUT_OF_MEMORY);
+ rv = annosvc->RemoveItemAnnotations(bookmark.id, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (bookmark.type == TYPE_FOLDER) {
+ // Remove all of the folder's children.
+ rv = RemoveFolderChildren(bookmark.id, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "DELETE FROM moz_bookmarks WHERE id = :item_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Fix indices in the parent.
+ if (bookmark.position != DEFAULT_INDEX) {
+ rv = AdjustIndices(bookmark.parentId,
+ bookmark.position + 1, INT32_MAX, -1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ bookmark.lastModified = RoundedPRNow();
+ rv = SetItemDateInternal(LAST_MODIFIED, bookmark.parentId,
+ bookmark.lastModified);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIURI> uri;
+ if (bookmark.type == TYPE_BOOKMARK) {
+ // If not a tag, recalculate frecency for this entry, since it changed.
+ if (bookmark.grandParentId != mTagsRoot) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->UpdateFrecency(bookmark.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ // A broken url should not interrupt the removal process.
+ (void)NS_NewURI(getter_AddRefs(uri), bookmark.url);
+ // We cannot assert since some automated tests are checking this path.
+ NS_WARNING_ASSERTION(uri, "Invalid URI in RemoveItem");
+ }
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ SKIP_TAGS(bookmark.parentId == mTagsRoot ||
+ bookmark.grandParentId == mTagsRoot),
+ OnItemRemoved(bookmark.id,
+ bookmark.parentId,
+ bookmark.position,
+ bookmark.type,
+ uri,
+ bookmark.guid,
+ bookmark.parentGuid,
+ aSource));
+
+ if (bookmark.type == TYPE_BOOKMARK && bookmark.grandParentId == mTagsRoot &&
+ uri) {
+ // If the removed bookmark was child of a tag container, notify a tags
+ // change to all bookmarks for this URI.
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(uri, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ DontSkip,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("tags"),
+ false,
+ EmptyCString(),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::CreateFolder(int64_t aParent, const nsACString& aName,
+ int32_t aIndex, const nsACString& aGUID,
+ uint16_t aSource, int64_t* aNewFolder)
+{
+ // NOTE: aParent can be null for root creation, so not checked
+ NS_ENSURE_ARG_POINTER(aNewFolder);
+
+ if (!aGUID.IsEmpty() && !IsValidGUID(aGUID))
+ return NS_ERROR_INVALID_ARG;
+
+ // CreateContainerWithID returns the index of the new folder, but that's not
+ // used here. To avoid any risk of corrupting data should this function
+ // be changed, we'll use a local variable to hold it. The true argument
+ // will cause notifications to be sent to bookmark observers.
+ int32_t localIndex = aIndex;
+ nsresult rv = CreateContainerWithID(-1, aParent, aName, true, &localIndex,
+ aGUID, aSource, aNewFolder);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+bool nsNavBookmarks::IsLivemark(int64_t aFolderId)
+{
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ NS_ENSURE_TRUE(annosvc, false);
+ bool isLivemark;
+ nsresult rv = annosvc->ItemHasAnnotation(aFolderId,
+ FEED_URI_ANNO,
+ &isLivemark);
+ NS_ENSURE_SUCCESS(rv, false);
+ return isLivemark;
+}
+
+nsresult
+nsNavBookmarks::CreateContainerWithID(int64_t aItemId,
+ int64_t aParent,
+ const nsACString& aTitle,
+ bool aIsBookmarkFolder,
+ int32_t* aIndex,
+ const nsACString& aGUID,
+ uint16_t aSource,
+ int64_t* aNewFolder)
+{
+ NS_ENSURE_ARG_MIN(*aIndex, nsINavBookmarksService::DEFAULT_INDEX);
+
+ // Get the correct index for insertion. This also ensures the parent exists.
+ int32_t index, folderCount;
+ int64_t grandParentId;
+ nsAutoCString folderGuid;
+ nsresult rv = FetchFolderInfo(aParent, &folderCount, folderGuid, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ if (*aIndex == nsINavBookmarksService::DEFAULT_INDEX ||
+ *aIndex >= folderCount) {
+ index = folderCount;
+ } else {
+ index = *aIndex;
+ // Create space for the insertion.
+ rv = AdjustIndices(aParent, index, INT32_MAX, 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ *aNewFolder = aItemId;
+ PRTime dateAdded = RoundedPRNow();
+ nsAutoCString guid(aGUID);
+ nsCString title;
+ TruncateTitle(aTitle, title);
+
+ rv = InsertBookmarkInDB(-1, FOLDER, aParent, index,
+ title, dateAdded, 0, folderGuid, grandParentId,
+ nullptr, aSource, aNewFolder, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ SKIP_TAGS(aParent == mTagsRoot),
+ OnItemAdded(*aNewFolder, aParent, index, FOLDER,
+ nullptr, title, dateAdded, guid,
+ folderGuid, aSource));
+
+ *aIndex = index;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::InsertSeparator(int64_t aParent,
+ int32_t aIndex,
+ const nsACString& aGUID,
+ uint16_t aSource,
+ int64_t* aNewItemId)
+{
+ NS_ENSURE_ARG_MIN(aParent, 1);
+ NS_ENSURE_ARG_MIN(aIndex, nsINavBookmarksService::DEFAULT_INDEX);
+ NS_ENSURE_ARG_POINTER(aNewItemId);
+
+ if (!aGUID.IsEmpty() && !IsValidGUID(aGUID))
+ return NS_ERROR_INVALID_ARG;
+
+ // Get the correct index for insertion. This also ensures the parent exists.
+ int32_t index, folderCount;
+ int64_t grandParentId;
+ nsAutoCString folderGuid;
+ nsresult rv = FetchFolderInfo(aParent, &folderCount, folderGuid, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ if (aIndex == nsINavBookmarksService::DEFAULT_INDEX ||
+ aIndex >= folderCount) {
+ index = folderCount;
+ }
+ else {
+ index = aIndex;
+ // Create space for the insertion.
+ rv = AdjustIndices(aParent, index, INT32_MAX, 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ *aNewItemId = -1;
+ // Set a NULL title rather than an empty string.
+ nsAutoCString guid(aGUID);
+ PRTime dateAdded = RoundedPRNow();
+ rv = InsertBookmarkInDB(-1, SEPARATOR, aParent, index, NullCString(), dateAdded,
+ 0, folderGuid, grandParentId, nullptr, aSource,
+ aNewItemId, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ DontSkip,
+ OnItemAdded(*aNewItemId, aParent, index, TYPE_SEPARATOR,
+ nullptr, NullCString(), dateAdded, guid,
+ folderGuid, aSource));
+
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::GetLastChildId(int64_t aFolderId, int64_t* aItemId)
+{
+ NS_ASSERTION(aFolderId > 0, "Invalid folder id");
+ *aItemId = -1;
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT id FROM moz_bookmarks WHERE parent = :parent "
+ "ORDER BY position DESC LIMIT 1"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool found;
+ rv = stmt->ExecuteStep(&found);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (found) {
+ rv = stmt->GetInt64(0, aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetIdForItemAt(int64_t aFolder,
+ int32_t aIndex,
+ int64_t* aItemId)
+{
+ NS_ENSURE_ARG_MIN(aFolder, 1);
+ NS_ENSURE_ARG_POINTER(aItemId);
+
+ *aItemId = -1;
+
+ nsresult rv;
+ if (aIndex == nsINavBookmarksService::DEFAULT_INDEX) {
+ // Get last item within aFolder.
+ rv = GetLastChildId(aFolder, aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ // Get the item in aFolder with position aIndex.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT id, fk, type FROM moz_bookmarks "
+ "WHERE parent = :parent AND position = :item_index"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolder);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_index"), aIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool found;
+ rv = stmt->ExecuteStep(&found);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (found) {
+ rv = stmt->GetInt64(0, aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+ return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS(nsNavBookmarks::RemoveFolderTransaction, nsITransaction)
+
+NS_IMETHODIMP
+nsNavBookmarks::GetRemoveFolderTransaction(int64_t aFolderId, uint16_t aSource,
+ nsITransaction** aResult)
+{
+ NS_ENSURE_ARG_MIN(aFolderId, 1);
+ NS_ENSURE_ARG_POINTER(aResult);
+
+ // Create and initialize a RemoveFolderTransaction object that can be used to
+ // recreate the folder safely later.
+
+ RemoveFolderTransaction* rft =
+ new RemoveFolderTransaction(aFolderId, aSource);
+ if (!rft)
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ NS_ADDREF(*aResult = rft);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::GetDescendantFolders(int64_t aFolderId,
+ nsTArray<int64_t>& aDescendantFoldersArray) {
+ nsresult rv;
+ // New descendant folders will be added from this index on.
+ uint32_t startIndex = aDescendantFoldersArray.Length();
+ {
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT id "
+ "FROM moz_bookmarks "
+ "WHERE parent = :parent "
+ "AND type = :item_type "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_type"), TYPE_FOLDER);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ int64_t itemId;
+ rv = stmt->GetInt64(0, &itemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ aDescendantFoldersArray.AppendElement(itemId);
+ }
+ }
+
+ // Recursively call GetDescendantFolders for added folders.
+ // We start at startIndex since previous folders are checked
+ // by previous calls to this method.
+ uint32_t childCount = aDescendantFoldersArray.Length();
+ for (uint32_t i = startIndex; i < childCount; ++i) {
+ GetDescendantFolders(aDescendantFoldersArray[i], aDescendantFoldersArray);
+ }
+
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::GetDescendantChildren(int64_t aFolderId,
+ const nsACString& aFolderGuid,
+ int64_t aGrandParentId,
+ nsTArray<BookmarkData>& aFolderChildrenArray) {
+ // New children will be added from this index on.
+ uint32_t startIndex = aFolderChildrenArray.Length();
+ nsresult rv;
+ {
+ // Collect children informations.
+ // Select all children of a given folder, sorted by position.
+ // This is a LEFT JOIN because not all bookmarks types have a place.
+ // We construct a result where the first columns exactly match
+ // kGetInfoIndex_* order, and additionally contains columns for position,
+ // item_child, and folder_child from moz_bookmarks.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT h.id, h.url, IFNULL(b.title, h.title), h.rev_host, h.visit_count, "
+ "h.last_visit_date, f.url, b.id, b.dateAdded, b.lastModified, "
+ "b.parent, null, h.frecency, h.hidden, h.guid, null, null, null, "
+ "b.guid, b.position, b.type, b.fk "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_places h ON b.fk = h.id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE b.parent = :parent "
+ "ORDER BY b.position ASC"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ BookmarkData child;
+ rv = stmt->GetInt64(nsNavHistory::kGetInfoIndex_ItemId, &child.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ child.parentId = aFolderId;
+ child.grandParentId = aGrandParentId;
+ child.parentGuid = aFolderGuid;
+ rv = stmt->GetInt32(kGetChildrenIndex_Type, &child.type);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(kGetChildrenIndex_PlaceID, &child.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt32(kGetChildrenIndex_Position, &child.position);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(kGetChildrenIndex_Guid, child.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (child.type == TYPE_BOOKMARK) {
+ rv = stmt->GetUTF8String(nsNavHistory::kGetInfoIndex_URL, child.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Append item to children's array.
+ aFolderChildrenArray.AppendElement(child);
+ }
+ }
+
+ // Recursively call GetDescendantChildren for added folders.
+ // We start at startIndex since previous folders are checked
+ // by previous calls to this method.
+ uint32_t childCount = aFolderChildrenArray.Length();
+ for (uint32_t i = startIndex; i < childCount; ++i) {
+ if (aFolderChildrenArray[i].type == TYPE_FOLDER) {
+ // nsTarray assumes that all children can be memmove()d, thus we can't
+ // just pass aFolderChildrenArray[i].guid to a method that will change
+ // the array itself. Otherwise, since it's passed by reference, after a
+ // memmove() it could point to garbage and cause intermittent crashes.
+ nsCString guid = aFolderChildrenArray[i].guid;
+ GetDescendantChildren(aFolderChildrenArray[i].id,
+ guid,
+ aFolderId,
+ aFolderChildrenArray);
+ }
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::RemoveFolderChildren(int64_t aFolderId, uint16_t aSource)
+{
+ PROFILER_LABEL("nsNavBookmarks", "RemoveFolderChilder",
+ js::ProfileEntry::Category::OTHER);
+
+ NS_ENSURE_ARG_MIN(aFolderId, 1);
+ NS_ENSURE_ARG(aFolderId != mRoot);
+
+ BookmarkData folder;
+ nsresult rv = FetchItemInfo(aFolderId, folder);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_ARG(folder.type == TYPE_FOLDER);
+
+ // Fill folder children array recursively.
+ nsTArray<BookmarkData> folderChildrenArray;
+ rv = GetDescendantChildren(folder.id, folder.guid, folder.parentId,
+ folderChildrenArray);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Build a string of folders whose children will be removed.
+ nsCString foldersToRemove;
+ for (uint32_t i = 0; i < folderChildrenArray.Length(); ++i) {
+ BookmarkData& child = folderChildrenArray[i];
+
+ if (child.type == TYPE_FOLDER) {
+ foldersToRemove.Append(',');
+ foldersToRemove.AppendInt(child.id);
+ }
+ }
+
+ // Delete items from the database now.
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ nsCOMPtr<mozIStorageStatement> deleteStatement = mDB->GetStatement(
+ NS_LITERAL_CSTRING(
+ "DELETE FROM moz_bookmarks "
+ "WHERE parent IN (:parent") + foldersToRemove + NS_LITERAL_CSTRING(")")
+ );
+ NS_ENSURE_STATE(deleteStatement);
+ mozStorageStatementScoper deleteStatementScoper(deleteStatement);
+
+ rv = deleteStatement->BindInt64ByName(NS_LITERAL_CSTRING("parent"), folder.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = deleteStatement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clean up orphan items annotations.
+ rv = mDB->MainConn()->ExecuteSimpleSQL(
+ NS_LITERAL_CSTRING(
+ "DELETE FROM moz_items_annos "
+ "WHERE id IN ("
+ "SELECT a.id from moz_items_annos a "
+ "LEFT JOIN moz_bookmarks b ON a.item_id = b.id "
+ "WHERE b.id ISNULL)"));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Set the lastModified date.
+ rv = SetItemDateInternal(LAST_MODIFIED, folder.id, RoundedPRNow());
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Call observers in reverse order to serve children before their parent.
+ for (int32_t i = folderChildrenArray.Length() - 1; i >= 0; --i) {
+ BookmarkData& child = folderChildrenArray[i];
+
+ nsCOMPtr<nsIURI> uri;
+ if (child.type == TYPE_BOOKMARK) {
+ // If not a tag, recalculate frecency for this entry, since it changed.
+ if (child.grandParentId != mTagsRoot) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->UpdateFrecency(child.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ // A broken url should not interrupt the removal process.
+ (void)NS_NewURI(getter_AddRefs(uri), child.url);
+ // We cannot assert since some automated tests are checking this path.
+ NS_WARNING_ASSERTION(uri, "Invalid URI in RemoveFolderChildren");
+ }
+
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ ((child.grandParentId == mTagsRoot) ? SkipTags : SkipDescendants),
+ OnItemRemoved(child.id,
+ child.parentId,
+ child.position,
+ child.type,
+ uri,
+ child.guid,
+ child.parentGuid,
+ aSource));
+
+ if (child.type == TYPE_BOOKMARK && child.grandParentId == mTagsRoot &&
+ uri) {
+ // If the removed bookmark was a child of a tag container, notify all
+ // bookmark-folder result nodes which contain a bookmark for the removed
+ // bookmark's url.
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(uri, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ NOTIFY_BOOKMARKS_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ DontSkip,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("tags"),
+ false,
+ EmptyCString(),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::MoveItem(int64_t aItemId,
+ int64_t aNewParent,
+ int32_t aIndex,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG(!IsRoot(aItemId));
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_MIN(aNewParent, 1);
+ // -1 is append, but no other negative number is allowed.
+ NS_ENSURE_ARG_MIN(aIndex, -1);
+ // Disallow making an item its own parent.
+ NS_ENSURE_ARG(aItemId != aNewParent);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // if parent and index are the same, nothing to do
+ if (bookmark.parentId == aNewParent && bookmark.position == aIndex)
+ return NS_OK;
+
+ // Make sure aNewParent is not aFolder or a subfolder of aFolder.
+ // TODO: make this performant, maybe with a nested tree (bug 408991).
+ if (bookmark.type == TYPE_FOLDER) {
+ int64_t ancestorId = aNewParent;
+
+ while (ancestorId) {
+ if (ancestorId == bookmark.id) {
+ return NS_ERROR_INVALID_ARG;
+ }
+ rv = GetFolderIdForItem(ancestorId, &ancestorId);
+ if (NS_FAILED(rv)) {
+ break;
+ }
+ }
+ }
+
+ // calculate new index
+ int32_t newIndex, folderCount;
+ int64_t grandParentId;
+ nsAutoCString newParentGuid;
+ rv = FetchFolderInfo(aNewParent, &folderCount, newParentGuid, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aIndex == nsINavBookmarksService::DEFAULT_INDEX ||
+ aIndex >= folderCount) {
+ newIndex = folderCount;
+ // If the parent remains the same, then the folder is really being moved
+ // to count - 1 (since it's being removed from the old position)
+ if (bookmark.parentId == aNewParent) {
+ --newIndex;
+ }
+ } else {
+ newIndex = aIndex;
+
+ if (bookmark.parentId == aNewParent && newIndex > bookmark.position) {
+ // when an item is being moved lower in the same folder, the new index
+ // refers to the index before it was removed. Removal causes everything
+ // to shift up.
+ --newIndex;
+ }
+ }
+
+ // this is like the previous check, except this covers if
+ // the specified index was -1 (append), and the calculated
+ // new index is the same as the existing index
+ if (aNewParent == bookmark.parentId && newIndex == bookmark.position) {
+ // Nothing to do!
+ return NS_OK;
+ }
+
+ // adjust indices to account for the move
+ // do this before we update the parent/index fields
+ // or we'll re-adjust the index for the item we are moving
+ if (bookmark.parentId == aNewParent) {
+ // We can optimize the updates if moving within the same container.
+ // We only shift the items between the old and new positions, since the
+ // insertion will offset the deletion.
+ if (bookmark.position > newIndex) {
+ rv = AdjustIndices(bookmark.parentId, newIndex, bookmark.position - 1, 1);
+ }
+ else {
+ rv = AdjustIndices(bookmark.parentId, bookmark.position + 1, newIndex, -1);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ // We're moving between containers, so this happens in two steps.
+ // First, fill the hole from the removal from the old parent.
+ rv = AdjustIndices(bookmark.parentId, bookmark.position + 1, INT32_MAX, -1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // Now, make room in the new parent for the insertion.
+ rv = AdjustIndices(aNewParent, newIndex, INT32_MAX, 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ {
+ // Update parent and position.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET parent = :parent, position = :item_index "
+ "WHERE id = :item_id "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aNewParent);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_index"), newIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ PRTime now = RoundedPRNow();
+ rv = SetItemDateInternal(LAST_MODIFIED, bookmark.parentId, now);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = SetItemDateInternal(LAST_MODIFIED, aNewParent, now);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemMoved(bookmark.id,
+ bookmark.parentId,
+ bookmark.position,
+ aNewParent,
+ newIndex,
+ bookmark.type,
+ bookmark.guid,
+ bookmark.parentGuid,
+ newParentGuid,
+ aSource));
+ return NS_OK;
+}
+
+nsresult
+nsNavBookmarks::FetchItemInfo(int64_t aItemId,
+ BookmarkData& _bookmark)
+{
+ // LEFT JOIN since not all bookmarks have an associated place.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT b.id, h.url, b.title, b.position, b.fk, b.parent, b.type, "
+ "b.dateAdded, b.lastModified, b.guid, t.guid, t.parent "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_bookmarks t ON t.id = b.parent "
+ "LEFT JOIN moz_places h ON h.id = b.fk "
+ "WHERE b.id = :item_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasResult) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ _bookmark.id = aItemId;
+ rv = stmt->GetUTF8String(1, _bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool isNull;
+ rv = stmt->GetIsNull(2, &isNull);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (isNull) {
+ _bookmark.title.SetIsVoid(true);
+ }
+ else {
+ rv = stmt->GetUTF8String(2, _bookmark.title);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ rv = stmt->GetInt32(3, &_bookmark.position);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(4, &_bookmark.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(5, &_bookmark.parentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt32(6, &_bookmark.type);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(7, reinterpret_cast<int64_t*>(&_bookmark.dateAdded));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(8, reinterpret_cast<int64_t*>(&_bookmark.lastModified));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(9, _bookmark.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // Getting properties of the root would show no parent.
+ rv = stmt->GetIsNull(10, &isNull);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!isNull) {
+ rv = stmt->GetUTF8String(10, _bookmark.parentGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(11, &_bookmark.grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ _bookmark.grandParentId = -1;
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsNavBookmarks::SetItemDateInternal(enum BookmarkDate aDateType,
+ int64_t aItemId,
+ PRTime aValue)
+{
+ aValue = RoundToMilliseconds(aValue);
+
+ nsCOMPtr<mozIStorageStatement> stmt;
+ if (aDateType == DATE_ADDED) {
+ // lastModified is set to the same value as dateAdded. We do this for
+ // performance reasons, since it will allow us to use an index to sort items
+ // by date.
+ stmt = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET dateAdded = :date, lastModified = :date "
+ "WHERE id = :item_id"
+ );
+ }
+ else {
+ stmt = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET lastModified = :date WHERE id = :item_id"
+ );
+ }
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("date"), aValue);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // note, we are not notifying the observers
+ // that the item has changed.
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::SetItemDateAdded(int64_t aItemId, PRTime aDateAdded,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Round here so that we notify with the right value.
+ bookmark.dateAdded = RoundToMilliseconds(aDateAdded);
+
+ rv = SetItemDateInternal(DATE_ADDED, bookmark.id, bookmark.dateAdded);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Note: mDBSetItemDateAdded also sets lastModified to aDateAdded.
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmark.id,
+ NS_LITERAL_CSTRING("dateAdded"),
+ false,
+ nsPrintfCString("%lld", bookmark.dateAdded),
+ bookmark.dateAdded,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid,
+ bookmark.parentGuid,
+ EmptyCString(),
+ aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetItemDateAdded(int64_t aItemId, PRTime* _dateAdded)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_dateAdded);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *_dateAdded = bookmark.dateAdded;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::SetItemLastModified(int64_t aItemId, PRTime aLastModified,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Round here so that we notify with the right value.
+ bookmark.lastModified = RoundToMilliseconds(aLastModified);
+
+ rv = SetItemDateInternal(LAST_MODIFIED, bookmark.id, bookmark.lastModified);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Note: mDBSetItemDateAdded also sets lastModified to aDateAdded.
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmark.id,
+ NS_LITERAL_CSTRING("lastModified"),
+ false,
+ nsPrintfCString("%lld", bookmark.lastModified),
+ bookmark.lastModified,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid,
+ bookmark.parentGuid,
+ EmptyCString(),
+ aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetItemLastModified(int64_t aItemId, PRTime* _lastModified)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_lastModified);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *_lastModified = bookmark.lastModified;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::SetItemTitle(int64_t aItemId, const nsACString& aTitle,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET title = :item_title, lastModified = :date "
+ "WHERE id = :item_id "
+ );
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsCString title;
+ TruncateTitle(aTitle, title);
+
+ // Support setting a null title, we support this in insertBookmark.
+ if (title.IsVoid()) {
+ rv = statement->BindNullByName(NS_LITERAL_CSTRING("item_title"));
+ }
+ else {
+ rv = statement->BindUTF8StringByName(NS_LITERAL_CSTRING("item_title"),
+ title);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ bookmark.lastModified = RoundToMilliseconds(RoundedPRNow());
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("date"),
+ bookmark.lastModified);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmark.id,
+ NS_LITERAL_CSTRING("title"),
+ false,
+ title,
+ bookmark.lastModified,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid,
+ bookmark.parentGuid,
+ EmptyCString(),
+ aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetItemTitle(int64_t aItemId,
+ nsACString& _title)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ _title = bookmark.title;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetBookmarkURI(int64_t aItemId,
+ nsIURI** _URI)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_URI);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = NS_NewURI(_URI, bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetItemType(int64_t aItemId, uint16_t* _type)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_type);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *_type = static_cast<uint16_t>(bookmark.type);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::ResultNodeForContainer(int64_t aItemId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aNode)
+{
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (bookmark.type == TYPE_FOLDER) { // TYPE_FOLDER
+ *aNode = new nsNavHistoryFolderResultNode(bookmark.title,
+ aOptions,
+ bookmark.id);
+ }
+ else {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ (*aNode)->mDateAdded = bookmark.dateAdded;
+ (*aNode)->mLastModified = bookmark.lastModified;
+ (*aNode)->mBookmarkGuid = bookmark.guid;
+ (*aNode)->GetAsFolder()->mTargetFolderGuid = bookmark.guid;
+
+ NS_ADDREF(*aNode);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::QueryFolderChildren(
+ int64_t aFolderId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aChildren)
+{
+ NS_ENSURE_ARG_POINTER(aOptions);
+ NS_ENSURE_ARG_POINTER(aChildren);
+
+ // Select all children of a given folder, sorted by position.
+ // This is a LEFT JOIN because not all bookmarks types have a place.
+ // We construct a result where the first columns exactly match those returned
+ // by mDBGetURLPageInfo, and additionally contains columns for position,
+ // item_child, and folder_child from moz_bookmarks.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT h.id, h.url, IFNULL(b.title, h.title), h.rev_host, h.visit_count, "
+ "h.last_visit_date, f.url, b.id, b.dateAdded, b.lastModified, "
+ "b.parent, null, h.frecency, h.hidden, h.guid, null, null, null, "
+ "b.guid, b.position, b.type, b.fk "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_places h ON b.fk = h.id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE b.parent = :parent "
+ "ORDER BY b.position ASC"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStorageValueArray> row = do_QueryInterface(stmt, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ int32_t index = -1;
+ bool hasResult;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && hasResult) {
+ rv = ProcessFolderNodeRow(row, aOptions, aChildren, index);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::ProcessFolderNodeRow(
+ mozIStorageValueArray* aRow,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aChildren,
+ int32_t& aCurrentIndex)
+{
+ NS_ENSURE_ARG_POINTER(aRow);
+ NS_ENSURE_ARG_POINTER(aOptions);
+ NS_ENSURE_ARG_POINTER(aChildren);
+
+ // The results will be in order of aCurrentIndex. Even if we don't add a node
+ // because it was excluded, we need to count its index, so do that before
+ // doing anything else.
+ aCurrentIndex++;
+
+ int32_t itemType;
+ nsresult rv = aRow->GetInt32(kGetChildrenIndex_Type, &itemType);
+ NS_ENSURE_SUCCESS(rv, rv);
+ int64_t id;
+ rv = aRow->GetInt64(nsNavHistory::kGetInfoIndex_ItemId, &id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ RefPtr<nsNavHistoryResultNode> node;
+
+ if (itemType == TYPE_BOOKMARK) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->RowToResult(aRow, aOptions, getter_AddRefs(node));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint32_t nodeType;
+ node->GetType(&nodeType);
+ if ((nodeType == nsINavHistoryResultNode::RESULT_TYPE_QUERY &&
+ aOptions->ExcludeQueries()) ||
+ (nodeType != nsINavHistoryResultNode::RESULT_TYPE_QUERY &&
+ nodeType != nsINavHistoryResultNode::RESULT_TYPE_FOLDER_SHORTCUT &&
+ aOptions->ExcludeItems())) {
+ return NS_OK;
+ }
+ }
+ else if (itemType == TYPE_FOLDER) {
+ // ExcludeReadOnlyFolders currently means "ExcludeLivemarks" (to be fixed in
+ // bug 1072833)
+ if (aOptions->ExcludeReadOnlyFolders()) {
+ if (IsLivemark(id))
+ return NS_OK;
+ }
+
+ nsAutoCString title;
+ rv = aRow->GetUTF8String(nsNavHistory::kGetInfoIndex_Title, title);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ node = new nsNavHistoryFolderResultNode(title, aOptions, id);
+
+ rv = aRow->GetUTF8String(kGetChildrenIndex_Guid, node->mBookmarkGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ node->GetAsFolder()->mTargetFolderGuid = node->mBookmarkGuid;
+
+ rv = aRow->GetInt64(nsNavHistory::kGetInfoIndex_ItemDateAdded,
+ reinterpret_cast<int64_t*>(&node->mDateAdded));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aRow->GetInt64(nsNavHistory::kGetInfoIndex_ItemLastModified,
+ reinterpret_cast<int64_t*>(&node->mLastModified));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else {
+ // This is a separator.
+ if (aOptions->ExcludeItems()) {
+ return NS_OK;
+ }
+ node = new nsNavHistorySeparatorResultNode();
+
+ node->mItemId = id;
+ rv = aRow->GetUTF8String(kGetChildrenIndex_Guid, node->mBookmarkGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aRow->GetInt64(nsNavHistory::kGetInfoIndex_ItemDateAdded,
+ reinterpret_cast<int64_t*>(&node->mDateAdded));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = aRow->GetInt64(nsNavHistory::kGetInfoIndex_ItemLastModified,
+ reinterpret_cast<int64_t*>(&node->mLastModified));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Store the index of the node within this container. Note that this is not
+ // moz_bookmarks.position.
+ node->mBookmarkIndex = aCurrentIndex;
+
+ NS_ENSURE_TRUE(aChildren->AppendObject(node), NS_ERROR_OUT_OF_MEMORY);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::QueryFolderChildrenAsync(
+ nsNavHistoryFolderResultNode* aNode,
+ int64_t aFolderId,
+ mozIStoragePendingStatement** _pendingStmt)
+{
+ NS_ENSURE_ARG_POINTER(aNode);
+ NS_ENSURE_ARG_POINTER(_pendingStmt);
+
+ // Select all children of a given folder, sorted by position.
+ // This is a LEFT JOIN because not all bookmarks types have a place.
+ // We construct a result where the first columns exactly match those returned
+ // by mDBGetURLPageInfo, and additionally contains columns for position,
+ // item_child, and folder_child from moz_bookmarks.
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
+ "SELECT h.id, h.url, IFNULL(b.title, h.title), h.rev_host, h.visit_count, "
+ "h.last_visit_date, f.url, b.id, b.dateAdded, b.lastModified, "
+ "b.parent, null, h.frecency, h.hidden, h.guid, null, null, null, "
+ "b.guid, b.position, b.type, b.fk "
+ "FROM moz_bookmarks b "
+ "LEFT JOIN moz_places h ON b.fk = h.id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE b.parent = :parent "
+ "ORDER BY b.position ASC"
+ );
+ NS_ENSURE_STATE(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<mozIStoragePendingStatement> pendingStmt;
+ rv = stmt->ExecuteAsync(aNode, getter_AddRefs(pendingStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NS_IF_ADDREF(*_pendingStmt = pendingStmt);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::FetchFolderInfo(int64_t aFolderId,
+ int32_t* _folderCount,
+ nsACString& _guid,
+ int64_t* _parentId)
+{
+ *_folderCount = 0;
+ *_parentId = -1;
+
+ // This query has to always return results, so it can't be written as a join,
+ // though a left join of 2 subqueries would have the same cost.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT count(*), "
+ "(SELECT guid FROM moz_bookmarks WHERE id = :parent), "
+ "(SELECT parent FROM moz_bookmarks WHERE id = :parent) "
+ "FROM moz_bookmarks "
+ "WHERE parent = :parent"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("parent"), aFolderId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResult;
+ rv = stmt->ExecuteStep(&hasResult);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(hasResult, NS_ERROR_UNEXPECTED);
+
+ // Ensure that the folder we are looking for exists.
+ // Can't rely only on parent, since the root has parent 0, that doesn't exist.
+ bool isNull;
+ rv = stmt->GetIsNull(2, &isNull);
+ NS_ENSURE_TRUE(NS_SUCCEEDED(rv) && (!isNull || aFolderId == 0),
+ NS_ERROR_INVALID_ARG);
+
+ rv = stmt->GetInt32(0, _folderCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!isNull) {
+ rv = stmt->GetUTF8String(1, _guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(2, _parentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::IsBookmarked(nsIURI* aURI, bool* aBookmarked)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(aBookmarked);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT 1 FROM moz_bookmarks b "
+ "JOIN moz_places h ON b.fk = h.id "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->ExecuteStep(aBookmarked);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetBookmarkedURIFor(nsIURI* aURI, nsIURI** _retval)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ *_retval = nullptr;
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ int64_t placeId;
+ nsAutoCString placeGuid;
+ nsresult rv = history->GetIdForPage(aURI, &placeId, placeGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!placeId) {
+ // This URI is unknown, just return null.
+ return NS_OK;
+ }
+
+ // Check if a bookmark exists in the redirects chain for this URI.
+ // The query will also check if the page is directly bookmarked, and return
+ // the first found bookmark in case. The check is directly on moz_bookmarks
+ // without special filtering.
+ // The next query finds the bookmarked ancestors in a redirects chain.
+ // It won't go further than 3 levels of redirects (a->b->c->your_place_id).
+ // To make this path 100% correct (up to any level) we would need either:
+ // - A separate hash, build through recursive querying of the database.
+ // This solution was previously implemented, but it had a negative effect
+ // on startup since at each startup we have to recursively query the
+ // database to rebuild a hash that is always the same across sessions.
+ // It must be updated at each visit and bookmarks change too. The code to
+ // manage it is complex and prone to errors, sometimes causing incorrect
+ // data fetches (for example wrong favicon for a redirected bookmark).
+ // - A better way to track redirects for a visit.
+ // We would need a separate table to track redirects, in the table we would
+ // have visit_id, redirect_session. To get all sources for
+ // a visit then we could just join this table and get all visit_id that
+ // are in the same redirect_session as our visit. This has the drawback
+ // that we can't ensure data integrity in the downgrade -> upgrade path,
+ // since an old version would not update the table on new visits.
+ //
+ // For most cases these levels of redirects should be fine though, it's hard
+ // to hit a page that is 4 or 5 levels of redirects below a bookmarked page.
+ //
+ // As a bonus the query also checks first if place_id is already a bookmark,
+ // so you don't have to check that apart.
+
+ nsCString query = nsPrintfCString(
+ "SELECT url FROM moz_places WHERE id = ( "
+ "SELECT :page_id FROM moz_bookmarks WHERE fk = :page_id "
+ "UNION ALL "
+ "SELECT COALESCE(grandparent.place_id, parent.place_id) AS r_place_id "
+ "FROM moz_historyvisits dest "
+ "LEFT JOIN moz_historyvisits parent ON parent.id = dest.from_visit "
+ "AND dest.visit_type IN (%d, %d) "
+ "LEFT JOIN moz_historyvisits grandparent ON parent.from_visit = grandparent.id "
+ "AND parent.visit_type IN (%d, %d) "
+ "WHERE dest.place_id = :page_id "
+ "AND EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = r_place_id) "
+ "LIMIT 1 "
+ ")",
+ nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT,
+ nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY,
+ nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT,
+ nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY
+ );
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(query);
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool hasBookmarkedOrigin;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasBookmarkedOrigin)) &&
+ hasBookmarkedOrigin) {
+ nsAutoCString spec;
+ rv = stmt->GetUTF8String(0, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = NS_NewURI(_retval, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // If there is no bookmarked origin, we will just return null.
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::ChangeBookmarkURI(int64_t aBookmarkId, nsIURI* aNewURI,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aBookmarkId, 1);
+ NS_ENSURE_ARG(aNewURI);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aBookmarkId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_ARG(bookmark.type == TYPE_BOOKMARK);
+
+ mozStorageTransaction transaction(mDB->MainConn(), false);
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ int64_t newPlaceId;
+ nsAutoCString newPlaceGuid;
+ rv = history->GetOrCreateIdForPage(aNewURI, &newPlaceId, newPlaceGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!newPlaceId)
+ return NS_ERROR_INVALID_ARG;
+
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET fk = :page_id, lastModified = :date "
+ "WHERE id = :item_id "
+ );
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("page_id"), newPlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bookmark.lastModified = RoundedPRNow();
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("date"),
+ bookmark.lastModified);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = statement->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = transaction.Commit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = history->UpdateFrecency(newPlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Upon changing the URI for a bookmark, update the frecency for the old
+ // place as well.
+ rv = history->UpdateFrecency(bookmark.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString spec;
+ rv = aNewURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmark.id,
+ NS_LITERAL_CSTRING("uri"),
+ false,
+ spec,
+ bookmark.lastModified,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid,
+ bookmark.parentGuid,
+ bookmark.url,
+ aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetFolderIdForItem(int64_t aItemId, int64_t* _parentId)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_parentId);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // this should not happen, but see bug #400448 for details
+ NS_ENSURE_TRUE(bookmark.id != bookmark.parentId, NS_ERROR_UNEXPECTED);
+
+ *_parentId = bookmark.parentId;
+ return NS_OK;
+}
+
+
+nsresult
+nsNavBookmarks::GetBookmarkIdsForURITArray(nsIURI* aURI,
+ nsTArray<int64_t>& aResult,
+ bool aSkipTags)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // Double ordering covers possible lastModified ties, that could happen when
+ // importing, syncing or due to extensions.
+ // Note: not using a JOIN is cheaper in this case.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "/* do not warn (bug 1175249) */ "
+ "SELECT b.id, b.guid, b.parent, b.lastModified, t.guid, t.parent "
+ "FROM moz_bookmarks b "
+ "JOIN moz_bookmarks t on t.id = b.parent "
+ "WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url) "
+ "ORDER BY b.lastModified DESC, b.id DESC "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool more;
+ while (NS_SUCCEEDED((rv = stmt->ExecuteStep(&more))) && more) {
+ if (aSkipTags) {
+ // Skip tags, for the use-cases of this async getter they are useless.
+ int64_t grandParentId;
+ nsresult rv = stmt->GetInt64(5, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (grandParentId == mTagsRoot) {
+ continue;
+ }
+ }
+ int64_t bookmarkId;
+ rv = stmt->GetInt64(0, &bookmarkId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(aResult.AppendElement(bookmarkId), NS_ERROR_OUT_OF_MEMORY);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult
+nsNavBookmarks::GetBookmarksForURI(nsIURI* aURI,
+ nsTArray<BookmarkData>& aBookmarks)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // Double ordering covers possible lastModified ties, that could happen when
+ // importing, syncing or due to extensions.
+ // Note: not using a JOIN is cheaper in this case.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "/* do not warn (bug 1175249) */ "
+ "SELECT b.id, b.guid, b.parent, b.lastModified, t.guid, t.parent "
+ "FROM moz_bookmarks b "
+ "JOIN moz_bookmarks t on t.id = b.parent "
+ "WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url) "
+ "ORDER BY b.lastModified DESC, b.id DESC "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool more;
+ nsAutoString tags;
+ while (NS_SUCCEEDED((rv = stmt->ExecuteStep(&more))) && more) {
+ // Skip tags.
+ int64_t grandParentId;
+ nsresult rv = stmt->GetInt64(5, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (grandParentId == mTagsRoot) {
+ continue;
+ }
+
+ BookmarkData bookmark;
+ bookmark.grandParentId = grandParentId;
+ rv = stmt->GetInt64(0, &bookmark.id);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(1, bookmark.guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(2, &bookmark.parentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetInt64(3, reinterpret_cast<int64_t*>(&bookmark.lastModified));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(4, bookmark.parentGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NS_ENSURE_TRUE(aBookmarks.AppendElement(bookmark), NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavBookmarks::GetBookmarkIdsForURI(nsIURI* aURI, uint32_t* aCount,
+ int64_t** aBookmarks)
+{
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(aCount);
+ NS_ENSURE_ARG_POINTER(aBookmarks);
+
+ *aCount = 0;
+ *aBookmarks = nullptr;
+ nsTArray<int64_t> bookmarks;
+
+ // Get the information from the DB as a TArray
+ // TODO (bug 653816): make this API skip tags by default.
+ nsresult rv = GetBookmarkIdsForURITArray(aURI, bookmarks, false);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Copy the results into a new array for output
+ if (bookmarks.Length()) {
+ *aBookmarks =
+ static_cast<int64_t*>(moz_xmalloc(sizeof(int64_t) * bookmarks.Length()));
+ if (!*aBookmarks)
+ return NS_ERROR_OUT_OF_MEMORY;
+ for (uint32_t i = 0; i < bookmarks.Length(); i ++)
+ (*aBookmarks)[i] = bookmarks[i];
+ }
+
+ *aCount = bookmarks.Length();
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetItemIndex(int64_t aItemId, int32_t* _index)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_POINTER(_index);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ // With respect to the API.
+ if (NS_FAILED(rv)) {
+ *_index = -1;
+ return NS_OK;
+ }
+
+ *_index = bookmark.position;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavBookmarks::SetItemIndex(int64_t aItemId,
+ int32_t aNewIndex,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aItemId, 1);
+ NS_ENSURE_ARG_MIN(aNewIndex, 0);
+
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Ensure we are not going out of range.
+ int32_t folderCount;
+ int64_t grandParentId;
+ nsAutoCString folderGuid;
+ rv = FetchFolderInfo(bookmark.parentId, &folderCount, folderGuid, &grandParentId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(aNewIndex < folderCount, NS_ERROR_INVALID_ARG);
+ // Check the parent's guid is the expected one.
+ MOZ_ASSERT(bookmark.parentGuid == folderGuid);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "UPDATE moz_bookmarks SET position = :item_index WHERE id = :item_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"), aItemId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("item_index"), aNewIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemMoved(bookmark.id,
+ bookmark.parentId,
+ bookmark.position,
+ bookmark.parentId,
+ aNewIndex,
+ bookmark.type,
+ bookmark.guid,
+ bookmark.parentGuid,
+ bookmark.parentGuid,
+ aSource));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::SetKeywordForBookmark(int64_t aBookmarkId,
+ const nsAString& aUserCasedKeyword,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG_MIN(aBookmarkId, 1);
+
+ // This also ensures the bookmark is valid.
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aBookmarkId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewURI(getter_AddRefs(uri), bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Shortcuts are always lowercased internally.
+ nsAutoString keyword(aUserCasedKeyword);
+ ToLowerCase(keyword);
+
+ // The same URI can be associated to more than one keyword, provided the post
+ // data differs. Check if there are already keywords associated to this uri.
+ nsTArray<nsString> oldKeywords;
+ {
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT keyword FROM moz_keywords WHERE place_id = :place_id"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("place_id"), bookmark.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ nsString oldKeyword;
+ rv = stmt->GetString(0, oldKeyword);
+ NS_ENSURE_SUCCESS(rv, rv);
+ oldKeywords.AppendElement(oldKeyword);
+ }
+ }
+
+ // Trying to remove a non-existent keyword is a no-op.
+ if (keyword.IsEmpty() && oldKeywords.Length() == 0) {
+ return NS_OK;
+ }
+
+ if (keyword.IsEmpty()) {
+ // We are removing the existing keywords.
+ for (uint32_t i = 0; i < oldKeywords.Length(); ++i) {
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "DELETE FROM moz_keywords WHERE keyword = :old_keyword"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("old_keyword"),
+ oldKeywords[i]);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(uri, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("keyword"),
+ false,
+ EmptyCString(),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+
+ return NS_OK;
+ }
+
+ // A keyword can only be associated to a single URI. Check if the requested
+ // keyword was already associated, in such a case we will need to notify about
+ // the change.
+ nsCOMPtr<nsIURI> oldUri;
+ {
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT url "
+ "FROM moz_keywords "
+ "JOIN moz_places h ON h.id = place_id "
+ "WHERE keyword = :keyword"
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ nsAutoCString spec;
+ rv = stmt->GetUTF8String(0, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = NS_NewURI(getter_AddRefs(oldUri), spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ // If another uri is using the new keyword, we must update the keyword entry.
+ // Note we cannot use INSERT OR REPLACE cause it wouldn't invoke the delete
+ // trigger.
+ nsCOMPtr<mozIStorageStatement> stmt;
+ if (oldUri) {
+ // In both cases, notify about the change.
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(oldUri, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("keyword"),
+ false,
+ EmptyCString(),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+
+ stmt = mDB->GetStatement(
+ "UPDATE moz_keywords SET place_id = :place_id WHERE keyword = :keyword"
+ );
+ NS_ENSURE_STATE(stmt);
+ }
+ else {
+ stmt = mDB->GetStatement(
+ "INSERT INTO moz_keywords (keyword, place_id) "
+ "VALUES (:keyword, :place_id)"
+ );
+ }
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("place_id"), bookmark.placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // In both cases, notify about the change.
+ nsTArray<BookmarkData> bookmarks;
+ rv = GetBookmarksForURI(uri, bookmarks);
+ NS_ENSURE_SUCCESS(rv, rv);
+ for (uint32_t i = 0; i < bookmarks.Length(); ++i) {
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmarks[i].id,
+ NS_LITERAL_CSTRING("keyword"),
+ false,
+ NS_ConvertUTF16toUTF8(keyword),
+ bookmarks[i].lastModified,
+ TYPE_BOOKMARK,
+ bookmarks[i].parentId,
+ bookmarks[i].guid,
+ bookmarks[i].parentGuid,
+ EmptyCString(),
+ aSource));
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetKeywordForBookmark(int64_t aBookmarkId, nsAString& aKeyword)
+{
+ NS_ENSURE_ARG_MIN(aBookmarkId, 1);
+ aKeyword.Truncate(0);
+
+ // We can have multiple keywords for the same uri, here we'll just return the
+ // last created one.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT k.keyword "
+ "FROM moz_bookmarks b "
+ "JOIN moz_keywords k ON k.place_id = b.fk "
+ "WHERE b.id = :item_id "
+ "ORDER BY k.ROWID DESC "
+ "LIMIT 1"
+ ));
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"),
+ aBookmarkId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ nsAutoString keyword;
+ rv = stmt->GetString(0, keyword);
+ NS_ENSURE_SUCCESS(rv, rv);
+ aKeyword = keyword;
+ return NS_OK;
+ }
+
+ aKeyword.SetIsVoid(true);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::GetURIForKeyword(const nsAString& aUserCasedKeyword,
+ nsIURI** aURI)
+{
+ NS_ENSURE_ARG_POINTER(aURI);
+ NS_ENSURE_TRUE(!aUserCasedKeyword.IsEmpty(), NS_ERROR_INVALID_ARG);
+ *aURI = nullptr;
+
+ PLACES_WARN_DEPRECATED();
+
+ // Shortcuts are always lowercased internally.
+ nsAutoString keyword(aUserCasedKeyword);
+ ToLowerCase(keyword);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT h.url "
+ "FROM moz_places h "
+ "JOIN moz_keywords k ON k.place_id = h.id "
+ "WHERE k.keyword = :keyword"
+ ));
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindStringByName(NS_LITERAL_CSTRING("keyword"), keyword);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ nsAutoCString spec;
+ rv = stmt->GetUTF8String(0, spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewURI(getter_AddRefs(uri), spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ uri.forget(aURI);
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::RunInBatchMode(nsINavHistoryBatchCallback* aCallback,
+ nsISupports* aUserData) {
+ PROFILER_LABEL("nsNavBookmarks", "RunInBatchMode",
+ js::ProfileEntry::Category::OTHER);
+
+ NS_ENSURE_ARG(aCallback);
+
+ mBatching = true;
+
+ // Just forward the request to history. History service must exist for
+ // bookmarks to work and we are observing it, thus batch notifications will be
+ // forwarded to bookmarks observers.
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ nsresult rv = history->RunInBatchMode(aCallback, aUserData);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::AddObserver(nsINavBookmarkObserver* aObserver,
+ bool aOwnsWeak)
+{
+ NS_ENSURE_ARG(aObserver);
+
+ if (NS_WARN_IF(!mCanNotify))
+ return NS_ERROR_UNEXPECTED;
+
+ return mObservers.AppendWeakElement(aObserver, aOwnsWeak);
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::RemoveObserver(nsINavBookmarkObserver* aObserver)
+{
+ return mObservers.RemoveWeakElement(aObserver);
+}
+
+NS_IMETHODIMP
+nsNavBookmarks::GetObservers(uint32_t* _count,
+ nsINavBookmarkObserver*** _observers)
+{
+ NS_ENSURE_ARG_POINTER(_count);
+ NS_ENSURE_ARG_POINTER(_observers);
+
+ *_count = 0;
+ *_observers = nullptr;
+
+ if (!mCanNotify)
+ return NS_OK;
+
+ nsCOMArray<nsINavBookmarkObserver> observers;
+
+ // First add the category cache observers.
+ mCacheObservers.GetEntries(observers);
+
+ // Then add the other observers.
+ for (uint32_t i = 0; i < mObservers.Length(); ++i) {
+ const nsCOMPtr<nsINavBookmarkObserver> &observer = mObservers.ElementAt(i).GetValue();
+ // Skip nullified weak observers.
+ if (observer)
+ observers.AppendElement(observer);
+ }
+
+ if (observers.Count() == 0)
+ return NS_OK;
+
+ *_count = observers.Count();
+ observers.Forget(_observers);
+
+ return NS_OK;
+}
+
+void
+nsNavBookmarks::NotifyItemVisited(const ItemVisitData& aData)
+{
+ nsCOMPtr<nsIURI> uri;
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(uri), aData.bookmark.url));
+ // Notify the visit only if we have a valid uri, otherwise the observer
+ // couldn't gather enough data from the notification.
+ // This should be false only if there's a bug in the code preceding us.
+ if (uri) {
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemVisited(aData.bookmark.id,
+ aData.visitId,
+ aData.time,
+ aData.transitionType,
+ uri,
+ aData.bookmark.parentId,
+ aData.bookmark.guid,
+ aData.bookmark.parentGuid));
+ }
+}
+
+void
+nsNavBookmarks::NotifyItemChanged(const ItemChangeData& aData)
+{
+ // A guid must always be defined.
+ MOZ_ASSERT(!aData.bookmark.guid.IsEmpty());
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(aData.bookmark.id,
+ aData.property,
+ aData.isAnnotation,
+ aData.newValue,
+ aData.bookmark.lastModified,
+ aData.bookmark.type,
+ aData.bookmark.parentId,
+ aData.bookmark.guid,
+ aData.bookmark.parentGuid,
+ aData.oldValue,
+ // We specify the default source here because
+ // this method is only called for history
+ // visits, and we don't track sources in
+ // history.
+ SOURCE_DEFAULT));
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+nsNavBookmarks::Observe(nsISupports *aSubject, const char *aTopic,
+ const char16_t *aData)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ if (strcmp(aTopic, TOPIC_PLACES_SHUTDOWN) == 0) {
+ // Stop Observing annotations.
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ if (annosvc) {
+ annosvc->RemoveObserver(this);
+ }
+ }
+ else if (strcmp(aTopic, TOPIC_PLACES_CONNECTION_CLOSED) == 0) {
+ // Don't even try to notify observers from this point on, the category
+ // cache would init services that could try to use our APIs.
+ mCanNotify = false;
+ mObservers.Clear();
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsINavHistoryObserver
+
+NS_IMETHODIMP
+nsNavBookmarks::OnBeginUpdateBatch()
+{
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver, OnBeginUpdateBatch());
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnEndUpdateBatch()
+{
+ if (mBatching) {
+ mBatching = false;
+ }
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver, OnEndUpdateBatch());
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnVisit(nsIURI* aURI, int64_t aVisitId, PRTime aTime,
+ int64_t aSessionID, int64_t aReferringID,
+ uint32_t aTransitionType, const nsACString& aGUID,
+ bool aHidden, uint32_t aVisitCount, uint32_t aTyped)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // If the page is bookmarked, notify observers for each associated bookmark.
+ ItemVisitData visitData;
+ nsresult rv = aURI->GetSpec(visitData.bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ visitData.visitId = aVisitId;
+ visitData.time = aTime;
+ visitData.transitionType = aTransitionType;
+
+ RefPtr< AsyncGetBookmarksForURI<ItemVisitMethod, ItemVisitData> > notifier =
+ new AsyncGetBookmarksForURI<ItemVisitMethod, ItemVisitData>(this, &nsNavBookmarks::NotifyItemVisited, visitData);
+ notifier->Init();
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnDeleteURI(nsIURI* aURI,
+ const nsACString& aGUID,
+ uint16_t aReason)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnClearHistory()
+{
+ // TODO(bryner): we should notify on visited-time change for all URIs
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnTitleChanged(nsIURI* aURI,
+ const nsAString& aPageTitle,
+ const nsACString& aGUID)
+{
+ // NOOP. We don't consume page titles from moz_places anymore.
+ // Title-change notifications are sent from SetItemTitle.
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnFrecencyChanged(nsIURI* aURI,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnManyFrecenciesChanged()
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnPageChanged(nsIURI* aURI,
+ uint32_t aChangedAttribute,
+ const nsAString& aNewValue,
+ const nsACString& aGUID)
+{
+ NS_ENSURE_ARG(aURI);
+
+ nsresult rv;
+ if (aChangedAttribute == nsINavHistoryObserver::ATTRIBUTE_FAVICON) {
+ ItemChangeData changeData;
+ rv = aURI->GetSpec(changeData.bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ changeData.property = NS_LITERAL_CSTRING("favicon");
+ changeData.isAnnotation = false;
+ changeData.newValue = NS_ConvertUTF16toUTF8(aNewValue);
+ changeData.bookmark.lastModified = 0;
+ changeData.bookmark.type = TYPE_BOOKMARK;
+
+ // Favicons may be set to either pure URIs or to folder URIs
+ bool isPlaceURI;
+ rv = aURI->SchemeIs("place", &isPlaceURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (isPlaceURI) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ nsCOMArray<nsNavHistoryQuery> queries;
+ nsCOMPtr<nsNavHistoryQueryOptions> options;
+ rv = history->QueryStringToQueryArray(changeData.bookmark.url,
+ &queries, getter_AddRefs(options));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (queries.Count() == 1 && queries[0]->Folders().Length() == 1) {
+ // Fetch missing data.
+ rv = FetchItemInfo(queries[0]->Folders()[0], changeData.bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NotifyItemChanged(changeData);
+ }
+ }
+ else {
+ RefPtr< AsyncGetBookmarksForURI<ItemChangeMethod, ItemChangeData> > notifier =
+ new AsyncGetBookmarksForURI<ItemChangeMethod, ItemChangeData>(this, &nsNavBookmarks::NotifyItemChanged, changeData);
+ notifier->Init();
+ }
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnDeleteVisits(nsIURI* aURI, PRTime aVisitTime,
+ const nsACString& aGUID,
+ uint16_t aReason, uint32_t aTransitionType)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // Notify "cleartime" only if all visits to the page have been removed.
+ if (!aVisitTime) {
+ // If the page is bookmarked, notify observers for each associated bookmark.
+ ItemChangeData changeData;
+ nsresult rv = aURI->GetSpec(changeData.bookmark.url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ changeData.property = NS_LITERAL_CSTRING("cleartime");
+ changeData.isAnnotation = false;
+ changeData.bookmark.lastModified = 0;
+ changeData.bookmark.type = TYPE_BOOKMARK;
+
+ RefPtr< AsyncGetBookmarksForURI<ItemChangeMethod, ItemChangeData> > notifier =
+ new AsyncGetBookmarksForURI<ItemChangeMethod, ItemChangeData>(this, &nsNavBookmarks::NotifyItemChanged, changeData);
+ notifier->Init();
+ }
+ return NS_OK;
+}
+
+
+// nsIAnnotationObserver
+
+NS_IMETHODIMP
+nsNavBookmarks::OnPageAnnotationSet(nsIURI* aPage, const nsACString& aName)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnItemAnnotationSet(int64_t aItemId, const nsACString& aName,
+ uint16_t aSource)
+{
+ BookmarkData bookmark;
+ nsresult rv = FetchItemInfo(aItemId, bookmark);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bookmark.lastModified = RoundedPRNow();
+ rv = SetItemDateInternal(LAST_MODIFIED, bookmark.id, bookmark.lastModified);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavBookmarkObserver,
+ OnItemChanged(bookmark.id,
+ aName,
+ true,
+ EmptyCString(),
+ bookmark.lastModified,
+ bookmark.type,
+ bookmark.parentId,
+ bookmark.guid,
+ bookmark.parentGuid,
+ EmptyCString(),
+ aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnPageAnnotationRemoved(nsIURI* aPage, const nsACString& aName)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavBookmarks::OnItemAnnotationRemoved(int64_t aItemId, const nsACString& aName,
+ uint16_t aSource)
+{
+ // As of now this is doing the same as OnItemAnnotationSet, so just forward
+ // the call.
+ nsresult rv = OnItemAnnotationSet(aItemId, aName, aSource);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
diff --git a/toolkit/components/places/nsNavBookmarks.h b/toolkit/components/places/nsNavBookmarks.h
new file mode 100644
index 000000000..d5cc3b5b7
--- /dev/null
+++ b/toolkit/components/places/nsNavBookmarks.h
@@ -0,0 +1,445 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsNavBookmarks_h_
+#define nsNavBookmarks_h_
+
+#include "nsINavBookmarksService.h"
+#include "nsIAnnotationService.h"
+#include "nsITransaction.h"
+#include "nsNavHistory.h"
+#include "nsToolkitCompsCID.h"
+#include "nsCategoryCache.h"
+#include "nsTHashtable.h"
+#include "nsWeakReference.h"
+#include "mozilla/Attributes.h"
+#include "prtime.h"
+
+class nsNavBookmarks;
+
+namespace mozilla {
+namespace places {
+
+ enum BookmarkStatementId {
+ DB_FIND_REDIRECTED_BOOKMARK = 0
+ , DB_GET_BOOKMARKS_FOR_URI
+ };
+
+ struct BookmarkData {
+ int64_t id;
+ nsCString url;
+ nsCString title;
+ int32_t position;
+ int64_t placeId;
+ int64_t parentId;
+ int64_t grandParentId;
+ int32_t type;
+ nsCString serviceCID;
+ PRTime dateAdded;
+ PRTime lastModified;
+ nsCString guid;
+ nsCString parentGuid;
+ };
+
+ struct ItemVisitData {
+ BookmarkData bookmark;
+ int64_t visitId;
+ uint32_t transitionType;
+ PRTime time;
+ };
+
+ struct ItemChangeData {
+ BookmarkData bookmark;
+ nsCString property;
+ bool isAnnotation;
+ nsCString newValue;
+ nsCString oldValue;
+ };
+
+ typedef void (nsNavBookmarks::*ItemVisitMethod)(const ItemVisitData&);
+ typedef void (nsNavBookmarks::*ItemChangeMethod)(const ItemChangeData&);
+
+ enum BookmarkDate {
+ DATE_ADDED = 0
+ , LAST_MODIFIED
+ };
+
+} // namespace places
+} // namespace mozilla
+
+class nsNavBookmarks final : public nsINavBookmarksService
+ , public nsINavHistoryObserver
+ , public nsIAnnotationObserver
+ , public nsIObserver
+ , public nsSupportsWeakReference
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSINAVBOOKMARKSSERVICE
+ NS_DECL_NSINAVHISTORYOBSERVER
+ NS_DECL_NSIANNOTATIONOBSERVER
+ NS_DECL_NSIOBSERVER
+
+ nsNavBookmarks();
+
+ /**
+ * Obtains the service's object.
+ */
+ static already_AddRefed<nsNavBookmarks> GetSingleton();
+
+ /**
+ * Initializes the service's object. This should only be called once.
+ */
+ nsresult Init();
+
+ static nsNavBookmarks* GetBookmarksService() {
+ if (!gBookmarksService) {
+ nsCOMPtr<nsINavBookmarksService> serv =
+ do_GetService(NS_NAVBOOKMARKSSERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(serv, nullptr);
+ NS_ASSERTION(gBookmarksService,
+ "Should have static instance pointer now");
+ }
+ return gBookmarksService;
+ }
+
+ typedef mozilla::places::BookmarkData BookmarkData;
+ typedef mozilla::places::ItemVisitData ItemVisitData;
+ typedef mozilla::places::ItemChangeData ItemChangeData;
+ typedef mozilla::places::BookmarkStatementId BookmarkStatementId;
+
+ nsresult ResultNodeForContainer(int64_t aID,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aNode);
+
+ // Find all the children of a folder, using the given query and options.
+ // For each child, a ResultNode is created and added to |children|.
+ // The results are ordered by folder position.
+ nsresult QueryFolderChildren(int64_t aFolderId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* children);
+
+ /**
+ * Turns aRow into a node and appends it to aChildren if it is appropriate to
+ * do so.
+ *
+ * @param aRow
+ * A Storage statement (in the case of synchronous execution) or row of
+ * a result set (in the case of asynchronous execution).
+ * @param aOptions
+ * The options of the parent folder node.
+ * @param aChildren
+ * The children of the parent folder node.
+ * @param aCurrentIndex
+ * The index of aRow within the results. When called on the first row,
+ * this should be set to -1.
+ */
+ nsresult ProcessFolderNodeRow(mozIStorageValueArray* aRow,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aChildren,
+ int32_t& aCurrentIndex);
+
+ /**
+ * The async version of QueryFolderChildren.
+ *
+ * @param aNode
+ * The folder node that will receive the children.
+ * @param _pendingStmt
+ * The Storage pending statement that will be used to control async
+ * execution.
+ */
+ nsresult QueryFolderChildrenAsync(nsNavHistoryFolderResultNode* aNode,
+ int64_t aFolderId,
+ mozIStoragePendingStatement** _pendingStmt);
+
+ /**
+ * @return index of the new folder in aIndex, whether it was passed in or
+ * generated by autoincrement.
+ *
+ * @note If aFolder is -1, uses the autoincrement id for folder index.
+ * @note aTitle will be truncated to TITLE_LENGTH_MAX
+ */
+ nsresult CreateContainerWithID(int64_t aId, int64_t aParent,
+ const nsACString& aTitle,
+ bool aIsBookmarkFolder,
+ int32_t* aIndex,
+ const nsACString& aGUID,
+ uint16_t aSource,
+ int64_t* aNewFolder);
+
+ /**
+ * Fetches information about the specified id from the database.
+ *
+ * @param aItemId
+ * Id of the item to fetch information for.
+ * @param aBookmark
+ * BookmarkData to store the information.
+ */
+ nsresult FetchItemInfo(int64_t aItemId,
+ BookmarkData& _bookmark);
+
+ /**
+ * Notifies that a bookmark has been visited.
+ *
+ * @param aItemId
+ * The visited item id.
+ * @param aData
+ * Details about the new visit.
+ */
+ void NotifyItemVisited(const ItemVisitData& aData);
+
+ /**
+ * Notifies that a bookmark has changed.
+ *
+ * @param aItemId
+ * The changed item id.
+ * @param aData
+ * Details about the change.
+ */
+ void NotifyItemChanged(const ItemChangeData& aData);
+
+ /**
+ * Recursively builds an array of descendant folders inside a given folder.
+ *
+ * @param aFolderId
+ * The folder to fetch descendants from.
+ * @param aDescendantFoldersArray
+ * Output array to put descendant folders id.
+ */
+ nsresult GetDescendantFolders(int64_t aFolderId,
+ nsTArray<int64_t>& aDescendantFoldersArray);
+
+ static const int32_t kGetChildrenIndex_Guid;
+ static const int32_t kGetChildrenIndex_Position;
+ static const int32_t kGetChildrenIndex_Type;
+ static const int32_t kGetChildrenIndex_PlaceID;
+
+ static mozilla::Atomic<int64_t> sLastInsertedItemId;
+ static void StoreLastInsertedId(const nsACString& aTable,
+ const int64_t aLastInsertedId);
+
+private:
+ static nsNavBookmarks* gBookmarksService;
+
+ ~nsNavBookmarks();
+
+ /**
+ * Checks whether or not aFolderId points to a live bookmark.
+ *
+ * @param aFolderId
+ * the item-id of the folder to check.
+ * @return true if aFolderId points to live bookmarks, false otherwise.
+ */
+ bool IsLivemark(int64_t aFolderId);
+
+ /**
+ * Locates the root items in the bookmarks folder hierarchy assigning folder
+ * ids to the root properties that are exposed through the service interface.
+ */
+ nsresult ReadRoots();
+
+ nsresult AdjustIndices(int64_t aFolder,
+ int32_t aStartIndex,
+ int32_t aEndIndex,
+ int32_t aDelta);
+
+ /**
+ * Fetches properties of a folder.
+ *
+ * @param aFolderId
+ * Folder to count children for.
+ * @param _folderCount
+ * Number of children in the folder.
+ * @param _guid
+ * Unique id of the folder.
+ * @param _parentId
+ * Id of the parent of the folder.
+ *
+ * @throws If folder does not exist.
+ */
+ nsresult FetchFolderInfo(int64_t aFolderId,
+ int32_t* _folderCount,
+ nsACString& _guid,
+ int64_t* _parentId);
+
+ nsresult GetLastChildId(int64_t aFolder, int64_t* aItemId);
+
+ /**
+ * This is an handle to the Places database.
+ */
+ RefPtr<mozilla::places::Database> mDB;
+
+ int32_t mItemCount;
+
+ nsMaybeWeakPtrArray<nsINavBookmarkObserver> mObservers;
+
+ int64_t mRoot;
+ int64_t mMenuRoot;
+ int64_t mTagsRoot;
+ int64_t mUnfiledRoot;
+ int64_t mToolbarRoot;
+ int64_t mMobileRoot;
+
+ inline bool IsRoot(int64_t aFolderId) {
+ return aFolderId == mRoot || aFolderId == mMenuRoot ||
+ aFolderId == mTagsRoot || aFolderId == mUnfiledRoot ||
+ aFolderId == mToolbarRoot || aFolderId == mMobileRoot;
+ }
+
+ nsresult IsBookmarkedInDatabase(int64_t aBookmarkID, bool* aIsBookmarked);
+
+ nsresult SetItemDateInternal(enum mozilla::places::BookmarkDate aDateType,
+ int64_t aItemId,
+ PRTime aValue);
+
+ // Recursive method to build an array of folder's children
+ nsresult GetDescendantChildren(int64_t aFolderId,
+ const nsACString& aFolderGuid,
+ int64_t aGrandParentId,
+ nsTArray<BookmarkData>& aFolderChildrenArray);
+
+ enum ItemType {
+ BOOKMARK = TYPE_BOOKMARK,
+ FOLDER = TYPE_FOLDER,
+ SEPARATOR = TYPE_SEPARATOR,
+ };
+
+ /**
+ * Helper to insert a bookmark in the database.
+ *
+ * @param aItemId
+ * The itemId to insert, pass -1 to generate a new one.
+ * @param aPlaceId
+ * The placeId to which this bookmark refers to, pass nullptr for
+ * items that don't refer to an URI (eg. folders, separators, ...).
+ * @param aItemType
+ * The type of the new bookmark, see TYPE_* constants.
+ * @param aParentId
+ * The itemId of the parent folder.
+ * @param aIndex
+ * The position inside the parent folder.
+ * @param aTitle
+ * The title for the new bookmark.
+ * Pass a void string to set a NULL title.
+ * @param aDateAdded
+ * The date for the insertion.
+ * @param [optional] aLastModified
+ * The last modified date for the insertion.
+ * It defaults to aDateAdded.
+ *
+ * @return The new item id that has been inserted.
+ *
+ * @note This will also update last modified date of the parent folder.
+ */
+ nsresult InsertBookmarkInDB(int64_t aPlaceId,
+ enum ItemType aItemType,
+ int64_t aParentId,
+ int32_t aIndex,
+ const nsACString& aTitle,
+ PRTime aDateAdded,
+ PRTime aLastModified,
+ const nsACString& aParentGuid,
+ int64_t aGrandParentId,
+ nsIURI* aURI,
+ uint16_t aSource,
+ int64_t* _itemId,
+ nsACString& _guid);
+
+ /**
+ * TArray version of getBookmarksIdForURI for ease of use in C++ code.
+ * Pass in a reference to a TArray; it will get filled with the
+ * resulting list of bookmark IDs.
+ *
+ * @param aURI
+ * URI to get bookmarks for.
+ * @param aResult
+ * Array of bookmark ids.
+ * @param aSkipTags
+ * If true ids of tags-as-bookmarks entries will be excluded.
+ */
+ nsresult GetBookmarkIdsForURITArray(nsIURI* aURI,
+ nsTArray<int64_t>& aResult,
+ bool aSkipTags);
+
+ nsresult GetBookmarksForURI(nsIURI* aURI,
+ nsTArray<BookmarkData>& _bookmarks);
+
+ int64_t RecursiveFindRedirectedBookmark(int64_t aPlaceId);
+
+ class RemoveFolderTransaction final : public nsITransaction {
+ public:
+ RemoveFolderTransaction(int64_t aID, uint16_t aSource)
+ : mID(aID)
+ , mSource(aSource)
+ {}
+
+ NS_DECL_ISUPPORTS
+
+ NS_IMETHOD DoTransaction() override {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ BookmarkData folder;
+ nsresult rv = bookmarks->FetchItemInfo(mID, folder);
+ // TODO (Bug 656935): store the BookmarkData struct instead.
+ mParent = folder.parentId;
+ mIndex = folder.position;
+
+ rv = bookmarks->GetItemTitle(mID, mTitle);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return bookmarks->RemoveItem(mID, mSource);
+ }
+
+ NS_IMETHOD UndoTransaction() override {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ int64_t newFolder;
+ return bookmarks->CreateContainerWithID(mID, mParent, mTitle, true,
+ &mIndex, EmptyCString(),
+ mSource, &newFolder);
+ }
+
+ NS_IMETHOD RedoTransaction() override {
+ return DoTransaction();
+ }
+
+ NS_IMETHOD GetIsTransient(bool* aResult) override {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ NS_IMETHOD Merge(nsITransaction* aTransaction, bool* aResult) override {
+ *aResult = false;
+ return NS_OK;
+ }
+
+ private:
+ ~RemoveFolderTransaction() {}
+
+ int64_t mID;
+ uint16_t mSource;
+ MOZ_INIT_OUTSIDE_CTOR int64_t mParent;
+ nsCString mTitle;
+ MOZ_INIT_OUTSIDE_CTOR int32_t mIndex;
+ };
+
+ // Used to enable and disable the observer notifications.
+ bool mCanNotify;
+ nsCategoryCache<nsINavBookmarkObserver> mCacheObservers;
+
+ // Tracks whether we are in batch mode.
+ // Note: this is only tracking bookmarks batches, not history ones.
+ bool mBatching;
+
+ /**
+ * This function must be called every time a bookmark is removed.
+ *
+ * @param aURI
+ * Uri to test.
+ */
+ nsresult UpdateKeywordsForRemovedBookmark(const BookmarkData& aBookmark);
+};
+
+#endif // nsNavBookmarks_h_
diff --git a/toolkit/components/places/nsNavHistory.cpp b/toolkit/components/places/nsNavHistory.cpp
new file mode 100644
index 000000000..8cf3a2e32
--- /dev/null
+++ b/toolkit/components/places/nsNavHistory.cpp
@@ -0,0 +1,4523 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <stdio.h>
+
+#include "mozilla/DebugOnly.h"
+
+#include "nsNavHistory.h"
+
+#include "mozIPlacesAutoComplete.h"
+#include "nsNavBookmarks.h"
+#include "nsAnnotationService.h"
+#include "nsFaviconService.h"
+#include "nsPlacesMacros.h"
+#include "History.h"
+#include "Helpers.h"
+
+#include "nsTArray.h"
+#include "nsCollationCID.h"
+#include "nsILocaleService.h"
+#include "nsNetUtil.h"
+#include "nsPrintfCString.h"
+#include "nsPromiseFlatString.h"
+#include "nsString.h"
+#include "nsUnicharUtils.h"
+#include "prsystem.h"
+#include "prtime.h"
+#include "nsEscape.h"
+#include "nsIEffectiveTLDService.h"
+#include "nsIClassInfoImpl.h"
+#include "nsIIDNService.h"
+#include "nsThreadUtils.h"
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsMathUtils.h"
+#include "mozilla/storage.h"
+#include "mozilla/Preferences.h"
+#include <algorithm>
+
+#ifdef MOZ_XUL
+#include "nsIAutoCompleteInput.h"
+#include "nsIAutoCompletePopup.h"
+#endif
+
+using namespace mozilla;
+using namespace mozilla::places;
+
+// The maximum number of things that we will store in the recent events list
+// before calling ExpireNonrecentEvents. This number should be big enough so it
+// is very difficult to get that many unconsumed events (for example, typed but
+// never visited) in the RECENT_EVENT_THRESHOLD. Otherwise, we'll start
+// checking each one for every page visit, which will be somewhat slower.
+#define RECENT_EVENT_QUEUE_MAX_LENGTH 128
+
+// preference ID strings
+#define PREF_HISTORY_ENABLED "places.history.enabled"
+
+#define PREF_FREC_NUM_VISITS "places.frecency.numVisits"
+#define PREF_FREC_NUM_VISITS_DEF 10
+#define PREF_FREC_FIRST_BUCKET_CUTOFF "places.frecency.firstBucketCutoff"
+#define PREF_FREC_FIRST_BUCKET_CUTOFF_DEF 4
+#define PREF_FREC_SECOND_BUCKET_CUTOFF "places.frecency.secondBucketCutoff"
+#define PREF_FREC_SECOND_BUCKET_CUTOFF_DEF 14
+#define PREF_FREC_THIRD_BUCKET_CUTOFF "places.frecency.thirdBucketCutoff"
+#define PREF_FREC_THIRD_BUCKET_CUTOFF_DEF 31
+#define PREF_FREC_FOURTH_BUCKET_CUTOFF "places.frecency.fourthBucketCutoff"
+#define PREF_FREC_FOURTH_BUCKET_CUTOFF_DEF 90
+#define PREF_FREC_FIRST_BUCKET_WEIGHT "places.frecency.firstBucketWeight"
+#define PREF_FREC_FIRST_BUCKET_WEIGHT_DEF 100
+#define PREF_FREC_SECOND_BUCKET_WEIGHT "places.frecency.secondBucketWeight"
+#define PREF_FREC_SECOND_BUCKET_WEIGHT_DEF 70
+#define PREF_FREC_THIRD_BUCKET_WEIGHT "places.frecency.thirdBucketWeight"
+#define PREF_FREC_THIRD_BUCKET_WEIGHT_DEF 50
+#define PREF_FREC_FOURTH_BUCKET_WEIGHT "places.frecency.fourthBucketWeight"
+#define PREF_FREC_FOURTH_BUCKET_WEIGHT_DEF 30
+#define PREF_FREC_DEFAULT_BUCKET_WEIGHT "places.frecency.defaultBucketWeight"
+#define PREF_FREC_DEFAULT_BUCKET_WEIGHT_DEF 10
+#define PREF_FREC_EMBED_VISIT_BONUS "places.frecency.embedVisitBonus"
+#define PREF_FREC_EMBED_VISIT_BONUS_DEF 0
+#define PREF_FREC_FRAMED_LINK_VISIT_BONUS "places.frecency.framedLinkVisitBonus"
+#define PREF_FREC_FRAMED_LINK_VISIT_BONUS_DEF 0
+#define PREF_FREC_LINK_VISIT_BONUS "places.frecency.linkVisitBonus"
+#define PREF_FREC_LINK_VISIT_BONUS_DEF 100
+#define PREF_FREC_TYPED_VISIT_BONUS "places.frecency.typedVisitBonus"
+#define PREF_FREC_TYPED_VISIT_BONUS_DEF 2000
+#define PREF_FREC_BOOKMARK_VISIT_BONUS "places.frecency.bookmarkVisitBonus"
+#define PREF_FREC_BOOKMARK_VISIT_BONUS_DEF 75
+#define PREF_FREC_DOWNLOAD_VISIT_BONUS "places.frecency.downloadVisitBonus"
+#define PREF_FREC_DOWNLOAD_VISIT_BONUS_DEF 0
+#define PREF_FREC_PERM_REDIRECT_VISIT_BONUS "places.frecency.permRedirectVisitBonus"
+#define PREF_FREC_PERM_REDIRECT_VISIT_BONUS_DEF 0
+#define PREF_FREC_TEMP_REDIRECT_VISIT_BONUS "places.frecency.tempRedirectVisitBonus"
+#define PREF_FREC_TEMP_REDIRECT_VISIT_BONUS_DEF 0
+#define PREF_FREC_DEFAULT_VISIT_BONUS "places.frecency.defaultVisitBonus"
+#define PREF_FREC_DEFAULT_VISIT_BONUS_DEF 0
+#define PREF_FREC_UNVISITED_BOOKMARK_BONUS "places.frecency.unvisitedBookmarkBonus"
+#define PREF_FREC_UNVISITED_BOOKMARK_BONUS_DEF 140
+#define PREF_FREC_UNVISITED_TYPED_BONUS "places.frecency.unvisitedTypedBonus"
+#define PREF_FREC_UNVISITED_TYPED_BONUS_DEF 200
+#define PREF_FREC_RELOAD_VISIT_BONUS "places.frecency.reloadVisitBonus"
+#define PREF_FREC_RELOAD_VISIT_BONUS_DEF 0
+
+// In order to avoid calling PR_now() too often we use a cached "now" value
+// for repeating stuff. These are milliseconds between "now" cache refreshes.
+#define RENEW_CACHED_NOW_TIMEOUT ((int32_t)3 * PR_MSEC_PER_SEC)
+
+// character-set annotation
+#define CHARSET_ANNO NS_LITERAL_CSTRING("URIProperties/characterSet")
+
+// These macros are used when splitting history by date.
+// These are the day containers and catch-all final container.
+#define HISTORY_ADDITIONAL_DATE_CONT_NUM 3
+// We use a guess of the number of months considering all of them 30 days
+// long, but we split only the last 6 months.
+#define HISTORY_DATE_CONT_NUM(_daysFromOldestVisit) \
+ (HISTORY_ADDITIONAL_DATE_CONT_NUM + \
+ std::min(6, (int32_t)ceilf((float)_daysFromOldestVisit/30)))
+// Max number of containers, used to initialize the params hash.
+#define HISTORY_DATE_CONT_LENGTH 8
+
+// Initial length of the embed visits cache.
+#define EMBED_VISITS_INITIAL_CACHE_LENGTH 64
+
+// Initial length of the recent events cache.
+#define RECENT_EVENTS_INITIAL_CACHE_LENGTH 64
+
+// Observed topics.
+#ifdef MOZ_XUL
+#define TOPIC_AUTOCOMPLETE_FEEDBACK_INCOMING "autocomplete-will-enter-text"
+#endif
+#define TOPIC_IDLE_DAILY "idle-daily"
+#define TOPIC_PREF_CHANGED "nsPref:changed"
+#define TOPIC_PROFILE_TEARDOWN "profile-change-teardown"
+#define TOPIC_PROFILE_CHANGE "profile-before-change"
+
+static const char* kObservedPrefs[] = {
+ PREF_HISTORY_ENABLED
+, PREF_FREC_NUM_VISITS
+, PREF_FREC_FIRST_BUCKET_CUTOFF
+, PREF_FREC_SECOND_BUCKET_CUTOFF
+, PREF_FREC_THIRD_BUCKET_CUTOFF
+, PREF_FREC_FOURTH_BUCKET_CUTOFF
+, PREF_FREC_FIRST_BUCKET_WEIGHT
+, PREF_FREC_SECOND_BUCKET_WEIGHT
+, PREF_FREC_THIRD_BUCKET_WEIGHT
+, PREF_FREC_FOURTH_BUCKET_WEIGHT
+, PREF_FREC_DEFAULT_BUCKET_WEIGHT
+, PREF_FREC_EMBED_VISIT_BONUS
+, PREF_FREC_FRAMED_LINK_VISIT_BONUS
+, PREF_FREC_LINK_VISIT_BONUS
+, PREF_FREC_TYPED_VISIT_BONUS
+, PREF_FREC_BOOKMARK_VISIT_BONUS
+, PREF_FREC_DOWNLOAD_VISIT_BONUS
+, PREF_FREC_PERM_REDIRECT_VISIT_BONUS
+, PREF_FREC_TEMP_REDIRECT_VISIT_BONUS
+, PREF_FREC_DEFAULT_VISIT_BONUS
+, PREF_FREC_UNVISITED_BOOKMARK_BONUS
+, PREF_FREC_UNVISITED_TYPED_BONUS
+, nullptr
+};
+
+NS_IMPL_ADDREF(nsNavHistory)
+NS_IMPL_RELEASE(nsNavHistory)
+
+NS_IMPL_CLASSINFO(nsNavHistory, nullptr, nsIClassInfo::SINGLETON,
+ NS_NAVHISTORYSERVICE_CID)
+NS_INTERFACE_MAP_BEGIN(nsNavHistory)
+ NS_INTERFACE_MAP_ENTRY(nsINavHistoryService)
+ NS_INTERFACE_MAP_ENTRY(nsIBrowserHistory)
+ NS_INTERFACE_MAP_ENTRY(nsIObserver)
+ NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference)
+ NS_INTERFACE_MAP_ENTRY(nsPIPlacesDatabase)
+ NS_INTERFACE_MAP_ENTRY(mozIStorageVacuumParticipant)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsINavHistoryService)
+ NS_IMPL_QUERY_CLASSINFO(nsNavHistory)
+NS_INTERFACE_MAP_END
+
+// We don't care about flattening everything
+NS_IMPL_CI_INTERFACE_GETTER(nsNavHistory,
+ nsINavHistoryService,
+ nsIBrowserHistory)
+
+namespace {
+
+static int64_t GetSimpleBookmarksQueryFolder(
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions);
+static void ParseSearchTermsFromQueries(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsTArray<nsTArray<nsString>*>* aTerms);
+
+void GetTagsSqlFragment(int64_t aTagsFolder,
+ const nsACString& aRelation,
+ bool aHasSearchTerms,
+ nsACString& _sqlFragment) {
+ if (!aHasSearchTerms)
+ _sqlFragment.AssignLiteral("null");
+ else {
+ // This subquery DOES NOT order tags for performance reasons.
+ _sqlFragment.Assign(NS_LITERAL_CSTRING(
+ "(SELECT GROUP_CONCAT(t_t.title, ',') "
+ "FROM moz_bookmarks b_t "
+ "JOIN moz_bookmarks t_t ON t_t.id = +b_t.parent "
+ "WHERE b_t.fk = ") + aRelation + NS_LITERAL_CSTRING(" "
+ "AND t_t.parent = ") +
+ nsPrintfCString("%lld", aTagsFolder) + NS_LITERAL_CSTRING(" "
+ ")"));
+ }
+
+ _sqlFragment.AppendLiteral(" AS tags ");
+}
+
+/**
+ * This class sets begin/end of batch updates to correspond to C++ scopes so
+ * we can be sure end always gets called.
+ */
+class UpdateBatchScoper
+{
+public:
+ explicit UpdateBatchScoper(nsNavHistory& aNavHistory) : mNavHistory(aNavHistory)
+ {
+ mNavHistory.BeginUpdateBatch();
+ }
+ ~UpdateBatchScoper()
+ {
+ mNavHistory.EndUpdateBatch();
+ }
+protected:
+ nsNavHistory& mNavHistory;
+};
+
+} // namespace
+
+
+// Queries rows indexes to bind or get values, if adding a new one, be sure to
+// update nsNavBookmarks statements and its kGetChildrenIndex_* constants
+const int32_t nsNavHistory::kGetInfoIndex_PageID = 0;
+const int32_t nsNavHistory::kGetInfoIndex_URL = 1;
+const int32_t nsNavHistory::kGetInfoIndex_Title = 2;
+const int32_t nsNavHistory::kGetInfoIndex_RevHost = 3;
+const int32_t nsNavHistory::kGetInfoIndex_VisitCount = 4;
+const int32_t nsNavHistory::kGetInfoIndex_VisitDate = 5;
+const int32_t nsNavHistory::kGetInfoIndex_FaviconURL = 6;
+const int32_t nsNavHistory::kGetInfoIndex_ItemId = 7;
+const int32_t nsNavHistory::kGetInfoIndex_ItemDateAdded = 8;
+const int32_t nsNavHistory::kGetInfoIndex_ItemLastModified = 9;
+const int32_t nsNavHistory::kGetInfoIndex_ItemParentId = 10;
+const int32_t nsNavHistory::kGetInfoIndex_ItemTags = 11;
+const int32_t nsNavHistory::kGetInfoIndex_Frecency = 12;
+const int32_t nsNavHistory::kGetInfoIndex_Hidden = 13;
+const int32_t nsNavHistory::kGetInfoIndex_Guid = 14;
+const int32_t nsNavHistory::kGetInfoIndex_VisitId = 15;
+const int32_t nsNavHistory::kGetInfoIndex_FromVisitId = 16;
+const int32_t nsNavHistory::kGetInfoIndex_VisitType = 17;
+// These columns are followed by corresponding constants in nsNavBookmarks.cpp,
+// which must be kept in sync:
+// nsNavBookmarks::kGetChildrenIndex_Guid = 18;
+// nsNavBookmarks::kGetChildrenIndex_Position = 19;
+// nsNavBookmarks::kGetChildrenIndex_Type = 20;
+// nsNavBookmarks::kGetChildrenIndex_PlaceID = 21;
+
+PLACES_FACTORY_SINGLETON_IMPLEMENTATION(nsNavHistory, gHistoryService)
+
+
+nsNavHistory::nsNavHistory()
+ : mBatchLevel(0)
+ , mBatchDBTransaction(nullptr)
+ , mCachedNow(0)
+ , mRecentTyped(RECENT_EVENTS_INITIAL_CACHE_LENGTH)
+ , mRecentLink(RECENT_EVENTS_INITIAL_CACHE_LENGTH)
+ , mRecentBookmark(RECENT_EVENTS_INITIAL_CACHE_LENGTH)
+ , mEmbedVisits(EMBED_VISITS_INITIAL_CACHE_LENGTH)
+ , mHistoryEnabled(true)
+ , mNumVisitsForFrecency(10)
+ , mTagsFolder(-1)
+ , mDaysOfHistory(-1)
+ , mLastCachedStartOfDay(INT64_MAX)
+ , mLastCachedEndOfDay(0)
+ , mCanNotify(true)
+ , mCacheObservers("history-observers")
+{
+ NS_ASSERTION(!gHistoryService,
+ "Attempting to create two instances of the service!");
+ gHistoryService = this;
+}
+
+
+nsNavHistory::~nsNavHistory()
+{
+ // remove the static reference to the service. Check to make sure its us
+ // in case somebody creates an extra instance of the service.
+ NS_ASSERTION(gHistoryService == this,
+ "Deleting a non-singleton instance of the service");
+ if (gHistoryService == this)
+ gHistoryService = nullptr;
+}
+
+
+nsresult
+nsNavHistory::Init()
+{
+ LoadPrefs();
+
+ mDB = Database::GetDatabase();
+ NS_ENSURE_STATE(mDB);
+
+ /*****************************************************************************
+ *** IMPORTANT NOTICE!
+ ***
+ *** Nothing after these add observer calls should return anything but NS_OK.
+ *** If a failure code is returned, this nsNavHistory object will be held onto
+ *** by the observer service and the preference service.
+ ****************************************************************************/
+
+ // Observe preferences changes.
+ Preferences::AddWeakObservers(this, kObservedPrefs);
+
+ nsCOMPtr<nsIObserverService> obsSvc = services::GetObserverService();
+ if (obsSvc) {
+ (void)obsSvc->AddObserver(this, TOPIC_PLACES_CONNECTION_CLOSED, true);
+ (void)obsSvc->AddObserver(this, TOPIC_IDLE_DAILY, true);
+#ifdef MOZ_XUL
+ (void)obsSvc->AddObserver(this, TOPIC_AUTOCOMPLETE_FEEDBACK_INCOMING, true);
+#endif
+ }
+
+ // Don't add code that can fail here! Do it up above, before we add our
+ // observers.
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistory::GetDatabaseStatus(uint16_t *aDatabaseStatus)
+{
+ NS_ENSURE_ARG_POINTER(aDatabaseStatus);
+ *aDatabaseStatus = mDB->GetDatabaseStatus();
+ return NS_OK;
+}
+
+uint32_t
+nsNavHistory::GetRecentFlags(nsIURI *aURI)
+{
+ uint32_t result = 0;
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Unable to get aURI's spec");
+
+ if (NS_SUCCEEDED(rv)) {
+ if (CheckIsRecentEvent(&mRecentTyped, spec))
+ result |= RECENT_TYPED;
+ if (CheckIsRecentEvent(&mRecentLink, spec))
+ result |= RECENT_ACTIVATED;
+ if (CheckIsRecentEvent(&mRecentBookmark, spec))
+ result |= RECENT_BOOKMARKED;
+ }
+
+ return result;
+}
+
+nsresult
+nsNavHistory::GetIdForPage(nsIURI* aURI,
+ int64_t* _pageId,
+ nsCString& _GUID)
+{
+ *_pageId = 0;
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT id, url, title, rev_host, visit_count, guid "
+ "FROM moz_places "
+ "WHERE url_hash = hash(:page_url) AND url = :page_url "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasEntry = false;
+ rv = stmt->ExecuteStep(&hasEntry);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (hasEntry) {
+ rv = stmt->GetInt64(0, _pageId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->GetUTF8String(5, _GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsNavHistory::GetOrCreateIdForPage(nsIURI* aURI,
+ int64_t* _pageId,
+ nsCString& _GUID)
+{
+ nsresult rv = GetIdForPage(aURI, _pageId, _GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (*_pageId != 0) {
+ return NS_OK;
+ }
+
+ // Create a new hidden, untyped and unvisited entry.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "INSERT INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid) "
+ "VALUES (:page_url, hash(:page_url), :rev_host, :hidden, :frecency, :guid) "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // host (reversed with trailing period)
+ nsAutoString revHost;
+ rv = GetReversedHostname(aURI, revHost);
+ // Not all URI types have hostnames, so this is optional.
+ if (NS_SUCCEEDED(rv)) {
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("rev_host"), revHost);
+ } else {
+ rv = stmt->BindNullByName(NS_LITERAL_CSTRING("rev_host"));
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("hidden"), 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString spec;
+ rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindInt32ByName(NS_LITERAL_CSTRING("frecency"),
+ IsQueryURI(spec) ? 0 : -1);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString guid;
+ rv = GenerateGUID(_GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("guid"), _GUID);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *_pageId = sLastInsertedPlaceId;
+
+ return NS_OK;
+}
+
+
+void
+nsNavHistory::LoadPrefs()
+{
+ // History preferences.
+ mHistoryEnabled = Preferences::GetBool(PREF_HISTORY_ENABLED, true);
+
+ // Frecency preferences.
+#define FRECENCY_PREF(_prop, _pref) \
+ _prop = Preferences::GetInt(_pref, _pref##_DEF)
+
+ FRECENCY_PREF(mNumVisitsForFrecency, PREF_FREC_NUM_VISITS);
+ FRECENCY_PREF(mFirstBucketCutoffInDays, PREF_FREC_FIRST_BUCKET_CUTOFF);
+ FRECENCY_PREF(mSecondBucketCutoffInDays, PREF_FREC_SECOND_BUCKET_CUTOFF);
+ FRECENCY_PREF(mThirdBucketCutoffInDays, PREF_FREC_THIRD_BUCKET_CUTOFF);
+ FRECENCY_PREF(mFourthBucketCutoffInDays, PREF_FREC_FOURTH_BUCKET_CUTOFF);
+ FRECENCY_PREF(mEmbedVisitBonus, PREF_FREC_EMBED_VISIT_BONUS);
+ FRECENCY_PREF(mFramedLinkVisitBonus, PREF_FREC_FRAMED_LINK_VISIT_BONUS);
+ FRECENCY_PREF(mLinkVisitBonus, PREF_FREC_LINK_VISIT_BONUS);
+ FRECENCY_PREF(mTypedVisitBonus, PREF_FREC_TYPED_VISIT_BONUS);
+ FRECENCY_PREF(mBookmarkVisitBonus, PREF_FREC_BOOKMARK_VISIT_BONUS);
+ FRECENCY_PREF(mDownloadVisitBonus, PREF_FREC_DOWNLOAD_VISIT_BONUS);
+ FRECENCY_PREF(mPermRedirectVisitBonus, PREF_FREC_PERM_REDIRECT_VISIT_BONUS);
+ FRECENCY_PREF(mTempRedirectVisitBonus, PREF_FREC_TEMP_REDIRECT_VISIT_BONUS);
+ FRECENCY_PREF(mDefaultVisitBonus, PREF_FREC_DEFAULT_VISIT_BONUS);
+ FRECENCY_PREF(mUnvisitedBookmarkBonus, PREF_FREC_UNVISITED_BOOKMARK_BONUS);
+ FRECENCY_PREF(mUnvisitedTypedBonus, PREF_FREC_UNVISITED_TYPED_BONUS);
+ FRECENCY_PREF(mReloadVisitBonus, PREF_FREC_RELOAD_VISIT_BONUS);
+ FRECENCY_PREF(mFirstBucketWeight, PREF_FREC_FIRST_BUCKET_WEIGHT);
+ FRECENCY_PREF(mSecondBucketWeight, PREF_FREC_SECOND_BUCKET_WEIGHT);
+ FRECENCY_PREF(mThirdBucketWeight, PREF_FREC_THIRD_BUCKET_WEIGHT);
+ FRECENCY_PREF(mFourthBucketWeight, PREF_FREC_FOURTH_BUCKET_WEIGHT);
+ FRECENCY_PREF(mDefaultWeight, PREF_FREC_DEFAULT_BUCKET_WEIGHT);
+
+#undef FRECENCY_PREF
+}
+
+
+void
+nsNavHistory::NotifyOnVisit(nsIURI* aURI,
+ int64_t aVisitId,
+ PRTime aTime,
+ int64_t aReferrerVisitId,
+ int32_t aTransitionType,
+ const nsACString& aGuid,
+ bool aHidden,
+ uint32_t aVisitCount,
+ uint32_t aTyped)
+{
+ MOZ_ASSERT(!aGuid.IsEmpty());
+ // If there's no history, this visit will surely add a day. If the visit is
+ // added before or after the last cached day, the day count may have changed.
+ // Otherwise adding multiple visits in the same day should not invalidate
+ // the cache.
+ if (mDaysOfHistory == 0) {
+ mDaysOfHistory = 1;
+ } else if (aTime > mLastCachedEndOfDay || aTime < mLastCachedStartOfDay) {
+ mDaysOfHistory = -1;
+ }
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnVisit(aURI, aVisitId, aTime, 0, aReferrerVisitId,
+ aTransitionType, aGuid, aHidden, aVisitCount, aTyped));
+}
+
+void
+nsNavHistory::NotifyTitleChange(nsIURI* aURI,
+ const nsString& aTitle,
+ const nsACString& aGUID)
+{
+ MOZ_ASSERT(!aGUID.IsEmpty());
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver, OnTitleChanged(aURI, aTitle, aGUID));
+}
+
+void
+nsNavHistory::NotifyFrecencyChanged(nsIURI* aURI,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate)
+{
+ MOZ_ASSERT(!aGUID.IsEmpty());
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnFrecencyChanged(aURI, aNewFrecency, aGUID, aHidden,
+ aLastVisitDate));
+}
+
+void
+nsNavHistory::NotifyManyFrecenciesChanged()
+{
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnManyFrecenciesChanged());
+}
+
+namespace {
+
+class FrecencyNotification : public Runnable
+{
+public:
+ FrecencyNotification(const nsACString& aSpec,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate)
+ : mSpec(aSpec)
+ , mNewFrecency(aNewFrecency)
+ , mGUID(aGUID)
+ , mHidden(aHidden)
+ , mLastVisitDate(aLastVisitDate)
+ {
+ }
+
+ NS_IMETHOD Run() override
+ {
+ MOZ_ASSERT(NS_IsMainThread(), "Must be called on the main thread");
+ nsNavHistory* navHistory = nsNavHistory::GetHistoryService();
+ if (navHistory) {
+ nsCOMPtr<nsIURI> uri;
+ (void)NS_NewURI(getter_AddRefs(uri), mSpec);
+ // We cannot assert since some automated tests are checking this path.
+ NS_WARNING_ASSERTION(uri, "Invalid URI in FrecencyNotification");
+ // Notify a frecency change only if we have a valid uri, otherwise
+ // the observer couldn't gather any useful data from the notification.
+ if (uri) {
+ navHistory->NotifyFrecencyChanged(uri, mNewFrecency, mGUID, mHidden,
+ mLastVisitDate);
+ }
+ }
+ return NS_OK;
+ }
+
+private:
+ nsCString mSpec;
+ int32_t mNewFrecency;
+ nsCString mGUID;
+ bool mHidden;
+ PRTime mLastVisitDate;
+};
+
+} // namespace
+
+void
+nsNavHistory::DispatchFrecencyChangedNotification(const nsACString& aSpec,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate) const
+{
+ nsCOMPtr<nsIRunnable> notif = new FrecencyNotification(aSpec, aNewFrecency,
+ aGUID, aHidden,
+ aLastVisitDate);
+ (void)NS_DispatchToMainThread(notif);
+}
+
+Atomic<int64_t> nsNavHistory::sLastInsertedPlaceId(0);
+Atomic<int64_t> nsNavHistory::sLastInsertedVisitId(0);
+
+void // static
+nsNavHistory::StoreLastInsertedId(const nsACString& aTable,
+ const int64_t aLastInsertedId) {
+ if (aTable.Equals(NS_LITERAL_CSTRING("moz_places"))) {
+ nsNavHistory::sLastInsertedPlaceId = aLastInsertedId;
+ } else if (aTable.Equals(NS_LITERAL_CSTRING("moz_historyvisits"))) {
+ nsNavHistory::sLastInsertedVisitId = aLastInsertedId;
+ } else {
+ MOZ_ASSERT(false, "Trying to store the insert id for an unknown table?");
+ }
+}
+
+int32_t
+nsNavHistory::GetDaysOfHistory() {
+ MOZ_ASSERT(NS_IsMainThread(), "This can only be called on the main thread");
+
+ if (mDaysOfHistory != -1)
+ return mDaysOfHistory;
+
+ // SQLite doesn't have a CEIL() function, so we must do that later.
+ // We should also take into account timers resolution, that may be as bad as
+ // 16ms on Windows, so in some cases the difference may be 0, if the
+ // check is done near the visit. Thus remember to check for NULL separately.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT CAST(( "
+ "strftime('%s','now','localtime','utc') - "
+ "(SELECT MIN(visit_date)/1000000 FROM moz_historyvisits) "
+ ") AS DOUBLE) "
+ "/86400, "
+ "strftime('%s','now','localtime','+1 day','start of day','utc') * 1000000"
+ );
+ NS_ENSURE_TRUE(stmt, 0);
+ mozStorageStatementScoper scoper(stmt);
+
+ bool hasResult;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasResult)) && hasResult) {
+ // If we get NULL, then there are no visits, otherwise there must always be
+ // at least 1 day of history.
+ bool hasNoVisits;
+ (void)stmt->GetIsNull(0, &hasNoVisits);
+ mDaysOfHistory = hasNoVisits ?
+ 0 : std::max(1, static_cast<int32_t>(ceil(stmt->AsDouble(0))));
+ mLastCachedStartOfDay =
+ NormalizeTime(nsINavHistoryQuery::TIME_RELATIVE_TODAY, 0);
+ mLastCachedEndOfDay = stmt->AsInt64(1) - 1; // Start of tomorrow - 1.
+ }
+
+ return mDaysOfHistory;
+}
+
+PRTime
+nsNavHistory::GetNow()
+{
+ if (!mCachedNow) {
+ mCachedNow = PR_Now();
+ if (!mExpireNowTimer)
+ mExpireNowTimer = do_CreateInstance("@mozilla.org/timer;1");
+ if (mExpireNowTimer)
+ mExpireNowTimer->InitWithFuncCallback(expireNowTimerCallback, this,
+ RENEW_CACHED_NOW_TIMEOUT,
+ nsITimer::TYPE_ONE_SHOT);
+ }
+ return mCachedNow;
+}
+
+
+void nsNavHistory::expireNowTimerCallback(nsITimer* aTimer, void* aClosure)
+{
+ nsNavHistory *history = static_cast<nsNavHistory *>(aClosure);
+ if (history) {
+ history->mCachedNow = 0;
+ history->mExpireNowTimer = nullptr;
+ }
+}
+
+
+/**
+ * Code borrowed from mozilla/xpfe/components/history/src/nsGlobalHistory.cpp
+ * Pass in a pre-normalized now and a date, and we'll find the difference since
+ * midnight on each of the days.
+ */
+static PRTime
+NormalizeTimeRelativeToday(PRTime aTime)
+{
+ // round to midnight this morning
+ PRExplodedTime explodedTime;
+ PR_ExplodeTime(aTime, PR_LocalTimeParameters, &explodedTime);
+
+ // set to midnight (0:00)
+ explodedTime.tm_min =
+ explodedTime.tm_hour =
+ explodedTime.tm_sec =
+ explodedTime.tm_usec = 0;
+
+ return PR_ImplodeTime(&explodedTime);
+}
+
+// nsNavHistory::NormalizeTime
+//
+// Converts a nsINavHistoryQuery reference+offset time into a PRTime
+// relative to the epoch.
+//
+// It is important that this function NOT use the current time optimization.
+// It is called to update queries, and we really need to know what right
+// now is because those incoming values will also have current times that
+// we will have to compare against.
+
+PRTime // static
+nsNavHistory::NormalizeTime(uint32_t aRelative, PRTime aOffset)
+{
+ PRTime ref;
+ switch (aRelative)
+ {
+ case nsINavHistoryQuery::TIME_RELATIVE_EPOCH:
+ return aOffset;
+ case nsINavHistoryQuery::TIME_RELATIVE_TODAY:
+ ref = NormalizeTimeRelativeToday(PR_Now());
+ break;
+ case nsINavHistoryQuery::TIME_RELATIVE_NOW:
+ ref = PR_Now();
+ break;
+ default:
+ NS_NOTREACHED("Invalid relative time");
+ return 0;
+ }
+ return ref + aOffset;
+}
+
+// nsNavHistory::GetUpdateRequirements
+//
+// Returns conditions for query update.
+//
+// QUERYUPDATE_TIME:
+// This query is only limited by an inclusive time range on the first
+// query object. The caller can quickly evaluate the time itself if it
+// chooses. This is even simpler than "simple" below.
+// QUERYUPDATE_SIMPLE:
+// This query is evaluatable using EvaluateQueryForNode to do live
+// updating.
+// QUERYUPDATE_COMPLEX:
+// This query is not evaluatable using EvaluateQueryForNode. When something
+// happens that this query updates, you will need to re-run the query.
+// QUERYUPDATE_COMPLEX_WITH_BOOKMARKS:
+// A complex query that additionally has dependence on bookmarks. All
+// bookmark-dependent queries fall under this category.
+//
+// aHasSearchTerms will be set to true if the query has any dependence on
+// keywords. When there is no dependence on keywords, we can handle title
+// change operations as simple instead of complex.
+
+uint32_t
+nsNavHistory::GetUpdateRequirements(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ bool* aHasSearchTerms)
+{
+ NS_ASSERTION(aQueries.Count() > 0, "Must have at least one query");
+
+ // first check if there are search terms
+ *aHasSearchTerms = false;
+ int32_t i;
+ for (i = 0; i < aQueries.Count(); i ++) {
+ aQueries[i]->GetHasSearchTerms(aHasSearchTerms);
+ if (*aHasSearchTerms)
+ break;
+ }
+
+ bool nonTimeBasedItems = false;
+ bool domainBasedItems = false;
+
+ for (i = 0; i < aQueries.Count(); i ++) {
+ nsNavHistoryQuery* query = aQueries[i];
+
+ if (query->Folders().Length() > 0 ||
+ query->OnlyBookmarked() ||
+ query->Tags().Length() > 0) {
+ return QUERYUPDATE_COMPLEX_WITH_BOOKMARKS;
+ }
+
+ // Note: we don't currently have any complex non-bookmarked items, but these
+ // are expected to be added. Put detection of these items here.
+ if (!query->SearchTerms().IsEmpty() ||
+ !query->Domain().IsVoid() ||
+ query->Uri() != nullptr)
+ nonTimeBasedItems = true;
+
+ if (! query->Domain().IsVoid())
+ domainBasedItems = true;
+ }
+
+ if (aOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_TAG_QUERY)
+ return QUERYUPDATE_COMPLEX_WITH_BOOKMARKS;
+
+ // Whenever there is a maximum number of results,
+ // and we are not a bookmark query we must requery. This
+ // is because we can't generally know if any given addition/change causes
+ // the item to be in the top N items in the database.
+ if (aOptions->MaxResults() > 0)
+ return QUERYUPDATE_COMPLEX;
+
+ if (aQueries.Count() == 1 && domainBasedItems)
+ return QUERYUPDATE_HOST;
+ if (aQueries.Count() == 1 && !nonTimeBasedItems)
+ return QUERYUPDATE_TIME;
+
+ return QUERYUPDATE_SIMPLE;
+}
+
+
+// nsNavHistory::EvaluateQueryForNode
+//
+// This runs the node through the given queries to see if satisfies the
+// query conditions. Not every query parameters are handled by this code,
+// but we handle the most common ones so that performance is better.
+//
+// We assume that the time on the node is the time that we want to compare.
+// This is not necessarily true because URL nodes have the last access time,
+// which is not necessarily the same. However, since this is being called
+// to update the list, we assume that the last access time is the current
+// access time that we are being asked to compare so it works out.
+//
+// Returns true if node matches the query, false if not.
+
+bool
+nsNavHistory::EvaluateQueryForNode(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode* aNode)
+{
+ // lazily created from the node's string when we need to match URIs
+ nsCOMPtr<nsIURI> nodeUri;
+
+ // --- hidden ---
+ if (aNode->mHidden && !aOptions->IncludeHidden())
+ return false;
+
+ for (int32_t i = 0; i < aQueries.Count(); i ++) {
+ bool hasIt;
+ nsCOMPtr<nsNavHistoryQuery> query = aQueries[i];
+
+ // --- begin time ---
+ query->GetHasBeginTime(&hasIt);
+ if (hasIt) {
+ PRTime beginTime = NormalizeTime(query->BeginTimeReference(),
+ query->BeginTime());
+ if (aNode->mTime < beginTime)
+ continue; // before our time range
+ }
+
+ // --- end time ---
+ query->GetHasEndTime(&hasIt);
+ if (hasIt) {
+ PRTime endTime = NormalizeTime(query->EndTimeReference(),
+ query->EndTime());
+ if (aNode->mTime > endTime)
+ continue; // after our time range
+ }
+
+ // --- search terms ---
+ if (! query->SearchTerms().IsEmpty()) {
+ // we can use the existing filtering code, just give it our one object in
+ // an array.
+ nsCOMArray<nsNavHistoryResultNode> inputSet;
+ inputSet.AppendObject(aNode);
+ nsCOMArray<nsNavHistoryQuery> queries;
+ queries.AppendObject(query);
+ nsCOMArray<nsNavHistoryResultNode> filteredSet;
+ nsresult rv = FilterResultSet(nullptr, inputSet, &filteredSet, queries, aOptions);
+ if (NS_FAILED(rv))
+ continue;
+ if (! filteredSet.Count())
+ continue; // did not make it through the filter, doesn't match
+ }
+
+ // --- domain/host matching ---
+ query->GetHasDomain(&hasIt);
+ if (hasIt) {
+ if (! nodeUri) {
+ // lazy creation of nodeUri, which might be checked for multiple queries
+ if (NS_FAILED(NS_NewURI(getter_AddRefs(nodeUri), aNode->mURI)))
+ continue;
+ }
+ nsAutoCString asciiRequest;
+ if (NS_FAILED(AsciiHostNameFromHostString(query->Domain(), asciiRequest)))
+ continue;
+
+ if (query->DomainIsHost()) {
+ nsAutoCString host;
+ if (NS_FAILED(nodeUri->GetAsciiHost(host)))
+ continue;
+
+ if (! asciiRequest.Equals(host))
+ continue; // host names don't match
+ }
+ // check domain names
+ nsAutoCString domain;
+ DomainNameFromURI(nodeUri, domain);
+ if (! asciiRequest.Equals(domain))
+ continue; // domain names don't match
+ }
+
+ // --- URI matching ---
+ if (query->Uri()) {
+ if (! nodeUri) { // lazy creation of nodeUri
+ if (NS_FAILED(NS_NewURI(getter_AddRefs(nodeUri), aNode->mURI)))
+ continue;
+ }
+
+ bool equals;
+ nsresult rv = query->Uri()->Equals(nodeUri, &equals);
+ NS_ENSURE_SUCCESS(rv, false);
+ if (! equals)
+ continue;
+ }
+
+ // Transitions matching.
+ const nsTArray<uint32_t>& transitions = query->Transitions();
+ if (aNode->mTransitionType > 0 &&
+ transitions.Length() &&
+ !transitions.Contains(aNode->mTransitionType)) {
+ continue; // transition doesn't match.
+ }
+
+ // If we ever make it to the bottom of this loop, that means it passed all
+ // tests for the given query. Since queries are ORed together, that means
+ // it passed everything and we are done.
+ return true;
+ }
+
+ // didn't match any query
+ return false;
+}
+
+
+// nsNavHistory::AsciiHostNameFromHostString
+//
+// We might have interesting encodings and different case in the host name.
+// This will convert that host name into an ASCII host name by sending it
+// through the URI canonicalization. The result can be used for comparison
+// with other ASCII host name strings.
+nsresult // static
+nsNavHistory::AsciiHostNameFromHostString(const nsACString& aHostName,
+ nsACString& aAscii)
+{
+ // To properly generate a uri we must provide a protocol.
+ nsAutoCString fakeURL("http://");
+ fakeURL.Append(aHostName);
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), fakeURL);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = uri->GetAsciiHost(aAscii);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+
+// nsNavHistory::DomainNameFromURI
+//
+// This does the www.mozilla.org -> mozilla.org and
+// foo.theregister.co.uk -> theregister.co.uk conversion
+void
+nsNavHistory::DomainNameFromURI(nsIURI *aURI,
+ nsACString& aDomainName)
+{
+ // lazily get the effective tld service
+ if (!mTLDService)
+ mTLDService = do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID);
+
+ if (mTLDService) {
+ // get the base domain for a given hostname.
+ // e.g. for "images.bbc.co.uk", this would be "bbc.co.uk".
+ nsresult rv = mTLDService->GetBaseDomain(aURI, 0, aDomainName);
+ if (NS_SUCCEEDED(rv))
+ return;
+ }
+
+ // just return the original hostname
+ // (it's also possible the host is an IP address)
+ aURI->GetAsciiHost(aDomainName);
+}
+
+
+NS_IMETHODIMP
+nsNavHistory::GetHasHistoryEntries(bool* aHasEntries)
+{
+ NS_ENSURE_ARG_POINTER(aHasEntries);
+ *aHasEntries = GetDaysOfHistory() > 0;
+ return NS_OK;
+}
+
+
+namespace {
+
+class InvalidateAllFrecenciesCallback : public AsyncStatementCallback
+{
+public:
+ InvalidateAllFrecenciesCallback()
+ {
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason)
+ {
+ if (aReason == REASON_FINISHED) {
+ nsNavHistory *navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ navHistory->NotifyManyFrecenciesChanged();
+ }
+ return NS_OK;
+ }
+};
+
+} // namespace
+
+nsresult
+nsNavHistory::invalidateFrecencies(const nsCString& aPlaceIdsQueryString)
+{
+ // Exclude place: queries by setting their frecency to zero.
+ nsCString invalidFrecenciesSQLFragment(
+ "UPDATE moz_places SET frecency = "
+ );
+ if (!aPlaceIdsQueryString.IsEmpty())
+ invalidFrecenciesSQLFragment.AppendLiteral("NOTIFY_FRECENCY(");
+ invalidFrecenciesSQLFragment.AppendLiteral(
+ "(CASE "
+ "WHEN url_hash BETWEEN hash('place', 'prefix_lo') AND "
+ "hash('place', 'prefix_hi') "
+ "THEN 0 "
+ "ELSE -1 "
+ "END) "
+ );
+ if (!aPlaceIdsQueryString.IsEmpty()) {
+ invalidFrecenciesSQLFragment.AppendLiteral(
+ ", url, guid, hidden, last_visit_date) "
+ );
+ }
+ invalidFrecenciesSQLFragment.AppendLiteral(
+ "WHERE frecency > 0 "
+ );
+ if (!aPlaceIdsQueryString.IsEmpty()) {
+ invalidFrecenciesSQLFragment.AppendLiteral("AND id IN(");
+ invalidFrecenciesSQLFragment.Append(aPlaceIdsQueryString);
+ invalidFrecenciesSQLFragment.Append(')');
+ }
+ RefPtr<InvalidateAllFrecenciesCallback> cb =
+ aPlaceIdsQueryString.IsEmpty() ? new InvalidateAllFrecenciesCallback()
+ : nullptr;
+
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
+ invalidFrecenciesSQLFragment
+ );
+ NS_ENSURE_STATE(stmt);
+
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ nsresult rv = stmt->ExecuteAsync(cb, getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+// Call this method before visiting a URL in order to help determine the
+// transition type of the visit.
+//
+// @see MarkPageAsTyped
+
+NS_IMETHODIMP
+nsNavHistory::MarkPageAsFollowedBookmark(nsIURI* aURI)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+
+ // don't add when history is disabled
+ if (IsHistoryDisabled())
+ return NS_OK;
+
+ nsAutoCString uriString;
+ nsresult rv = aURI->GetSpec(uriString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // if URL is already in the bookmark queue, then we need to remove the old one
+ int64_t unusedEventTime;
+ if (mRecentBookmark.Get(uriString, &unusedEventTime))
+ mRecentBookmark.Remove(uriString);
+
+ if (mRecentBookmark.Count() > RECENT_EVENT_QUEUE_MAX_LENGTH)
+ ExpireNonrecentEvents(&mRecentBookmark);
+
+ mRecentBookmark.Put(uriString, GetNow());
+ return NS_OK;
+}
+
+
+// nsNavHistory::CanAddURI
+//
+// Filter out unwanted URIs such as "chrome:", "mailbox:", etc.
+//
+// The model is if we don't know differently then add which basically means
+// we are suppose to try all the things we know not to allow in and then if
+// we don't bail go on and allow it in.
+
+NS_IMETHODIMP
+nsNavHistory::CanAddURI(nsIURI* aURI, bool* canAdd)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+ NS_ENSURE_ARG_POINTER(canAdd);
+
+ // Default to false.
+ *canAdd = false;
+
+ // If history is disabled, don't add any entry.
+ if (IsHistoryDisabled()) {
+ return NS_OK;
+ }
+
+ // If the url length is over a threshold, don't add it.
+ nsCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!mDB || spec.Length() > mDB->MaxUrlLength()) {
+ return NS_OK;
+ }
+
+ nsAutoCString scheme;
+ rv = aURI->GetScheme(scheme);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // first check the most common cases (HTTP, HTTPS) to allow in to avoid most
+ // of the work
+ if (scheme.EqualsLiteral("http")) {
+ *canAdd = true;
+ return NS_OK;
+ }
+ if (scheme.EqualsLiteral("https")) {
+ *canAdd = true;
+ return NS_OK;
+ }
+
+ // now check for all bad things
+ if (scheme.EqualsLiteral("about") ||
+ scheme.EqualsLiteral("blob") ||
+ scheme.EqualsLiteral("chrome") ||
+ scheme.EqualsLiteral("data") ||
+ scheme.EqualsLiteral("imap") ||
+ scheme.EqualsLiteral("javascript") ||
+ scheme.EqualsLiteral("mailbox") ||
+ scheme.EqualsLiteral("moz-anno") ||
+ scheme.EqualsLiteral("news") ||
+ scheme.EqualsLiteral("page-icon") ||
+ scheme.EqualsLiteral("resource") ||
+ scheme.EqualsLiteral("view-source") ||
+ scheme.EqualsLiteral("wyciwyg")) {
+ return NS_OK;
+ }
+ *canAdd = true;
+ return NS_OK;
+}
+
+// nsNavHistory::GetNewQuery
+
+NS_IMETHODIMP
+nsNavHistory::GetNewQuery(nsINavHistoryQuery **_retval)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ RefPtr<nsNavHistoryQuery> query = new nsNavHistoryQuery();
+ query.forget(_retval);
+ return NS_OK;
+}
+
+// nsNavHistory::GetNewQueryOptions
+
+NS_IMETHODIMP
+nsNavHistory::GetNewQueryOptions(nsINavHistoryQueryOptions **_retval)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ RefPtr<nsNavHistoryQueryOptions> queryOptions = new nsNavHistoryQueryOptions();
+ queryOptions.forget(_retval);
+ return NS_OK;
+}
+
+// nsNavHistory::ExecuteQuery
+//
+
+NS_IMETHODIMP
+nsNavHistory::ExecuteQuery(nsINavHistoryQuery *aQuery, nsINavHistoryQueryOptions *aOptions,
+ nsINavHistoryResult** _retval)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aQuery);
+ NS_ENSURE_ARG(aOptions);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ return ExecuteQueries(&aQuery, 1, aOptions, _retval);
+}
+
+
+// nsNavHistory::ExecuteQueries
+//
+// This function is actually very simple, we just create the proper root node (either
+// a bookmark folder or a complex query node) and assign it to the result. The node
+// will then populate itself accordingly.
+//
+// Quick overview of query operation: When you call this function, we will construct
+// the correct container node and set the options you give it. This node will then
+// fill itself. Folder nodes will call nsNavBookmarks::QueryFolderChildren, and
+// all other queries will call GetQueryResults. If these results contain other
+// queries, those will be populated when the container is opened.
+
+NS_IMETHODIMP
+nsNavHistory::ExecuteQueries(nsINavHistoryQuery** aQueries, uint32_t aQueryCount,
+ nsINavHistoryQueryOptions *aOptions,
+ nsINavHistoryResult** _retval)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aQueries);
+ NS_ENSURE_ARG(aOptions);
+ NS_ENSURE_ARG(aQueryCount);
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ nsresult rv;
+ // concrete options
+ nsCOMPtr<nsNavHistoryQueryOptions> options = do_QueryInterface(aOptions);
+ NS_ENSURE_TRUE(options, NS_ERROR_INVALID_ARG);
+
+ // concrete queries array
+ nsCOMArray<nsNavHistoryQuery> queries;
+ for (uint32_t i = 0; i < aQueryCount; i ++) {
+ nsCOMPtr<nsNavHistoryQuery> query = do_QueryInterface(aQueries[i], &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+ queries.AppendElement(query.forget());
+ }
+
+ // Create the root node.
+ RefPtr<nsNavHistoryContainerResultNode> rootNode;
+ int64_t folderId = GetSimpleBookmarksQueryFolder(queries, options);
+ if (folderId) {
+ // In the simple case where we're just querying children of a single
+ // bookmark folder, we can more efficiently generate results.
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ RefPtr<nsNavHistoryResultNode> tempRootNode;
+ rv = bookmarks->ResultNodeForContainer(folderId, options,
+ getter_AddRefs(tempRootNode));
+ if (NS_SUCCEEDED(rv)) {
+ rootNode = tempRootNode->GetAsContainer();
+ }
+ else {
+ NS_WARNING("Generating a generic empty node for a broken query!");
+ // This is a perf hack to generate an empty query that skips filtering.
+ options->SetExcludeItems(true);
+ }
+ }
+
+ if (!rootNode) {
+ // Either this is not a folder shortcut, or is a broken one. In both cases
+ // just generate a query node.
+ rootNode = new nsNavHistoryQueryResultNode(EmptyCString(), EmptyCString(),
+ queries, options);
+ }
+
+ // Create the result that will hold nodes. Inject batching status into it.
+ RefPtr<nsNavHistoryResult> result;
+ rv = nsNavHistoryResult::NewHistoryResult(aQueries, aQueryCount, options,
+ rootNode, isBatching(),
+ getter_AddRefs(result));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ result.forget(_retval);
+ return NS_OK;
+}
+
+// determine from our nsNavHistoryQuery array and nsNavHistoryQueryOptions
+// if this is the place query from the history menu.
+// from browser-menubar.inc, our history menu query is:
+// place:sort=4&maxResults=10
+// note, any maxResult > 0 will still be considered a history menu query
+// or if this is the place query from the "Most Visited" item in the
+// "Smart Bookmarks" folder: place:sort=8&maxResults=10
+// note, any maxResult > 0 will still be considered a Most Visited menu query
+static
+bool IsOptimizableHistoryQuery(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions *aOptions,
+ uint16_t aSortMode)
+{
+ if (aQueries.Count() != 1)
+ return false;
+
+ nsNavHistoryQuery *aQuery = aQueries[0];
+
+ if (aOptions->QueryType() != nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY)
+ return false;
+
+ if (aOptions->ResultType() != nsINavHistoryQueryOptions::RESULTS_AS_URI)
+ return false;
+
+ if (aOptions->SortingMode() != aSortMode)
+ return false;
+
+ if (aOptions->MaxResults() <= 0)
+ return false;
+
+ if (aOptions->ExcludeItems())
+ return false;
+
+ if (aOptions->IncludeHidden())
+ return false;
+
+ if (aQuery->MinVisits() != -1 || aQuery->MaxVisits() != -1)
+ return false;
+
+ if (aQuery->BeginTime() || aQuery->BeginTimeReference())
+ return false;
+
+ if (aQuery->EndTime() || aQuery->EndTimeReference())
+ return false;
+
+ if (!aQuery->SearchTerms().IsEmpty())
+ return false;
+
+ if (aQuery->OnlyBookmarked())
+ return false;
+
+ if (aQuery->DomainIsHost() || !aQuery->Domain().IsEmpty())
+ return false;
+
+ if (aQuery->AnnotationIsNot() || !aQuery->Annotation().IsEmpty())
+ return false;
+
+ if (aQuery->Folders().Length() > 0)
+ return false;
+
+ if (aQuery->Tags().Length() > 0)
+ return false;
+
+ if (aQuery->Transitions().Length() > 0)
+ return false;
+
+ return true;
+}
+
+static
+bool NeedToFilterResultSet(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions *aOptions)
+{
+ uint16_t resultType = aOptions->ResultType();
+ return resultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS;
+}
+
+// ** Helper class for ConstructQueryString **/
+
+class PlacesSQLQueryBuilder
+{
+public:
+ PlacesSQLQueryBuilder(const nsCString& aConditions,
+ nsNavHistoryQueryOptions* aOptions,
+ bool aUseLimit,
+ nsNavHistory::StringHash& aAddParams,
+ bool aHasSearchTerms);
+
+ nsresult GetQueryString(nsCString& aQueryString);
+
+private:
+ nsresult Select();
+
+ nsresult SelectAsURI();
+ nsresult SelectAsVisit();
+ nsresult SelectAsDay();
+ nsresult SelectAsSite();
+ nsresult SelectAsTag();
+
+ nsresult Where();
+ nsresult GroupBy();
+ nsresult OrderBy();
+ nsresult Limit();
+
+ void OrderByColumnIndexAsc(int32_t aIndex);
+ void OrderByColumnIndexDesc(int32_t aIndex);
+ // Use these if you want a case insensitive sorting.
+ void OrderByTextColumnIndexAsc(int32_t aIndex);
+ void OrderByTextColumnIndexDesc(int32_t aIndex);
+
+ const nsCString& mConditions;
+ bool mUseLimit;
+ bool mHasSearchTerms;
+
+ uint16_t mResultType;
+ uint16_t mQueryType;
+ bool mIncludeHidden;
+ uint16_t mSortingMode;
+ uint32_t mMaxResults;
+
+ nsCString mQueryString;
+ nsCString mGroupBy;
+ bool mHasDateColumns;
+ bool mSkipOrderBy;
+ nsNavHistory::StringHash& mAddParams;
+};
+
+PlacesSQLQueryBuilder::PlacesSQLQueryBuilder(
+ const nsCString& aConditions,
+ nsNavHistoryQueryOptions* aOptions,
+ bool aUseLimit,
+ nsNavHistory::StringHash& aAddParams,
+ bool aHasSearchTerms)
+: mConditions(aConditions)
+, mUseLimit(aUseLimit)
+, mHasSearchTerms(aHasSearchTerms)
+, mResultType(aOptions->ResultType())
+, mQueryType(aOptions->QueryType())
+, mIncludeHidden(aOptions->IncludeHidden())
+, mSortingMode(aOptions->SortingMode())
+, mMaxResults(aOptions->MaxResults())
+, mSkipOrderBy(false)
+, mAddParams(aAddParams)
+{
+ mHasDateColumns = (mQueryType == nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS);
+}
+
+nsresult
+PlacesSQLQueryBuilder::GetQueryString(nsCString& aQueryString)
+{
+ nsresult rv = Select();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = Where();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = GroupBy();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = OrderBy();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = Limit();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aQueryString = mQueryString;
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::Select()
+{
+ nsresult rv;
+
+ switch (mResultType)
+ {
+ case nsINavHistoryQueryOptions::RESULTS_AS_URI:
+ case nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS:
+ rv = SelectAsURI();
+ NS_ENSURE_SUCCESS(rv, rv);
+ break;
+
+ case nsINavHistoryQueryOptions::RESULTS_AS_VISIT:
+ case nsINavHistoryQueryOptions::RESULTS_AS_FULL_VISIT:
+ rv = SelectAsVisit();
+ NS_ENSURE_SUCCESS(rv, rv);
+ break;
+
+ case nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY:
+ case nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY:
+ rv = SelectAsDay();
+ NS_ENSURE_SUCCESS(rv, rv);
+ break;
+
+ case nsINavHistoryQueryOptions::RESULTS_AS_SITE_QUERY:
+ rv = SelectAsSite();
+ NS_ENSURE_SUCCESS(rv, rv);
+ break;
+
+ case nsINavHistoryQueryOptions::RESULTS_AS_TAG_QUERY:
+ rv = SelectAsTag();
+ NS_ENSURE_SUCCESS(rv, rv);
+ break;
+
+ default:
+ NS_NOTREACHED("Invalid result type");
+ }
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::SelectAsURI()
+{
+ nsNavHistory *history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ nsAutoCString tagsSqlFragment;
+
+ switch (mQueryType) {
+ case nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY:
+ GetTagsSqlFragment(history->GetTagsFolder(),
+ NS_LITERAL_CSTRING("h.id"),
+ mHasSearchTerms,
+ tagsSqlFragment);
+
+ mQueryString = NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.title AS page_title, h.rev_host, h.visit_count, "
+ "h.last_visit_date, f.url, null, null, null, null, ") +
+ tagsSqlFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null "
+ "FROM moz_places h "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ // WHERE 1 is a no-op since additonal conditions will start with AND.
+ "WHERE 1 "
+ "{QUERY_OPTIONS_VISITS} {QUERY_OPTIONS_PLACES} "
+ "{ADDITIONAL_CONDITIONS} ");
+ break;
+
+ case nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS:
+ if (mResultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS) {
+ // Order-by clause is hardcoded because we need to discard duplicates
+ // in FilterResultSet. We will retain only the last modified item,
+ // so we are ordering by place id and last modified to do a faster
+ // filtering.
+ mSkipOrderBy = true;
+
+ GetTagsSqlFragment(history->GetTagsFolder(),
+ NS_LITERAL_CSTRING("b2.fk"),
+ mHasSearchTerms,
+ tagsSqlFragment);
+
+ mQueryString = NS_LITERAL_CSTRING(
+ "SELECT b2.fk, h.url, COALESCE(b2.title, h.title) AS page_title, "
+ "h.rev_host, h.visit_count, h.last_visit_date, f.url, b2.id, "
+ "b2.dateAdded, b2.lastModified, b2.parent, ") +
+ tagsSqlFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null, b2.guid, b2.position, b2.type, b2.fk "
+ "FROM moz_bookmarks b2 "
+ "JOIN (SELECT b.fk "
+ "FROM moz_bookmarks b "
+ // ADDITIONAL_CONDITIONS will filter on parent.
+ "WHERE b.type = 1 {ADDITIONAL_CONDITIONS} "
+ ") AS seed ON b2.fk = seed.fk "
+ "JOIN moz_places h ON h.id = b2.fk "
+ "LEFT OUTER JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE NOT EXISTS ( "
+ "SELECT id FROM moz_bookmarks WHERE id = b2.parent AND parent = ") +
+ nsPrintfCString("%lld", history->GetTagsFolder()) +
+ NS_LITERAL_CSTRING(") "
+ "ORDER BY b2.fk DESC, b2.lastModified DESC");
+ }
+ else {
+ GetTagsSqlFragment(history->GetTagsFolder(),
+ NS_LITERAL_CSTRING("b.fk"),
+ mHasSearchTerms,
+ tagsSqlFragment);
+ mQueryString = NS_LITERAL_CSTRING(
+ "SELECT b.fk, h.url, COALESCE(b.title, h.title) AS page_title, "
+ "h.rev_host, h.visit_count, h.last_visit_date, f.url, b.id, "
+ "b.dateAdded, b.lastModified, b.parent, ") +
+ tagsSqlFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid,"
+ "null, null, null, b.guid, b.position, b.type, b.fk "
+ "FROM moz_bookmarks b "
+ "JOIN moz_places h ON b.fk = h.id "
+ "LEFT OUTER JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE NOT EXISTS "
+ "(SELECT id FROM moz_bookmarks "
+ "WHERE id = b.parent AND parent = ") +
+ nsPrintfCString("%lld", history->GetTagsFolder()) +
+ NS_LITERAL_CSTRING(") "
+ "{ADDITIONAL_CONDITIONS}");
+ }
+ break;
+
+ default:
+ return NS_ERROR_NOT_IMPLEMENTED;
+ }
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::SelectAsVisit()
+{
+ nsNavHistory *history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ nsAutoCString tagsSqlFragment;
+ GetTagsSqlFragment(history->GetTagsFolder(),
+ NS_LITERAL_CSTRING("h.id"),
+ mHasSearchTerms,
+ tagsSqlFragment);
+ mQueryString = NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.title AS page_title, h.rev_host, h.visit_count, "
+ "v.visit_date, f.url, null, null, null, null, ") +
+ tagsSqlFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "v.id, v.from_visit, v.visit_type "
+ "FROM moz_places h "
+ "JOIN moz_historyvisits v ON h.id = v.place_id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ // WHERE 1 is a no-op since additonal conditions will start with AND.
+ "WHERE 1 "
+ "{QUERY_OPTIONS_VISITS} {QUERY_OPTIONS_PLACES} "
+ "{ADDITIONAL_CONDITIONS} ");
+
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::SelectAsDay()
+{
+ mSkipOrderBy = true;
+
+ // Sort child queries based on sorting mode if it's provided, otherwise
+ // fallback to default sort by title ascending.
+ uint16_t sortingMode = nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING;
+ if (mSortingMode != nsINavHistoryQueryOptions::SORT_BY_NONE &&
+ mResultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY)
+ sortingMode = mSortingMode;
+
+ uint16_t resultType =
+ mResultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY ?
+ (uint16_t)nsINavHistoryQueryOptions::RESULTS_AS_URI :
+ (uint16_t)nsINavHistoryQueryOptions::RESULTS_AS_SITE_QUERY;
+
+ // beginTime will become the node's time property, we don't use endTime
+ // because it could overlap, and we use time to sort containers and find
+ // insert position in a result.
+ mQueryString = nsPrintfCString(
+ "SELECT null, "
+ "'place:type=%ld&sort=%ld&beginTime='||beginTime||'&endTime='||endTime, "
+ "dayTitle, null, null, beginTime, null, null, null, null, null, null, "
+ "null, null, null "
+ "FROM (", // TOUTER BEGIN
+ resultType,
+ sortingMode);
+
+ nsNavHistory *history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(history);
+
+ int32_t daysOfHistory = history->GetDaysOfHistory();
+ for (int32_t i = 0; i <= HISTORY_DATE_CONT_NUM(daysOfHistory); i++) {
+ nsAutoCString dateName;
+ // Timeframes are calculated as BeginTime <= container < EndTime.
+ // Notice times can't be relative to now, since to recognize a query we
+ // must ensure it won't change based on the time it is built.
+ // So, to select till now, we really select till start of tomorrow, that is
+ // a fixed timestamp.
+ // These are used as limits for the inside containers.
+ nsAutoCString sqlFragmentContainerBeginTime, sqlFragmentContainerEndTime;
+ // These are used to query if the container should be visible.
+ nsAutoCString sqlFragmentSearchBeginTime, sqlFragmentSearchEndTime;
+ switch(i) {
+ case 0:
+ // Today
+ history->GetStringFromName(
+ u"finduri-AgeInDays-is-0", dateName);
+ // From start of today
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','utc')*1000000)");
+ // To now (tomorrow)
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','+1 day','utc')*1000000)");
+ // Search for the same timeframe.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = sqlFragmentContainerEndTime;
+ break;
+ case 1:
+ // Yesterday
+ history->GetStringFromName(
+ u"finduri-AgeInDays-is-1", dateName);
+ // From start of yesterday
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','-1 day','utc')*1000000)");
+ // To start of today
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','utc')*1000000)");
+ // Search for the same timeframe.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = sqlFragmentContainerEndTime;
+ break;
+ case 2:
+ // Last 7 days
+ history->GetAgeInDaysString(7,
+ u"finduri-AgeInDays-last-is", dateName);
+ // From start of 7 days ago
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','-7 days','utc')*1000000)");
+ // To now (tomorrow)
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','+1 day','utc')*1000000)");
+ // This is an overlapped container, but we show it only if there are
+ // visits older than yesterday.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','-2 days','utc')*1000000)");
+ break;
+ case 3:
+ // This month
+ history->GetStringFromName(
+ u"finduri-AgeInMonths-is-0", dateName);
+ // From start of this month
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of month','utc')*1000000)");
+ // To now (tomorrow)
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','+1 day','utc')*1000000)");
+ // This is an overlapped container, but we show it only if there are
+ // visits older than 7 days ago.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of day','-7 days','utc')*1000000)");
+ break;
+ default:
+ if (i == HISTORY_ADDITIONAL_DATE_CONT_NUM + 6) {
+ // Older than 6 months
+ history->GetAgeInDaysString(6,
+ u"finduri-AgeInMonths-isgreater", dateName);
+ // From start of epoch
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(datetime(0, 'unixepoch')*1000000)");
+ // To start of 6 months ago ( 5 months + this month).
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of month','-5 months','utc')*1000000)");
+ // Search for the same timeframe.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = sqlFragmentContainerEndTime;
+ break;
+ }
+ int32_t MonthIndex = i - HISTORY_ADDITIONAL_DATE_CONT_NUM;
+ // Previous months' titles are month's name if inside this year,
+ // month's name and year for previous years.
+ PRExplodedTime tm;
+ PR_ExplodeTime(PR_Now(), PR_LocalTimeParameters, &tm);
+ uint16_t currentYear = tm.tm_year;
+ // Set day before month, setting month without day could cause issues.
+ // For example setting month to February when today is 30, since
+ // February has not 30 days, will return March instead.
+ // Also, we use day 2 instead of day 1, so that the GMT month is always
+ // the same as the local month. (Bug 603002)
+ tm.tm_mday = 2;
+ tm.tm_month -= MonthIndex;
+ // Notice we use GMTParameters because we just want to get the first
+ // day of each month. Using LocalTimeParameters would instead force us
+ // to apply a DST correction that we don't really need here.
+ PR_NormalizeTime(&tm, PR_GMTParameters);
+ // If the container is for a past year, add the year to its title,
+ // otherwise just show the month name.
+ // Note that tm_month starts from 0, while we need a 1-based index.
+ if (tm.tm_year < currentYear) {
+ history->GetMonthYear(tm.tm_month + 1, tm.tm_year, dateName);
+ }
+ else {
+ history->GetMonthName(tm.tm_month + 1, dateName);
+ }
+
+ // From start of MonthIndex + 1 months ago
+ sqlFragmentContainerBeginTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of month','-");
+ sqlFragmentContainerBeginTime.AppendInt(MonthIndex);
+ sqlFragmentContainerBeginTime.Append(NS_LITERAL_CSTRING(
+ " months','utc')*1000000)"));
+ // To start of MonthIndex months ago
+ sqlFragmentContainerEndTime = NS_LITERAL_CSTRING(
+ "(strftime('%s','now','localtime','start of month','-");
+ sqlFragmentContainerEndTime.AppendInt(MonthIndex - 1);
+ sqlFragmentContainerEndTime.Append(NS_LITERAL_CSTRING(
+ " months','utc')*1000000)"));
+ // Search for the same timeframe.
+ sqlFragmentSearchBeginTime = sqlFragmentContainerBeginTime;
+ sqlFragmentSearchEndTime = sqlFragmentContainerEndTime;
+ break;
+ }
+
+ nsPrintfCString dateParam("dayTitle%d", i);
+ mAddParams.Put(dateParam, dateName);
+
+ nsPrintfCString dayRange(
+ "SELECT :%s AS dayTitle, "
+ "%s AS beginTime, "
+ "%s AS endTime "
+ "WHERE EXISTS ( "
+ "SELECT id FROM moz_historyvisits "
+ "WHERE visit_date >= %s "
+ "AND visit_date < %s "
+ "AND visit_type NOT IN (0,%d,%d) "
+ "{QUERY_OPTIONS_VISITS} "
+ "LIMIT 1 "
+ ") ",
+ dateParam.get(),
+ sqlFragmentContainerBeginTime.get(),
+ sqlFragmentContainerEndTime.get(),
+ sqlFragmentSearchBeginTime.get(),
+ sqlFragmentSearchEndTime.get(),
+ nsINavHistoryService::TRANSITION_EMBED,
+ nsINavHistoryService::TRANSITION_FRAMED_LINK
+ );
+
+ mQueryString.Append(dayRange);
+
+ if (i < HISTORY_DATE_CONT_NUM(daysOfHistory))
+ mQueryString.AppendLiteral(" UNION ALL ");
+ }
+
+ mQueryString.AppendLiteral(") "); // TOUTER END
+
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::SelectAsSite()
+{
+ nsAutoCString localFiles;
+
+ nsNavHistory *history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(history);
+
+ history->GetStringFromName(u"localhost", localFiles);
+ mAddParams.Put(NS_LITERAL_CSTRING("localhost"), localFiles);
+
+ // If there are additional conditions the query has to join on visits too.
+ nsAutoCString visitsJoin;
+ nsAutoCString additionalConditions;
+ nsAutoCString timeConstraints;
+ if (!mConditions.IsEmpty()) {
+ visitsJoin.AssignLiteral("JOIN moz_historyvisits v ON v.place_id = h.id ");
+ additionalConditions.AssignLiteral("{QUERY_OPTIONS_VISITS} "
+ "{QUERY_OPTIONS_PLACES} "
+ "{ADDITIONAL_CONDITIONS} ");
+ timeConstraints.AssignLiteral("||'&beginTime='||:begin_time||"
+ "'&endTime='||:end_time");
+ }
+
+ mQueryString = nsPrintfCString(
+ "SELECT null, 'place:type=%ld&sort=%ld&domain=&domainIsHost=true'%s, "
+ ":localhost, :localhost, null, null, null, null, null, null, null, "
+ "null, null, null "
+ "WHERE EXISTS ( "
+ "SELECT h.id FROM moz_places h "
+ "%s "
+ "WHERE h.hidden = 0 "
+ "AND h.visit_count > 0 "
+ "AND h.url_hash BETWEEN hash('file', 'prefix_lo') AND "
+ "hash('file', 'prefix_hi') "
+ "%s "
+ "LIMIT 1 "
+ ") "
+ "UNION ALL "
+ "SELECT null, "
+ "'place:type=%ld&sort=%ld&domain='||host||'&domainIsHost=true'%s, "
+ "host, host, null, null, null, null, null, null, null, "
+ "null, null, null "
+ "FROM ( "
+ "SELECT get_unreversed_host(h.rev_host) AS host "
+ "FROM moz_places h "
+ "%s "
+ "WHERE h.hidden = 0 "
+ "AND h.rev_host <> '.' "
+ "AND h.visit_count > 0 "
+ "%s "
+ "GROUP BY h.rev_host "
+ "ORDER BY host ASC "
+ ") ",
+ nsINavHistoryQueryOptions::RESULTS_AS_URI,
+ mSortingMode,
+ timeConstraints.get(),
+ visitsJoin.get(),
+ additionalConditions.get(),
+ nsINavHistoryQueryOptions::RESULTS_AS_URI,
+ mSortingMode,
+ timeConstraints.get(),
+ visitsJoin.get(),
+ additionalConditions.get()
+ );
+
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::SelectAsTag()
+{
+ nsNavHistory *history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(history);
+
+ // This allows sorting by date fields what is not possible with
+ // other history queries.
+ mHasDateColumns = true;
+
+ mQueryString = nsPrintfCString(
+ "SELECT null, 'place:folder=' || id || '&queryType=%d&type=%ld', "
+ "title, null, null, null, null, null, dateAdded, "
+ "lastModified, null, null, null, null, null, null "
+ "FROM moz_bookmarks "
+ "WHERE parent = %lld",
+ nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS,
+ nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS,
+ history->GetTagsFolder()
+ );
+
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::Where()
+{
+
+ // Set query options
+ nsAutoCString additionalVisitsConditions;
+ nsAutoCString additionalPlacesConditions;
+
+ if (!mIncludeHidden) {
+ additionalPlacesConditions += NS_LITERAL_CSTRING("AND hidden = 0 ");
+ }
+
+ if (mQueryType == nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY) {
+ // last_visit_date is updated for any kind of visit, so it's a good
+ // indicator whether the page has visits.
+ additionalPlacesConditions += NS_LITERAL_CSTRING(
+ "AND last_visit_date NOTNULL "
+ );
+ }
+
+ if (mResultType == nsINavHistoryQueryOptions::RESULTS_AS_URI &&
+ !additionalVisitsConditions.IsEmpty()) {
+ // URI results don't join on visits.
+ nsAutoCString tmp = additionalVisitsConditions;
+ additionalVisitsConditions = "AND EXISTS (SELECT 1 FROM moz_historyvisits WHERE place_id = h.id ";
+ additionalVisitsConditions.Append(tmp);
+ additionalVisitsConditions.AppendLiteral("LIMIT 1)");
+ }
+
+ mQueryString.ReplaceSubstring("{QUERY_OPTIONS_VISITS}",
+ additionalVisitsConditions.get());
+ mQueryString.ReplaceSubstring("{QUERY_OPTIONS_PLACES}",
+ additionalPlacesConditions.get());
+
+ // If we used WHERE already, we inject the conditions
+ // in place of {ADDITIONAL_CONDITIONS}
+ if (mQueryString.Find("{ADDITIONAL_CONDITIONS}", 0) != kNotFound) {
+ nsAutoCString innerCondition;
+ // If we have condition AND it
+ if (!mConditions.IsEmpty()) {
+ innerCondition = " AND (";
+ innerCondition += mConditions;
+ innerCondition += ")";
+ }
+ mQueryString.ReplaceSubstring("{ADDITIONAL_CONDITIONS}",
+ innerCondition.get());
+
+ } else if (!mConditions.IsEmpty()) {
+
+ mQueryString += "WHERE ";
+ mQueryString += mConditions;
+
+ }
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::GroupBy()
+{
+ mQueryString += mGroupBy;
+ return NS_OK;
+}
+
+nsresult
+PlacesSQLQueryBuilder::OrderBy()
+{
+ if (mSkipOrderBy)
+ return NS_OK;
+
+ // Sort clause: we will sort later, but if it comes out of the DB sorted,
+ // our later sort will be basically free. The DB can sort these for free
+ // most of the time anyway, because it has indices over these items.
+ switch(mSortingMode)
+ {
+ case nsINavHistoryQueryOptions::SORT_BY_NONE:
+ // Ensure sorting does not change based on tables status.
+ if (mResultType == nsINavHistoryQueryOptions::RESULTS_AS_URI) {
+ if (mQueryType == nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS)
+ mQueryString += NS_LITERAL_CSTRING(" ORDER BY b.id ASC ");
+ else if (mQueryType == nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY)
+ mQueryString += NS_LITERAL_CSTRING(" ORDER BY h.id ASC ");
+ }
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING:
+ case nsINavHistoryQueryOptions::SORT_BY_TITLE_DESCENDING:
+ // If the user wants few results, we limit them by date, necessitating
+ // a sort by date here (see the IDL definition for maxResults).
+ // Otherwise we will do actual sorting by title, but since we could need
+ // to special sort for some locale we will repeat a second sorting at the
+ // end in nsNavHistoryResult, that should be faster since the list will be
+ // almost ordered.
+ if (mMaxResults > 0)
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_VisitDate);
+ else if (mSortingMode == nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING)
+ OrderByTextColumnIndexAsc(nsNavHistory::kGetInfoIndex_Title);
+ else
+ OrderByTextColumnIndexDesc(nsNavHistory::kGetInfoIndex_Title);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_DATE_ASCENDING:
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_VisitDate);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING:
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_VisitDate);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_URI_ASCENDING:
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_URL);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_URI_DESCENDING:
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_URL);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_ASCENDING:
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_VisitCount);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING:
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_VisitCount);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_DATEADDED_ASCENDING:
+ if (mHasDateColumns)
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_ItemDateAdded);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_DATEADDED_DESCENDING:
+ if (mHasDateColumns)
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_ItemDateAdded);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_LASTMODIFIED_ASCENDING:
+ if (mHasDateColumns)
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_ItemLastModified);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_LASTMODIFIED_DESCENDING:
+ if (mHasDateColumns)
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_ItemLastModified);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_TAGS_ASCENDING:
+ case nsINavHistoryQueryOptions::SORT_BY_TAGS_DESCENDING:
+ case nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_ASCENDING:
+ case nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_DESCENDING:
+ break; // Sort later in nsNavHistoryQueryResultNode::FillChildren()
+ case nsINavHistoryQueryOptions::SORT_BY_FRECENCY_ASCENDING:
+ OrderByColumnIndexAsc(nsNavHistory::kGetInfoIndex_Frecency);
+ break;
+ case nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING:
+ OrderByColumnIndexDesc(nsNavHistory::kGetInfoIndex_Frecency);
+ break;
+ default:
+ NS_NOTREACHED("Invalid sorting mode");
+ }
+ return NS_OK;
+}
+
+void PlacesSQLQueryBuilder::OrderByColumnIndexAsc(int32_t aIndex)
+{
+ mQueryString += nsPrintfCString(" ORDER BY %d ASC", aIndex+1);
+}
+
+void PlacesSQLQueryBuilder::OrderByColumnIndexDesc(int32_t aIndex)
+{
+ mQueryString += nsPrintfCString(" ORDER BY %d DESC", aIndex+1);
+}
+
+void PlacesSQLQueryBuilder::OrderByTextColumnIndexAsc(int32_t aIndex)
+{
+ mQueryString += nsPrintfCString(" ORDER BY %d COLLATE NOCASE ASC",
+ aIndex+1);
+}
+
+void PlacesSQLQueryBuilder::OrderByTextColumnIndexDesc(int32_t aIndex)
+{
+ mQueryString += nsPrintfCString(" ORDER BY %d COLLATE NOCASE DESC",
+ aIndex+1);
+}
+
+nsresult
+PlacesSQLQueryBuilder::Limit()
+{
+ if (mUseLimit && mMaxResults > 0) {
+ mQueryString += NS_LITERAL_CSTRING(" LIMIT ");
+ mQueryString.AppendInt(mMaxResults);
+ mQueryString.Append(' ');
+ }
+ return NS_OK;
+}
+
+nsresult
+nsNavHistory::ConstructQueryString(
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCString& queryString,
+ bool& aParamsPresent,
+ nsNavHistory::StringHash& aAddParams)
+{
+ // For information about visit_type see nsINavHistoryService.idl.
+ // visitType == 0 is undefined (see bug #375777 for details).
+ // Some sites, especially Javascript-heavy ones, load things in frames to
+ // display them, resulting in a lot of these entries. This is the reason
+ // why such visits are filtered out.
+ nsresult rv;
+ aParamsPresent = false;
+
+ int32_t sortingMode = aOptions->SortingMode();
+ NS_ASSERTION(sortingMode >= nsINavHistoryQueryOptions::SORT_BY_NONE &&
+ sortingMode <= nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING,
+ "Invalid sortingMode found while building query!");
+
+ bool hasSearchTerms = false;
+ for (int32_t i = 0; i < aQueries.Count() && !hasSearchTerms; i++) {
+ aQueries[i]->GetHasSearchTerms(&hasSearchTerms);
+ }
+
+ nsAutoCString tagsSqlFragment;
+ GetTagsSqlFragment(GetTagsFolder(),
+ NS_LITERAL_CSTRING("h.id"),
+ hasSearchTerms,
+ tagsSqlFragment);
+
+ if (IsOptimizableHistoryQuery(aQueries, aOptions,
+ nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING) ||
+ IsOptimizableHistoryQuery(aQueries, aOptions,
+ nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING)) {
+ // Generate an optimized query for the history menu and most visited
+ // smart bookmark.
+ queryString = NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.title AS page_title, h.rev_host, h.visit_count, h.last_visit_date, "
+ "f.url, null, null, null, null, ") +
+ tagsSqlFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null "
+ "FROM moz_places h "
+ "LEFT OUTER JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE h.hidden = 0 "
+ "AND EXISTS (SELECT id FROM moz_historyvisits WHERE place_id = h.id "
+ "AND visit_type NOT IN ") +
+ nsPrintfCString("(0,%d,%d) ",
+ nsINavHistoryService::TRANSITION_EMBED,
+ nsINavHistoryService::TRANSITION_FRAMED_LINK) +
+ NS_LITERAL_CSTRING("LIMIT 1) "
+ "{QUERY_OPTIONS} "
+ );
+
+ queryString.AppendLiteral("ORDER BY ");
+ if (sortingMode == nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING)
+ queryString.AppendLiteral("last_visit_date DESC ");
+ else
+ queryString.AppendLiteral("visit_count DESC ");
+
+ queryString.AppendLiteral("LIMIT ");
+ queryString.AppendInt(aOptions->MaxResults());
+
+ nsAutoCString additionalQueryOptions;
+
+ queryString.ReplaceSubstring("{QUERY_OPTIONS}",
+ additionalQueryOptions.get());
+ return NS_OK;
+ }
+
+ nsAutoCString conditions;
+ for (int32_t i = 0; i < aQueries.Count(); i++) {
+ nsCString queryClause;
+ rv = QueryToSelectClause(aQueries[i], aOptions, i, &queryClause);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (! queryClause.IsEmpty()) {
+ aParamsPresent = true;
+ if (! conditions.IsEmpty()) // exists previous clause: multiple ones are ORed
+ conditions += NS_LITERAL_CSTRING(" OR ");
+ conditions += NS_LITERAL_CSTRING("(") + queryClause +
+ NS_LITERAL_CSTRING(")");
+ }
+ }
+
+ // Determine whether we can push maxResults constraints into the queries
+ // as LIMIT, or if we need to do result count clamping later
+ // using FilterResultSet()
+ bool useLimitClause = !NeedToFilterResultSet(aQueries, aOptions);
+
+ PlacesSQLQueryBuilder queryStringBuilder(conditions, aOptions,
+ useLimitClause, aAddParams,
+ hasSearchTerms);
+ rv = queryStringBuilder.GetQueryString(queryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+// nsNavHistory::GetQueryResults
+//
+// Call this to get the results from a complex query. This is used by
+// nsNavHistoryQueryResultNode to populate its children. For simple bookmark
+// queries, use nsNavBookmarks::QueryFolderChildren.
+//
+// THIS DOES NOT DO SORTING. You will need to sort the container yourself
+// when you get the results. This is because sorting depends on tree
+// statistics that will be built from the perspective of the tree. See
+// nsNavHistoryQueryResultNode::FillChildren
+//
+// FIXME: This only does keyword searching for the first query, and does
+// it ANDed with the all the rest of the queries.
+
+nsresult
+nsNavHistory::GetQueryResults(nsNavHistoryQueryResultNode *aResultNode,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions *aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aResults)
+{
+ NS_ENSURE_ARG_POINTER(aOptions);
+ NS_ASSERTION(aResults->Count() == 0, "Initial result array must be empty");
+ if (! aQueries.Count())
+ return NS_ERROR_INVALID_ARG;
+
+ nsCString queryString;
+ bool paramsPresent = false;
+ nsNavHistory::StringHash addParams(HISTORY_DATE_CONT_LENGTH);
+ nsresult rv = ConstructQueryString(aQueries, aOptions, queryString,
+ paramsPresent, addParams);
+ NS_ENSURE_SUCCESS(rv,rv);
+
+ // create statement
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(queryString);
+#ifdef DEBUG
+ if (!statement) {
+ nsAutoCString lastErrorString;
+ (void)mDB->MainConn()->GetLastErrorString(lastErrorString);
+ int32_t lastError = 0;
+ (void)mDB->MainConn()->GetLastError(&lastError);
+ printf("Places failed to create a statement from this query:\n%s\nStorage error (%d): %s\n",
+ queryString.get(), lastError, lastErrorString.get());
+ }
+#endif
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ if (paramsPresent) {
+ // bind parameters
+ int32_t i;
+ for (i = 0; i < aQueries.Count(); i++) {
+ rv = BindQueryClauseParameters(statement, i, aQueries[i], aOptions);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ for (auto iter = addParams.Iter(); !iter.Done(); iter.Next()) {
+ nsresult rv = statement->BindUTF8StringByName(iter.Key(), iter.Data());
+ if (NS_FAILED(rv)) {
+ break;
+ }
+ }
+
+ // Optimize the case where there is no need for any post-query filtering.
+ if (NeedToFilterResultSet(aQueries, aOptions)) {
+ // Generate the top-level results.
+ nsCOMArray<nsNavHistoryResultNode> toplevel;
+ rv = ResultsAsList(statement, aOptions, &toplevel);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ FilterResultSet(aResultNode, toplevel, aResults, aQueries, aOptions);
+ } else {
+ rv = ResultsAsList(statement, aOptions, aResults);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistory::AddObserver(nsINavHistoryObserver* aObserver, bool aOwnsWeak)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aObserver);
+
+ if (NS_WARN_IF(!mCanNotify))
+ return NS_ERROR_UNEXPECTED;
+
+ return mObservers.AppendWeakElement(aObserver, aOwnsWeak);
+}
+
+NS_IMETHODIMP
+nsNavHistory::RemoveObserver(nsINavHistoryObserver* aObserver)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aObserver);
+
+ return mObservers.RemoveWeakElement(aObserver);
+}
+
+NS_IMETHODIMP
+nsNavHistory::GetObservers(uint32_t* _count,
+ nsINavHistoryObserver*** _observers)
+{
+ NS_ENSURE_ARG_POINTER(_count);
+ NS_ENSURE_ARG_POINTER(_observers);
+
+ *_count = 0;
+ *_observers = nullptr;
+
+ // Clear any cached value, cause it's very likely the consumer has made
+ // changes to history and is now trying to notify them.
+ mDaysOfHistory = -1;
+
+ if (!mCanNotify)
+ return NS_OK;
+
+ nsCOMArray<nsINavHistoryObserver> observers;
+
+ // First add the category cache observers.
+ mCacheObservers.GetEntries(observers);
+
+ // Then add the other observers.
+ for (uint32_t i = 0; i < mObservers.Length(); ++i) {
+ const nsCOMPtr<nsINavHistoryObserver> &observer = mObservers.ElementAt(i).GetValue();
+ // Skip nullified weak observers.
+ if (observer)
+ observers.AppendElement(observer);
+ }
+
+ if (observers.Count() == 0)
+ return NS_OK;
+
+ *_count = observers.Count();
+ observers.Forget(_observers);
+
+ return NS_OK;
+}
+
+// See RunInBatchMode
+nsresult
+nsNavHistory::BeginUpdateBatch()
+{
+ if (mBatchLevel++ == 0) {
+ mBatchDBTransaction = new mozStorageTransaction(mDB->MainConn(), false,
+ mozIStorageConnection::TRANSACTION_DEFERRED,
+ true);
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver, OnBeginUpdateBatch());
+ }
+ return NS_OK;
+}
+
+// nsNavHistory::EndUpdateBatch
+nsresult
+nsNavHistory::EndUpdateBatch()
+{
+ if (--mBatchLevel == 0) {
+ if (mBatchDBTransaction) {
+ DebugOnly<nsresult> rv = mBatchDBTransaction->Commit();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Batch failed to commit transaction");
+ delete mBatchDBTransaction;
+ mBatchDBTransaction = nullptr;
+ }
+
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver, OnEndUpdateBatch());
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistory::RunInBatchMode(nsINavHistoryBatchCallback* aCallback,
+ nsISupports* aUserData)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aCallback);
+
+ UpdateBatchScoper batch(*this);
+ return aCallback->RunBatched(aUserData);
+}
+
+NS_IMETHODIMP
+nsNavHistory::GetHistoryDisabled(bool *_retval)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG_POINTER(_retval);
+
+ *_retval = IsHistoryDisabled();
+ return NS_OK;
+}
+
+// Browser history *************************************************************
+
+
+// nsNavHistory::RemovePagesInternal
+//
+// Deletes a list of placeIds from history.
+// This is an internal method used by RemovePages, RemovePagesFromHost and
+// RemovePagesByTimeframe.
+// Takes a comma separated list of place ids.
+// This method does not do any observer notification.
+
+nsresult
+nsNavHistory::RemovePagesInternal(const nsCString& aPlaceIdsQueryString)
+{
+ // Return early if there is nothing to delete.
+ if (aPlaceIdsQueryString.IsEmpty())
+ return NS_OK;
+
+ mozStorageTransaction transaction(mDB->MainConn(), false,
+ mozIStorageConnection::TRANSACTION_DEFERRED,
+ true);
+
+ // Delete all visits for the specified place ids.
+ nsresult rv = mDB->MainConn()->ExecuteSimpleSQL(
+ NS_LITERAL_CSTRING(
+ "DELETE FROM moz_historyvisits WHERE place_id IN (") +
+ aPlaceIdsQueryString +
+ NS_LITERAL_CSTRING(")")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = CleanupPlacesOnVisitsDelete(aPlaceIdsQueryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Invalidate the cached value for whether there's history or not.
+ mDaysOfHistory = -1;
+
+ return transaction.Commit();
+}
+
+
+/**
+ * Performs cleanup on places that just had all their visits removed, including
+ * deletion of those places. This is an internal method used by
+ * RemovePagesInternal. This method does not execute in a transaction, so
+ * callers should make sure they begin one if needed.
+ *
+ * @param aPlaceIdsQueryString
+ * A comma-separated list of place IDs, each of which just had all its
+ * visits removed
+ */
+nsresult
+nsNavHistory::CleanupPlacesOnVisitsDelete(const nsCString& aPlaceIdsQueryString)
+{
+ // Return early if there is nothing to delete.
+ if (aPlaceIdsQueryString.IsEmpty())
+ return NS_OK;
+
+ // Collect about-to-be-deleted URIs to notify onDeleteURI.
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.guid, "
+ "(SUBSTR(h.url, 1, 6) <> 'place:' "
+ " AND NOT EXISTS (SELECT b.id FROM moz_bookmarks b "
+ "WHERE b.fk = h.id LIMIT 1)) as whole_entry "
+ "FROM moz_places h "
+ "WHERE h.id IN ( ") + aPlaceIdsQueryString + NS_LITERAL_CSTRING(")")
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsCString filteredPlaceIds;
+ nsCOMArray<nsIURI> URIs;
+ nsTArray<nsCString> GUIDs;
+ bool hasMore;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ int64_t placeId;
+ nsresult rv = stmt->GetInt64(0, &placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString URLString;
+ rv = stmt->GetUTF8String(1, URLString);
+ nsCString guid;
+ rv = stmt->GetUTF8String(2, guid);
+ int32_t wholeEntry;
+ rv = stmt->GetInt32(3, &wholeEntry);
+ nsCOMPtr<nsIURI> uri;
+ rv = NS_NewURI(getter_AddRefs(uri), URLString);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (wholeEntry) {
+ if (!filteredPlaceIds.IsEmpty()) {
+ filteredPlaceIds.Append(',');
+ }
+ filteredPlaceIds.AppendInt(placeId);
+ URIs.AppendElement(uri.forget());
+ GUIDs.AppendElement(guid);
+ }
+ else {
+ // Notify that we will delete all visits for this page, but not the page
+ // itself, since it's bookmarked or a place: query.
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnDeleteVisits(uri, 0, guid, nsINavHistoryObserver::REASON_DELETED, 0));
+ }
+ }
+
+ // if the entry is not bookmarked and is not a place: uri
+ // then we can remove it from moz_places.
+ // Note that we do NOT delete favicons. Any unreferenced favicons will be
+ // deleted next time the browser is shut down.
+ nsresult rv = mDB->MainConn()->ExecuteSimpleSQL(
+ NS_LITERAL_CSTRING(
+ "DELETE FROM moz_places WHERE id IN ( "
+ ) + filteredPlaceIds + NS_LITERAL_CSTRING(
+ ") "
+ )
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Hosts accumulated during the places delete are updated through a trigger
+ // (see nsPlacesTriggers.h).
+ rv = mDB->MainConn()->ExecuteSimpleSQL(
+ NS_LITERAL_CSTRING("DELETE FROM moz_updatehosts_temp")
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Invalidate frecencies of touched places, since they need recalculation.
+ rv = invalidateFrecencies(aPlaceIdsQueryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Finally notify about the removed URIs.
+ for (int32_t i = 0; i < URIs.Count(); ++i) {
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnDeleteURI(URIs[i], GUIDs[i], nsINavHistoryObserver::REASON_DELETED));
+ }
+
+ return NS_OK;
+}
+
+
+// nsNavHistory::RemovePages
+//
+// Removes a bunch of uris from history.
+// Has better performance than RemovePage when deleting a lot of history.
+// We don't do duplicates removal, URIs array should be cleaned-up before.
+
+NS_IMETHODIMP
+nsNavHistory::RemovePages(nsIURI **aURIs, uint32_t aLength)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURIs);
+
+ nsresult rv;
+ // build a list of place ids to delete
+ nsCString deletePlaceIdsQueryString;
+ for (uint32_t i = 0; i < aLength; i++) {
+ int64_t placeId;
+ nsAutoCString guid;
+ if (!aURIs[i])
+ continue;
+ rv = GetIdForPage(aURIs[i], &placeId, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (placeId != 0) {
+ if (!deletePlaceIdsQueryString.IsEmpty())
+ deletePlaceIdsQueryString.Append(',');
+ deletePlaceIdsQueryString.AppendInt(placeId);
+ }
+ }
+
+ UpdateBatchScoper batch(*this); // sends Begin/EndUpdateBatch to observers
+
+ rv = RemovePagesInternal(deletePlaceIdsQueryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clear the registered embed visits.
+ clearEmbedVisits();
+
+ return NS_OK;
+}
+
+
+// nsNavHistory::RemovePage
+//
+// Removes all visits and the main history entry for the given URI.
+// Silently fails if we have no knowledge of the page.
+
+NS_IMETHODIMP
+nsNavHistory::RemovePage(nsIURI *aURI)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+
+ // Build a list of place ids to delete.
+ int64_t placeId;
+ nsAutoCString guid;
+ nsresult rv = GetIdForPage(aURI, &placeId, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (placeId == 0) {
+ return NS_OK;
+ }
+ nsAutoCString deletePlaceIdQueryString;
+ deletePlaceIdQueryString.AppendInt(placeId);
+
+ rv = RemovePagesInternal(deletePlaceIdQueryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clear the registered embed visits.
+ clearEmbedVisits();
+
+ return NS_OK;
+}
+
+
+// nsNavHistory::RemovePagesFromHost
+//
+// This function will delete all history information about pages from a
+// given host. If aEntireDomain is set, we will also delete pages from
+// sub hosts (so if we are passed in "microsoft.com" we delete
+// "www.microsoft.com", "msdn.microsoft.com", etc.). An empty host name
+// means local files and anything else with no host name. You can also pass
+// in the localized "(local files)" title given to you from a history query.
+//
+// Silently fails if we have no knowledge of the host.
+//
+// This sends onBeginUpdateBatch/onEndUpdateBatch to observers
+
+NS_IMETHODIMP
+nsNavHistory::RemovePagesFromHost(const nsACString& aHost, bool aEntireDomain)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ nsresult rv;
+ // Local files don't have any host name. We don't want to delete all files in
+ // history when we get passed an empty string, so force to exact match
+ if (aHost.IsEmpty())
+ aEntireDomain = false;
+
+ // translate "(local files)" to an empty host name
+ // be sure to use the TitleForDomain to get the localized name
+ nsCString localFiles;
+ TitleForDomain(EmptyCString(), localFiles);
+ nsAutoString host16;
+ if (!aHost.Equals(localFiles))
+ CopyUTF8toUTF16(aHost, host16);
+
+ // see BindQueryClauseParameters for how this host selection works
+ nsAutoString revHostDot;
+ GetReversedHostname(host16, revHostDot);
+ NS_ASSERTION(revHostDot[revHostDot.Length() - 1] == '.', "Invalid rev. host");
+ nsAutoString revHostSlash(revHostDot);
+ revHostSlash.Truncate(revHostSlash.Length() - 1);
+ revHostSlash.Append('/');
+
+ // build condition string based on host selection type
+ nsAutoCString conditionString;
+ if (aEntireDomain)
+ conditionString.AssignLiteral("rev_host >= ?1 AND rev_host < ?2 ");
+ else
+ conditionString.AssignLiteral("rev_host = ?1 ");
+
+ // create statement depending on delete type
+ nsCOMPtr<mozIStorageStatement> statement = mDB->GetStatement(
+ NS_LITERAL_CSTRING("SELECT id FROM moz_places WHERE ") + conditionString
+ );
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ rv = statement->BindStringByIndex(0, revHostDot);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aEntireDomain) {
+ rv = statement->BindStringByIndex(1, revHostSlash);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsCString hostPlaceIds;
+ bool hasMore = false;
+ while (NS_SUCCEEDED(statement->ExecuteStep(&hasMore)) && hasMore) {
+ if (!hostPlaceIds.IsEmpty())
+ hostPlaceIds.Append(',');
+ int64_t placeId;
+ rv = statement->GetInt64(0, &placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ hostPlaceIds.AppendInt(placeId);
+ }
+
+ // force a full refresh calling onEndUpdateBatch (will call Refresh())
+ UpdateBatchScoper batch(*this); // sends Begin/EndUpdateBatch to observers
+
+ rv = RemovePagesInternal(hostPlaceIds);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clear the registered embed visits.
+ clearEmbedVisits();
+
+ return NS_OK;
+}
+
+
+// nsNavHistory::RemovePagesByTimeframe
+//
+// This function will delete all history information about
+// pages for a given timeframe.
+// Limits are included: aBeginTime <= timeframe <= aEndTime
+//
+// This method sends onBeginUpdateBatch/onEndUpdateBatch to observers
+
+NS_IMETHODIMP
+nsNavHistory::RemovePagesByTimeframe(PRTime aBeginTime, PRTime aEndTime)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ nsresult rv;
+ // build a list of place ids to delete
+ nsCString deletePlaceIdsQueryString;
+
+ // we only need to know if a place has a visit into the given timeframe
+ // this query is faster than actually selecting in moz_historyvisits
+ nsCOMPtr<mozIStorageStatement> selectByTime = mDB->GetStatement(
+ "SELECT h.id FROM moz_places h WHERE "
+ "EXISTS "
+ "(SELECT id FROM moz_historyvisits v WHERE v.place_id = h.id "
+ "AND v.visit_date >= :from_date AND v.visit_date <= :to_date LIMIT 1)"
+ );
+ NS_ENSURE_STATE(selectByTime);
+ mozStorageStatementScoper selectByTimeScoper(selectByTime);
+
+ rv = selectByTime->BindInt64ByName(NS_LITERAL_CSTRING("from_date"), aBeginTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = selectByTime->BindInt64ByName(NS_LITERAL_CSTRING("to_date"), aEndTime);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(selectByTime->ExecuteStep(&hasMore)) && hasMore) {
+ int64_t placeId;
+ rv = selectByTime->GetInt64(0, &placeId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (placeId != 0) {
+ if (!deletePlaceIdsQueryString.IsEmpty())
+ deletePlaceIdsQueryString.Append(',');
+ deletePlaceIdsQueryString.AppendInt(placeId);
+ }
+ }
+
+ // force a full refresh calling onEndUpdateBatch (will call Refresh())
+ UpdateBatchScoper batch(*this); // sends Begin/EndUpdateBatch to observers
+
+ rv = RemovePagesInternal(deletePlaceIdsQueryString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clear the registered embed visits.
+ clearEmbedVisits();
+
+ return NS_OK;
+}
+
+
+// Call this method before visiting a URL in order to help determine the
+// transition type of the visit.
+//
+// @see MarkPageAsFollowedBookmark
+
+NS_IMETHODIMP
+nsNavHistory::MarkPageAsTyped(nsIURI *aURI)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+
+ // don't add when history is disabled
+ if (IsHistoryDisabled())
+ return NS_OK;
+
+ nsAutoCString uriString;
+ nsresult rv = aURI->GetSpec(uriString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // if URL is already in the typed queue, then we need to remove the old one
+ int64_t unusedEventTime;
+ if (mRecentTyped.Get(uriString, &unusedEventTime))
+ mRecentTyped.Remove(uriString);
+
+ if (mRecentTyped.Count() > RECENT_EVENT_QUEUE_MAX_LENGTH)
+ ExpireNonrecentEvents(&mRecentTyped);
+
+ mRecentTyped.Put(uriString, GetNow());
+ return NS_OK;
+}
+
+
+// Call this method before visiting a URL in order to help determine the
+// transition type of the visit.
+//
+// @see MarkPageAsTyped
+
+NS_IMETHODIMP
+nsNavHistory::MarkPageAsFollowedLink(nsIURI *aURI)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+
+ // don't add when history is disabled
+ if (IsHistoryDisabled())
+ return NS_OK;
+
+ nsAutoCString uriString;
+ nsresult rv = aURI->GetSpec(uriString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // if URL is already in the links queue, then we need to remove the old one
+ int64_t unusedEventTime;
+ if (mRecentLink.Get(uriString, &unusedEventTime))
+ mRecentLink.Remove(uriString);
+
+ if (mRecentLink.Count() > RECENT_EVENT_QUEUE_MAX_LENGTH)
+ ExpireNonrecentEvents(&mRecentLink);
+
+ mRecentLink.Put(uriString, GetNow());
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistory::GetPageTitle(nsIURI* aURI, nsAString& aTitle)
+{
+ PLACES_WARN_DEPRECATED();
+
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aURI);
+
+ aTitle.Truncate(0);
+
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(
+ "SELECT id, url, title, rev_host, visit_count, guid "
+ "FROM moz_places "
+ "WHERE url_hash = hash(:page_url) AND url = :page_url "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasResults = false;
+ rv = stmt->ExecuteStep(&hasResults);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (!hasResults) {
+ aTitle.SetIsVoid(true);
+ return NS_OK; // Not found, return a void string.
+ }
+
+ rv = stmt->GetString(2, aTitle);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// mozIStorageVacuumParticipant
+
+NS_IMETHODIMP
+nsNavHistory::GetDatabaseConnection(mozIStorageConnection** _DBConnection)
+{
+ return GetDBConnection(_DBConnection);
+}
+
+
+NS_IMETHODIMP
+nsNavHistory::GetExpectedDatabasePageSize(int32_t* _expectedPageSize)
+{
+ NS_ENSURE_STATE(mDB);
+ NS_ENSURE_STATE(mDB->MainConn());
+ return mDB->MainConn()->GetDefaultPageSize(_expectedPageSize);
+}
+
+
+NS_IMETHODIMP
+nsNavHistory::OnBeginVacuum(bool* _vacuumGranted)
+{
+ // TODO: Check if we have to deny the vacuum in some heavy-load case.
+ // We could maybe want to do that during batches?
+ *_vacuumGranted = true;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistory::OnEndVacuum(bool aSucceeded)
+{
+ NS_WARNING_ASSERTION(aSucceeded, "Places.sqlite vacuum failed.");
+ return NS_OK;
+}
+
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsPIPlacesDatabase
+
+NS_IMETHODIMP
+nsNavHistory::GetDBConnection(mozIStorageConnection **_DBConnection)
+{
+ NS_ENSURE_ARG_POINTER(_DBConnection);
+ RefPtr<mozIStorageConnection> connection = mDB->MainConn();
+ connection.forget(_DBConnection);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistory::GetShutdownClient(nsIAsyncShutdownClient **_shutdownClient)
+{
+ NS_ENSURE_ARG_POINTER(_shutdownClient);
+ RefPtr<nsIAsyncShutdownClient> client = mDB->GetClientsShutdown();
+ MOZ_ASSERT(client);
+ client.forget(_shutdownClient);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistory::AsyncExecuteLegacyQueries(nsINavHistoryQuery** aQueries,
+ uint32_t aQueryCount,
+ nsINavHistoryQueryOptions* aOptions,
+ mozIStorageStatementCallback* aCallback,
+ mozIStoragePendingStatement** _stmt)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ NS_ENSURE_ARG(aQueries);
+ NS_ENSURE_ARG(aOptions);
+ NS_ENSURE_ARG(aCallback);
+ NS_ENSURE_ARG_POINTER(_stmt);
+
+ nsCOMArray<nsNavHistoryQuery> queries;
+ for (uint32_t i = 0; i < aQueryCount; i ++) {
+ nsCOMPtr<nsNavHistoryQuery> query = do_QueryInterface(aQueries[i]);
+ NS_ENSURE_STATE(query);
+ queries.AppendElement(query.forget());
+ }
+ NS_ENSURE_ARG_MIN(queries.Count(), 1);
+
+ nsCOMPtr<nsNavHistoryQueryOptions> options = do_QueryInterface(aOptions);
+ NS_ENSURE_ARG(options);
+
+ nsCString queryString;
+ bool paramsPresent = false;
+ nsNavHistory::StringHash addParams(HISTORY_DATE_CONT_LENGTH);
+ nsresult rv = ConstructQueryString(queries, options, queryString,
+ paramsPresent, addParams);
+ NS_ENSURE_SUCCESS(rv,rv);
+
+ nsCOMPtr<mozIStorageAsyncStatement> statement =
+ mDB->GetAsyncStatement(queryString);
+ NS_ENSURE_STATE(statement);
+
+#ifdef DEBUG
+ if (NS_FAILED(rv)) {
+ nsAutoCString lastErrorString;
+ (void)mDB->MainConn()->GetLastErrorString(lastErrorString);
+ int32_t lastError = 0;
+ (void)mDB->MainConn()->GetLastError(&lastError);
+ printf("Places failed to create a statement from this query:\n%s\nStorage error (%d): %s\n",
+ queryString.get(), lastError, lastErrorString.get());
+ }
+#endif
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (paramsPresent) {
+ // bind parameters
+ int32_t i;
+ for (i = 0; i < queries.Count(); i++) {
+ rv = BindQueryClauseParameters(statement, i, queries[i], options);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ for (auto iter = addParams.Iter(); !iter.Done(); iter.Next()) {
+ nsresult rv = statement->BindUTF8StringByName(iter.Key(), iter.Data());
+ if (NS_FAILED(rv)) {
+ break;
+ }
+ }
+
+ rv = statement->ExecuteAsync(aCallback, _stmt);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+nsresult
+nsNavHistory::NotifyOnPageExpired(nsIURI *aURI, PRTime aVisitTime,
+ bool aWholeEntry, const nsACString& aGUID,
+ uint16_t aReason, uint32_t aTransitionType)
+{
+ // Invalidate the cached value for whether there's history or not.
+ mDaysOfHistory = -1;
+
+ MOZ_ASSERT(!aGUID.IsEmpty());
+ if (aWholeEntry) {
+ // Notify our observers that the page has been removed.
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver, OnDeleteURI(aURI, aGUID, aReason));
+ }
+ else {
+ // Notify our observers that some visits for the page have been removed.
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnDeleteVisits(aURI, aVisitTime, aGUID, aReason,
+ aTransitionType));
+ }
+
+ return NS_OK;
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIObserver
+
+NS_IMETHODIMP
+nsNavHistory::Observe(nsISupports *aSubject, const char *aTopic,
+ const char16_t *aData)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+ if (strcmp(aTopic, TOPIC_PROFILE_TEARDOWN) == 0 ||
+ strcmp(aTopic, TOPIC_PROFILE_CHANGE) == 0 ||
+ strcmp(aTopic, TOPIC_SIMULATE_PLACES_SHUTDOWN) == 0) {
+ // These notifications are used by tests to simulate a Places shutdown.
+ // They should just be forwarded to the Database handle.
+ mDB->Observe(aSubject, aTopic, aData);
+ }
+
+ else if (strcmp(aTopic, TOPIC_PLACES_CONNECTION_CLOSED) == 0) {
+ // Don't even try to notify observers from this point on, the category
+ // cache would init services that could try to use our APIs.
+ mCanNotify = false;
+ mObservers.Clear();
+ }
+
+#ifdef MOZ_XUL
+ else if (strcmp(aTopic, TOPIC_AUTOCOMPLETE_FEEDBACK_INCOMING) == 0) {
+ nsCOMPtr<nsIAutoCompleteInput> input = do_QueryInterface(aSubject);
+ if (!input)
+ return NS_OK;
+
+ // If the source is a private window, don't add any input history.
+ bool isPrivate;
+ nsresult rv = input->GetInPrivateContext(&isPrivate);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (isPrivate)
+ return NS_OK;
+
+ nsCOMPtr<nsIAutoCompletePopup> popup;
+ input->GetPopup(getter_AddRefs(popup));
+ if (!popup)
+ return NS_OK;
+
+ nsCOMPtr<nsIAutoCompleteController> controller;
+ input->GetController(getter_AddRefs(controller));
+ if (!controller)
+ return NS_OK;
+
+ // Don't bother if the popup is closed
+ bool open;
+ rv = popup->GetPopupOpen(&open);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!open)
+ return NS_OK;
+
+ // Ignore if nothing selected from the popup
+ int32_t selectedIndex;
+ rv = popup->GetSelectedIndex(&selectedIndex);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (selectedIndex == -1)
+ return NS_OK;
+
+ rv = AutoCompleteFeedback(selectedIndex, controller);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+#endif
+ else if (strcmp(aTopic, TOPIC_PREF_CHANGED) == 0) {
+ LoadPrefs();
+ }
+
+ else if (strcmp(aTopic, TOPIC_IDLE_DAILY) == 0) {
+ (void)DecayFrecency();
+ }
+
+ return NS_OK;
+}
+
+
+namespace {
+
+class DecayFrecencyCallback : public AsyncStatementTelemetryTimer
+{
+public:
+ DecayFrecencyCallback()
+ : AsyncStatementTelemetryTimer(Telemetry::PLACES_IDLE_FRECENCY_DECAY_TIME_MS)
+ {
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason)
+ {
+ (void)AsyncStatementTelemetryTimer::HandleCompletion(aReason);
+ if (aReason == REASON_FINISHED) {
+ nsNavHistory *navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ navHistory->NotifyManyFrecenciesChanged();
+ }
+ return NS_OK;
+ }
+};
+
+} // namespace
+
+nsresult
+nsNavHistory::DecayFrecency()
+{
+ nsresult rv = FixInvalidFrecencies();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Globally decay places frecency rankings to estimate reduced frecency
+ // values of pages that haven't been visited for a while, i.e., they do
+ // not get an updated frecency. A scaling factor of .975 results in .5 the
+ // original value after 28 days.
+ // When changing the scaling factor, ensure that the barrier in
+ // moz_places_afterupdate_frecency_trigger still ignores these changes.
+ nsCOMPtr<mozIStorageAsyncStatement> decayFrecency = mDB->GetAsyncStatement(
+ "UPDATE moz_places SET frecency = ROUND(frecency * .975) "
+ "WHERE frecency > 0"
+ );
+ NS_ENSURE_STATE(decayFrecency);
+
+ // Decay potentially unused adaptive entries (e.g. those that are at 1)
+ // to allow better chances for new entries that will start at 1.
+ nsCOMPtr<mozIStorageAsyncStatement> decayAdaptive = mDB->GetAsyncStatement(
+ "UPDATE moz_inputhistory SET use_count = use_count * .975"
+ );
+ NS_ENSURE_STATE(decayAdaptive);
+
+ // Delete any adaptive entries that won't help in ordering anymore.
+ nsCOMPtr<mozIStorageAsyncStatement> deleteAdaptive = mDB->GetAsyncStatement(
+ "DELETE FROM moz_inputhistory WHERE use_count < .01"
+ );
+ NS_ENSURE_STATE(deleteAdaptive);
+
+ mozIStorageBaseStatement *stmts[] = {
+ decayFrecency.get(),
+ decayAdaptive.get(),
+ deleteAdaptive.get()
+ };
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ RefPtr<DecayFrecencyCallback> cb = new DecayFrecencyCallback();
+ rv = mDB->MainConn()->ExecuteAsync(stmts, ArrayLength(stmts), cb,
+ getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+// Query stuff *****************************************************************
+
+// Helper class for QueryToSelectClause
+//
+// This class helps to build part of the WHERE clause. It supports
+// multiple queries by appending the query index to the parameter name.
+// For the query with index 0 the parameter name is not altered what
+// allows using this parameter in other situations (see SelectAsSite).
+
+class ConditionBuilder
+{
+public:
+
+ explicit ConditionBuilder(int32_t aQueryIndex): mQueryIndex(aQueryIndex)
+ { }
+
+ ConditionBuilder& Condition(const char* aStr)
+ {
+ if (!mClause.IsEmpty())
+ mClause.AppendLiteral(" AND ");
+ Str(aStr);
+ return *this;
+ }
+
+ ConditionBuilder& Str(const char* aStr)
+ {
+ mClause.Append(' ');
+ mClause.Append(aStr);
+ mClause.Append(' ');
+ return *this;
+ }
+
+ ConditionBuilder& Param(const char* aParam)
+ {
+ mClause.Append(' ');
+ if (!mQueryIndex)
+ mClause.Append(aParam);
+ else
+ mClause += nsPrintfCString("%s%d", aParam, mQueryIndex);
+
+ mClause.Append(' ');
+ return *this;
+ }
+
+ void GetClauseString(nsCString& aResult)
+ {
+ aResult = mClause;
+ }
+
+private:
+
+ int32_t mQueryIndex;
+ nsCString mClause;
+};
+
+
+// nsNavHistory::QueryToSelectClause
+//
+// THE BEHAVIOR SHOULD BE IN SYNC WITH BindQueryClauseParameters
+//
+// I don't check return values from the query object getters because there's
+// no way for those to fail.
+
+nsresult
+nsNavHistory::QueryToSelectClause(nsNavHistoryQuery* aQuery, // const
+ nsNavHistoryQueryOptions* aOptions,
+ int32_t aQueryIndex,
+ nsCString* aClause)
+{
+ bool hasIt;
+ bool excludeQueries = aOptions->ExcludeQueries();
+
+ ConditionBuilder clause(aQueryIndex);
+
+ if ((NS_SUCCEEDED(aQuery->GetHasBeginTime(&hasIt)) && hasIt) ||
+ (NS_SUCCEEDED(aQuery->GetHasEndTime(&hasIt)) && hasIt)) {
+ clause.Condition("EXISTS (SELECT 1 FROM moz_historyvisits "
+ "WHERE place_id = h.id");
+ // begin time
+ if (NS_SUCCEEDED(aQuery->GetHasBeginTime(&hasIt)) && hasIt)
+ clause.Condition("visit_date >=").Param(":begin_time");
+ // end time
+ if (NS_SUCCEEDED(aQuery->GetHasEndTime(&hasIt)) && hasIt)
+ clause.Condition("visit_date <=").Param(":end_time");
+ clause.Str(" LIMIT 1)");
+ }
+
+ // search terms
+ bool hasSearchTerms;
+ int32_t searchBehavior = mozIPlacesAutoComplete::BEHAVIOR_HISTORY |
+ mozIPlacesAutoComplete::BEHAVIOR_BOOKMARK;
+ if (NS_SUCCEEDED(aQuery->GetHasSearchTerms(&hasSearchTerms)) && hasSearchTerms) {
+ // Re-use the autocomplete_match function. Setting the behavior to match
+ // history or typed history or bookmarks or open pages will match almost
+ // everything.
+ clause.Condition("AUTOCOMPLETE_MATCH(").Param(":search_string")
+ .Str(", h.url, page_title, tags, ")
+ .Str(nsPrintfCString("1, 1, 1, 1, %d, %d)",
+ mozIPlacesAutoComplete::MATCH_ANYWHERE_UNMODIFIED,
+ searchBehavior).get());
+ // Serching by terms implicitly exclude queries.
+ excludeQueries = true;
+ }
+
+ // min and max visit count
+ if (aQuery->MinVisits() >= 0)
+ clause.Condition("h.visit_count >=").Param(":min_visits");
+
+ if (aQuery->MaxVisits() >= 0)
+ clause.Condition("h.visit_count <=").Param(":max_visits");
+
+ // only bookmarked, has no affect on bookmarks-only queries
+ if (aOptions->QueryType() != nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS &&
+ aQuery->OnlyBookmarked())
+ clause.Condition("EXISTS (SELECT b.fk FROM moz_bookmarks b WHERE b.type = ")
+ .Str(nsPrintfCString("%d", nsNavBookmarks::TYPE_BOOKMARK).get())
+ .Str("AND b.fk = h.id)");
+
+ // domain
+ if (NS_SUCCEEDED(aQuery->GetHasDomain(&hasIt)) && hasIt) {
+ bool domainIsHost = false;
+ aQuery->GetDomainIsHost(&domainIsHost);
+ if (domainIsHost)
+ clause.Condition("h.rev_host =").Param(":domain_lower");
+ else
+ // see domain setting in BindQueryClauseParameters for why we do this
+ clause.Condition("h.rev_host >=").Param(":domain_lower")
+ .Condition("h.rev_host <").Param(":domain_upper");
+ }
+
+ // URI
+ if (NS_SUCCEEDED(aQuery->GetHasUri(&hasIt)) && hasIt) {
+ clause.Condition("h.url_hash = hash(").Param(":uri").Str(")")
+ .Condition("h.url =").Param(":uri");
+ }
+
+ // annotation
+ aQuery->GetHasAnnotation(&hasIt);
+ if (hasIt) {
+ clause.Condition("");
+ if (aQuery->AnnotationIsNot())
+ clause.Str("NOT");
+ clause.Str(
+ "EXISTS "
+ "(SELECT h.id "
+ "FROM moz_annos anno "
+ "JOIN moz_anno_attributes annoname "
+ "ON anno.anno_attribute_id = annoname.id "
+ "WHERE anno.place_id = h.id "
+ "AND annoname.name = ").Param(":anno").Str(")");
+ // annotation-based queries don't get the common conditions, so you get
+ // all URLs with that annotation
+ }
+
+ // tags
+ const nsTArray<nsString> &tags = aQuery->Tags();
+ if (tags.Length() > 0) {
+ clause.Condition("h.id");
+ if (aQuery->TagsAreNot())
+ clause.Str("NOT");
+ clause.Str(
+ "IN "
+ "(SELECT bms.fk "
+ "FROM moz_bookmarks bms "
+ "JOIN moz_bookmarks tags ON bms.parent = tags.id "
+ "WHERE tags.parent =").
+ Param(":tags_folder").
+ Str("AND tags.title IN (");
+ for (uint32_t i = 0; i < tags.Length(); ++i) {
+ nsPrintfCString param(":tag%d_", i);
+ clause.Param(param.get());
+ if (i < tags.Length() - 1)
+ clause.Str(",");
+ }
+ clause.Str(")");
+ if (!aQuery->TagsAreNot())
+ clause.Str("GROUP BY bms.fk HAVING count(*) >=").Param(":tag_count");
+ clause.Str(")");
+ }
+
+ // transitions
+ const nsTArray<uint32_t>& transitions = aQuery->Transitions();
+ for (uint32_t i = 0; i < transitions.Length(); ++i) {
+ nsPrintfCString param(":transition%d_", i);
+ clause.Condition("h.id IN (SELECT place_id FROM moz_historyvisits "
+ "WHERE visit_type = ")
+ .Param(param.get())
+ .Str(")");
+ }
+
+ // folders
+ const nsTArray<int64_t>& folders = aQuery->Folders();
+ if (folders.Length() > 0) {
+ aOptions->SetQueryType(nsNavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS);
+
+ nsTArray<int64_t> includeFolders;
+ includeFolders.AppendElements(folders);
+
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_STATE(bookmarks);
+
+ for (nsTArray<int64_t>::size_type i = 0; i < folders.Length(); ++i) {
+ nsTArray<int64_t> subFolders;
+ if (NS_FAILED(bookmarks->GetDescendantFolders(folders[i], subFolders)))
+ continue;
+ includeFolders.AppendElements(subFolders);
+ }
+
+ clause.Condition("b.parent IN(");
+ for (nsTArray<int64_t>::size_type i = 0; i < includeFolders.Length(); ++i) {
+ clause.Str(nsPrintfCString("%lld", includeFolders[i]).get());
+ if (i < includeFolders.Length() - 1) {
+ clause.Str(",");
+ }
+ }
+ clause.Str(")");
+ }
+
+ if (excludeQueries) {
+ // Serching by terms implicitly exclude queries.
+ clause.Condition("NOT h.url_hash BETWEEN hash('place', 'prefix_lo') AND "
+ "hash('place', 'prefix_hi')");
+ }
+
+ clause.GetClauseString(*aClause);
+ return NS_OK;
+}
+
+
+// nsNavHistory::BindQueryClauseParameters
+//
+// THE BEHAVIOR SHOULD BE IN SYNC WITH QueryToSelectClause
+
+nsresult
+nsNavHistory::BindQueryClauseParameters(mozIStorageBaseStatement* statement,
+ int32_t aQueryIndex,
+ nsNavHistoryQuery* aQuery, // const
+ nsNavHistoryQueryOptions* aOptions)
+{
+ nsresult rv;
+
+ bool hasIt;
+ // Append numbered index to param names, to replace them correctly in
+ // case of multiple queries. If we have just one query we don't change the
+ // param name though.
+ nsAutoCString qIndex;
+ if (aQueryIndex > 0)
+ qIndex.AppendInt(aQueryIndex);
+
+ // begin time
+ if (NS_SUCCEEDED(aQuery->GetHasBeginTime(&hasIt)) && hasIt) {
+ PRTime time = NormalizeTime(aQuery->BeginTimeReference(),
+ aQuery->BeginTime());
+ rv = statement->BindInt64ByName(
+ NS_LITERAL_CSTRING("begin_time") + qIndex, time);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // end time
+ if (NS_SUCCEEDED(aQuery->GetHasEndTime(&hasIt)) && hasIt) {
+ PRTime time = NormalizeTime(aQuery->EndTimeReference(),
+ aQuery->EndTime());
+ rv = statement->BindInt64ByName(
+ NS_LITERAL_CSTRING("end_time") + qIndex, time
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // search terms
+ if (NS_SUCCEEDED(aQuery->GetHasSearchTerms(&hasIt)) && hasIt) {
+ rv = statement->BindStringByName(
+ NS_LITERAL_CSTRING("search_string") + qIndex,
+ aQuery->SearchTerms()
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // min and max visit count
+ int32_t visits = aQuery->MinVisits();
+ if (visits >= 0) {
+ rv = statement->BindInt32ByName(
+ NS_LITERAL_CSTRING("min_visits") + qIndex, visits
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ visits = aQuery->MaxVisits();
+ if (visits >= 0) {
+ rv = statement->BindInt32ByName(
+ NS_LITERAL_CSTRING("max_visits") + qIndex, visits
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // domain (see GetReversedHostname for more info on reversed host names)
+ if (NS_SUCCEEDED(aQuery->GetHasDomain(&hasIt)) && hasIt) {
+ nsString revDomain;
+ GetReversedHostname(NS_ConvertUTF8toUTF16(aQuery->Domain()), revDomain);
+
+ if (aQuery->DomainIsHost()) {
+ rv = statement->BindStringByName(
+ NS_LITERAL_CSTRING("domain_lower") + qIndex, revDomain
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ } else {
+ // for "mozilla.org" do query >= "gro.allizom." AND < "gro.allizom/"
+ // which will get everything starting with "gro.allizom." while using the
+ // index (using SUBSTRING() causes indexes to be discarded).
+ NS_ASSERTION(revDomain[revDomain.Length() - 1] == '.', "Invalid rev. host");
+ rv = statement->BindStringByName(
+ NS_LITERAL_CSTRING("domain_lower") + qIndex, revDomain
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ revDomain.Truncate(revDomain.Length() - 1);
+ revDomain.Append(char16_t('/'));
+ rv = statement->BindStringByName(
+ NS_LITERAL_CSTRING("domain_upper") + qIndex, revDomain
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ // URI
+ if (aQuery->Uri()) {
+ rv = URIBinder::Bind(
+ statement, NS_LITERAL_CSTRING("uri") + qIndex, aQuery->Uri()
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // annotation
+ if (!aQuery->Annotation().IsEmpty()) {
+ rv = statement->BindUTF8StringByName(
+ NS_LITERAL_CSTRING("anno") + qIndex, aQuery->Annotation()
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // tags
+ const nsTArray<nsString> &tags = aQuery->Tags();
+ if (tags.Length() > 0) {
+ for (uint32_t i = 0; i < tags.Length(); ++i) {
+ nsPrintfCString paramName("tag%d_", i);
+ NS_ConvertUTF16toUTF8 tag(tags[i]);
+ rv = statement->BindUTF8StringByName(paramName + qIndex, tag);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ int64_t tagsFolder = GetTagsFolder();
+ rv = statement->BindInt64ByName(
+ NS_LITERAL_CSTRING("tags_folder") + qIndex, tagsFolder
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!aQuery->TagsAreNot()) {
+ rv = statement->BindInt32ByName(
+ NS_LITERAL_CSTRING("tag_count") + qIndex, tags.Length()
+ );
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ // transitions
+ const nsTArray<uint32_t>& transitions = aQuery->Transitions();
+ if (transitions.Length() > 0) {
+ for (uint32_t i = 0; i < transitions.Length(); ++i) {
+ nsPrintfCString paramName("transition%d_", i);
+ rv = statement->BindInt64ByName(paramName + qIndex, transitions[i]);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return NS_OK;
+}
+
+
+// nsNavHistory::ResultsAsList
+//
+
+nsresult
+nsNavHistory::ResultsAsList(mozIStorageStatement* statement,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aResults)
+{
+ nsresult rv;
+ nsCOMPtr<mozIStorageValueArray> row = do_QueryInterface(statement, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ while (NS_SUCCEEDED(statement->ExecuteStep(&hasMore)) && hasMore) {
+ RefPtr<nsNavHistoryResultNode> result;
+ rv = RowToResult(row, aOptions, getter_AddRefs(result));
+ NS_ENSURE_SUCCESS(rv, rv);
+ aResults->AppendElement(result.forget());
+ }
+ return NS_OK;
+}
+
+const int64_t UNDEFINED_URN_VALUE = -1;
+
+// Create a urn (like
+// urn:places-persist:place:group=0&group=1&sort=1&type=1,,%28local%20files%29)
+// to be used to persist the open state of this container
+nsresult
+CreatePlacesPersistURN(nsNavHistoryQueryResultNode *aResultNode,
+ int64_t aValue, const nsCString& aTitle, nsCString& aURN)
+{
+ nsAutoCString uri;
+ nsresult rv = aResultNode->GetUri(uri);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ aURN.AssignLiteral("urn:places-persist:");
+ aURN.Append(uri);
+
+ aURN.Append(',');
+ if (aValue != UNDEFINED_URN_VALUE)
+ aURN.AppendInt(aValue);
+
+ aURN.Append(',');
+ if (!aTitle.IsEmpty()) {
+ nsAutoCString escapedTitle;
+ bool success = NS_Escape(aTitle, escapedTitle, url_XAlphas);
+ NS_ENSURE_TRUE(success, NS_ERROR_OUT_OF_MEMORY);
+ aURN.Append(escapedTitle);
+ }
+
+ return NS_OK;
+}
+
+int64_t
+nsNavHistory::GetTagsFolder()
+{
+ // cache our tags folder
+ // note, we can't do this in nsNavHistory::Init(),
+ // as getting the bookmarks service would initialize it.
+ if (mTagsFolder == -1) {
+ nsNavBookmarks *bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, -1);
+
+ nsresult rv = bookmarks->GetTagsFolder(&mTagsFolder);
+ NS_ENSURE_SUCCESS(rv, -1);
+ }
+ return mTagsFolder;
+}
+
+// nsNavHistory::FilterResultSet
+//
+// This does some post-query-execution filtering:
+// - searching on title, url and tags
+// - limit count
+//
+// Note: changes to filtering in FilterResultSet()
+// may require changes to NeedToFilterResultSet()
+
+nsresult
+nsNavHistory::FilterResultSet(nsNavHistoryQueryResultNode* aQueryNode,
+ const nsCOMArray<nsNavHistoryResultNode>& aSet,
+ nsCOMArray<nsNavHistoryResultNode>* aFiltered,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions *aOptions)
+{
+ // get the bookmarks service
+ nsNavBookmarks *bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+
+ // parse the search terms
+ nsTArray<nsTArray<nsString>*> terms;
+ ParseSearchTermsFromQueries(aQueries, &terms);
+
+ uint16_t resultType = aOptions->ResultType();
+ for (int32_t nodeIndex = 0; nodeIndex < aSet.Count(); nodeIndex++) {
+ // exclude-queries is implicit when searching, we're only looking at
+ // plan URI nodes
+ if (!aSet[nodeIndex]->IsURI())
+ continue;
+
+ // RESULTS_AS_TAG_CONTENTS returns a set ordered by place_id and
+ // lastModified. So, to remove duplicates, we can retain the first result
+ // for each uri.
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS &&
+ nodeIndex > 0 && aSet[nodeIndex]->mURI == aSet[nodeIndex-1]->mURI)
+ continue;
+
+ if (aSet[nodeIndex]->mItemId != -1 && aQueryNode &&
+ aQueryNode->mItemId == aSet[nodeIndex]->mItemId) {
+ continue;
+ }
+
+ // Append the node only if it matches one of the queries.
+ bool appendNode = false;
+ for (int32_t queryIndex = 0;
+ queryIndex < aQueries.Count() && !appendNode; queryIndex++) {
+
+ if (terms[queryIndex]->Length()) {
+ // Filter based on search terms.
+ // Convert title and url for the current node to UTF16 strings.
+ NS_ConvertUTF8toUTF16 nodeTitle(aSet[nodeIndex]->mTitle);
+ // Unescape the URL for search terms matching.
+ nsAutoCString cNodeURL(aSet[nodeIndex]->mURI);
+ NS_ConvertUTF8toUTF16 nodeURL(NS_UnescapeURL(cNodeURL));
+
+ // Determine if every search term matches anywhere in the title, url or
+ // tag.
+ bool matchAll = true;
+ for (int32_t termIndex = terms[queryIndex]->Length() - 1;
+ termIndex >= 0 && matchAll;
+ termIndex--) {
+ nsString& term = terms[queryIndex]->ElementAt(termIndex);
+
+ // True if any of them match; false makes us quit the loop
+ matchAll = CaseInsensitiveFindInReadable(term, nodeTitle) ||
+ CaseInsensitiveFindInReadable(term, nodeURL) ||
+ CaseInsensitiveFindInReadable(term, aSet[nodeIndex]->mTags);
+ }
+
+ // Skip the node if we don't match all terms in the title, url or tag
+ if (!matchAll)
+ continue;
+ }
+
+ // We passed all filters, so we can append the node to filtered results.
+ appendNode = true;
+ }
+
+ if (appendNode)
+ aFiltered->AppendObject(aSet[nodeIndex]);
+
+ // Stop once we have reached max results.
+ if (aOptions->MaxResults() > 0 &&
+ (uint32_t)aFiltered->Count() >= aOptions->MaxResults())
+ break;
+ }
+
+ // De-allocate the temporary matrixes.
+ for (int32_t i = 0; i < aQueries.Count(); i++) {
+ delete terms[i];
+ }
+
+ return NS_OK;
+}
+
+void
+nsNavHistory::registerEmbedVisit(nsIURI* aURI,
+ int64_t aTime)
+{
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ VisitHashKey* visit = mEmbedVisits.PutEntry(aURI);
+ if (!visit) {
+ NS_WARNING("Unable to register a EMBED visit.");
+ return;
+ }
+ visit->visitTime = aTime;
+}
+
+bool
+nsNavHistory::hasEmbedVisit(nsIURI* aURI) {
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ return !!mEmbedVisits.GetEntry(aURI);
+}
+
+void
+nsNavHistory::clearEmbedVisits() {
+ NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
+
+ mEmbedVisits.Clear();
+}
+
+NS_IMETHODIMP
+nsNavHistory::ClearEmbedVisits() {
+ clearEmbedVisits();
+ return NS_OK;
+}
+
+// nsNavHistory::CheckIsRecentEvent
+//
+// Sees if this URL happened "recently."
+//
+// It is always removed from our recent list no matter what. It only counts
+// as "recent" if the event happened more recently than our event
+// threshold ago.
+
+bool
+nsNavHistory::CheckIsRecentEvent(RecentEventHash* hashTable,
+ const nsACString& url)
+{
+ PRTime eventTime;
+ if (hashTable->Get(url, reinterpret_cast<int64_t*>(&eventTime))) {
+ hashTable->Remove(url);
+ if (eventTime > GetNow() - RECENT_EVENT_THRESHOLD)
+ return true;
+ return false;
+ }
+ return false;
+}
+
+
+// nsNavHistory::ExpireNonrecentEvents
+//
+// This goes through our
+
+void
+nsNavHistory::ExpireNonrecentEvents(RecentEventHash* hashTable)
+{
+ int64_t threshold = GetNow() - RECENT_EVENT_THRESHOLD;
+ for (auto iter = hashTable->Iter(); !iter.Done(); iter.Next()) {
+ if (iter.Data() < threshold) {
+ iter.Remove();
+ }
+ }
+}
+
+
+// nsNavHistory::RowToResult
+//
+// Here, we just have a generic row. It could be a query, URL, visit,
+// or full visit.
+
+nsresult
+nsNavHistory::RowToResult(mozIStorageValueArray* aRow,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult)
+{
+ NS_ASSERTION(aRow && aOptions && aResult, "Null pointer in RowToResult");
+
+ // URL
+ nsAutoCString url;
+ nsresult rv = aRow->GetUTF8String(kGetInfoIndex_URL, url);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // title
+ nsAutoCString title;
+ rv = aRow->GetUTF8String(kGetInfoIndex_Title, title);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ uint32_t accessCount = aRow->AsInt32(kGetInfoIndex_VisitCount);
+ PRTime time = aRow->AsInt64(kGetInfoIndex_VisitDate);
+
+ // favicon
+ nsAutoCString favicon;
+ rv = aRow->GetUTF8String(kGetInfoIndex_FaviconURL, favicon);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // itemId
+ int64_t itemId = aRow->AsInt64(kGetInfoIndex_ItemId);
+ int64_t parentId = -1;
+ if (itemId == 0) {
+ // This is not a bookmark. For non-bookmarks we use a -1 itemId value.
+ // Notice ids in sqlite tables start from 1, so itemId cannot ever be 0.
+ itemId = -1;
+ }
+ else {
+ // This is a bookmark, so it has a parent.
+ int64_t itemParentId = aRow->AsInt64(kGetInfoIndex_ItemParentId);
+ if (itemParentId > 0) {
+ // The Places root has parent == 0, but that item id does not really
+ // exist. We want to set the parent only if it's a real one.
+ parentId = itemParentId;
+ }
+ }
+
+ if (IsQueryURI(url)) {
+ // Special case "place:" URIs: turn them into containers.
+ if (itemId != -1) {
+ // We should never expose the history title for query nodes if the
+ // bookmark-item's title is set to null (the history title may be the
+ // query string without the place: prefix). Thus we call getItemTitle
+ // explicitly. Doing this in the SQL query would be less performant since
+ // it should be done for all results rather than only for queries.
+ nsNavBookmarks *bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+
+ rv = bookmarks->GetItemTitle(itemId, title);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ nsAutoCString guid;
+ if (itemId != -1) {
+ rv = aRow->GetUTF8String(nsNavBookmarks::kGetChildrenIndex_Guid, guid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ RefPtr<nsNavHistoryResultNode> resultNode;
+ rv = QueryRowToResult(itemId, guid, url, title, accessCount, time, favicon,
+ getter_AddRefs(resultNode));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (itemId != -1 ||
+ aOptions->ResultType() == nsNavHistoryQueryOptions::RESULTS_AS_TAG_QUERY) {
+ // RESULTS_AS_TAG_QUERY has date columns
+ resultNode->mDateAdded = aRow->AsInt64(kGetInfoIndex_ItemDateAdded);
+ resultNode->mLastModified = aRow->AsInt64(kGetInfoIndex_ItemLastModified);
+ if (resultNode->IsFolder()) {
+ // If it's a simple folder node (i.e. a shortcut to another folder), apply
+ // our options for it. However, if the parent type was tag query, we do not
+ // apply them, because it would not yield any results.
+ resultNode->GetAsContainer()->mOptions = aOptions;
+ }
+ }
+
+ resultNode.forget(aResult);
+ return rv;
+ } else if (aOptions->ResultType() == nsNavHistoryQueryOptions::RESULTS_AS_URI ||
+ aOptions->ResultType() == nsNavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS) {
+ RefPtr<nsNavHistoryResultNode> resultNode =
+ new nsNavHistoryResultNode(url, title, accessCount, time, favicon);
+
+ if (itemId != -1) {
+ resultNode->mItemId = itemId;
+ resultNode->mFolderId = parentId;
+ resultNode->mDateAdded = aRow->AsInt64(kGetInfoIndex_ItemDateAdded);
+ resultNode->mLastModified = aRow->AsInt64(kGetInfoIndex_ItemLastModified);
+
+ rv = aRow->GetUTF8String(nsNavBookmarks::kGetChildrenIndex_Guid,
+ resultNode->mBookmarkGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ resultNode->mFrecency = aRow->AsInt32(kGetInfoIndex_Frecency);
+ resultNode->mHidden = !!aRow->AsInt32(kGetInfoIndex_Hidden);
+
+ nsAutoString tags;
+ rv = aRow->GetString(kGetInfoIndex_ItemTags, tags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!tags.IsVoid()) {
+ resultNode->mTags.Assign(tags);
+ }
+
+ rv = aRow->GetUTF8String(kGetInfoIndex_Guid, resultNode->mPageGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ resultNode.forget(aResult);
+ return NS_OK;
+ }
+
+ if (aOptions->ResultType() == nsNavHistoryQueryOptions::RESULTS_AS_VISIT) {
+ RefPtr<nsNavHistoryResultNode> resultNode =
+ new nsNavHistoryResultNode(url, title, accessCount, time, favicon);
+
+ nsAutoString tags;
+ rv = aRow->GetString(kGetInfoIndex_ItemTags, tags);
+ if (!tags.IsVoid())
+ resultNode->mTags.Assign(tags);
+
+ rv = aRow->GetUTF8String(kGetInfoIndex_Guid, resultNode->mPageGuid);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = aRow->GetInt64(kGetInfoIndex_VisitId, &resultNode->mVisitId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ int64_t fromVisitId;
+ rv = aRow->GetInt64(kGetInfoIndex_FromVisitId, &fromVisitId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (fromVisitId > 0) {
+ resultNode->mFromVisitId = fromVisitId;
+ }
+
+ resultNode->mTransitionType = aRow->AsInt32(kGetInfoIndex_VisitType);
+
+ resultNode.forget(aResult);
+ return NS_OK;
+ }
+
+ return NS_ERROR_FAILURE;
+}
+
+
+// nsNavHistory::QueryRowToResult
+//
+// Called by RowToResult when the URI is a place: URI to generate the proper
+// folder or query node.
+
+nsresult
+nsNavHistory::QueryRowToResult(int64_t itemId,
+ const nsACString& aBookmarkGuid,
+ const nsACString& aURI,
+ const nsACString& aTitle,
+ uint32_t aAccessCount, PRTime aTime,
+ const nsACString& aFavicon,
+ nsNavHistoryResultNode** aNode)
+{
+ MOZ_ASSERT((itemId != -1 && !aBookmarkGuid.IsEmpty()) ||
+ (itemId == -1 && aBookmarkGuid.IsEmpty()));
+
+ nsCOMArray<nsNavHistoryQuery> queries;
+ nsCOMPtr<nsNavHistoryQueryOptions> options;
+ nsresult rv = QueryStringToQueryArray(aURI, &queries,
+ getter_AddRefs(options));
+
+ RefPtr<nsNavHistoryResultNode> resultNode;
+ // If this failed the query does not parse correctly, let the error pass and
+ // handle it later.
+ if (NS_SUCCEEDED(rv)) {
+ // Check if this is a folder shortcut, so we can take a faster path.
+ int64_t targetFolderId = GetSimpleBookmarksQueryFolder(queries, options);
+ if (targetFolderId) {
+ nsNavBookmarks *bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+
+ rv = bookmarks->ResultNodeForContainer(targetFolderId, options,
+ getter_AddRefs(resultNode));
+ // If this failed the shortcut is pointing to nowhere, let the error pass
+ // and handle it later.
+ if (NS_SUCCEEDED(rv)) {
+ // At this point the node is set up like a regular folder node. Here
+ // we make the necessary change to make it a folder shortcut.
+ resultNode->GetAsFolder()->mTargetFolderItemId = targetFolderId;
+ resultNode->mItemId = itemId;
+ nsAutoCString targetFolderGuid(resultNode->GetAsFolder()->mBookmarkGuid);
+ resultNode->mBookmarkGuid = aBookmarkGuid;
+ resultNode->GetAsFolder()->mTargetFolderGuid = targetFolderGuid;
+
+ // Use the query item title, unless it's void (in that case use the
+ // concrete folder title).
+ if (!aTitle.IsVoid()) {
+ resultNode->mTitle = aTitle;
+ }
+ }
+ }
+ else {
+ // This is a regular query.
+ resultNode = new nsNavHistoryQueryResultNode(aTitle, EmptyCString(),
+ aTime, queries, options);
+ resultNode->mItemId = itemId;
+ }
+ }
+
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Generating a generic empty node for a broken query!");
+ // This is a broken query, that either did not parse or points to not
+ // existing data. We don't want to return failure since that will kill the
+ // whole result. Instead make a generic empty query node.
+ resultNode = new nsNavHistoryQueryResultNode(aTitle, aFavicon, aURI);
+ resultNode->mItemId = itemId;
+ // This is a perf hack to generate an empty query that skips filtering.
+ resultNode->GetAsQuery()->Options()->SetExcludeItems(true);
+ }
+
+ resultNode.forget(aNode);
+ return NS_OK;
+}
+
+
+// nsNavHistory::VisitIdToResultNode
+//
+// Used by the query results to create new nodes on the fly when
+// notifications come in. This just creates a node for the given visit ID.
+
+nsresult
+nsNavHistory::VisitIdToResultNode(int64_t visitId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult)
+{
+ nsAutoCString tagsFragment;
+ GetTagsSqlFragment(GetTagsFolder(), NS_LITERAL_CSTRING("h.id"),
+ true, tagsFragment);
+
+ nsCOMPtr<mozIStorageStatement> statement;
+ switch (aOptions->ResultType())
+ {
+ case nsNavHistoryQueryOptions::RESULTS_AS_VISIT:
+ case nsNavHistoryQueryOptions::RESULTS_AS_FULL_VISIT:
+ // visit query - want exact visit time
+ // Should match kGetInfoIndex_* (see GetQueryResults)
+ statement = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.title, h.rev_host, h.visit_count, "
+ "v.visit_date, f.url, null, null, null, null, "
+ ) + tagsFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "v.id, v.from_visit, v.visit_type "
+ "FROM moz_places h "
+ "JOIN moz_historyvisits v ON h.id = v.place_id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE v.id = :visit_id ")
+ );
+ break;
+
+ case nsNavHistoryQueryOptions::RESULTS_AS_URI:
+ // URL results - want last visit time
+ // Should match kGetInfoIndex_* (see GetQueryResults)
+ statement = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT h.id, h.url, h.title, h.rev_host, h.visit_count, "
+ "h.last_visit_date, f.url, null, null, null, null, "
+ ) + tagsFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null "
+ "FROM moz_places h "
+ "JOIN moz_historyvisits v ON h.id = v.place_id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE v.id = :visit_id ")
+ );
+ break;
+
+ default:
+ // Query base types like RESULTS_AS_*_QUERY handle additions
+ // by registering their own observers when they are expanded.
+ return NS_OK;
+ }
+ NS_ENSURE_STATE(statement);
+ mozStorageStatementScoper scoper(statement);
+
+ nsresult rv = statement->BindInt64ByName(NS_LITERAL_CSTRING("visit_id"),
+ visitId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ rv = statement->ExecuteStep(&hasMore);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (! hasMore) {
+ NS_NOTREACHED("Trying to get a result node for an invalid visit");
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ nsCOMPtr<mozIStorageValueArray> row = do_QueryInterface(statement, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return RowToResult(row, aOptions, aResult);
+}
+
+nsresult
+nsNavHistory::BookmarkIdToResultNode(int64_t aBookmarkId, nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult)
+{
+ nsAutoCString tagsFragment;
+ GetTagsSqlFragment(GetTagsFolder(), NS_LITERAL_CSTRING("h.id"),
+ true, tagsFragment);
+ // Should match kGetInfoIndex_*
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT b.fk, h.url, COALESCE(b.title, h.title), "
+ "h.rev_host, h.visit_count, h.last_visit_date, f.url, b.id, "
+ "b.dateAdded, b.lastModified, b.parent, "
+ ) + tagsFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null, b.guid, b.position, b.type, b.fk "
+ "FROM moz_bookmarks b "
+ "JOIN moz_places h ON b.fk = h.id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE b.id = :item_id ")
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("item_id"),
+ aBookmarkId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ rv = stmt->ExecuteStep(&hasMore);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasMore) {
+ NS_NOTREACHED("Trying to get a result node for an invalid bookmark identifier");
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ nsCOMPtr<mozIStorageValueArray> row = do_QueryInterface(stmt, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return RowToResult(row, aOptions, aResult);
+}
+
+nsresult
+nsNavHistory::URIToResultNode(nsIURI* aURI,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult)
+{
+ nsAutoCString tagsFragment;
+ GetTagsSqlFragment(GetTagsFolder(), NS_LITERAL_CSTRING("h.id"),
+ true, tagsFragment);
+ // Should match kGetInfoIndex_*
+ nsCOMPtr<mozIStorageStatement> stmt = mDB->GetStatement(NS_LITERAL_CSTRING(
+ "SELECT h.id, :page_url, COALESCE(b.title, h.title), "
+ "h.rev_host, h.visit_count, h.last_visit_date, f.url, "
+ "b.id, b.dateAdded, b.lastModified, b.parent, "
+ ) + tagsFragment + NS_LITERAL_CSTRING(", h.frecency, h.hidden, h.guid, "
+ "null, null, null, b.guid, b.position, b.type, b.fk "
+ "FROM moz_places h "
+ "LEFT JOIN moz_bookmarks b ON b.fk = h.id "
+ "LEFT JOIN moz_favicons f ON h.favicon_id = f.id "
+ "WHERE h.url_hash = hash(:page_url) AND h.url = :page_url ")
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsresult rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), aURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasMore = false;
+ rv = stmt->ExecuteStep(&hasMore);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!hasMore) {
+ NS_NOTREACHED("Trying to get a result node for an invalid url");
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ nsCOMPtr<mozIStorageValueArray> row = do_QueryInterface(stmt, &rv);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return RowToResult(row, aOptions, aResult);
+}
+
+void
+nsNavHistory::SendPageChangedNotification(nsIURI* aURI,
+ uint32_t aChangedAttribute,
+ const nsAString& aNewValue,
+ const nsACString& aGUID)
+{
+ MOZ_ASSERT(!aGUID.IsEmpty());
+ NOTIFY_OBSERVERS(mCanNotify, mCacheObservers, mObservers,
+ nsINavHistoryObserver,
+ OnPageChanged(aURI, aChangedAttribute, aNewValue, aGUID));
+}
+
+// nsNavHistory::TitleForDomain
+//
+// This computes the title for a given domain. Normally, this is just the
+// domain name, but we specially handle empty cases to give you a nice
+// localized string.
+
+void
+nsNavHistory::TitleForDomain(const nsCString& domain, nsACString& aTitle)
+{
+ if (! domain.IsEmpty()) {
+ aTitle = domain;
+ return;
+ }
+
+ // use the localized one instead
+ GetStringFromName(u"localhost", aTitle);
+}
+
+void
+nsNavHistory::GetAgeInDaysString(int32_t aInt, const char16_t *aName,
+ nsACString& aResult)
+{
+ nsIStringBundle *bundle = GetBundle();
+ if (bundle) {
+ nsAutoString intString;
+ intString.AppendInt(aInt);
+ const char16_t* strings[1] = { intString.get() };
+ nsXPIDLString value;
+ nsresult rv = bundle->FormatStringFromName(aName, strings,
+ 1, getter_Copies(value));
+ if (NS_SUCCEEDED(rv)) {
+ CopyUTF16toUTF8(value, aResult);
+ return;
+ }
+ }
+ CopyUTF16toUTF8(nsDependentString(aName), aResult);
+}
+
+void
+nsNavHistory::GetStringFromName(const char16_t *aName, nsACString& aResult)
+{
+ nsIStringBundle *bundle = GetBundle();
+ if (bundle) {
+ nsXPIDLString value;
+ nsresult rv = bundle->GetStringFromName(aName, getter_Copies(value));
+ if (NS_SUCCEEDED(rv)) {
+ CopyUTF16toUTF8(value, aResult);
+ return;
+ }
+ }
+ CopyUTF16toUTF8(nsDependentString(aName), aResult);
+}
+
+void
+nsNavHistory::GetMonthName(int32_t aIndex, nsACString& aResult)
+{
+ nsIStringBundle *bundle = GetDateFormatBundle();
+ if (bundle) {
+ nsCString name = nsPrintfCString("month.%d.name", aIndex);
+ nsXPIDLString value;
+ nsresult rv = bundle->GetStringFromName(NS_ConvertUTF8toUTF16(name).get(),
+ getter_Copies(value));
+ if (NS_SUCCEEDED(rv)) {
+ CopyUTF16toUTF8(value, aResult);
+ return;
+ }
+ }
+ aResult = nsPrintfCString("[%d]", aIndex);
+}
+
+void
+nsNavHistory::GetMonthYear(int32_t aMonth, int32_t aYear, nsACString& aResult)
+{
+ nsIStringBundle *bundle = GetBundle();
+ if (bundle) {
+ nsAutoCString monthName;
+ GetMonthName(aMonth, monthName);
+ nsAutoString yearString;
+ yearString.AppendInt(aYear);
+ const char16_t* strings[2] = {
+ NS_ConvertUTF8toUTF16(monthName).get()
+ , yearString.get()
+ };
+ nsXPIDLString value;
+ if (NS_SUCCEEDED(bundle->FormatStringFromName(
+ u"finduri-MonthYear", strings, 2,
+ getter_Copies(value)
+ ))) {
+ CopyUTF16toUTF8(value, aResult);
+ return;
+ }
+ }
+ aResult.AppendLiteral("finduri-MonthYear");
+}
+
+
+namespace {
+
+// GetSimpleBookmarksQueryFolder
+//
+// Determines if this set of queries is a simple bookmarks query for a
+// folder with no other constraints. In these common cases, we can more
+// efficiently compute the results.
+//
+// A simple bookmarks query will result in a hierarchical tree of
+// bookmark items, folders and separators.
+//
+// Returns the folder ID if it is a simple folder query, 0 if not.
+static int64_t
+GetSimpleBookmarksQueryFolder(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions)
+{
+ if (aQueries.Count() != 1)
+ return 0;
+
+ nsNavHistoryQuery* query = aQueries[0];
+ if (query->Folders().Length() != 1)
+ return 0;
+
+ bool hasIt;
+ query->GetHasBeginTime(&hasIt);
+ if (hasIt)
+ return 0;
+ query->GetHasEndTime(&hasIt);
+ if (hasIt)
+ return 0;
+ query->GetHasDomain(&hasIt);
+ if (hasIt)
+ return 0;
+ query->GetHasUri(&hasIt);
+ if (hasIt)
+ return 0;
+ (void)query->GetHasSearchTerms(&hasIt);
+ if (hasIt)
+ return 0;
+ if (query->Tags().Length() > 0)
+ return 0;
+ if (aOptions->MaxResults() > 0)
+ return 0;
+
+ // RESULTS_AS_TAG_CONTENTS is quite similar to a folder shortcut, but it must
+ // not be treated like that, since it needs all query options.
+ if(aOptions->ResultType() == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS)
+ return 0;
+
+ // Don't care about onlyBookmarked flag, since specifying a bookmark
+ // folder is inferring onlyBookmarked.
+
+ return query->Folders()[0];
+}
+
+
+// ParseSearchTermsFromQueries
+//
+// Construct a matrix of search terms from the given queries array.
+// All of the query objects are ORed together. Within a query, all the terms
+// are ANDed together. See nsINavHistoryService.idl.
+//
+// This just breaks the query up into words. We don't do anything fancy,
+// not even quoting. We do, however, strip quotes, because people might
+// try to input quotes expecting them to do something and get no results
+// back.
+
+inline bool isQueryWhitespace(char16_t ch)
+{
+ return ch == ' ';
+}
+
+void ParseSearchTermsFromQueries(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsTArray<nsTArray<nsString>*>* aTerms)
+{
+ int32_t lastBegin = -1;
+ for (int32_t i = 0; i < aQueries.Count(); i++) {
+ nsTArray<nsString> *queryTerms = new nsTArray<nsString>();
+ bool hasSearchTerms;
+ if (NS_SUCCEEDED(aQueries[i]->GetHasSearchTerms(&hasSearchTerms)) &&
+ hasSearchTerms) {
+ const nsString& searchTerms = aQueries[i]->SearchTerms();
+ for (uint32_t j = 0; j < searchTerms.Length(); j++) {
+ if (isQueryWhitespace(searchTerms[j]) ||
+ searchTerms[j] == '"') {
+ if (lastBegin >= 0) {
+ // found the end of a word
+ queryTerms->AppendElement(Substring(searchTerms, lastBegin,
+ j - lastBegin));
+ lastBegin = -1;
+ }
+ } else {
+ if (lastBegin < 0) {
+ // found the beginning of a word
+ lastBegin = j;
+ }
+ }
+ }
+ // last word
+ if (lastBegin >= 0)
+ queryTerms->AppendElement(Substring(searchTerms, lastBegin));
+ }
+ aTerms->AppendElement(queryTerms);
+ }
+}
+
+} // namespace
+
+
+nsresult
+nsNavHistory::UpdateFrecency(int64_t aPlaceId)
+{
+ nsCOMPtr<mozIStorageAsyncStatement> updateFrecencyStmt = mDB->GetAsyncStatement(
+ "UPDATE moz_places "
+ "SET frecency = NOTIFY_FRECENCY("
+ "CALCULATE_FRECENCY(:page_id), url, guid, hidden, last_visit_date"
+ ") "
+ "WHERE id = :page_id"
+ );
+ NS_ENSURE_STATE(updateFrecencyStmt);
+ nsresult rv = updateFrecencyStmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"),
+ aPlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCOMPtr<mozIStorageAsyncStatement> updateHiddenStmt = mDB->GetAsyncStatement(
+ "UPDATE moz_places "
+ "SET hidden = 0 "
+ "WHERE id = :page_id AND frecency <> 0"
+ );
+ NS_ENSURE_STATE(updateHiddenStmt);
+ rv = updateHiddenStmt->BindInt64ByName(NS_LITERAL_CSTRING("page_id"),
+ aPlaceId);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mozIStorageBaseStatement *stmts[] = {
+ updateFrecencyStmt.get()
+ , updateHiddenStmt.get()
+ };
+
+ RefPtr<AsyncStatementCallbackNotifier> cb =
+ new AsyncStatementCallbackNotifier(TOPIC_FRECENCY_UPDATED);
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ rv = mDB->MainConn()->ExecuteAsync(stmts, ArrayLength(stmts), cb,
+ getter_AddRefs(ps));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+namespace {
+
+class FixInvalidFrecenciesCallback : public AsyncStatementCallbackNotifier
+{
+public:
+ FixInvalidFrecenciesCallback()
+ : AsyncStatementCallbackNotifier(TOPIC_FRECENCY_UPDATED)
+ {
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t aReason)
+ {
+ nsresult rv = AsyncStatementCallbackNotifier::HandleCompletion(aReason);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (aReason == REASON_FINISHED) {
+ nsNavHistory *navHistory = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(navHistory);
+ navHistory->NotifyManyFrecenciesChanged();
+ }
+ return NS_OK;
+ }
+};
+
+} // namespace
+
+nsresult
+nsNavHistory::FixInvalidFrecencies()
+{
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
+ "UPDATE moz_places "
+ "SET frecency = CALCULATE_FRECENCY(id) "
+ "WHERE frecency < 0"
+ );
+ NS_ENSURE_STATE(stmt);
+
+ RefPtr<FixInvalidFrecenciesCallback> callback =
+ new FixInvalidFrecenciesCallback();
+ nsCOMPtr<mozIStoragePendingStatement> ps;
+ (void)stmt->ExecuteAsync(callback, getter_AddRefs(ps));
+
+ return NS_OK;
+}
+
+
+#ifdef MOZ_XUL
+
+nsresult
+nsNavHistory::AutoCompleteFeedback(int32_t aIndex,
+ nsIAutoCompleteController *aController)
+{
+ nsCOMPtr<mozIStorageAsyncStatement> stmt = mDB->GetAsyncStatement(
+ "INSERT OR REPLACE INTO moz_inputhistory "
+ // use_count will asymptotically approach the max of 10.
+ "SELECT h.id, IFNULL(i.input, :input_text), IFNULL(i.use_count, 0) * .9 + 1 "
+ "FROM moz_places h "
+ "LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input_text "
+ "WHERE url_hash = hash(:page_url) AND url = :page_url "
+ );
+ NS_ENSURE_STATE(stmt);
+
+ nsAutoString input;
+ nsresult rv = aController->GetSearchString(input);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindStringByName(NS_LITERAL_CSTRING("input_text"), input);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoString url;
+ rv = aController->GetValueAt(aIndex, url);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"),
+ NS_ConvertUTF16toUTF8(url));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // We do the update asynchronously and we do not care about failures.
+ RefPtr<AsyncStatementCallbackNotifier> callback =
+ new AsyncStatementCallbackNotifier(TOPIC_AUTOCOMPLETE_FEEDBACK_UPDATED);
+ nsCOMPtr<mozIStoragePendingStatement> canceler;
+ rv = stmt->ExecuteAsync(callback, getter_AddRefs(canceler));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+#endif
+
+
+nsICollation *
+nsNavHistory::GetCollation()
+{
+ if (mCollation)
+ return mCollation;
+
+ // locale
+ nsCOMPtr<nsILocale> locale;
+ nsCOMPtr<nsILocaleService> ls(do_GetService(NS_LOCALESERVICE_CONTRACTID));
+ NS_ENSURE_TRUE(ls, nullptr);
+ nsresult rv = ls->GetApplicationLocale(getter_AddRefs(locale));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+
+ // collation
+ nsCOMPtr<nsICollationFactory> cfact =
+ do_CreateInstance(NS_COLLATIONFACTORY_CONTRACTID);
+ NS_ENSURE_TRUE(cfact, nullptr);
+ rv = cfact->CreateCollation(locale, getter_AddRefs(mCollation));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+
+ return mCollation;
+}
+
+nsIStringBundle *
+nsNavHistory::GetBundle()
+{
+ if (!mBundle) {
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ services::GetStringBundleService();
+ NS_ENSURE_TRUE(bundleService, nullptr);
+ nsresult rv = bundleService->CreateBundle(
+ "chrome://places/locale/places.properties",
+ getter_AddRefs(mBundle));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+ }
+ return mBundle;
+}
+
+nsIStringBundle *
+nsNavHistory::GetDateFormatBundle()
+{
+ if (!mDateFormatBundle) {
+ nsCOMPtr<nsIStringBundleService> bundleService =
+ services::GetStringBundleService();
+ NS_ENSURE_TRUE(bundleService, nullptr);
+ nsresult rv = bundleService->CreateBundle(
+ "chrome://global/locale/dateFormat.properties",
+ getter_AddRefs(mDateFormatBundle));
+ NS_ENSURE_SUCCESS(rv, nullptr);
+ }
+ return mDateFormatBundle;
+}
diff --git a/toolkit/components/places/nsNavHistory.h b/toolkit/components/places/nsNavHistory.h
new file mode 100644
index 000000000..ed5272ce0
--- /dev/null
+++ b/toolkit/components/places/nsNavHistory.h
@@ -0,0 +1,659 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsNavHistory_h_
+#define nsNavHistory_h_
+
+#include "nsINavHistoryService.h"
+#include "nsPIPlacesDatabase.h"
+#include "nsIBrowserHistory.h"
+#include "nsINavBookmarksService.h"
+#include "nsIFaviconService.h"
+
+#include "nsIObserverService.h"
+#include "nsICollation.h"
+#include "nsIStringBundle.h"
+#include "nsITimer.h"
+#include "nsMaybeWeakPtr.h"
+#include "nsCategoryCache.h"
+#include "nsNetCID.h"
+#include "nsToolkitCompsCID.h"
+#include "nsURIHashKey.h"
+#include "nsTHashtable.h"
+
+#include "nsNavHistoryResult.h"
+#include "nsNavHistoryQuery.h"
+#include "Database.h"
+#include "mozilla/Attributes.h"
+#include "mozilla/Atomics.h"
+
+#define QUERYUPDATE_TIME 0
+#define QUERYUPDATE_SIMPLE 1
+#define QUERYUPDATE_COMPLEX 2
+#define QUERYUPDATE_COMPLEX_WITH_BOOKMARKS 3
+#define QUERYUPDATE_HOST 4
+
+// Clamp title and URL to generously large, but not too large, length.
+// See bug 319004 for details.
+#define URI_LENGTH_MAX 65536
+#define TITLE_LENGTH_MAX 4096
+
+// Microsecond timeout for "recent" events such as typed and bookmark following.
+// If you typed it more than this time ago, it's not recent.
+#define RECENT_EVENT_THRESHOLD PRTime((int64_t)15 * 60 * PR_USEC_PER_SEC)
+
+#ifdef MOZ_XUL
+// Fired after autocomplete feedback has been updated.
+#define TOPIC_AUTOCOMPLETE_FEEDBACK_UPDATED "places-autocomplete-feedback-updated"
+#endif
+
+// Fired after frecency has been updated.
+#define TOPIC_FRECENCY_UPDATED "places-frecency-updated"
+
+class nsNavHistory;
+class QueryKeyValuePair;
+class nsIEffectiveTLDService;
+class nsIIDNService;
+class PlacesSQLQueryBuilder;
+class nsIAutoCompleteController;
+
+// nsNavHistory
+
+class nsNavHistory final : public nsSupportsWeakReference
+ , public nsINavHistoryService
+ , public nsIObserver
+ , public nsIBrowserHistory
+ , public nsPIPlacesDatabase
+ , public mozIStorageVacuumParticipant
+{
+ friend class PlacesSQLQueryBuilder;
+
+public:
+ nsNavHistory();
+
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_NSINAVHISTORYSERVICE
+ NS_DECL_NSIBROWSERHISTORY
+ NS_DECL_NSIOBSERVER
+ NS_DECL_NSPIPLACESDATABASE
+ NS_DECL_MOZISTORAGEVACUUMPARTICIPANT
+
+ /**
+ * Obtains the nsNavHistory object.
+ */
+ static already_AddRefed<nsNavHistory> GetSingleton();
+
+ /**
+ * Initializes the nsNavHistory object. This should only be called once.
+ */
+ nsresult Init();
+
+ /**
+ * Used by other components in the places directory such as the annotation
+ * service to get a reference to this history object. Returns a pointer to
+ * the service if it exists. Otherwise creates one. Returns nullptr on error.
+ */
+ static nsNavHistory* GetHistoryService()
+ {
+ if (!gHistoryService) {
+ nsCOMPtr<nsINavHistoryService> serv =
+ do_GetService(NS_NAVHISTORYSERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(serv, nullptr);
+ NS_ASSERTION(gHistoryService, "Should have static instance pointer now");
+ }
+ return gHistoryService;
+ }
+
+ /**
+ * Used by other components in the places directory to get a reference to a
+ * const version of this history object.
+ *
+ * @return A pointer to a const version of the service if it exists,
+ * nullptr otherwise.
+ */
+ static const nsNavHistory* GetConstHistoryService()
+ {
+ const nsNavHistory* const history = gHistoryService;
+ return history;
+ }
+
+ /**
+ * Fetches the database id and the GUID associated to the given URI.
+ *
+ * @param aURI
+ * The page to look for.
+ * @param _pageId
+ * Will be set to the database id associated with the page.
+ * If the page doesn't exist, this will be zero.
+ * @param _GUID
+ * Will be set to the unique id associated with the page.
+ * If the page doesn't exist, this will be empty.
+ * @note This DOES NOT check for bad URLs other than that they're nonempty.
+ */
+ nsresult GetIdForPage(nsIURI* aURI,
+ int64_t* _pageId, nsCString& _GUID);
+
+ /**
+ * Fetches the database id and the GUID associated to the given URI, creating
+ * a new database entry if one doesn't exist yet.
+ *
+ * @param aURI
+ * The page to look for or create.
+ * @param _pageId
+ * Will be set to the database id associated with the page.
+ * @param _GUID
+ * Will be set to the unique id associated with the page.
+ * @note This DOES NOT check for bad URLs other than that they're nonempty.
+ * @note This DOES NOT update frecency of the page.
+ */
+ nsresult GetOrCreateIdForPage(nsIURI* aURI,
+ int64_t* _pageId, nsCString& _GUID);
+
+ /**
+ * Asynchronously recalculates frecency for a given page.
+ *
+ * @param aPlaceId
+ * Place id to recalculate the frecency for.
+ * @note If the new frecency is a non-zero value it will also unhide the page,
+ * otherwise will reuse the old hidden value.
+ */
+ nsresult UpdateFrecency(int64_t aPlaceId);
+
+ /**
+ * Recalculates frecency for all pages requesting that (frecency < 0). Those
+ * may be generated:
+ * * After a "clear private data"
+ * * After removing visits
+ * * After migrating from older versions
+ */
+ nsresult FixInvalidFrecencies();
+
+ /**
+ * Invalidate the frecencies of a list of places, so they will be recalculated
+ * at the first idle-daily notification.
+ *
+ * @param aPlacesIdsQueryString
+ * Query string containing list of places to be invalidated. If it's
+ * an empty string all places will be invalidated.
+ */
+ nsresult invalidateFrecencies(const nsCString& aPlaceIdsQueryString);
+
+ /**
+ * Calls onDeleteVisits and onDeleteURI notifications on registered listeners
+ * with the history service.
+ *
+ * @param aURI
+ * The nsIURI object representing the URI of the page being expired.
+ * @param aVisitTime
+ * The time, in microseconds, that the page being expired was visited.
+ * @param aWholeEntry
+ * Indicates if this is the last visit for this URI.
+ * @param aGUID
+ * The unique ID associated with the page.
+ * @param aReason
+ * Indicates the reason for the removal.
+ * See nsINavHistoryObserver::REASON_* constants.
+ * @param aTransitionType
+ * If it's a valid TRANSITION_* value, all visits of the specified type
+ * have been removed.
+ */
+ nsresult NotifyOnPageExpired(nsIURI *aURI, PRTime aVisitTime,
+ bool aWholeEntry, const nsACString& aGUID,
+ uint16_t aReason, uint32_t aTransitionType);
+
+ /**
+ * These functions return non-owning references to the locale-specific
+ * objects for places components.
+ */
+ nsIStringBundle* GetBundle();
+ nsIStringBundle* GetDateFormatBundle();
+ nsICollation* GetCollation();
+ void GetStringFromName(const char16_t* aName, nsACString& aResult);
+ void GetAgeInDaysString(int32_t aInt, const char16_t *aName,
+ nsACString& aResult);
+ void GetMonthName(int32_t aIndex, nsACString& aResult);
+ void GetMonthYear(int32_t aMonth, int32_t aYear, nsACString& aResult);
+
+ // Returns whether history is enabled or not.
+ bool IsHistoryDisabled() {
+ return !mHistoryEnabled;
+ }
+
+ // Constants for the columns returned by the above statement.
+ static const int32_t kGetInfoIndex_PageID;
+ static const int32_t kGetInfoIndex_URL;
+ static const int32_t kGetInfoIndex_Title;
+ static const int32_t kGetInfoIndex_RevHost;
+ static const int32_t kGetInfoIndex_VisitCount;
+ static const int32_t kGetInfoIndex_VisitDate;
+ static const int32_t kGetInfoIndex_FaviconURL;
+ static const int32_t kGetInfoIndex_ItemId;
+ static const int32_t kGetInfoIndex_ItemDateAdded;
+ static const int32_t kGetInfoIndex_ItemLastModified;
+ static const int32_t kGetInfoIndex_ItemParentId;
+ static const int32_t kGetInfoIndex_ItemTags;
+ static const int32_t kGetInfoIndex_Frecency;
+ static const int32_t kGetInfoIndex_Hidden;
+ static const int32_t kGetInfoIndex_Guid;
+ static const int32_t kGetInfoIndex_VisitId;
+ static const int32_t kGetInfoIndex_FromVisitId;
+ static const int32_t kGetInfoIndex_VisitType;
+
+ int64_t GetTagsFolder();
+
+ // this actually executes a query and gives you results, it is used by
+ // nsNavHistoryQueryResultNode
+ nsresult GetQueryResults(nsNavHistoryQueryResultNode *aResultNode,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions *aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aResults);
+
+ // Take a row of kGetInfoIndex_* columns and construct a ResultNode.
+ // The row must contain the full set of columns.
+ nsresult RowToResult(mozIStorageValueArray* aRow,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult);
+ nsresult QueryRowToResult(int64_t aItemId,
+ const nsACString& aBookmarkGuid,
+ const nsACString& aURI,
+ const nsACString& aTitle,
+ uint32_t aAccessCount, PRTime aTime,
+ const nsACString& aFavicon,
+ nsNavHistoryResultNode** aNode);
+
+ nsresult VisitIdToResultNode(int64_t visitId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult);
+
+ nsresult BookmarkIdToResultNode(int64_t aBookmarkId,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult);
+ nsresult URIToResultNode(nsIURI* aURI,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode** aResult);
+
+ // used by other places components to send history notifications (for example,
+ // when the favicon has changed)
+ void SendPageChangedNotification(nsIURI* aURI, uint32_t aChangedAttribute,
+ const nsAString& aValue,
+ const nsACString& aGUID);
+
+ /**
+ * Returns current number of days stored in history.
+ */
+ int32_t GetDaysOfHistory();
+
+ // used by query result nodes to update: see comment on body of CanLiveUpdateQuery
+ static uint32_t GetUpdateRequirements(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ bool* aHasSearchTerms);
+ bool EvaluateQueryForNode(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryResultNode* aNode);
+
+ static nsresult AsciiHostNameFromHostString(const nsACString& aHostName,
+ nsACString& aAscii);
+ void DomainNameFromURI(nsIURI* aURI,
+ nsACString& aDomainName);
+ static PRTime NormalizeTime(uint32_t aRelative, PRTime aOffset);
+
+ // Don't use these directly, inside nsNavHistory use UpdateBatchScoper,
+ // else use nsINavHistoryService::RunInBatchMode
+ nsresult BeginUpdateBatch();
+ nsresult EndUpdateBatch();
+
+ // The level of batches' nesting, 0 when no batches are open.
+ int32_t mBatchLevel;
+ // Current active transaction for a batch.
+ mozStorageTransaction* mBatchDBTransaction;
+
+ // better alternative to QueryStringToQueries (in nsNavHistoryQuery.cpp)
+ nsresult QueryStringToQueryArray(const nsACString& aQueryString,
+ nsCOMArray<nsNavHistoryQuery>* aQueries,
+ nsNavHistoryQueryOptions** aOptions);
+
+ typedef nsDataHashtable<nsCStringHashKey, nsCString> StringHash;
+
+ /**
+ * Indicates if it is OK to notify history observers or not.
+ *
+ * @return true if it is OK to notify, false otherwise.
+ */
+ bool canNotify() { return mCanNotify; }
+
+ enum RecentEventFlags {
+ RECENT_TYPED = 1 << 0, // User typed in URL recently
+ RECENT_ACTIVATED = 1 << 1, // User tapped URL link recently
+ RECENT_BOOKMARKED = 1 << 2 // User bookmarked URL recently
+ };
+
+ /**
+ * Returns any recent activity done with a URL.
+ * @return Any recent events associated with this URI. Each bit is set
+ * according to RecentEventFlags enum values.
+ */
+ uint32_t GetRecentFlags(nsIURI *aURI);
+
+ /**
+ * Registers a TRANSITION_EMBED visit for the session.
+ *
+ * @param aURI
+ * URI of the page.
+ * @param aTime
+ * Visit time. Only the last registered visit time is retained.
+ */
+ void registerEmbedVisit(nsIURI* aURI, int64_t aTime);
+
+ /**
+ * Returns whether the specified url has a embed visit.
+ *
+ * @param aURI
+ * URI of the page.
+ * @return whether the page has a embed visit.
+ */
+ bool hasEmbedVisit(nsIURI* aURI);
+
+ /**
+ * Clears all registered embed visits.
+ */
+ void clearEmbedVisits();
+
+ int32_t GetFrecencyAgedWeight(int32_t aAgeInDays) const
+ {
+ if (aAgeInDays <= mFirstBucketCutoffInDays) {
+ return mFirstBucketWeight;
+ }
+ if (aAgeInDays <= mSecondBucketCutoffInDays) {
+ return mSecondBucketWeight;
+ }
+ if (aAgeInDays <= mThirdBucketCutoffInDays) {
+ return mThirdBucketWeight;
+ }
+ if (aAgeInDays <= mFourthBucketCutoffInDays) {
+ return mFourthBucketWeight;
+ }
+ return mDefaultWeight;
+ }
+
+ int32_t GetFrecencyBucketWeight(int32_t aBucketIndex) const
+ {
+ switch(aBucketIndex) {
+ case 1:
+ return mFirstBucketWeight;
+ case 2:
+ return mSecondBucketWeight;
+ case 3:
+ return mThirdBucketWeight;
+ case 4:
+ return mFourthBucketWeight;
+ default:
+ return mDefaultWeight;
+ }
+ }
+
+ int32_t GetFrecencyTransitionBonus(int32_t aTransitionType,
+ bool aVisited) const
+ {
+ switch (aTransitionType) {
+ case nsINavHistoryService::TRANSITION_EMBED:
+ return mEmbedVisitBonus;
+ case nsINavHistoryService::TRANSITION_FRAMED_LINK:
+ return mFramedLinkVisitBonus;
+ case nsINavHistoryService::TRANSITION_LINK:
+ return mLinkVisitBonus;
+ case nsINavHistoryService::TRANSITION_TYPED:
+ return aVisited ? mTypedVisitBonus : mUnvisitedTypedBonus;
+ case nsINavHistoryService::TRANSITION_BOOKMARK:
+ return aVisited ? mBookmarkVisitBonus : mUnvisitedBookmarkBonus;
+ case nsINavHistoryService::TRANSITION_DOWNLOAD:
+ return mDownloadVisitBonus;
+ case nsINavHistoryService::TRANSITION_REDIRECT_PERMANENT:
+ return mPermRedirectVisitBonus;
+ case nsINavHistoryService::TRANSITION_REDIRECT_TEMPORARY:
+ return mTempRedirectVisitBonus;
+ case nsINavHistoryService::TRANSITION_RELOAD:
+ return mReloadVisitBonus;
+ default:
+ // 0 == undefined (see bug #375777 for details)
+ NS_WARNING_ASSERTION(!aTransitionType,
+ "new transition but no bonus for frecency");
+ return mDefaultVisitBonus;
+ }
+ }
+
+ int32_t GetNumVisitsForFrecency() const
+ {
+ return mNumVisitsForFrecency;
+ }
+
+ /**
+ * Fires onVisit event to nsINavHistoryService observers
+ */
+ void NotifyOnVisit(nsIURI* aURI,
+ int64_t aVisitId,
+ PRTime aTime,
+ int64_t aReferrerVisitId,
+ int32_t aTransitionType,
+ const nsACString& aGuid,
+ bool aHidden,
+ uint32_t aVisitCount,
+ uint32_t aTyped);
+
+ /**
+ * Fires onTitleChanged event to nsINavHistoryService observers
+ */
+ void NotifyTitleChange(nsIURI* aURI,
+ const nsString& title,
+ const nsACString& aGUID);
+
+ /**
+ * Fires onFrecencyChanged event to nsINavHistoryService observers
+ */
+ void NotifyFrecencyChanged(nsIURI* aURI,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate);
+
+ /**
+ * Fires onManyFrecenciesChanged event to nsINavHistoryService observers
+ */
+ void NotifyManyFrecenciesChanged();
+
+ /**
+ * Posts a runnable to the main thread that calls NotifyFrecencyChanged.
+ */
+ void DispatchFrecencyChangedNotification(const nsACString& aSpec,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate) const;
+
+ /**
+ * Store last insterted id for a table.
+ */
+ static mozilla::Atomic<int64_t> sLastInsertedPlaceId;
+ static mozilla::Atomic<int64_t> sLastInsertedVisitId;
+
+ static void StoreLastInsertedId(const nsACString& aTable,
+ const int64_t aLastInsertedId);
+
+ bool isBatching() {
+ return mBatchLevel > 0;
+ }
+
+private:
+ ~nsNavHistory();
+
+ // used by GetHistoryService
+ static nsNavHistory *gHistoryService;
+
+protected:
+
+ // Database handle.
+ RefPtr<mozilla::places::Database> mDB;
+
+ /**
+ * Decays frecency and inputhistory values. Runs on idle-daily.
+ */
+ nsresult DecayFrecency();
+
+ nsresult RemovePagesInternal(const nsCString& aPlaceIdsQueryString);
+ nsresult CleanupPlacesOnVisitsDelete(const nsCString& aPlaceIdsQueryString);
+
+ /**
+ * Loads all of the preferences that we use into member variables.
+ *
+ * @note If mPrefBranch is nullptr, this does nothing.
+ */
+ void LoadPrefs();
+
+ /**
+ * Calculates and returns value for mCachedNow.
+ * This is an hack to avoid calling PR_Now() too often, as is the case when
+ * we're asked the ageindays of many history entries in a row. A timer is
+ * set which will clear our valid flag after a short timeout.
+ */
+ PRTime GetNow();
+ PRTime mCachedNow;
+ nsCOMPtr<nsITimer> mExpireNowTimer;
+ /**
+ * Called when the cached now value is expired and needs renewal.
+ */
+ static void expireNowTimerCallback(nsITimer* aTimer, void* aClosure);
+
+ nsresult ConstructQueryString(const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCString& queryString,
+ bool& aParamsPresent,
+ StringHash& aAddParams);
+
+ nsresult QueryToSelectClause(nsNavHistoryQuery* aQuery,
+ nsNavHistoryQueryOptions* aOptions,
+ int32_t aQueryIndex,
+ nsCString* aClause);
+ nsresult BindQueryClauseParameters(mozIStorageBaseStatement* statement,
+ int32_t aQueryIndex,
+ nsNavHistoryQuery* aQuery,
+ nsNavHistoryQueryOptions* aOptions);
+
+ nsresult ResultsAsList(mozIStorageStatement* statement,
+ nsNavHistoryQueryOptions* aOptions,
+ nsCOMArray<nsNavHistoryResultNode>* aResults);
+
+ void TitleForDomain(const nsCString& domain, nsACString& aTitle);
+
+ nsresult FilterResultSet(nsNavHistoryQueryResultNode *aParentNode,
+ const nsCOMArray<nsNavHistoryResultNode>& aSet,
+ nsCOMArray<nsNavHistoryResultNode>* aFiltered,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions);
+
+ // observers
+ nsMaybeWeakPtrArray<nsINavHistoryObserver> mObservers;
+
+ // effective tld service
+ nsCOMPtr<nsIEffectiveTLDService> mTLDService;
+ nsCOMPtr<nsIIDNService> mIDNService;
+
+ // localization
+ nsCOMPtr<nsIStringBundle> mBundle;
+ nsCOMPtr<nsIStringBundle> mDateFormatBundle;
+ nsCOMPtr<nsICollation> mCollation;
+
+ // recent events
+ typedef nsDataHashtable<nsCStringHashKey, int64_t> RecentEventHash;
+ RecentEventHash mRecentTyped;
+ RecentEventHash mRecentLink;
+ RecentEventHash mRecentBookmark;
+
+ // Embed visits tracking.
+ class VisitHashKey : public nsURIHashKey
+ {
+ public:
+ explicit VisitHashKey(const nsIURI* aURI)
+ : nsURIHashKey(aURI)
+ {
+ }
+ VisitHashKey(const VisitHashKey& aOther)
+ : nsURIHashKey(aOther)
+ {
+ NS_NOTREACHED("Do not call me!");
+ }
+ PRTime visitTime;
+ };
+
+ nsTHashtable<VisitHashKey> mEmbedVisits;
+
+ bool CheckIsRecentEvent(RecentEventHash* hashTable,
+ const nsACString& url);
+ void ExpireNonrecentEvents(RecentEventHash* hashTable);
+
+#ifdef MOZ_XUL
+ nsresult AutoCompleteFeedback(int32_t aIndex,
+ nsIAutoCompleteController *aController);
+#endif
+
+ // Whether history is enabled or not.
+ // Will mimic value of the places.history.enabled preference.
+ bool mHistoryEnabled;
+
+ // Frecency preferences.
+ int32_t mNumVisitsForFrecency;
+ int32_t mFirstBucketCutoffInDays;
+ int32_t mSecondBucketCutoffInDays;
+ int32_t mThirdBucketCutoffInDays;
+ int32_t mFourthBucketCutoffInDays;
+ int32_t mFirstBucketWeight;
+ int32_t mSecondBucketWeight;
+ int32_t mThirdBucketWeight;
+ int32_t mFourthBucketWeight;
+ int32_t mDefaultWeight;
+ int32_t mEmbedVisitBonus;
+ int32_t mFramedLinkVisitBonus;
+ int32_t mLinkVisitBonus;
+ int32_t mTypedVisitBonus;
+ int32_t mBookmarkVisitBonus;
+ int32_t mDownloadVisitBonus;
+ int32_t mPermRedirectVisitBonus;
+ int32_t mTempRedirectVisitBonus;
+ int32_t mDefaultVisitBonus;
+ int32_t mUnvisitedBookmarkBonus;
+ int32_t mUnvisitedTypedBonus;
+ int32_t mReloadVisitBonus;
+
+ // in nsNavHistoryQuery.cpp
+ nsresult TokensToQueries(const nsTArray<QueryKeyValuePair>& aTokens,
+ nsCOMArray<nsNavHistoryQuery>* aQueries,
+ nsNavHistoryQueryOptions* aOptions);
+
+ int64_t mTagsFolder;
+
+ int32_t mDaysOfHistory;
+ int64_t mLastCachedStartOfDay;
+ int64_t mLastCachedEndOfDay;
+
+ // Used to enable and disable the observer notifications
+ bool mCanNotify;
+ nsCategoryCache<nsINavHistoryObserver> mCacheObservers;
+};
+
+
+#define PLACES_URI_PREFIX "place:"
+
+/* Returns true if the given URI represents a history query. */
+inline bool IsQueryURI(const nsCString &uri)
+{
+ return StringBeginsWith(uri, NS_LITERAL_CSTRING(PLACES_URI_PREFIX));
+}
+
+/* Extracts the query string from a query URI. */
+inline const nsDependentCSubstring QueryURIToQuery(const nsCString &uri)
+{
+ NS_ASSERTION(IsQueryURI(uri), "should only be called for query URIs");
+ return Substring(uri, NS_LITERAL_CSTRING(PLACES_URI_PREFIX).Length());
+}
+
+#endif // nsNavHistory_h_
diff --git a/toolkit/components/places/nsNavHistoryQuery.cpp b/toolkit/components/places/nsNavHistoryQuery.cpp
new file mode 100644
index 000000000..1a7b1c239
--- /dev/null
+++ b/toolkit/components/places/nsNavHistoryQuery.cpp
@@ -0,0 +1,1694 @@
+//* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file contains the definitions of nsNavHistoryQuery,
+ * nsNavHistoryQueryOptions, and those functions in nsINavHistory that directly
+ * support queries (specifically QueryStringToQueries and QueriesToQueryString).
+ */
+
+#include "mozilla/DebugOnly.h"
+
+#include "nsNavHistory.h"
+#include "nsNavBookmarks.h"
+#include "nsEscape.h"
+#include "nsCOMArray.h"
+#include "nsNetUtil.h"
+#include "nsTArray.h"
+#include "prprf.h"
+#include "nsVariant.h"
+
+using namespace mozilla;
+
+class QueryKeyValuePair
+{
+public:
+
+ // QueryKeyValuePair
+ //
+ // 01234567890
+ // input : qwerty&key=value&qwerty
+ // ^ ^ ^
+ // aKeyBegin | aPastEnd (may point to null terminator)
+ // aEquals
+ //
+ // Special case: if aKeyBegin == aEquals, then there is only one string
+ // and no equal sign, so we treat the entire thing as a key with no value
+
+ QueryKeyValuePair(const nsCSubstring& aSource, int32_t aKeyBegin,
+ int32_t aEquals, int32_t aPastEnd)
+ {
+ if (aEquals == aKeyBegin)
+ aEquals = aPastEnd;
+ key = Substring(aSource, aKeyBegin, aEquals - aKeyBegin);
+ if (aPastEnd - aEquals > 0)
+ value = Substring(aSource, aEquals + 1, aPastEnd - aEquals - 1);
+ }
+ nsCString key;
+ nsCString value;
+};
+
+static nsresult TokenizeQueryString(const nsACString& aQuery,
+ nsTArray<QueryKeyValuePair>* aTokens);
+static nsresult ParseQueryBooleanString(const nsCString& aString,
+ bool* aValue);
+
+// query getters
+typedef NS_STDCALL_FUNCPROTO(nsresult, BoolQueryGetter, nsINavHistoryQuery,
+ GetOnlyBookmarked, (bool*));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Uint32QueryGetter, nsINavHistoryQuery,
+ GetBeginTimeReference, (uint32_t*));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Int64QueryGetter, nsINavHistoryQuery,
+ GetBeginTime, (int64_t*));
+static void AppendBoolKeyValueIfTrue(nsACString& aString,
+ const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ BoolQueryGetter getter);
+static void AppendUint32KeyValueIfNonzero(nsACString& aString,
+ const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ Uint32QueryGetter getter);
+static void AppendInt64KeyValueIfNonzero(nsACString& aString,
+ const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ Int64QueryGetter getter);
+
+// query setters
+typedef NS_STDCALL_FUNCPROTO(nsresult, BoolQuerySetter, nsINavHistoryQuery,
+ SetOnlyBookmarked, (bool));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Uint32QuerySetter, nsINavHistoryQuery,
+ SetBeginTimeReference, (uint32_t));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Int64QuerySetter, nsINavHistoryQuery,
+ SetBeginTime, (int64_t));
+static void SetQueryKeyBool(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ BoolQuerySetter setter);
+static void SetQueryKeyUint32(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ Uint32QuerySetter setter);
+static void SetQueryKeyInt64(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ Int64QuerySetter setter);
+
+// options setters
+typedef NS_STDCALL_FUNCPROTO(nsresult, BoolOptionsSetter,
+ nsINavHistoryQueryOptions,
+ SetExpandQueries, (bool));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Uint32OptionsSetter,
+ nsINavHistoryQueryOptions,
+ SetMaxResults, (uint32_t));
+typedef NS_STDCALL_FUNCPROTO(nsresult, Uint16OptionsSetter,
+ nsINavHistoryQueryOptions,
+ SetResultType, (uint16_t));
+static void SetOptionsKeyBool(const nsCString& aValue,
+ nsINavHistoryQueryOptions* aOptions,
+ BoolOptionsSetter setter);
+static void SetOptionsKeyUint16(const nsCString& aValue,
+ nsINavHistoryQueryOptions* aOptions,
+ Uint16OptionsSetter setter);
+static void SetOptionsKeyUint32(const nsCString& aValue,
+ nsINavHistoryQueryOptions* aOptions,
+ Uint32OptionsSetter setter);
+
+// Components of a query string.
+// Note that query strings are also generated in nsNavBookmarks::GetFolderURI
+// for performance reasons, so if you change these values, change that, too.
+#define QUERYKEY_BEGIN_TIME "beginTime"
+#define QUERYKEY_BEGIN_TIME_REFERENCE "beginTimeRef"
+#define QUERYKEY_END_TIME "endTime"
+#define QUERYKEY_END_TIME_REFERENCE "endTimeRef"
+#define QUERYKEY_SEARCH_TERMS "terms"
+#define QUERYKEY_MIN_VISITS "minVisits"
+#define QUERYKEY_MAX_VISITS "maxVisits"
+#define QUERYKEY_ONLY_BOOKMARKED "onlyBookmarked"
+#define QUERYKEY_DOMAIN_IS_HOST "domainIsHost"
+#define QUERYKEY_DOMAIN "domain"
+#define QUERYKEY_FOLDER "folder"
+#define QUERYKEY_NOTANNOTATION "!annotation"
+#define QUERYKEY_ANNOTATION "annotation"
+#define QUERYKEY_URI "uri"
+#define QUERYKEY_SEPARATOR "OR"
+#define QUERYKEY_GROUP "group"
+#define QUERYKEY_SORT "sort"
+#define QUERYKEY_SORTING_ANNOTATION "sortingAnnotation"
+#define QUERYKEY_RESULT_TYPE "type"
+#define QUERYKEY_EXCLUDE_ITEMS "excludeItems"
+#define QUERYKEY_EXCLUDE_QUERIES "excludeQueries"
+#define QUERYKEY_EXCLUDE_READ_ONLY_FOLDERS "excludeReadOnlyFolders"
+#define QUERYKEY_EXPAND_QUERIES "expandQueries"
+#define QUERYKEY_FORCE_ORIGINAL_TITLE "originalTitle"
+#define QUERYKEY_INCLUDE_HIDDEN "includeHidden"
+#define QUERYKEY_MAX_RESULTS "maxResults"
+#define QUERYKEY_QUERY_TYPE "queryType"
+#define QUERYKEY_TAG "tag"
+#define QUERYKEY_NOTTAGS "!tags"
+#define QUERYKEY_ASYNC_ENABLED "asyncEnabled"
+#define QUERYKEY_TRANSITION "transition"
+
+inline void AppendAmpersandIfNonempty(nsACString& aString)
+{
+ if (! aString.IsEmpty())
+ aString.Append('&');
+}
+inline void AppendInt16(nsACString& str, int16_t i)
+{
+ nsAutoCString tmp;
+ tmp.AppendInt(i);
+ str.Append(tmp);
+}
+inline void AppendInt32(nsACString& str, int32_t i)
+{
+ nsAutoCString tmp;
+ tmp.AppendInt(i);
+ str.Append(tmp);
+}
+inline void AppendInt64(nsACString& str, int64_t i)
+{
+ nsCString tmp;
+ tmp.AppendInt(i);
+ str.Append(tmp);
+}
+
+namespace PlacesFolderConversion {
+ #define PLACES_ROOT_FOLDER "PLACES_ROOT"
+ #define BOOKMARKS_MENU_FOLDER "BOOKMARKS_MENU"
+ #define TAGS_FOLDER "TAGS"
+ #define UNFILED_BOOKMARKS_FOLDER "UNFILED_BOOKMARKS"
+ #define TOOLBAR_FOLDER "TOOLBAR"
+ #define MOBILE_BOOKMARKS_FOLDER "MOBILE_BOOKMARKS"
+
+ /**
+ * Converts a folder name to a folder id.
+ *
+ * @param aName
+ * The name of the folder to convert to a folder id.
+ * @returns the folder id if aName is a recognizable name, -1 otherwise.
+ */
+ inline int64_t DecodeFolder(const nsCString &aName)
+ {
+ nsNavBookmarks *bs = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bs, false);
+ int64_t folderID = -1;
+
+ if (aName.EqualsLiteral(PLACES_ROOT_FOLDER))
+ (void)bs->GetPlacesRoot(&folderID);
+ else if (aName.EqualsLiteral(BOOKMARKS_MENU_FOLDER))
+ (void)bs->GetBookmarksMenuFolder(&folderID);
+ else if (aName.EqualsLiteral(TAGS_FOLDER))
+ (void)bs->GetTagsFolder(&folderID);
+ else if (aName.EqualsLiteral(UNFILED_BOOKMARKS_FOLDER))
+ (void)bs->GetUnfiledBookmarksFolder(&folderID);
+ else if (aName.EqualsLiteral(TOOLBAR_FOLDER))
+ (void)bs->GetToolbarFolder(&folderID);
+ else if (aName.EqualsLiteral(MOBILE_BOOKMARKS_FOLDER))
+ (void)bs->GetMobileFolder(&folderID);
+
+ return folderID;
+ }
+
+ /**
+ * Converts a folder id to a named constant, or a string representation of the
+ * folder id if there is no named constant for the folder, and appends it to
+ * aQuery.
+ *
+ * @param aQuery
+ * The string to append the folder string to. This is generally a
+ * query string, but could really be anything.
+ * @param aFolderID
+ * The folder ID to convert to the proper named constant.
+ */
+ inline nsresult AppendFolder(nsCString &aQuery, int64_t aFolderID)
+ {
+ nsNavBookmarks *bs = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_STATE(bs);
+ int64_t folderID;
+
+ if (NS_SUCCEEDED(bs->GetPlacesRoot(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(PLACES_ROOT_FOLDER);
+ }
+ else if (NS_SUCCEEDED(bs->GetBookmarksMenuFolder(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(BOOKMARKS_MENU_FOLDER);
+ }
+ else if (NS_SUCCEEDED(bs->GetTagsFolder(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(TAGS_FOLDER);
+ }
+ else if (NS_SUCCEEDED(bs->GetUnfiledBookmarksFolder(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(UNFILED_BOOKMARKS_FOLDER);
+ }
+ else if (NS_SUCCEEDED(bs->GetToolbarFolder(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(TOOLBAR_FOLDER);
+ }
+ else if (NS_SUCCEEDED(bs->GetMobileFolder(&folderID)) &&
+ aFolderID == folderID) {
+ aQuery.AppendLiteral(MOBILE_BOOKMARKS_FOLDER);
+ }
+ else {
+ // It wasn't one of our named constants, so just convert it to a string.
+ aQuery.AppendInt(aFolderID);
+ }
+
+ return NS_OK;
+ }
+} // namespace PlacesFolderConversion
+
+// nsNavHistory::QueryStringToQueries
+//
+// From C++ places code, you should use QueryStringToQueryArray, this is
+// the harder-to-use XPCOM version.
+
+NS_IMETHODIMP
+nsNavHistory::QueryStringToQueries(const nsACString& aQueryString,
+ nsINavHistoryQuery*** aQueries,
+ uint32_t* aResultCount,
+ nsINavHistoryQueryOptions** aOptions)
+{
+ NS_ENSURE_ARG_POINTER(aQueries);
+ NS_ENSURE_ARG_POINTER(aResultCount);
+ NS_ENSURE_ARG_POINTER(aOptions);
+
+ *aQueries = nullptr;
+ *aResultCount = 0;
+ nsCOMPtr<nsNavHistoryQueryOptions> options;
+ nsCOMArray<nsNavHistoryQuery> queries;
+ nsresult rv = QueryStringToQueryArray(aQueryString, &queries,
+ getter_AddRefs(options));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ *aResultCount = queries.Count();
+ if (queries.Count() > 0) {
+ // convert COM array to raw
+ *aQueries = static_cast<nsINavHistoryQuery**>
+ (moz_xmalloc(sizeof(nsINavHistoryQuery*) * queries.Count()));
+ NS_ENSURE_TRUE(*aQueries, NS_ERROR_OUT_OF_MEMORY);
+ for (int32_t i = 0; i < queries.Count(); i ++) {
+ (*aQueries)[i] = queries[i];
+ NS_ADDREF((*aQueries)[i]);
+ }
+ }
+ options.forget(aOptions);
+ return NS_OK;
+}
+
+
+// nsNavHistory::QueryStringToQueryArray
+//
+// An internal version of QueryStringToQueries that fills a COM array for
+// ease-of-use.
+
+nsresult
+nsNavHistory::QueryStringToQueryArray(const nsACString& aQueryString,
+ nsCOMArray<nsNavHistoryQuery>* aQueries,
+ nsNavHistoryQueryOptions** aOptions)
+{
+ nsresult rv;
+ aQueries->Clear();
+ *aOptions = nullptr;
+
+ RefPtr<nsNavHistoryQueryOptions> options(new nsNavHistoryQueryOptions());
+ if (! options)
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ nsTArray<QueryKeyValuePair> tokens;
+ rv = TokenizeQueryString(aQueryString, &tokens);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = TokensToQueries(tokens, aQueries, options);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Unable to parse the query string: ");
+ NS_WARNING(PromiseFlatCString(aQueryString).get());
+ return rv;
+ }
+
+ options.forget(aOptions);
+ return NS_OK;
+}
+
+
+// nsNavHistory::QueriesToQueryString
+
+NS_IMETHODIMP
+nsNavHistory::QueriesToQueryString(nsINavHistoryQuery **aQueries,
+ uint32_t aQueryCount,
+ nsINavHistoryQueryOptions* aOptions,
+ nsACString& aQueryString)
+{
+ NS_ENSURE_ARG(aQueries);
+ NS_ENSURE_ARG(aOptions);
+
+ nsCOMPtr<nsNavHistoryQueryOptions> options = do_QueryInterface(aOptions);
+ NS_ENSURE_TRUE(options, NS_ERROR_INVALID_ARG);
+
+ nsAutoCString queryString;
+ for (uint32_t queryIndex = 0; queryIndex < aQueryCount; queryIndex ++) {
+ nsCOMPtr<nsNavHistoryQuery> query = do_QueryInterface(aQueries[queryIndex]);
+ if (queryIndex > 0) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_SEPARATOR);
+ }
+
+ bool hasIt;
+
+ // begin time
+ query->GetHasBeginTime(&hasIt);
+ if (hasIt) {
+ AppendInt64KeyValueIfNonzero(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_BEGIN_TIME),
+ query, &nsINavHistoryQuery::GetBeginTime);
+ AppendUint32KeyValueIfNonzero(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_BEGIN_TIME_REFERENCE),
+ query, &nsINavHistoryQuery::GetBeginTimeReference);
+ }
+
+ // end time
+ query->GetHasEndTime(&hasIt);
+ if (hasIt) {
+ AppendInt64KeyValueIfNonzero(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_END_TIME),
+ query, &nsINavHistoryQuery::GetEndTime);
+ AppendUint32KeyValueIfNonzero(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_END_TIME_REFERENCE),
+ query, &nsINavHistoryQuery::GetEndTimeReference);
+ }
+
+ // search terms
+ query->GetHasSearchTerms(&hasIt);
+ if (hasIt) {
+ nsAutoString searchTerms;
+ query->GetSearchTerms(searchTerms);
+ nsCString escapedTerms;
+ if (! NS_Escape(NS_ConvertUTF16toUTF8(searchTerms), escapedTerms,
+ url_XAlphas))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_SEARCH_TERMS "=");
+ queryString += escapedTerms;
+ }
+
+ // min and max visits
+ int32_t minVisits;
+ if (NS_SUCCEEDED(query->GetMinVisits(&minVisits)) && minVisits >= 0) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString.AppendLiteral(QUERYKEY_MIN_VISITS "=");
+ AppendInt32(queryString, minVisits);
+ }
+
+ int32_t maxVisits;
+ if (NS_SUCCEEDED(query->GetMaxVisits(&maxVisits)) && maxVisits >= 0) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString.AppendLiteral(QUERYKEY_MAX_VISITS "=");
+ AppendInt32(queryString, maxVisits);
+ }
+
+ // only bookmarked
+ AppendBoolKeyValueIfTrue(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_ONLY_BOOKMARKED),
+ query, &nsINavHistoryQuery::GetOnlyBookmarked);
+
+ // domain (+ is host), only call if hasDomain, which means non-IsVoid
+ // this means we may get an empty string for the domain in the result,
+ // which is valid
+ query->GetHasDomain(&hasIt);
+ if (hasIt) {
+ AppendBoolKeyValueIfTrue(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_DOMAIN_IS_HOST),
+ query, &nsINavHistoryQuery::GetDomainIsHost);
+ nsAutoCString domain;
+ nsresult rv = query->GetDomain(domain);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCString escapedDomain;
+ bool success = NS_Escape(domain, escapedDomain, url_XAlphas);
+ NS_ENSURE_TRUE(success, NS_ERROR_OUT_OF_MEMORY);
+
+ AppendAmpersandIfNonempty(queryString);
+ queryString.AppendLiteral(QUERYKEY_DOMAIN "=");
+ queryString.Append(escapedDomain);
+ }
+
+ // uri
+ query->GetHasUri(&hasIt);
+ if (hasIt) {
+ nsCOMPtr<nsIURI> uri;
+ query->GetUri(getter_AddRefs(uri));
+ NS_ENSURE_TRUE(uri, NS_ERROR_FAILURE); // hasURI should tell is if invalid
+ nsAutoCString uriSpec;
+ nsresult rv = uri->GetSpec(uriSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString escaped;
+ bool success = NS_Escape(uriSpec, escaped, url_XAlphas);
+ NS_ENSURE_TRUE(success, NS_ERROR_OUT_OF_MEMORY);
+
+ AppendAmpersandIfNonempty(queryString);
+ queryString.AppendLiteral(QUERYKEY_URI "=");
+ queryString.Append(escaped);
+ }
+
+ // annotation
+ query->GetHasAnnotation(&hasIt);
+ if (hasIt) {
+ AppendAmpersandIfNonempty(queryString);
+ bool annotationIsNot;
+ query->GetAnnotationIsNot(&annotationIsNot);
+ if (annotationIsNot)
+ queryString.AppendLiteral(QUERYKEY_NOTANNOTATION "=");
+ else
+ queryString.AppendLiteral(QUERYKEY_ANNOTATION "=");
+ nsAutoCString annot;
+ query->GetAnnotation(annot);
+ nsAutoCString escaped;
+ bool success = NS_Escape(annot, escaped, url_XAlphas);
+ NS_ENSURE_TRUE(success, NS_ERROR_OUT_OF_MEMORY);
+ queryString.Append(escaped);
+ }
+
+ // folders
+ int64_t *folders = nullptr;
+ uint32_t folderCount = 0;
+ query->GetFolders(&folderCount, &folders);
+ for (uint32_t i = 0; i < folderCount; ++i) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_FOLDER "=");
+ nsresult rv = PlacesFolderConversion::AppendFolder(queryString, folders[i]);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ free(folders);
+
+ // tags
+ const nsTArray<nsString> &tags = query->Tags();
+ for (uint32_t i = 0; i < tags.Length(); ++i) {
+ nsAutoCString escapedTag;
+ if (!NS_Escape(NS_ConvertUTF16toUTF8(tags[i]), escapedTag, url_XAlphas))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_TAG "=");
+ queryString += escapedTag;
+ }
+ AppendBoolKeyValueIfTrue(queryString,
+ NS_LITERAL_CSTRING(QUERYKEY_NOTTAGS),
+ query,
+ &nsINavHistoryQuery::GetTagsAreNot);
+
+ // transitions
+ const nsTArray<uint32_t>& transitions = query->Transitions();
+ for (uint32_t i = 0; i < transitions.Length(); ++i) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_TRANSITION "=");
+ AppendInt64(queryString, transitions[i]);
+ }
+ }
+
+ // sorting
+ if (options->SortingMode() != nsINavHistoryQueryOptions::SORT_BY_NONE) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_SORT "=");
+ AppendInt16(queryString, options->SortingMode());
+ if (options->SortingMode() == nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_DESCENDING ||
+ options->SortingMode() == nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_ASCENDING) {
+ // sortingAnnotation
+ nsAutoCString sortingAnnotation;
+ if (NS_SUCCEEDED(options->GetSortingAnnotation(sortingAnnotation))) {
+ nsCString escaped;
+ if (!NS_Escape(sortingAnnotation, escaped, url_XAlphas))
+ return NS_ERROR_OUT_OF_MEMORY;
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_SORTING_ANNOTATION "=");
+ queryString.Append(escaped);
+ }
+ }
+ }
+
+ // result type
+ if (options->ResultType() != nsINavHistoryQueryOptions::RESULTS_AS_URI) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_RESULT_TYPE "=");
+ AppendInt16(queryString, options->ResultType());
+ }
+
+ // exclude items
+ if (options->ExcludeItems()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_EXCLUDE_ITEMS "=1");
+ }
+
+ // exclude queries
+ if (options->ExcludeQueries()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_EXCLUDE_QUERIES "=1");
+ }
+
+ // exclude read only folders
+ if (options->ExcludeReadOnlyFolders()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_EXCLUDE_READ_ONLY_FOLDERS "=1");
+ }
+
+ // expand queries
+ if (!options->ExpandQueries()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_EXPAND_QUERIES "=0");
+ }
+
+ // include hidden
+ if (options->IncludeHidden()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_INCLUDE_HIDDEN "=1");
+ }
+
+ // max results
+ if (options->MaxResults()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_MAX_RESULTS "=");
+ AppendInt32(queryString, options->MaxResults());
+ }
+
+ // queryType
+ if (options->QueryType() != nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_QUERY_TYPE "=");
+ AppendInt16(queryString, options->QueryType());
+ }
+
+ // async enabled
+ if (options->AsyncEnabled()) {
+ AppendAmpersandIfNonempty(queryString);
+ queryString += NS_LITERAL_CSTRING(QUERYKEY_ASYNC_ENABLED "=1");
+ }
+
+ aQueryString.AssignLiteral("place:");
+ aQueryString.Append(queryString);
+ return NS_OK;
+}
+
+
+// TokenizeQueryString
+
+nsresult
+TokenizeQueryString(const nsACString& aQuery,
+ nsTArray<QueryKeyValuePair>* aTokens)
+{
+ // Strip off the "place:" prefix
+ const uint32_t prefixlen = 6; // = strlen("place:");
+ nsCString query;
+ if (aQuery.Length() >= prefixlen &&
+ Substring(aQuery, 0, prefixlen).EqualsLiteral("place:"))
+ query = Substring(aQuery, prefixlen);
+ else
+ query = aQuery;
+
+ int32_t keyFirstIndex = 0;
+ int32_t equalsIndex = 0;
+ for (uint32_t i = 0; i < query.Length(); i ++) {
+ if (query[i] == '&') {
+ // new clause, save last one
+ if (i - keyFirstIndex > 1) {
+ if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
+ equalsIndex, i)))
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ keyFirstIndex = equalsIndex = i + 1;
+ } else if (query[i] == '=') {
+ equalsIndex = i;
+ }
+ }
+
+ // handle last pair, if any
+ if (query.Length() - keyFirstIndex > 1) {
+ if (! aTokens->AppendElement(QueryKeyValuePair(query, keyFirstIndex,
+ equalsIndex, query.Length())))
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ return NS_OK;
+}
+
+// nsNavHistory::TokensToQueries
+
+nsresult
+nsNavHistory::TokensToQueries(const nsTArray<QueryKeyValuePair>& aTokens,
+ nsCOMArray<nsNavHistoryQuery>* aQueries,
+ nsNavHistoryQueryOptions* aOptions)
+{
+ nsresult rv;
+
+ nsCOMPtr<nsNavHistoryQuery> query(new nsNavHistoryQuery());
+ if (! query)
+ return NS_ERROR_OUT_OF_MEMORY;
+ if (! aQueries->AppendObject(query))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ if (aTokens.Length() == 0)
+ return NS_OK; // nothing to do
+
+ nsTArray<int64_t> folders;
+ nsTArray<nsString> tags;
+ nsTArray<uint32_t> transitions;
+ for (uint32_t i = 0; i < aTokens.Length(); i ++) {
+ const QueryKeyValuePair& kvp = aTokens[i];
+
+ // begin time
+ if (kvp.key.EqualsLiteral(QUERYKEY_BEGIN_TIME)) {
+ SetQueryKeyInt64(kvp.value, query, &nsINavHistoryQuery::SetBeginTime);
+
+ // begin time reference
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_BEGIN_TIME_REFERENCE)) {
+ SetQueryKeyUint32(kvp.value, query, &nsINavHistoryQuery::SetBeginTimeReference);
+
+ // end time
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_END_TIME)) {
+ SetQueryKeyInt64(kvp.value, query, &nsINavHistoryQuery::SetEndTime);
+
+ // end time reference
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_END_TIME_REFERENCE)) {
+ SetQueryKeyUint32(kvp.value, query, &nsINavHistoryQuery::SetEndTimeReference);
+
+ // search terms
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_SEARCH_TERMS)) {
+ nsCString unescapedTerms = kvp.value;
+ NS_UnescapeURL(unescapedTerms); // modifies input
+ rv = query->SetSearchTerms(NS_ConvertUTF8toUTF16(unescapedTerms));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // min visits
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_MIN_VISITS)) {
+ int32_t visits = kvp.value.ToInteger(&rv);
+ if (NS_SUCCEEDED(rv))
+ query->SetMinVisits(visits);
+ else
+ NS_WARNING("Bad number for minVisits in query");
+
+ // max visits
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_MAX_VISITS)) {
+ int32_t visits = kvp.value.ToInteger(&rv);
+ if (NS_SUCCEEDED(rv))
+ query->SetMaxVisits(visits);
+ else
+ NS_WARNING("Bad number for maxVisits in query");
+
+ // onlyBookmarked flag
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_ONLY_BOOKMARKED)) {
+ SetQueryKeyBool(kvp.value, query, &nsINavHistoryQuery::SetOnlyBookmarked);
+
+ // domainIsHost flag
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_DOMAIN_IS_HOST)) {
+ SetQueryKeyBool(kvp.value, query, &nsINavHistoryQuery::SetDomainIsHost);
+
+ // domain string
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_DOMAIN)) {
+ nsAutoCString unescapedDomain(kvp.value);
+ NS_UnescapeURL(unescapedDomain); // modifies input
+ rv = query->SetDomain(unescapedDomain);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // folders
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_FOLDER)) {
+ int64_t folder;
+ if (PR_sscanf(kvp.value.get(), "%lld", &folder) == 1) {
+ NS_ENSURE_TRUE(folders.AppendElement(folder), NS_ERROR_OUT_OF_MEMORY);
+ } else {
+ folder = PlacesFolderConversion::DecodeFolder(kvp.value);
+ if (folder != -1)
+ NS_ENSURE_TRUE(folders.AppendElement(folder), NS_ERROR_OUT_OF_MEMORY);
+ else
+ NS_WARNING("folders value in query is invalid, ignoring");
+ }
+
+ // uri
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_URI)) {
+ nsAutoCString unescapedUri(kvp.value);
+ NS_UnescapeURL(unescapedUri); // modifies input
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), unescapedUri);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Unable to parse URI");
+ }
+ rv = query->SetUri(uri);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // not annotation
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_NOTANNOTATION)) {
+ nsAutoCString unescaped(kvp.value);
+ NS_UnescapeURL(unescaped); // modifies input
+ query->SetAnnotationIsNot(true);
+ query->SetAnnotation(unescaped);
+
+ // annotation
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_ANNOTATION)) {
+ nsAutoCString unescaped(kvp.value);
+ NS_UnescapeURL(unescaped); // modifies input
+ query->SetAnnotationIsNot(false);
+ query->SetAnnotation(unescaped);
+
+ // tag
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_TAG)) {
+ nsAutoCString unescaped(kvp.value);
+ NS_UnescapeURL(unescaped); // modifies input
+ NS_ConvertUTF8toUTF16 tag(unescaped);
+ if (!tags.Contains(tag)) {
+ NS_ENSURE_TRUE(tags.AppendElement(tag), NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ // not tags
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_NOTTAGS)) {
+ SetQueryKeyBool(kvp.value, query, &nsINavHistoryQuery::SetTagsAreNot);
+
+ // transition
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_TRANSITION)) {
+ uint32_t transition = kvp.value.ToInteger(&rv);
+ if (NS_SUCCEEDED(rv)) {
+ if (!transitions.Contains(transition))
+ NS_ENSURE_TRUE(transitions.AppendElement(transition),
+ NS_ERROR_OUT_OF_MEMORY);
+ }
+ else {
+ NS_WARNING("Invalid Int32 transition value.");
+ }
+
+ // new query component
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_SEPARATOR)) {
+
+ if (folders.Length() != 0) {
+ query->SetFolders(folders.Elements(), folders.Length());
+ folders.Clear();
+ }
+
+ if (tags.Length() > 0) {
+ rv = query->SetTags(tags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ tags.Clear();
+ }
+
+ if (transitions.Length() > 0) {
+ rv = query->SetTransitions(transitions);
+ NS_ENSURE_SUCCESS(rv, rv);
+ transitions.Clear();
+ }
+
+ query = new nsNavHistoryQuery();
+ if (! query)
+ return NS_ERROR_OUT_OF_MEMORY;
+ if (! aQueries->AppendObject(query))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ // sorting mode
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_SORT)) {
+ SetOptionsKeyUint16(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetSortingMode);
+ // sorting annotation
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_SORTING_ANNOTATION)) {
+ nsCString sortingAnnotation = kvp.value;
+ NS_UnescapeURL(sortingAnnotation);
+ rv = aOptions->SetSortingAnnotation(sortingAnnotation);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // result type
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_RESULT_TYPE)) {
+ SetOptionsKeyUint16(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetResultType);
+
+ // exclude items
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_EXCLUDE_ITEMS)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetExcludeItems);
+
+ // exclude queries
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_EXCLUDE_QUERIES)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetExcludeQueries);
+
+ // exclude read only folders
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_EXCLUDE_READ_ONLY_FOLDERS)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetExcludeReadOnlyFolders);
+
+ // expand queries
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_EXPAND_QUERIES)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetExpandQueries);
+ // include hidden
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_INCLUDE_HIDDEN)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetIncludeHidden);
+ // max results
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_MAX_RESULTS)) {
+ SetOptionsKeyUint32(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetMaxResults);
+ // query type
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_QUERY_TYPE)) {
+ SetOptionsKeyUint16(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetQueryType);
+ // async enabled
+ } else if (kvp.key.EqualsLiteral(QUERYKEY_ASYNC_ENABLED)) {
+ SetOptionsKeyBool(kvp.value, aOptions,
+ &nsINavHistoryQueryOptions::SetAsyncEnabled);
+ // unknown key
+ } else {
+ NS_WARNING("TokensToQueries(), ignoring unknown key: ");
+ NS_WARNING(kvp.key.get());
+ }
+ }
+
+ if (folders.Length() != 0)
+ query->SetFolders(folders.Elements(), folders.Length());
+
+ if (tags.Length() > 0) {
+ rv = query->SetTags(tags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ if (transitions.Length() > 0) {
+ rv = query->SetTransitions(transitions);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+
+// ParseQueryBooleanString
+//
+// Converts a 0/1 or true/false string into a bool
+
+nsresult
+ParseQueryBooleanString(const nsCString& aString, bool* aValue)
+{
+ if (aString.EqualsLiteral("1") || aString.EqualsLiteral("true")) {
+ *aValue = true;
+ return NS_OK;
+ } else if (aString.EqualsLiteral("0") || aString.EqualsLiteral("false")) {
+ *aValue = false;
+ return NS_OK;
+ }
+ return NS_ERROR_INVALID_ARG;
+}
+
+
+// nsINavHistoryQuery **********************************************************
+
+NS_IMPL_ISUPPORTS(nsNavHistoryQuery, nsNavHistoryQuery, nsINavHistoryQuery)
+
+// nsINavHistoryQuery::nsNavHistoryQuery
+//
+// This must initialize the object such that the default values will cause
+// all history to be returned if this query is used. Then the caller can
+// just set the things it's interested in.
+
+nsNavHistoryQuery::nsNavHistoryQuery()
+ : mMinVisits(-1), mMaxVisits(-1), mBeginTime(0),
+ mBeginTimeReference(TIME_RELATIVE_EPOCH),
+ mEndTime(0), mEndTimeReference(TIME_RELATIVE_EPOCH),
+ mOnlyBookmarked(false),
+ mDomainIsHost(false),
+ mAnnotationIsNot(false),
+ mTagsAreNot(false)
+{
+ // differentiate not set (IsVoid) from empty string (local files)
+ mDomain.SetIsVoid(true);
+}
+
+nsNavHistoryQuery::nsNavHistoryQuery(const nsNavHistoryQuery& aOther)
+ : mMinVisits(aOther.mMinVisits), mMaxVisits(aOther.mMaxVisits),
+ mBeginTime(aOther.mBeginTime),
+ mBeginTimeReference(aOther.mBeginTimeReference),
+ mEndTime(aOther.mEndTime), mEndTimeReference(aOther.mEndTimeReference),
+ mSearchTerms(aOther.mSearchTerms), mOnlyBookmarked(aOther.mOnlyBookmarked),
+ mDomainIsHost(aOther.mDomainIsHost), mDomain(aOther.mDomain),
+ mUri(aOther.mUri),
+ mAnnotationIsNot(aOther.mAnnotationIsNot),
+ mAnnotation(aOther.mAnnotation), mTags(aOther.mTags),
+ mTagsAreNot(aOther.mTagsAreNot), mTransitions(aOther.mTransitions)
+{}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetBeginTime(PRTime *aBeginTime)
+{
+ *aBeginTime = mBeginTime;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetBeginTime(PRTime aBeginTime)
+{
+ mBeginTime = aBeginTime;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetBeginTimeReference(uint32_t* _retval)
+{
+ *_retval = mBeginTimeReference;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetBeginTimeReference(uint32_t aReference)
+{
+ if (aReference > TIME_RELATIVE_NOW)
+ return NS_ERROR_INVALID_ARG;
+ mBeginTimeReference = aReference;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetHasBeginTime(bool* _retval)
+{
+ *_retval = ! (mBeginTimeReference == TIME_RELATIVE_EPOCH && mBeginTime == 0);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetAbsoluteBeginTime(PRTime* _retval)
+{
+ *_retval = nsNavHistory::NormalizeTime(mBeginTimeReference, mBeginTime);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetEndTime(PRTime *aEndTime)
+{
+ *aEndTime = mEndTime;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetEndTime(PRTime aEndTime)
+{
+ mEndTime = aEndTime;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetEndTimeReference(uint32_t* _retval)
+{
+ *_retval = mEndTimeReference;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetEndTimeReference(uint32_t aReference)
+{
+ if (aReference > TIME_RELATIVE_NOW)
+ return NS_ERROR_INVALID_ARG;
+ mEndTimeReference = aReference;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetHasEndTime(bool* _retval)
+{
+ *_retval = ! (mEndTimeReference == TIME_RELATIVE_EPOCH && mEndTime == 0);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetAbsoluteEndTime(PRTime* _retval)
+{
+ *_retval = nsNavHistory::NormalizeTime(mEndTimeReference, mEndTime);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetSearchTerms(nsAString& aSearchTerms)
+{
+ aSearchTerms = mSearchTerms;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetSearchTerms(const nsAString& aSearchTerms)
+{
+ mSearchTerms = aSearchTerms;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::GetHasSearchTerms(bool* _retval)
+{
+ *_retval = (! mSearchTerms.IsEmpty());
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetMinVisits(int32_t* _retval)
+{
+ NS_ENSURE_ARG_POINTER(_retval);
+ *_retval = mMinVisits;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetMinVisits(int32_t aVisits)
+{
+ mMinVisits = aVisits;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetMaxVisits(int32_t* _retval)
+{
+ NS_ENSURE_ARG_POINTER(_retval);
+ *_retval = mMaxVisits;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetMaxVisits(int32_t aVisits)
+{
+ mMaxVisits = aVisits;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetOnlyBookmarked(bool *aOnlyBookmarked)
+{
+ *aOnlyBookmarked = mOnlyBookmarked;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetOnlyBookmarked(bool aOnlyBookmarked)
+{
+ mOnlyBookmarked = aOnlyBookmarked;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetDomainIsHost(bool *aDomainIsHost)
+{
+ *aDomainIsHost = mDomainIsHost;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetDomainIsHost(bool aDomainIsHost)
+{
+ mDomainIsHost = aDomainIsHost;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetDomain(nsACString& aDomain)
+{
+ aDomain = mDomain;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetDomain(const nsACString& aDomain)
+{
+ mDomain = aDomain;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::GetHasDomain(bool* _retval)
+{
+ // note that empty but not void is still a valid query (local files)
+ *_retval = (! mDomain.IsVoid());
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetUri(nsIURI** aUri)
+{
+ NS_IF_ADDREF(*aUri = mUri);
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetUri(nsIURI* aUri)
+{
+ mUri = aUri;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::GetHasUri(bool* aHasUri)
+{
+ *aHasUri = (mUri != nullptr);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetAnnotationIsNot(bool* aIsNot)
+{
+ *aIsNot = mAnnotationIsNot;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetAnnotationIsNot(bool aIsNot)
+{
+ mAnnotationIsNot = aIsNot;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetAnnotation(nsACString& aAnnotation)
+{
+ aAnnotation = mAnnotation;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::SetAnnotation(const nsACString& aAnnotation)
+{
+ mAnnotation = aAnnotation;
+ return NS_OK;
+}
+NS_IMETHODIMP nsNavHistoryQuery::GetHasAnnotation(bool* aHasIt)
+{
+ *aHasIt = ! mAnnotation.IsEmpty();
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetTags(nsIVariant **aTags)
+{
+ NS_ENSURE_ARG_POINTER(aTags);
+
+ RefPtr<nsVariant> out = new nsVariant();
+
+ uint32_t arrayLen = mTags.Length();
+
+ nsresult rv;
+ if (arrayLen == 0)
+ rv = out->SetAsEmptyArray();
+ else {
+ // Note: The resulting nsIVariant dupes both the array and its elements.
+ const char16_t **array = reinterpret_cast<const char16_t **>
+ (moz_xmalloc(arrayLen * sizeof(char16_t *)));
+ NS_ENSURE_TRUE(array, NS_ERROR_OUT_OF_MEMORY);
+
+ for (uint32_t i = 0; i < arrayLen; ++i) {
+ array[i] = mTags[i].get();
+ }
+
+ rv = out->SetAsArray(nsIDataType::VTYPE_WCHAR_STR,
+ nullptr,
+ arrayLen,
+ reinterpret_cast<void *>(array));
+ free(array);
+ }
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ out.forget(aTags);
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::SetTags(nsIVariant *aTags)
+{
+ NS_ENSURE_ARG(aTags);
+
+ uint16_t dataType;
+ aTags->GetDataType(&dataType);
+
+ // Caller passed in empty array. Easy -- clear our mTags array and return.
+ if (dataType == nsIDataType::VTYPE_EMPTY_ARRAY) {
+ mTags.Clear();
+ return NS_OK;
+ }
+
+ // Before we go any further, make sure caller passed in an array.
+ NS_ENSURE_TRUE(dataType == nsIDataType::VTYPE_ARRAY, NS_ERROR_ILLEGAL_VALUE);
+
+ uint16_t eltType;
+ nsIID eltIID;
+ uint32_t arrayLen;
+ void *array;
+
+ // Convert the nsIVariant to an array. We own the resulting buffer and its
+ // elements.
+ nsresult rv = aTags->GetAsArray(&eltType, &eltIID, &arrayLen, &array);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If element type is not wstring, thanks a lot. Your memory die now.
+ if (eltType != nsIDataType::VTYPE_WCHAR_STR) {
+ switch (eltType) {
+ case nsIDataType::VTYPE_ID:
+ case nsIDataType::VTYPE_CHAR_STR:
+ {
+ char **charArray = reinterpret_cast<char **>(array);
+ for (uint32_t i = 0; i < arrayLen; ++i) {
+ if (charArray[i])
+ free(charArray[i]);
+ }
+ }
+ break;
+ case nsIDataType::VTYPE_INTERFACE:
+ case nsIDataType::VTYPE_INTERFACE_IS:
+ {
+ nsISupports **supportsArray = reinterpret_cast<nsISupports **>(array);
+ for (uint32_t i = 0; i < arrayLen; ++i) {
+ NS_IF_RELEASE(supportsArray[i]);
+ }
+ }
+ break;
+ // The other types are primitives that do not need to be freed.
+ }
+ free(array);
+ return NS_ERROR_ILLEGAL_VALUE;
+ }
+
+ char16_t **tags = reinterpret_cast<char16_t **>(array);
+ mTags.Clear();
+
+ // Finally, add each passed-in tag to our mTags array and then sort it.
+ for (uint32_t i = 0; i < arrayLen; ++i) {
+
+ // Don't allow nulls.
+ if (!tags[i]) {
+ free(tags);
+ return NS_ERROR_ILLEGAL_VALUE;
+ }
+
+ nsDependentString tag(tags[i]);
+
+ // Don't store duplicate tags. This isn't just to save memory or to be
+ // fancy; the SQL that's built from the tags relies on no dupes.
+ if (!mTags.Contains(tag)) {
+ if (!mTags.AppendElement(tag)) {
+ free(tags[i]);
+ free(tags);
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ }
+ free(tags[i]);
+ }
+ free(tags);
+
+ mTags.Sort();
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetTagsAreNot(bool *aTagsAreNot)
+{
+ NS_ENSURE_ARG_POINTER(aTagsAreNot);
+ *aTagsAreNot = mTagsAreNot;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::SetTagsAreNot(bool aTagsAreNot)
+{
+ mTagsAreNot = aTagsAreNot;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetFolders(uint32_t *aCount,
+ int64_t **aFolders)
+{
+ uint32_t count = mFolders.Length();
+ int64_t *folders = nullptr;
+ if (count > 0) {
+ folders = static_cast<int64_t*>
+ (moz_xmalloc(count * sizeof(int64_t)));
+ NS_ENSURE_TRUE(folders, NS_ERROR_OUT_OF_MEMORY);
+
+ for (uint32_t i = 0; i < count; ++i) {
+ folders[i] = mFolders[i];
+ }
+ }
+ *aCount = count;
+ *aFolders = folders;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetFolderCount(uint32_t *aCount)
+{
+ *aCount = mFolders.Length();
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::SetFolders(const int64_t *aFolders,
+ uint32_t aFolderCount)
+{
+ if (!mFolders.ReplaceElementsAt(0, mFolders.Length(),
+ aFolders, aFolderCount)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetTransitions(uint32_t* aCount,
+ uint32_t** aTransitions)
+{
+ uint32_t count = mTransitions.Length();
+ uint32_t* transitions = nullptr;
+ if (count > 0) {
+ transitions = reinterpret_cast<uint32_t*>
+ (moz_xmalloc(count * sizeof(uint32_t)));
+ NS_ENSURE_TRUE(transitions, NS_ERROR_OUT_OF_MEMORY);
+ for (uint32_t i = 0; i < count; ++i) {
+ transitions[i] = mTransitions[i];
+ }
+ }
+ *aCount = count;
+ *aTransitions = transitions;
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::GetTransitionCount(uint32_t* aCount)
+{
+ *aCount = mTransitions.Length();
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::SetTransitions(const uint32_t* aTransitions,
+ uint32_t aCount)
+{
+ if (!mTransitions.ReplaceElementsAt(0, mTransitions.Length(), aTransitions,
+ aCount))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP nsNavHistoryQuery::Clone(nsINavHistoryQuery** _retval)
+{
+ *_retval = nullptr;
+
+ RefPtr<nsNavHistoryQuery> clone = new nsNavHistoryQuery(*this);
+ NS_ENSURE_TRUE(clone, NS_ERROR_OUT_OF_MEMORY);
+
+ clone.forget(_retval);
+ return NS_OK;
+}
+
+
+// nsNavHistoryQueryOptions
+NS_IMPL_ISUPPORTS(nsNavHistoryQueryOptions, nsNavHistoryQueryOptions, nsINavHistoryQueryOptions)
+
+// sortingMode
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetSortingMode(uint16_t* aMode)
+{
+ *aMode = mSort;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetSortingMode(uint16_t aMode)
+{
+ if (aMode > SORT_BY_FRECENCY_DESCENDING)
+ return NS_ERROR_INVALID_ARG;
+ mSort = aMode;
+ return NS_OK;
+}
+
+// sortingAnnotation
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetSortingAnnotation(nsACString& _result) {
+ _result.Assign(mSortingAnnotation);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetSortingAnnotation(const nsACString& aSortingAnnotation) {
+ mSortingAnnotation.Assign(aSortingAnnotation);
+ return NS_OK;
+}
+
+// resultType
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetResultType(uint16_t* aType)
+{
+ *aType = mResultType;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetResultType(uint16_t aType)
+{
+ if (aType > RESULTS_AS_TAG_CONTENTS)
+ return NS_ERROR_INVALID_ARG;
+ // Tag queries and containers are bookmarks related, so we set the QueryType
+ // accordingly.
+ if (aType == RESULTS_AS_TAG_QUERY || aType == RESULTS_AS_TAG_CONTENTS)
+ mQueryType = QUERY_TYPE_BOOKMARKS;
+ mResultType = aType;
+ return NS_OK;
+}
+
+// excludeItems
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetExcludeItems(bool* aExclude)
+{
+ *aExclude = mExcludeItems;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetExcludeItems(bool aExclude)
+{
+ mExcludeItems = aExclude;
+ return NS_OK;
+}
+
+// excludeQueries
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetExcludeQueries(bool* aExclude)
+{
+ *aExclude = mExcludeQueries;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetExcludeQueries(bool aExclude)
+{
+ mExcludeQueries = aExclude;
+ return NS_OK;
+}
+
+// excludeReadOnlyFolders
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetExcludeReadOnlyFolders(bool* aExclude)
+{
+ *aExclude = mExcludeReadOnlyFolders;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetExcludeReadOnlyFolders(bool aExclude)
+{
+ mExcludeReadOnlyFolders = aExclude;
+ return NS_OK;
+}
+
+// expandQueries
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetExpandQueries(bool* aExpand)
+{
+ *aExpand = mExpandQueries;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetExpandQueries(bool aExpand)
+{
+ mExpandQueries = aExpand;
+ return NS_OK;
+}
+
+// includeHidden
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetIncludeHidden(bool* aIncludeHidden)
+{
+ *aIncludeHidden = mIncludeHidden;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetIncludeHidden(bool aIncludeHidden)
+{
+ mIncludeHidden = aIncludeHidden;
+ return NS_OK;
+}
+
+// maxResults
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetMaxResults(uint32_t* aMaxResults)
+{
+ *aMaxResults = mMaxResults;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetMaxResults(uint32_t aMaxResults)
+{
+ mMaxResults = aMaxResults;
+ return NS_OK;
+}
+
+// queryType
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetQueryType(uint16_t* _retval)
+{
+ *_retval = mQueryType;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetQueryType(uint16_t aQueryType)
+{
+ // Tag query and containers are forced to QUERY_TYPE_BOOKMARKS when the
+ // resultType is set.
+ if (mResultType == RESULTS_AS_TAG_CONTENTS ||
+ mResultType == RESULTS_AS_TAG_QUERY)
+ return NS_OK;
+ mQueryType = aQueryType;
+ return NS_OK;
+}
+
+// asyncEnabled
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::GetAsyncEnabled(bool* _asyncEnabled)
+{
+ *_asyncEnabled = mAsyncEnabled;
+ return NS_OK;
+}
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::SetAsyncEnabled(bool aAsyncEnabled)
+{
+ mAsyncEnabled = aAsyncEnabled;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryOptions::Clone(nsINavHistoryQueryOptions** aResult)
+{
+ nsNavHistoryQueryOptions *clone = nullptr;
+ nsresult rv = Clone(&clone);
+ *aResult = clone;
+ return rv;
+}
+
+nsresult
+nsNavHistoryQueryOptions::Clone(nsNavHistoryQueryOptions **aResult)
+{
+ *aResult = nullptr;
+ nsNavHistoryQueryOptions *result = new nsNavHistoryQueryOptions();
+
+ RefPtr<nsNavHistoryQueryOptions> resultHolder(result);
+ result->mSort = mSort;
+ result->mResultType = mResultType;
+ result->mExcludeItems = mExcludeItems;
+ result->mExcludeQueries = mExcludeQueries;
+ result->mExpandQueries = mExpandQueries;
+ result->mMaxResults = mMaxResults;
+ result->mQueryType = mQueryType;
+ result->mParentAnnotationToExclude = mParentAnnotationToExclude;
+ result->mAsyncEnabled = mAsyncEnabled;
+
+ resultHolder.forget(aResult);
+ return NS_OK;
+}
+
+
+// AppendBoolKeyValueIfTrue
+
+void // static
+AppendBoolKeyValueIfTrue(nsACString& aString, const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ BoolQueryGetter getter)
+{
+ bool value;
+ DebugOnly<nsresult> rv = (aQuery->*getter)(&value);
+ NS_ASSERTION(NS_SUCCEEDED(rv), "Failure getting boolean value");
+ if (value) {
+ AppendAmpersandIfNonempty(aString);
+ aString += aName;
+ aString.AppendLiteral("=1");
+ }
+}
+
+
+// AppendUint32KeyValueIfNonzero
+
+void // static
+AppendUint32KeyValueIfNonzero(nsACString& aString,
+ const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ Uint32QueryGetter getter)
+{
+ uint32_t value;
+ DebugOnly<nsresult> rv = (aQuery->*getter)(&value);
+ NS_ASSERTION(NS_SUCCEEDED(rv), "Failure getting value");
+ if (value) {
+ AppendAmpersandIfNonempty(aString);
+ aString += aName;
+
+ // AppendInt requires a concrete string
+ nsAutoCString appendMe("=");
+ appendMe.AppendInt(value);
+ aString.Append(appendMe);
+ }
+}
+
+
+// AppendInt64KeyValueIfNonzero
+
+void // static
+AppendInt64KeyValueIfNonzero(nsACString& aString,
+ const nsCString& aName,
+ nsINavHistoryQuery* aQuery,
+ Int64QueryGetter getter)
+{
+ PRTime value;
+ DebugOnly<nsresult> rv = (aQuery->*getter)(&value);
+ NS_ASSERTION(NS_SUCCEEDED(rv), "Failure getting value");
+ if (value) {
+ AppendAmpersandIfNonempty(aString);
+ aString += aName;
+ nsAutoCString appendMe("=");
+ appendMe.AppendInt(static_cast<int64_t>(value));
+ aString.Append(appendMe);
+ }
+}
+
+
+// SetQuery/OptionsKeyBool
+
+void // static
+SetQueryKeyBool(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ BoolQuerySetter setter)
+{
+ bool value;
+ nsresult rv = ParseQueryBooleanString(aValue, &value);
+ if (NS_SUCCEEDED(rv)) {
+ rv = (aQuery->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting boolean key value");
+ }
+ } else {
+ NS_WARNING("Invalid boolean key value in query string.");
+ }
+}
+void // static
+SetOptionsKeyBool(const nsCString& aValue, nsINavHistoryQueryOptions* aOptions,
+ BoolOptionsSetter setter)
+{
+ bool value;
+ nsresult rv = ParseQueryBooleanString(aValue, &value);
+ if (NS_SUCCEEDED(rv)) {
+ rv = (aOptions->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting boolean key value");
+ }
+ } else {
+ NS_WARNING("Invalid boolean key value in query string.");
+ }
+}
+
+
+// SetQuery/OptionsKeyUint32
+
+void // static
+SetQueryKeyUint32(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ Uint32QuerySetter setter)
+{
+ nsresult rv;
+ uint32_t value = aValue.ToInteger(&rv);
+ if (NS_SUCCEEDED(rv)) {
+ rv = (aQuery->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting Int32 key value");
+ }
+ } else {
+ NS_WARNING("Invalid Int32 key value in query string.");
+ }
+}
+void // static
+SetOptionsKeyUint32(const nsCString& aValue, nsINavHistoryQueryOptions* aOptions,
+ Uint32OptionsSetter setter)
+{
+ nsresult rv;
+ uint32_t value = aValue.ToInteger(&rv);
+ if (NS_SUCCEEDED(rv)) {
+ rv = (aOptions->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting Int32 key value");
+ }
+ } else {
+ NS_WARNING("Invalid Int32 key value in query string.");
+ }
+}
+
+void // static
+SetOptionsKeyUint16(const nsCString& aValue, nsINavHistoryQueryOptions* aOptions,
+ Uint16OptionsSetter setter)
+{
+ nsresult rv;
+ uint16_t value = static_cast<uint16_t>(aValue.ToInteger(&rv));
+ if (NS_SUCCEEDED(rv)) {
+ rv = (aOptions->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting Int16 key value");
+ }
+ } else {
+ NS_WARNING("Invalid Int16 key value in query string.");
+ }
+}
+
+
+// SetQueryKeyInt64
+
+void SetQueryKeyInt64(const nsCString& aValue, nsINavHistoryQuery* aQuery,
+ Int64QuerySetter setter)
+{
+ nsresult rv;
+ int64_t value;
+ if (PR_sscanf(aValue.get(), "%lld", &value) == 1) {
+ rv = (aQuery->*setter)(value);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Error setting Int64 key value");
+ }
+ } else {
+ NS_WARNING("Invalid Int64 value in query string.");
+ }
+}
diff --git a/toolkit/components/places/nsNavHistoryQuery.h b/toolkit/components/places/nsNavHistoryQuery.h
new file mode 100644
index 000000000..d1a8b759a
--- /dev/null
+++ b/toolkit/components/places/nsNavHistoryQuery.h
@@ -0,0 +1,160 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The definitions of nsNavHistoryQuery and nsNavHistoryQueryOptions. This
+ * header file should only be included from nsNavHistory.h, include that if
+ * you want these classes.
+ */
+
+#ifndef nsNavHistoryQuery_h_
+#define nsNavHistoryQuery_h_
+
+// nsNavHistoryQuery
+//
+// This class encapsulates the parameters for basic history queries for
+// building UI, trees, lists, etc.
+
+#include "mozilla/Attributes.h"
+
+#define NS_NAVHISTORYQUERY_IID \
+{ 0xb10185e0, 0x86eb, 0x4612, { 0x95, 0x7c, 0x09, 0x34, 0xf2, 0xb1, 0xce, 0xd7 } }
+
+class nsNavHistoryQuery final : public nsINavHistoryQuery
+{
+public:
+ nsNavHistoryQuery();
+ nsNavHistoryQuery(const nsNavHistoryQuery& aOther);
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_NAVHISTORYQUERY_IID)
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSINAVHISTORYQUERY
+
+ int32_t MinVisits() { return mMinVisits; }
+ int32_t MaxVisits() { return mMaxVisits; }
+ PRTime BeginTime() { return mBeginTime; }
+ uint32_t BeginTimeReference() { return mBeginTimeReference; }
+ PRTime EndTime() { return mEndTime; }
+ uint32_t EndTimeReference() { return mEndTimeReference; }
+ const nsString& SearchTerms() { return mSearchTerms; }
+ bool OnlyBookmarked() { return mOnlyBookmarked; }
+ bool DomainIsHost() { return mDomainIsHost; }
+ const nsCString& Domain() { return mDomain; }
+ nsIURI* Uri() { return mUri; } // NOT AddRef-ed!
+ bool AnnotationIsNot() { return mAnnotationIsNot; }
+ const nsCString& Annotation() { return mAnnotation; }
+ const nsTArray<int64_t>& Folders() const { return mFolders; }
+ const nsTArray<nsString>& Tags() const { return mTags; }
+ nsresult SetTags(const nsTArray<nsString>& aTags)
+ {
+ if (!mTags.ReplaceElementsAt(0, mTags.Length(), aTags))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ return NS_OK;
+ }
+ bool TagsAreNot() { return mTagsAreNot; }
+
+ const nsTArray<uint32_t>& Transitions() const { return mTransitions; }
+ nsresult SetTransitions(const nsTArray<uint32_t>& aTransitions)
+ {
+ if (!mTransitions.ReplaceElementsAt(0, mTransitions.Length(),
+ aTransitions))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ return NS_OK;
+ }
+
+private:
+ ~nsNavHistoryQuery() {}
+
+protected:
+
+ int32_t mMinVisits;
+ int32_t mMaxVisits;
+ PRTime mBeginTime;
+ uint32_t mBeginTimeReference;
+ PRTime mEndTime;
+ uint32_t mEndTimeReference;
+ nsString mSearchTerms;
+ bool mOnlyBookmarked;
+ bool mDomainIsHost;
+ nsCString mDomain; // Default is IsVoid, empty string is valid query
+ nsCOMPtr<nsIURI> mUri;
+ bool mAnnotationIsNot;
+ nsCString mAnnotation;
+ nsTArray<int64_t> mFolders;
+ nsTArray<nsString> mTags;
+ bool mTagsAreNot;
+ nsTArray<uint32_t> mTransitions;
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(nsNavHistoryQuery, NS_NAVHISTORYQUERY_IID)
+
+// nsNavHistoryQueryOptions
+
+#define NS_NAVHISTORYQUERYOPTIONS_IID \
+{0x95f8ba3b, 0xd681, 0x4d89, {0xab, 0xd1, 0xfd, 0xae, 0xf2, 0xa3, 0xde, 0x18}}
+
+class nsNavHistoryQueryOptions final : public nsINavHistoryQueryOptions
+{
+public:
+ nsNavHistoryQueryOptions()
+ : mSort(0)
+ , mResultType(0)
+ , mExcludeItems(false)
+ , mExcludeQueries(false)
+ , mExcludeReadOnlyFolders(false)
+ , mExpandQueries(true)
+ , mIncludeHidden(false)
+ , mMaxResults(0)
+ , mQueryType(nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY)
+ , mAsyncEnabled(false)
+ { }
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_NAVHISTORYQUERYOPTIONS_IID)
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSINAVHISTORYQUERYOPTIONS
+
+ uint16_t SortingMode() const { return mSort; }
+ uint16_t ResultType() const { return mResultType; }
+ bool ExcludeItems() const { return mExcludeItems; }
+ bool ExcludeQueries() const { return mExcludeQueries; }
+ bool ExcludeReadOnlyFolders() const { return mExcludeReadOnlyFolders; }
+ bool ExpandQueries() const { return mExpandQueries; }
+ bool IncludeHidden() const { return mIncludeHidden; }
+ uint32_t MaxResults() const { return mMaxResults; }
+ uint16_t QueryType() const { return mQueryType; }
+ bool AsyncEnabled() const { return mAsyncEnabled; }
+
+ nsresult Clone(nsNavHistoryQueryOptions **aResult);
+
+private:
+ ~nsNavHistoryQueryOptions() {}
+ nsNavHistoryQueryOptions(const nsNavHistoryQueryOptions& other) {} // no copy
+
+ // IF YOU ADD MORE ITEMS:
+ // * Add a new getter for C++ above if it makes sense
+ // * Add to the serialization code (see nsNavHistory::QueriesToQueryString())
+ // * Add to the deserialization code (see nsNavHistory::QueryStringToQueries)
+ // * Add to the nsNavHistoryQueryOptions::Clone() function
+ // * Add to the nsNavHistory.cpp::GetSimpleBookmarksQueryFolder function if applicable
+ uint16_t mSort;
+ nsCString mSortingAnnotation;
+ nsCString mParentAnnotationToExclude;
+ uint16_t mResultType;
+ bool mExcludeItems;
+ bool mExcludeQueries;
+ bool mExcludeReadOnlyFolders;
+ bool mExpandQueries;
+ bool mIncludeHidden;
+ uint32_t mMaxResults;
+ uint16_t mQueryType;
+ bool mAsyncEnabled;
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(nsNavHistoryQueryOptions, NS_NAVHISTORYQUERYOPTIONS_IID)
+
+#endif // nsNavHistoryQuery_h_
diff --git a/toolkit/components/places/nsNavHistoryResult.cpp b/toolkit/components/places/nsNavHistoryResult.cpp
new file mode 100644
index 000000000..7cd8c66cc
--- /dev/null
+++ b/toolkit/components/places/nsNavHistoryResult.cpp
@@ -0,0 +1,4813 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include <stdio.h>
+#include "nsNavHistory.h"
+#include "nsNavBookmarks.h"
+#include "nsFaviconService.h"
+#include "nsITaggingService.h"
+#include "nsAnnotationService.h"
+#include "Helpers.h"
+#include "mozilla/DebugOnly.h"
+#include "nsDebug.h"
+#include "nsNetUtil.h"
+#include "nsString.h"
+#include "nsReadableUtils.h"
+#include "nsUnicharUtils.h"
+#include "prtime.h"
+#include "prprf.h"
+#include "nsQueryObject.h"
+
+#include "nsCycleCollectionParticipant.h"
+
+// Thanks, Windows.h :(
+#undef CompareString
+
+#define TO_ICONTAINER(_node) \
+ static_cast<nsINavHistoryContainerResultNode*>(_node)
+
+#define TO_CONTAINER(_node) \
+ static_cast<nsNavHistoryContainerResultNode*>(_node)
+
+#define NOTIFY_RESULT_OBSERVERS_RET(_result, _method, _ret) \
+ PR_BEGIN_MACRO \
+ NS_ENSURE_TRUE(_result, _ret); \
+ if (!_result->mSuppressNotifications) { \
+ ENUMERATE_WEAKARRAY(_result->mObservers, nsINavHistoryResultObserver, \
+ _method) \
+ } \
+ PR_END_MACRO
+
+#define NOTIFY_RESULT_OBSERVERS(_result, _method) \
+ NOTIFY_RESULT_OBSERVERS_RET(_result, _method, NS_ERROR_UNEXPECTED)
+
+// What we want is: NS_INTERFACE_MAP_ENTRY(self) for static IID accessors,
+// but some of our classes (like nsNavHistoryResult) have an ambiguous base
+// class of nsISupports which prevents this from working (the default macro
+// converts it to nsISupports, then addrefs it, then returns it). Therefore, we
+// expand the macro here and change it so that it works. Yuck.
+#define NS_INTERFACE_MAP_STATIC_AMBIGUOUS(_class) \
+ if (aIID.Equals(NS_GET_IID(_class))) { \
+ NS_ADDREF(this); \
+ *aInstancePtr = this; \
+ return NS_OK; \
+ } else
+
+// Number of changes to handle separately in a batch. If more changes are
+// requested the node will switch to full refresh mode.
+#define MAX_BATCH_CHANGES_BEFORE_REFRESH 5
+
+// Emulate string comparison (used for sorting) for PRTime and int.
+inline int32_t ComparePRTime(PRTime a, PRTime b)
+{
+ if (a < b)
+ return -1;
+ else if (a > b)
+ return 1;
+ return 0;
+}
+inline int32_t CompareIntegers(uint32_t a, uint32_t b)
+{
+ return a - b;
+}
+
+using namespace mozilla;
+using namespace mozilla::places;
+
+NS_IMPL_CYCLE_COLLECTION(nsNavHistoryResultNode, mParent)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsNavHistoryResultNode)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsINavHistoryResultNode)
+ NS_INTERFACE_MAP_ENTRY(nsINavHistoryResultNode)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(nsNavHistoryResultNode)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(nsNavHistoryResultNode)
+
+nsNavHistoryResultNode::nsNavHistoryResultNode(
+ const nsACString& aURI, const nsACString& aTitle, uint32_t aAccessCount,
+ PRTime aTime, const nsACString& aIconURI) :
+ mParent(nullptr),
+ mURI(aURI),
+ mTitle(aTitle),
+ mAreTagsSorted(false),
+ mAccessCount(aAccessCount),
+ mTime(aTime),
+ mFaviconURI(aIconURI),
+ mBookmarkIndex(-1),
+ mItemId(-1),
+ mFolderId(-1),
+ mVisitId(-1),
+ mFromVisitId(-1),
+ mDateAdded(0),
+ mLastModified(0),
+ mIndentLevel(-1),
+ mFrecency(0),
+ mHidden(false),
+ mTransitionType(0)
+{
+ mTags.SetIsVoid(true);
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetIcon(nsACString& aIcon)
+{
+ if (mFaviconURI.IsEmpty()) {
+ aIcon.Truncate();
+ return NS_OK;
+ }
+
+ nsFaviconService* faviconService = nsFaviconService::GetFaviconService();
+ NS_ENSURE_TRUE(faviconService, NS_ERROR_OUT_OF_MEMORY);
+ faviconService->GetFaviconSpecForIconString(mFaviconURI, aIcon);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetParent(nsINavHistoryContainerResultNode** aParent)
+{
+ NS_IF_ADDREF(*aParent = mParent);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetParentResult(nsINavHistoryResult** aResult)
+{
+ *aResult = nullptr;
+ if (IsContainer())
+ NS_IF_ADDREF(*aResult = GetAsContainer()->mResult);
+ else if (mParent)
+ NS_IF_ADDREF(*aResult = mParent->mResult);
+
+ NS_ENSURE_STATE(*aResult);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetTags(nsAString& aTags) {
+ // Only URI-nodes may be associated with tags
+ if (!IsURI()) {
+ aTags.Truncate();
+ return NS_OK;
+ }
+
+ // Initially, the tags string is set to a void string (see constructor). We
+ // then build it the first time this method called is called (and by that,
+ // implicitly unset the void flag). Result observers may re-set the void flag
+ // in order to force rebuilding of the tags string.
+ if (!mTags.IsVoid()) {
+ // If mTags is assigned by a history query it is unsorted for performance
+ // reasons, it must be sorted by name on first read access.
+ if (!mAreTagsSorted) {
+ nsTArray<nsCString> tags;
+ ParseString(NS_ConvertUTF16toUTF8(mTags), ',', tags);
+ tags.Sort();
+ mTags.SetIsVoid(true);
+ for (nsTArray<nsCString>::index_type i = 0; i < tags.Length(); ++i) {
+ AppendUTF8toUTF16(tags[i], mTags);
+ if (i < tags.Length() - 1 )
+ mTags.AppendLiteral(", ");
+ }
+ mAreTagsSorted = true;
+ }
+ aTags.Assign(mTags);
+ return NS_OK;
+ }
+
+ // Fetch the tags
+ RefPtr<Database> DB = Database::GetDatabase();
+ NS_ENSURE_STATE(DB);
+ nsCOMPtr<mozIStorageStatement> stmt = DB->GetStatement(
+ "/* do not warn (bug 487594) */ "
+ "SELECT GROUP_CONCAT(tag_title, ', ') "
+ "FROM ( "
+ "SELECT t.title AS tag_title "
+ "FROM moz_bookmarks b "
+ "JOIN moz_bookmarks t ON t.id = +b.parent "
+ "WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url) "
+ "AND t.parent = :tags_folder "
+ "ORDER BY t.title COLLATE NOCASE ASC "
+ ") "
+ );
+ NS_ENSURE_STATE(stmt);
+ mozStorageStatementScoper scoper(stmt);
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_STATE(history);
+ nsresult rv = stmt->BindInt64ByName(NS_LITERAL_CSTRING("tags_folder"),
+ history->GetTagsFolder());
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = URIBinder::Bind(stmt, NS_LITERAL_CSTRING("page_url"), mURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ bool hasTags = false;
+ if (NS_SUCCEEDED(stmt->ExecuteStep(&hasTags)) && hasTags) {
+ rv = stmt->GetString(0, mTags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ aTags.Assign(mTags);
+ mAreTagsSorted = true;
+ }
+
+ // If this node is a child of a history query, we need to make sure changes
+ // to tags are properly live-updated.
+ if (mParent && mParent->IsQuery() &&
+ mParent->mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY) {
+ nsNavHistoryQueryResultNode* query = mParent->GetAsQuery();
+ nsNavHistoryResult* result = query->GetResult();
+ NS_ENSURE_STATE(result);
+ result->AddAllBookmarksObserver(query);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetPageGuid(nsACString& aPageGuid) {
+ aPageGuid = mPageGuid;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetBookmarkGuid(nsACString& aBookmarkGuid) {
+ aBookmarkGuid = mBookmarkGuid;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetVisitId(int64_t* aVisitId) {
+ *aVisitId = mVisitId;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetFromVisitId(int64_t* aFromVisitId) {
+ *aFromVisitId = mFromVisitId;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::GetVisitType(uint32_t* aVisitType) {
+ *aVisitType = mTransitionType;
+ return NS_OK;
+}
+
+
+void
+nsNavHistoryResultNode::OnRemoving()
+{
+ mParent = nullptr;
+}
+
+
+/**
+ * This will find the result for this node. We can ask the nearest container
+ * for this value (either ourselves or our parents should be a container,
+ * and all containers have result pointers).
+ *
+ * @note The result may be null, if the container is detached from the result
+ * who owns it.
+ */
+nsNavHistoryResult*
+nsNavHistoryResultNode::GetResult()
+{
+ nsNavHistoryResultNode* node = this;
+ do {
+ if (node->IsContainer()) {
+ nsNavHistoryContainerResultNode* container = TO_CONTAINER(node);
+ return container->mResult;
+ }
+ node = node->mParent;
+ } while (node);
+ MOZ_ASSERT(false, "No container node found in hierarchy!");
+ return nullptr;
+}
+
+
+/**
+ * Searches up the tree for the closest ancestor node that has an options
+ * structure. This will tell us the options that were used to generate this
+ * node.
+ *
+ * Be careful, this function walks up the tree, so it can not be used when
+ * result nodes are created because they have no parent. Only call this
+ * function after the tree has been built.
+ */
+nsNavHistoryQueryOptions*
+nsNavHistoryResultNode::GetGeneratingOptions()
+{
+ if (!mParent) {
+ // When we have no parent, it either means we haven't built the tree yet,
+ // in which case calling this function is a bug, or this node is the root
+ // of the tree. When we are the root of the tree, our own options are the
+ // generating options.
+ if (IsContainer())
+ return GetAsContainer()->mOptions;
+
+ NS_NOTREACHED("Can't find a generating node for this container, perhaps FillStats has not been called on this tree yet?");
+ return nullptr;
+ }
+
+ // Look up the tree. We want the options that were used to create this node,
+ // and since it has a parent, it's the options of an ancestor, not of the node
+ // itself. So start at the parent.
+ nsNavHistoryContainerResultNode* cur = mParent;
+ while (cur) {
+ if (cur->IsContainer())
+ return cur->GetAsContainer()->mOptions;
+ cur = cur->mParent;
+ }
+
+ // We should always find a container node as an ancestor.
+ NS_NOTREACHED("Can't find a generating node for this container, the tree seemes corrupted.");
+ return nullptr;
+}
+
+NS_IMPL_CYCLE_COLLECTION_INHERITED(nsNavHistoryContainerResultNode, nsNavHistoryResultNode,
+ mResult,
+ mChildren)
+
+NS_IMPL_ADDREF_INHERITED(nsNavHistoryContainerResultNode, nsNavHistoryResultNode)
+NS_IMPL_RELEASE_INHERITED(nsNavHistoryContainerResultNode, nsNavHistoryResultNode)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION_INHERITED(nsNavHistoryContainerResultNode)
+ NS_INTERFACE_MAP_STATIC_AMBIGUOUS(nsNavHistoryContainerResultNode)
+ NS_INTERFACE_MAP_ENTRY(nsINavHistoryContainerResultNode)
+NS_INTERFACE_MAP_END_INHERITING(nsNavHistoryResultNode)
+
+nsNavHistoryContainerResultNode::nsNavHistoryContainerResultNode(
+ const nsACString& aURI, const nsACString& aTitle,
+ const nsACString& aIconURI, uint32_t aContainerType,
+ nsNavHistoryQueryOptions* aOptions) :
+ nsNavHistoryResultNode(aURI, aTitle, 0, 0, aIconURI),
+ mResult(nullptr),
+ mContainerType(aContainerType),
+ mExpanded(false),
+ mOptions(aOptions),
+ mAsyncCanceledState(NOT_CANCELED)
+{
+}
+
+nsNavHistoryContainerResultNode::nsNavHistoryContainerResultNode(
+ const nsACString& aURI, const nsACString& aTitle,
+ PRTime aTime,
+ const nsACString& aIconURI, uint32_t aContainerType,
+ nsNavHistoryQueryOptions* aOptions) :
+ nsNavHistoryResultNode(aURI, aTitle, 0, aTime, aIconURI),
+ mResult(nullptr),
+ mContainerType(aContainerType),
+ mExpanded(false),
+ mOptions(aOptions),
+ mAsyncCanceledState(NOT_CANCELED)
+{
+}
+
+
+nsNavHistoryContainerResultNode::~nsNavHistoryContainerResultNode()
+{
+ // Explicitly clean up array of children of this container. We must ensure
+ // all references are gone and all of their destructors are called.
+ mChildren.Clear();
+}
+
+
+/**
+ * Containers should notify their children that they are being removed when the
+ * container is being removed.
+ */
+void
+nsNavHistoryContainerResultNode::OnRemoving()
+{
+ nsNavHistoryResultNode::OnRemoving();
+ for (int32_t i = 0; i < mChildren.Count(); ++i)
+ mChildren[i]->OnRemoving();
+ mChildren.Clear();
+ mResult = nullptr;
+}
+
+
+bool
+nsNavHistoryContainerResultNode::AreChildrenVisible()
+{
+ nsNavHistoryResult* result = GetResult();
+ if (!result) {
+ NS_NOTREACHED("Invalid result");
+ return false;
+ }
+
+ if (!mExpanded)
+ return false;
+
+ // Now check if any ancestor is closed.
+ nsNavHistoryContainerResultNode* ancestor = mParent;
+ while (ancestor) {
+ if (!ancestor->mExpanded)
+ return false;
+
+ ancestor = ancestor->mParent;
+ }
+
+ return true;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetContainerOpen(bool *aContainerOpen)
+{
+ *aContainerOpen = mExpanded;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::SetContainerOpen(bool aContainerOpen)
+{
+ if (aContainerOpen) {
+ if (!mExpanded) {
+ nsNavHistoryQueryOptions* options = GetGeneratingOptions();
+ if (options && options->AsyncEnabled())
+ OpenContainerAsync();
+ else
+ OpenContainer();
+ }
+ }
+ else {
+ if (mExpanded)
+ CloseContainer();
+ else if (mAsyncPendingStmt)
+ CancelAsyncOpen(false);
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * Notifies the result's observers of a change in the container's state. The
+ * notification includes both the old and new states: The old is aOldState, and
+ * the new is the container's current state.
+ *
+ * @param aOldState
+ * The state being transitioned out of.
+ */
+nsresult
+nsNavHistoryContainerResultNode::NotifyOnStateChange(uint16_t aOldState)
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+
+ nsresult rv;
+ uint16_t currState;
+ rv = GetState(&currState);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Notify via the new ContainerStateChanged observer method.
+ NOTIFY_RESULT_OBSERVERS(result,
+ ContainerStateChanged(this, aOldState, currState));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetState(uint16_t* _state)
+{
+ NS_ENSURE_ARG_POINTER(_state);
+
+ *_state = mExpanded ? (uint16_t)STATE_OPENED
+ : mAsyncPendingStmt ? (uint16_t)STATE_LOADING
+ : (uint16_t)STATE_CLOSED;
+
+ return NS_OK;
+}
+
+
+/**
+ * This handles the generic container case. Other container types should
+ * override this to do their own handling.
+ */
+nsresult
+nsNavHistoryContainerResultNode::OpenContainer()
+{
+ NS_ASSERTION(!mExpanded, "Container must not be expanded to open it");
+ mExpanded = true;
+
+ nsresult rv = NotifyOnStateChange(STATE_CLOSED);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+/**
+ * Unset aSuppressNotifications to notify observers on this change. That is
+ * the normal operation. This is set to false for the recursive calls since the
+ * root container that is being closed will handle recomputation of the visible
+ * elements for its entire subtree.
+ */
+nsresult
+nsNavHistoryContainerResultNode::CloseContainer(bool aSuppressNotifications)
+{
+ NS_ASSERTION((mExpanded && !mAsyncPendingStmt) ||
+ (!mExpanded && mAsyncPendingStmt),
+ "Container must be expanded or loading to close it");
+
+ nsresult rv;
+ uint16_t oldState;
+ rv = GetState(&oldState);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (mExpanded) {
+ // Recursively close all child containers.
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->IsContainer() &&
+ mChildren[i]->GetAsContainer()->mExpanded)
+ mChildren[i]->GetAsContainer()->CloseContainer(true);
+ }
+
+ mExpanded = false;
+ }
+
+ // Be sure to set this to null before notifying observers. It signifies that
+ // the container is no longer loading (if it was in the first place).
+ mAsyncPendingStmt = nullptr;
+
+ if (!aSuppressNotifications) {
+ rv = NotifyOnStateChange(oldState);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // If this is the root container of a result, we can tell the result to stop
+ // observing changes, otherwise the result will stay in memory and updates
+ // itself till it is cycle collected.
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mRootNode == this) {
+ result->StopObserving();
+ // When reopening this node its result will be out of sync.
+ // We must clear our children to ensure we will call FillChildren
+ // again in such a case.
+ if (this->IsQuery())
+ this->GetAsQuery()->ClearChildren(true);
+ else if (this->IsFolder())
+ this->GetAsFolder()->ClearChildren(true);
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * The async version of OpenContainer.
+ */
+nsresult
+nsNavHistoryContainerResultNode::OpenContainerAsync()
+{
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+
+/**
+ * Cancels the pending asynchronous Storage execution triggered by
+ * FillChildrenAsync, if it exists. This method doesn't do much, because after
+ * cancelation Storage will call this node's HandleCompletion callback, where
+ * the real work is done.
+ *
+ * @param aRestart
+ * If true, async execution will be restarted by HandleCompletion.
+ */
+void
+nsNavHistoryContainerResultNode::CancelAsyncOpen(bool aRestart)
+{
+ NS_ASSERTION(mAsyncPendingStmt, "Async execution canceled but not pending");
+
+ mAsyncCanceledState = aRestart ? CANCELED_RESTART_NEEDED : CANCELED;
+
+ // Cancel will fail if the pending statement has already been canceled.
+ // That's OK since this method may be called multiple times, and multiple
+ // cancels don't harm anything.
+ (void)mAsyncPendingStmt->Cancel();
+}
+
+
+/**
+ * This builds up tree statistics from the bottom up. Call with a container
+ * and the indent level of that container. To init the full tree, call with
+ * the root container. The default indent level is -1, which is appropriate
+ * for the root level.
+ *
+ * CALL THIS AFTER FILLING ANY CONTAINER to update the parent and result node
+ * pointers, even if you don't care about visit counts and last visit dates.
+ */
+void
+nsNavHistoryContainerResultNode::FillStats()
+{
+ uint32_t accessCount = 0;
+ PRTime newTime = 0;
+
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ nsNavHistoryResultNode* node = mChildren[i];
+ node->mParent = this;
+ node->mIndentLevel = mIndentLevel + 1;
+ if (node->IsContainer()) {
+ nsNavHistoryContainerResultNode* container = node->GetAsContainer();
+ container->mResult = mResult;
+ container->FillStats();
+ }
+ accessCount += node->mAccessCount;
+ // this is how container nodes get sorted by date
+ // The container gets the most recent time of the child nodes.
+ if (node->mTime > newTime)
+ newTime = node->mTime;
+ }
+
+ if (mExpanded) {
+ mAccessCount = accessCount;
+ if (!IsQuery() || newTime > mTime)
+ mTime = newTime;
+ }
+}
+
+
+/**
+ * This is used when one container changes to do a minimal update of the tree
+ * structure. When something changes, you want to call FillStats if necessary
+ * and update this container completely. Then call this function which will
+ * walk up the tree and fill in the previous containers.
+ *
+ * Note that you have to tell us by how much our access count changed. Our
+ * access count should already be set to the new value; this is used tochange
+ * the parents without having to re-count all their children.
+ *
+ * This does NOT update the last visit date downward. Therefore, if you are
+ * deleting a node that has the most recent last visit date, the parents will
+ * not get their last visit dates downshifted accordingly. This is a rather
+ * unusual case: we don't often delete things, and we usually don't even show
+ * the last visit date for folders. Updating would be slower because we would
+ * have to recompute it from scratch.
+ */
+nsresult
+nsNavHistoryContainerResultNode::ReverseUpdateStats(int32_t aAccessCountChange)
+{
+ if (mParent) {
+ nsNavHistoryResult* result = GetResult();
+ bool shouldNotify = result && mParent->mParent &&
+ mParent->mParent->AreChildrenVisible();
+
+ mParent->mAccessCount += aAccessCountChange;
+ bool timeChanged = false;
+ if (mTime > mParent->mTime) {
+ timeChanged = true;
+ mParent->mTime = mTime;
+ }
+
+ if (shouldNotify) {
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeHistoryDetailsChanged(TO_ICONTAINER(mParent),
+ mParent->mTime,
+ mParent->mAccessCount));
+ }
+
+ // check sorting, the stats may have caused this node to move if the
+ // sorting depended on something we are changing.
+ uint16_t sortMode = mParent->GetSortType();
+ bool sortingByVisitCount =
+ sortMode == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_ASCENDING ||
+ sortMode == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING;
+ bool sortingByTime =
+ sortMode == nsINavHistoryQueryOptions::SORT_BY_DATE_ASCENDING ||
+ sortMode == nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING;
+
+ if ((sortingByVisitCount && aAccessCountChange != 0) ||
+ (sortingByTime && timeChanged)) {
+ int32_t ourIndex = mParent->FindChild(this);
+ NS_ASSERTION(ourIndex >= 0, "Could not find self in parent");
+ if (ourIndex >= 0)
+ EnsureItemPosition(static_cast<uint32_t>(ourIndex));
+ }
+
+ nsresult rv = mParent->ReverseUpdateStats(aAccessCountChange);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * This walks up the tree until we find a query result node or the root to get
+ * the sorting type.
+ */
+uint16_t
+nsNavHistoryContainerResultNode::GetSortType()
+{
+ if (mParent)
+ return mParent->GetSortType();
+ if (mResult)
+ return mResult->mSortingMode;
+
+ // This is a detached container, just use natural order.
+ return nsINavHistoryQueryOptions::SORT_BY_NONE;
+}
+
+
+nsresult nsNavHistoryContainerResultNode::Refresh() {
+ NS_WARNING("Refresh() is supported by queries or folders, not generic containers.");
+ return NS_OK;
+}
+
+void
+nsNavHistoryContainerResultNode::GetSortingAnnotation(nsACString& aAnnotation)
+{
+ if (mParent)
+ mParent->GetSortingAnnotation(aAnnotation);
+ else if (mResult)
+ aAnnotation.Assign(mResult->mSortingAnnotation);
+}
+
+/**
+ * @return the sorting comparator function for the give sort type, or null if
+ * there is no comparator.
+ */
+nsNavHistoryContainerResultNode::SortComparator
+nsNavHistoryContainerResultNode::GetSortingComparator(uint16_t aSortType)
+{
+ switch (aSortType)
+ {
+ case nsINavHistoryQueryOptions::SORT_BY_NONE:
+ return &SortComparison_Bookmark;
+ case nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING:
+ return &SortComparison_TitleLess;
+ case nsINavHistoryQueryOptions::SORT_BY_TITLE_DESCENDING:
+ return &SortComparison_TitleGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_DATE_ASCENDING:
+ return &SortComparison_DateLess;
+ case nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING:
+ return &SortComparison_DateGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_URI_ASCENDING:
+ return &SortComparison_URILess;
+ case nsINavHistoryQueryOptions::SORT_BY_URI_DESCENDING:
+ return &SortComparison_URIGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_ASCENDING:
+ return &SortComparison_VisitCountLess;
+ case nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING:
+ return &SortComparison_VisitCountGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_KEYWORD_ASCENDING:
+ return &SortComparison_KeywordLess;
+ case nsINavHistoryQueryOptions::SORT_BY_KEYWORD_DESCENDING:
+ return &SortComparison_KeywordGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_ASCENDING:
+ return &SortComparison_AnnotationLess;
+ case nsINavHistoryQueryOptions::SORT_BY_ANNOTATION_DESCENDING:
+ return &SortComparison_AnnotationGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_DATEADDED_ASCENDING:
+ return &SortComparison_DateAddedLess;
+ case nsINavHistoryQueryOptions::SORT_BY_DATEADDED_DESCENDING:
+ return &SortComparison_DateAddedGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_LASTMODIFIED_ASCENDING:
+ return &SortComparison_LastModifiedLess;
+ case nsINavHistoryQueryOptions::SORT_BY_LASTMODIFIED_DESCENDING:
+ return &SortComparison_LastModifiedGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_TAGS_ASCENDING:
+ return &SortComparison_TagsLess;
+ case nsINavHistoryQueryOptions::SORT_BY_TAGS_DESCENDING:
+ return &SortComparison_TagsGreater;
+ case nsINavHistoryQueryOptions::SORT_BY_FRECENCY_ASCENDING:
+ return &SortComparison_FrecencyLess;
+ case nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING:
+ return &SortComparison_FrecencyGreater;
+ default:
+ NS_NOTREACHED("Bad sorting type");
+ return nullptr;
+ }
+}
+
+
+/**
+ * This is used by Result::SetSortingMode and QueryResultNode::FillChildren to
+ * sort the child list.
+ *
+ * This does NOT update any visibility or tree information. The caller will
+ * have to completely rebuild the visible list after this.
+ */
+void
+nsNavHistoryContainerResultNode::RecursiveSort(
+ const char* aData, SortComparator aComparator)
+{
+ void* data = const_cast<void*>(static_cast<const void*>(aData));
+
+ mChildren.Sort(aComparator, data);
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->IsContainer())
+ mChildren[i]->GetAsContainer()->RecursiveSort(aData, aComparator);
+ }
+}
+
+
+/**
+ * @return the index that the given item would fall on if it were to be
+ * inserted using the given sorting.
+ */
+uint32_t
+nsNavHistoryContainerResultNode::FindInsertionPoint(
+ nsNavHistoryResultNode* aNode, SortComparator aComparator,
+ const char* aData, bool* aItemExists)
+{
+ if (aItemExists)
+ (*aItemExists) = false;
+
+ if (mChildren.Count() == 0)
+ return 0;
+
+ void* data = const_cast<void*>(static_cast<const void*>(aData));
+
+ // The common case is the beginning or the end because this is used to insert
+ // new items that are added to history, which is usually sorted by date.
+ int32_t res;
+ res = aComparator(aNode, mChildren[0], data);
+ if (res <= 0) {
+ if (aItemExists && res == 0)
+ (*aItemExists) = true;
+ return 0;
+ }
+ res = aComparator(aNode, mChildren[mChildren.Count() - 1], data);
+ if (res >= 0) {
+ if (aItemExists && res == 0)
+ (*aItemExists) = true;
+ return mChildren.Count();
+ }
+
+ uint32_t beginRange = 0; // inclusive
+ uint32_t endRange = mChildren.Count(); // exclusive
+ while (1) {
+ if (beginRange == endRange)
+ return endRange;
+ uint32_t center = beginRange + (endRange - beginRange) / 2;
+ int32_t res = aComparator(aNode, mChildren[center], data);
+ if (res <= 0) {
+ endRange = center; // left side
+ if (aItemExists && res == 0)
+ (*aItemExists) = true;
+ }
+ else {
+ beginRange = center + 1; // right site
+ }
+ }
+}
+
+
+/**
+ * This checks the child node at the given index to see if its sorting is
+ * correct. This is called when nodes are updated and we need to see whether
+ * we need to move it.
+ *
+ * @returns true if not and it should be resorted.
+*/
+bool
+nsNavHistoryContainerResultNode::DoesChildNeedResorting(uint32_t aIndex,
+ SortComparator aComparator, const char* aData)
+{
+ NS_ASSERTION(aIndex < uint32_t(mChildren.Count()),
+ "Input index out of range");
+ if (mChildren.Count() == 1)
+ return false;
+
+ void* data = const_cast<void*>(static_cast<const void*>(aData));
+
+ if (aIndex > 0) {
+ // compare to previous item
+ if (aComparator(mChildren[aIndex - 1], mChildren[aIndex], data) > 0)
+ return true;
+ }
+ if (aIndex < uint32_t(mChildren.Count()) - 1) {
+ // compare to next item
+ if (aComparator(mChildren[aIndex], mChildren[aIndex + 1], data) > 0)
+ return true;
+ }
+ return false;
+}
+
+
+/* static */
+int32_t nsNavHistoryContainerResultNode::SortComparison_StringLess(
+ const nsAString& a, const nsAString& b) {
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, 0);
+ nsICollation* collation = history->GetCollation();
+ NS_ENSURE_TRUE(collation, 0);
+
+ int32_t res = 0;
+ collation->CompareString(nsICollation::kCollationCaseInSensitive, a, b, &res);
+ return res;
+}
+
+
+/**
+ * When there are bookmark indices, we should never have ties, so we don't
+ * need to worry about tiebreaking. When there are no bookmark indices,
+ * everything will be -1 and we don't worry about sorting.
+ */
+int32_t nsNavHistoryContainerResultNode::SortComparison_Bookmark(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return a->mBookmarkIndex - b->mBookmarkIndex;
+}
+
+/**
+ * These are a little more complicated because they do a localization
+ * conversion. If this is too slow, we can compute the sort keys once in
+ * advance, sort that array, and then reorder the real array accordingly.
+ * This would save some key generations.
+ *
+ * The collation object must be allocated before sorting on title!
+ */
+int32_t nsNavHistoryContainerResultNode::SortComparison_TitleLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ uint32_t aType;
+ a->GetType(&aType);
+
+ int32_t value = SortComparison_StringLess(NS_ConvertUTF8toUTF16(a->mTitle),
+ NS_ConvertUTF8toUTF16(b->mTitle));
+ if (value == 0) {
+ // resolve by URI
+ if (a->IsURI()) {
+ value = a->mURI.Compare(b->mURI.get());
+ }
+ if (value == 0) {
+ // resolve by date
+ value = ComparePRTime(a->mTime, b->mTime);
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_TitleGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -SortComparison_TitleLess(a, b, closure);
+}
+
+/**
+ * Equal times will be very unusual, but it is important that there is some
+ * deterministic ordering of the results so they don't move around.
+ */
+int32_t nsNavHistoryContainerResultNode::SortComparison_DateLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = ComparePRTime(a->mTime, b->mTime);
+ if (value == 0) {
+ value = SortComparison_StringLess(NS_ConvertUTF8toUTF16(a->mTitle),
+ NS_ConvertUTF8toUTF16(b->mTitle));
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_DateGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -nsNavHistoryContainerResultNode::SortComparison_DateLess(a, b, closure);
+}
+
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_DateAddedLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = ComparePRTime(a->mDateAdded, b->mDateAdded);
+ if (value == 0) {
+ value = SortComparison_StringLess(NS_ConvertUTF8toUTF16(a->mTitle),
+ NS_ConvertUTF8toUTF16(b->mTitle));
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_DateAddedGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -nsNavHistoryContainerResultNode::SortComparison_DateAddedLess(a, b, closure);
+}
+
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_LastModifiedLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = ComparePRTime(a->mLastModified, b->mLastModified);
+ if (value == 0) {
+ value = SortComparison_StringLess(NS_ConvertUTF8toUTF16(a->mTitle),
+ NS_ConvertUTF8toUTF16(b->mTitle));
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_LastModifiedGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -nsNavHistoryContainerResultNode::SortComparison_LastModifiedLess(a, b, closure);
+}
+
+
+/**
+ * Certain types of parent nodes are treated specially because URIs are not
+ * valid (like days or hosts).
+ */
+int32_t nsNavHistoryContainerResultNode::SortComparison_URILess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value;
+ if (a->IsURI() && b->IsURI()) {
+ // normal URI or visit
+ value = a->mURI.Compare(b->mURI.get());
+ } else {
+ // for everything else, use title (= host name)
+ value = SortComparison_StringLess(NS_ConvertUTF8toUTF16(a->mTitle),
+ NS_ConvertUTF8toUTF16(b->mTitle));
+ }
+
+ if (value == 0) {
+ value = ComparePRTime(a->mTime, b->mTime);
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_URIGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -SortComparison_URILess(a, b, closure);
+}
+
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_KeywordLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = 0;
+ if (a->mItemId != -1 || b->mItemId != -1) {
+ // compare the keywords
+ nsAutoString keywordA, keywordB;
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, 0);
+
+ nsresult rv;
+ if (a->mItemId != -1) {
+ rv = bookmarks->GetKeywordForBookmark(a->mItemId, keywordA);
+ NS_ENSURE_SUCCESS(rv, 0);
+ }
+ if (b->mItemId != -1) {
+ rv = bookmarks->GetKeywordForBookmark(b->mItemId, keywordB);
+ NS_ENSURE_SUCCESS(rv, 0);
+ }
+
+ value = SortComparison_StringLess(keywordA, keywordB);
+ }
+
+ // Fall back to title sorting.
+ if (value == 0)
+ value = SortComparison_TitleLess(a, b, closure);
+
+ return value;
+}
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_KeywordGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -SortComparison_KeywordLess(a, b, closure);
+}
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_AnnotationLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ nsAutoCString annoName(static_cast<char*>(closure));
+ NS_ENSURE_TRUE(!annoName.IsEmpty(), 0);
+
+ bool a_itemAnno = false;
+ bool b_itemAnno = false;
+
+ // Not used for item annos
+ nsCOMPtr<nsIURI> a_uri, b_uri;
+ if (a->mItemId != -1) {
+ a_itemAnno = true;
+ } else {
+ nsAutoCString spec;
+ if (NS_SUCCEEDED(a->GetUri(spec))){
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(a_uri), spec));
+ }
+ NS_ENSURE_TRUE(a_uri, 0);
+ }
+
+ if (b->mItemId != -1) {
+ b_itemAnno = true;
+ } else {
+ nsAutoCString spec;
+ if (NS_SUCCEEDED(b->GetUri(spec))) {
+ MOZ_ALWAYS_SUCCEEDS(NS_NewURI(getter_AddRefs(b_uri), spec));
+ }
+ NS_ENSURE_TRUE(b_uri, 0);
+ }
+
+ nsAnnotationService* annosvc = nsAnnotationService::GetAnnotationService();
+ NS_ENSURE_TRUE(annosvc, 0);
+
+ bool a_hasAnno, b_hasAnno;
+ if (a_itemAnno) {
+ NS_ENSURE_SUCCESS(annosvc->ItemHasAnnotation(a->mItemId, annoName,
+ &a_hasAnno), 0);
+ } else {
+ NS_ENSURE_SUCCESS(annosvc->PageHasAnnotation(a_uri, annoName,
+ &a_hasAnno), 0);
+ }
+ if (b_itemAnno) {
+ NS_ENSURE_SUCCESS(annosvc->ItemHasAnnotation(b->mItemId, annoName,
+ &b_hasAnno), 0);
+ } else {
+ NS_ENSURE_SUCCESS(annosvc->PageHasAnnotation(b_uri, annoName,
+ &b_hasAnno), 0);
+ }
+
+ int32_t value = 0;
+ if (a_hasAnno || b_hasAnno) {
+ uint16_t annoType;
+ if (a_hasAnno) {
+ if (a_itemAnno) {
+ NS_ENSURE_SUCCESS(annosvc->GetItemAnnotationType(a->mItemId,
+ annoName,
+ &annoType), 0);
+ } else {
+ NS_ENSURE_SUCCESS(annosvc->GetPageAnnotationType(a_uri, annoName,
+ &annoType), 0);
+ }
+ }
+ if (b_hasAnno) {
+ uint16_t b_type;
+ if (b_itemAnno) {
+ NS_ENSURE_SUCCESS(annosvc->GetItemAnnotationType(b->mItemId,
+ annoName,
+ &b_type), 0);
+ } else {
+ NS_ENSURE_SUCCESS(annosvc->GetPageAnnotationType(b_uri, annoName,
+ &b_type), 0);
+ }
+ // We better make the API not support this state, really
+ // XXXmano: this is actually wrong for double<->int and int64_t<->int32_t
+ if (a_hasAnno && b_type != annoType)
+ return 0;
+ annoType = b_type;
+ }
+
+#define GET_ANNOTATIONS_VALUES(METHOD_ITEM, METHOD_PAGE, A_VAL, B_VAL) \
+ if (a_hasAnno) { \
+ if (a_itemAnno) { \
+ NS_ENSURE_SUCCESS(annosvc->METHOD_ITEM(a->mItemId, annoName, \
+ A_VAL), 0); \
+ } else { \
+ NS_ENSURE_SUCCESS(annosvc->METHOD_PAGE(a_uri, annoName, \
+ A_VAL), 0); \
+ } \
+ } \
+ if (b_hasAnno) { \
+ if (b_itemAnno) { \
+ NS_ENSURE_SUCCESS(annosvc->METHOD_ITEM(b->mItemId, annoName, \
+ B_VAL), 0); \
+ } else { \
+ NS_ENSURE_SUCCESS(annosvc->METHOD_PAGE(b_uri, annoName, \
+ B_VAL), 0); \
+ } \
+ }
+
+ if (annoType == nsIAnnotationService::TYPE_STRING) {
+ nsAutoString a_val, b_val;
+ GET_ANNOTATIONS_VALUES(GetItemAnnotationString,
+ GetPageAnnotationString, a_val, b_val);
+ value = SortComparison_StringLess(a_val, b_val);
+ }
+ else if (annoType == nsIAnnotationService::TYPE_INT32) {
+ int32_t a_val = 0, b_val = 0;
+ GET_ANNOTATIONS_VALUES(GetItemAnnotationInt32,
+ GetPageAnnotationInt32, &a_val, &b_val);
+ value = (a_val < b_val) ? -1 : (a_val > b_val) ? 1 : 0;
+ }
+ else if (annoType == nsIAnnotationService::TYPE_INT64) {
+ int64_t a_val = 0, b_val = 0;
+ GET_ANNOTATIONS_VALUES(GetItemAnnotationInt64,
+ GetPageAnnotationInt64, &a_val, &b_val);
+ value = (a_val < b_val) ? -1 : (a_val > b_val) ? 1 : 0;
+ }
+ else if (annoType == nsIAnnotationService::TYPE_DOUBLE) {
+ double a_val = 0, b_val = 0;
+ GET_ANNOTATIONS_VALUES(GetItemAnnotationDouble,
+ GetPageAnnotationDouble, &a_val, &b_val);
+ value = (a_val < b_val) ? -1 : (a_val > b_val) ? 1 : 0;
+ }
+ }
+
+ // Note we also fall back to the title-sorting route one of the items didn't
+ // have the annotation set or if both had it set but in a different storage
+ // type
+ if (value == 0)
+ return SortComparison_TitleLess(a, b, nullptr);
+
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_AnnotationGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -SortComparison_AnnotationLess(a, b, closure);
+}
+
+/**
+ * Fall back on dates for conflict resolution
+ */
+int32_t nsNavHistoryContainerResultNode::SortComparison_VisitCountLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = CompareIntegers(a->mAccessCount, b->mAccessCount);
+ if (value == 0) {
+ value = ComparePRTime(a->mTime, b->mTime);
+ if (value == 0)
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ return value;
+}
+int32_t nsNavHistoryContainerResultNode::SortComparison_VisitCountGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -nsNavHistoryContainerResultNode::SortComparison_VisitCountLess(a, b, closure);
+}
+
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_TagsLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ int32_t value = 0;
+ nsAutoString aTags, bTags;
+
+ nsresult rv = a->GetTags(aTags);
+ NS_ENSURE_SUCCESS(rv, 0);
+
+ rv = b->GetTags(bTags);
+ NS_ENSURE_SUCCESS(rv, 0);
+
+ value = SortComparison_StringLess(aTags, bTags);
+
+ // fall back to title sorting
+ if (value == 0)
+ value = SortComparison_TitleLess(a, b, closure);
+
+ return value;
+}
+
+int32_t nsNavHistoryContainerResultNode::SortComparison_TagsGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure)
+{
+ return -SortComparison_TagsLess(a, b, closure);
+}
+
+/**
+ * Fall back on date and bookmarked status, for conflict resolution.
+ */
+int32_t
+nsNavHistoryContainerResultNode::SortComparison_FrecencyLess(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure
+)
+{
+ int32_t value = CompareIntegers(a->mFrecency, b->mFrecency);
+ if (value == 0) {
+ value = ComparePRTime(a->mTime, b->mTime);
+ if (value == 0) {
+ value = nsNavHistoryContainerResultNode::SortComparison_Bookmark(a, b, closure);
+ }
+ }
+ return value;
+}
+int32_t
+nsNavHistoryContainerResultNode::SortComparison_FrecencyGreater(
+ nsNavHistoryResultNode* a, nsNavHistoryResultNode* b, void* closure
+)
+{
+ return -nsNavHistoryContainerResultNode::SortComparison_FrecencyLess(a, b, closure);
+}
+
+/**
+ * Searches this folder for a node with the given URI. Returns null if not
+ * found.
+ *
+ * @note Does not addref the node!
+ */
+nsNavHistoryResultNode*
+nsNavHistoryContainerResultNode::FindChildURI(const nsACString& aSpec,
+ uint32_t* aNodeIndex)
+{
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->IsURI()) {
+ if (aSpec.Equals(mChildren[i]->mURI)) {
+ *aNodeIndex = i;
+ return mChildren[i];
+ }
+ }
+ }
+ return nullptr;
+}
+
+/**
+ * This does the work of adding a child to the container. The child can be
+ * either a container or or a single item that may even be collapsed with the
+ * adjacent ones.
+ */
+nsresult
+nsNavHistoryContainerResultNode::InsertChildAt(nsNavHistoryResultNode* aNode,
+ int32_t aIndex)
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+
+ aNode->mParent = this;
+ aNode->mIndentLevel = mIndentLevel + 1;
+ if (aNode->IsContainer()) {
+ // need to update all the new item's children
+ nsNavHistoryContainerResultNode* container = aNode->GetAsContainer();
+ container->mResult = result;
+ container->FillStats();
+ }
+
+ if (!mChildren.InsertObjectAt(aNode, aIndex))
+ return NS_ERROR_OUT_OF_MEMORY;
+
+ // Update our stats and notify the result's observers.
+ mAccessCount += aNode->mAccessCount;
+ if (mTime < aNode->mTime)
+ mTime = aNode->mTime;
+ if (!mParent || mParent->AreChildrenVisible()) {
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeHistoryDetailsChanged(TO_ICONTAINER(this),
+ mTime,
+ mAccessCount));
+ }
+
+ nsresult rv = ReverseUpdateStats(aNode->mAccessCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Update tree if we are visible. Note that we could be here and not
+ // expanded, like when there is a bookmark folder being updated because its
+ // parent is visible.
+ if (AreChildrenVisible())
+ NOTIFY_RESULT_OBSERVERS(result, NodeInserted(this, aNode, aIndex));
+
+ return NS_OK;
+}
+
+
+/**
+ * This locates the proper place for insertion according to the current sort
+ * and calls InsertChildAt
+ */
+nsresult
+nsNavHistoryContainerResultNode::InsertSortedChild(
+ nsNavHistoryResultNode* aNode,
+ bool aIgnoreDuplicates)
+{
+
+ if (mChildren.Count() == 0)
+ return InsertChildAt(aNode, 0);
+
+ SortComparator comparator = GetSortingComparator(GetSortType());
+ if (comparator) {
+ // When inserting a new node, it must have proper statistics because we use
+ // them to find the correct insertion point. The insert function will then
+ // recompute these statistics and fill in the proper parents and hierarchy
+ // level. Doing this twice shouldn't be a large performance penalty because
+ // when we are inserting new containers, they typically contain only one
+ // item (because we've browsed a new page).
+ if (aNode->IsContainer()) {
+ // need to update all the new item's children
+ nsNavHistoryContainerResultNode* container = aNode->GetAsContainer();
+ container->mResult = mResult;
+ container->FillStats();
+ }
+
+ nsAutoCString sortingAnnotation;
+ GetSortingAnnotation(sortingAnnotation);
+ bool itemExists;
+ uint32_t position = FindInsertionPoint(aNode, comparator,
+ sortingAnnotation.get(),
+ &itemExists);
+ if (aIgnoreDuplicates && itemExists)
+ return NS_OK;
+
+ return InsertChildAt(aNode, position);
+ }
+ return InsertChildAt(aNode, mChildren.Count());
+}
+
+/**
+ * This checks if the item at aIndex is located correctly given the sorting
+ * move. If it's not, the item is moved, and the result's observers are
+ * notified.
+ *
+ * @return true if the item position has been changed, false otherwise.
+ */
+bool
+nsNavHistoryContainerResultNode::EnsureItemPosition(uint32_t aIndex) {
+ NS_ASSERTION(aIndex < (uint32_t)mChildren.Count(), "Invalid index");
+ if (aIndex >= (uint32_t)mChildren.Count())
+ return false;
+
+ SortComparator comparator = GetSortingComparator(GetSortType());
+ if (!comparator)
+ return false;
+
+ nsAutoCString sortAnno;
+ GetSortingAnnotation(sortAnno);
+ if (!DoesChildNeedResorting(aIndex, comparator, sortAnno.get()))
+ return false;
+
+ RefPtr<nsNavHistoryResultNode> node(mChildren[aIndex]);
+ mChildren.RemoveObjectAt(aIndex);
+
+ uint32_t newIndex = FindInsertionPoint(
+ node, comparator,sortAnno.get(), nullptr);
+ mChildren.InsertObjectAt(node.get(), newIndex);
+
+ if (AreChildrenVisible()) {
+ nsNavHistoryResult* result = GetResult();
+ NOTIFY_RESULT_OBSERVERS_RET(result,
+ NodeMoved(node, this, aIndex, this, newIndex),
+ false);
+ }
+
+ return true;
+}
+
+/**
+ * This does all the work of removing a child from this container, including
+ * updating the tree if necessary. Note that we do not need to be open for
+ * this to work.
+ */
+nsresult
+nsNavHistoryContainerResultNode::RemoveChildAt(int32_t aIndex)
+{
+ NS_ASSERTION(aIndex >= 0 && aIndex < mChildren.Count(), "Invalid index");
+
+ // Hold an owning reference to keep from expiring while we work with it.
+ RefPtr<nsNavHistoryResultNode> oldNode = mChildren[aIndex];
+
+ // Update stats.
+ // XXX This assertion does not reliably pass -- investigate!! (bug 1049797)
+ // MOZ_ASSERT(mAccessCount >= mChildren[aIndex]->mAccessCount,
+ // "Invalid access count while updating!");
+ uint32_t oldAccessCount = mAccessCount;
+ mAccessCount -= mChildren[aIndex]->mAccessCount;
+
+ // Remove it from our list and notify the result's observers.
+ mChildren.RemoveObjectAt(aIndex);
+ if (AreChildrenVisible()) {
+ nsNavHistoryResult* result = GetResult();
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeRemoved(this, oldNode, aIndex));
+ }
+
+ nsresult rv = ReverseUpdateStats(mAccessCount - oldAccessCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ oldNode->OnRemoving();
+ return NS_OK;
+}
+
+
+/**
+ * Searches for matches for the given URI. If aOnlyOne is set, it will
+ * terminate as soon as it finds a single match. This would be used when there
+ * are URI results so there will only ever be one copy of any URI.
+ *
+ * When aOnlyOne is false, it will check all elements. This is for visit
+ * style results that may have multiple copies of any given URI.
+ */
+void
+nsNavHistoryContainerResultNode::RecursiveFindURIs(bool aOnlyOne,
+ nsNavHistoryContainerResultNode* aContainer, const nsCString& aSpec,
+ nsCOMArray<nsNavHistoryResultNode>* aMatches)
+{
+ for (int32_t child = 0; child < aContainer->mChildren.Count(); ++child) {
+ uint32_t type;
+ aContainer->mChildren[child]->GetType(&type);
+ if (nsNavHistoryResultNode::IsTypeURI(type)) {
+ // compare URIs
+ nsNavHistoryResultNode* uriNode = aContainer->mChildren[child];
+ if (uriNode->mURI.Equals(aSpec)) {
+ // found
+ aMatches->AppendObject(uriNode);
+ if (aOnlyOne)
+ return;
+ }
+ }
+ }
+}
+
+
+/**
+ * If aUpdateSort is true, we will also update the sorting of this item.
+ * Normally you want this to be true, but it can be false if the thing you are
+ * changing can not affect sorting (like favicons).
+ *
+ * You should NOT change any child lists as part of the callback function.
+ */
+bool
+nsNavHistoryContainerResultNode::UpdateURIs(bool aRecursive, bool aOnlyOne,
+ bool aUpdateSort, const nsCString& aSpec,
+ nsresult (*aCallback)(nsNavHistoryResultNode*, const void*, const nsNavHistoryResult*),
+ const void* aClosure)
+{
+ const nsNavHistoryResult* result = GetResult();
+ if (!result) {
+ MOZ_ASSERT(false, "Should have a result");
+ return false;
+ }
+
+ // this needs to be owning since sometimes we remove and re-insert nodes
+ // in their parents and we don't want them to go away.
+ nsCOMArray<nsNavHistoryResultNode> matches;
+
+ if (aRecursive) {
+ RecursiveFindURIs(aOnlyOne, this, aSpec, &matches);
+ } else if (aOnlyOne) {
+ uint32_t nodeIndex;
+ nsNavHistoryResultNode* node = FindChildURI(aSpec, &nodeIndex);
+ if (node)
+ matches.AppendObject(node);
+ } else {
+ MOZ_ASSERT(false,
+ "UpdateURIs does not handle nonrecursive updates of multiple items.");
+ // this case easy to add if you need it, just find all the matching URIs
+ // at this level. However, this isn't currently used. History uses
+ // recursive, Bookmarks uses one level and knows that the match is unique.
+ return false;
+ }
+
+ if (matches.Count() == 0)
+ return false;
+
+ // PERFORMANCE: This updates each container for each child in it that
+ // changes. In some cases, many elements have changed inside the same
+ // container. It would be better to compose a list of containers, and
+ // update each one only once for all the items that have changed in it.
+ for (int32_t i = 0; i < matches.Count(); ++i)
+ {
+ nsNavHistoryResultNode* node = matches[i];
+ nsNavHistoryContainerResultNode* parent = node->mParent;
+ if (!parent) {
+ MOZ_ASSERT(false, "All URI nodes being updated must have parents");
+ continue;
+ }
+
+ uint32_t oldAccessCount = node->mAccessCount;
+ PRTime oldTime = node->mTime;
+ aCallback(node, aClosure, result);
+
+ if (oldAccessCount != node->mAccessCount || oldTime != node->mTime) {
+ parent->mAccessCount += node->mAccessCount - oldAccessCount;
+ if (node->mTime > parent->mTime)
+ parent->mTime = node->mTime;
+ if (parent->AreChildrenVisible()) {
+ NOTIFY_RESULT_OBSERVERS_RET(result,
+ NodeHistoryDetailsChanged(
+ TO_ICONTAINER(parent),
+ parent->mTime,
+ parent->mAccessCount),
+ true);
+ }
+ DebugOnly<nsresult> rv = parent->ReverseUpdateStats(node->mAccessCount - oldAccessCount);
+ MOZ_ASSERT(NS_SUCCEEDED(rv), "should be able to ReverseUpdateStats");
+ }
+
+ if (aUpdateSort) {
+ int32_t childIndex = parent->FindChild(node);
+ MOZ_ASSERT(childIndex >= 0, "Could not find child we just got a reference to");
+ if (childIndex >= 0)
+ parent->EnsureItemPosition(childIndex);
+ }
+ }
+
+ return true;
+}
+
+
+/**
+ * This is used to update the titles in the tree. This is called from both
+ * query and bookmark folder containers to update the tree. Bookmark folders
+ * should be sure to set recursive to false, since child folders will have
+ * their own callbacks registered.
+ */
+static nsresult setTitleCallback(nsNavHistoryResultNode* aNode,
+ const void* aClosure,
+ const nsNavHistoryResult* aResult)
+{
+ const nsACString* newTitle = static_cast<const nsACString*>(aClosure);
+ aNode->mTitle = *newTitle;
+
+ if (aResult && (!aNode->mParent || aNode->mParent->AreChildrenVisible()))
+ NOTIFY_RESULT_OBSERVERS(aResult, NodeTitleChanged(aNode, *newTitle));
+
+ return NS_OK;
+}
+nsresult
+nsNavHistoryContainerResultNode::ChangeTitles(nsIURI* aURI,
+ const nsACString& aNewTitle,
+ bool aRecursive,
+ bool aOnlyOne)
+{
+ // uri string
+ nsAutoCString uriString;
+ nsresult rv = aURI->GetSpec(uriString);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // The recursive function will update the result's tree nodes, but only if we
+ // give it a non-null pointer. So if there isn't a tree, just pass nullptr
+ // so it doesn't bother trying to call the result.
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+
+ uint16_t sortType = GetSortType();
+ bool updateSorting =
+ (sortType == nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_TITLE_DESCENDING);
+
+ UpdateURIs(aRecursive, aOnlyOne, updateSorting, uriString,
+ setTitleCallback,
+ static_cast<const void*>(&aNewTitle));
+
+ return NS_OK;
+}
+
+
+/**
+ * Complex containers (folders and queries) will override this. Here, we
+ * handle the case of simple containers (like host groups) where the children
+ * are always stored.
+ */
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetHasChildren(bool *aHasChildren)
+{
+ *aHasChildren = (mChildren.Count() > 0);
+ return NS_OK;
+}
+
+
+/**
+ * @throws if this node is closed.
+ */
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetChildCount(uint32_t* aChildCount)
+{
+ if (!mExpanded)
+ return NS_ERROR_NOT_AVAILABLE;
+ *aChildCount = mChildren.Count();
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetChild(uint32_t aIndex,
+ nsINavHistoryResultNode** _retval)
+{
+ if (!mExpanded)
+ return NS_ERROR_NOT_AVAILABLE;
+ if (aIndex >= uint32_t(mChildren.Count()))
+ return NS_ERROR_INVALID_ARG;
+ NS_ADDREF(*_retval = mChildren[aIndex]);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::GetChildIndex(nsINavHistoryResultNode* aNode,
+ uint32_t* _retval)
+{
+ if (!mExpanded)
+ return NS_ERROR_NOT_AVAILABLE;
+
+ int32_t nodeIndex = FindChild(static_cast<nsNavHistoryResultNode*>(aNode));
+ if (nodeIndex == -1)
+ return NS_ERROR_INVALID_ARG;
+
+ *_retval = nodeIndex;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryContainerResultNode::FindNodeByDetails(const nsACString& aURIString,
+ PRTime aTime,
+ int64_t aItemId,
+ bool aRecursive,
+ nsINavHistoryResultNode** _retval) {
+ if (!mExpanded)
+ return NS_ERROR_NOT_AVAILABLE;
+
+ *_retval = nullptr;
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->mURI.Equals(aURIString) &&
+ mChildren[i]->mTime == aTime &&
+ mChildren[i]->mItemId == aItemId) {
+ *_retval = mChildren[i];
+ break;
+ }
+
+ if (aRecursive && mChildren[i]->IsContainer()) {
+ nsNavHistoryContainerResultNode* asContainer =
+ mChildren[i]->GetAsContainer();
+ if (asContainer->mExpanded) {
+ nsresult rv = asContainer->FindNodeByDetails(aURIString, aTime,
+ aItemId,
+ aRecursive,
+ _retval);
+
+ if (NS_SUCCEEDED(rv) && _retval)
+ break;
+ }
+ }
+ }
+ NS_IF_ADDREF(*_retval);
+ return NS_OK;
+}
+
+/**
+ * HOW QUERY UPDATING WORKS
+ *
+ * Queries are different than bookmark folders in that we can not always do
+ * dynamic updates (easily) and updates are more expensive. Therefore, we do
+ * NOT query if we are not open and want to see if we have any children (for
+ * drawing a twisty) and always assume we will.
+ *
+ * When the container is opened, we execute the query and register the
+ * listeners. Like bookmark folders, we stay registered even when closed, and
+ * clear ourselves as soon as a message comes in. This lets us respond quickly
+ * if the user closes and reopens the container.
+ *
+ * We try to handle the most common notifications for the most common query
+ * types dynamically, that is, figuring out what should happen in response to
+ * a message without doing a requery. For complex changes or complex queries,
+ * we give up and requery.
+ */
+NS_IMPL_ISUPPORTS_INHERITED(nsNavHistoryQueryResultNode,
+ nsNavHistoryContainerResultNode,
+ nsINavHistoryQueryResultNode)
+
+nsNavHistoryQueryResultNode::nsNavHistoryQueryResultNode(
+ const nsACString& aTitle, const nsACString& aIconURI,
+ const nsACString& aQueryURI) :
+ nsNavHistoryContainerResultNode(aQueryURI, aTitle, aIconURI,
+ nsNavHistoryResultNode::RESULT_TYPE_QUERY,
+ nullptr),
+ mLiveUpdate(QUERYUPDATE_COMPLEX_WITH_BOOKMARKS),
+ mHasSearchTerms(false),
+ mContentsValid(false),
+ mBatchChanges(0)
+{
+}
+
+nsNavHistoryQueryResultNode::nsNavHistoryQueryResultNode(
+ const nsACString& aTitle, const nsACString& aIconURI,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions) :
+ nsNavHistoryContainerResultNode(EmptyCString(), aTitle, aIconURI,
+ nsNavHistoryResultNode::RESULT_TYPE_QUERY,
+ aOptions),
+ mQueries(aQueries),
+ mContentsValid(false),
+ mBatchChanges(0),
+ mTransitions(mQueries[0]->Transitions())
+{
+ NS_ASSERTION(aQueries.Count() > 0, "Must have at least one query");
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ASSERTION(history, "History service missing");
+ if (history) {
+ mLiveUpdate = history->GetUpdateRequirements(mQueries, mOptions,
+ &mHasSearchTerms);
+ }
+
+ // Collect transitions shared by all queries.
+ for (int32_t i = 1; i < mQueries.Count(); ++i) {
+ const nsTArray<uint32_t>& queryTransitions = mQueries[i]->Transitions();
+ for (uint32_t j = 0; j < mTransitions.Length() ; ++j) {
+ uint32_t transition = mTransitions.SafeElementAt(j, 0);
+ if (transition && !queryTransitions.Contains(transition))
+ mTransitions.RemoveElement(transition);
+ }
+ }
+}
+
+nsNavHistoryQueryResultNode::nsNavHistoryQueryResultNode(
+ const nsACString& aTitle, const nsACString& aIconURI,
+ PRTime aTime,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions) :
+ nsNavHistoryContainerResultNode(EmptyCString(), aTitle, aTime, aIconURI,
+ nsNavHistoryResultNode::RESULT_TYPE_QUERY,
+ aOptions),
+ mQueries(aQueries),
+ mContentsValid(false),
+ mBatchChanges(0),
+ mTransitions(mQueries[0]->Transitions())
+{
+ NS_ASSERTION(aQueries.Count() > 0, "Must have at least one query");
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ASSERTION(history, "History service missing");
+ if (history) {
+ mLiveUpdate = history->GetUpdateRequirements(mQueries, mOptions,
+ &mHasSearchTerms);
+ }
+
+ // Collect transitions shared by all queries.
+ for (int32_t i = 1; i < mQueries.Count(); ++i) {
+ const nsTArray<uint32_t>& queryTransitions = mQueries[i]->Transitions();
+ for (uint32_t j = 0; j < mTransitions.Length() ; ++j) {
+ uint32_t transition = mTransitions.SafeElementAt(j, 0);
+ if (transition && !queryTransitions.Contains(transition))
+ mTransitions.RemoveElement(transition);
+ }
+ }
+}
+
+nsNavHistoryQueryResultNode::~nsNavHistoryQueryResultNode() {
+ // Remove this node from result's observers. We don't need to be notified
+ // anymore.
+ if (mResult && mResult->mAllBookmarksObservers.Contains(this))
+ mResult->RemoveAllBookmarksObserver(this);
+ if (mResult && mResult->mHistoryObservers.Contains(this))
+ mResult->RemoveHistoryObserver(this);
+}
+
+/**
+ * Whoever made us may want non-expanding queries. However, we always expand
+ * when we are the root node, or else asking for non-expanding queries would be
+ * useless. A query node is not expandable if excludeItems is set or if
+ * expandQueries is unset.
+ */
+bool
+nsNavHistoryQueryResultNode::CanExpand()
+{
+ if (IsContainersQuery())
+ return true;
+
+ // If ExcludeItems is set on the root or on the node itself, don't expand.
+ if ((mResult && mResult->mRootNode->mOptions->ExcludeItems()) ||
+ Options()->ExcludeItems())
+ return false;
+
+ // Check the ancestor container.
+ nsNavHistoryQueryOptions* options = GetGeneratingOptions();
+ if (options) {
+ if (options->ExcludeItems())
+ return false;
+ if (options->ExpandQueries())
+ return true;
+ }
+
+ if (mResult && mResult->mRootNode == this)
+ return true;
+
+ return false;
+}
+
+
+/**
+ * Some query with a particular result type can contain other queries. They
+ * must be always expandable
+ */
+bool
+nsNavHistoryQueryResultNode::IsContainersQuery()
+{
+ uint16_t resultType = Options()->ResultType();
+ return resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_SITE_QUERY;
+}
+
+
+/**
+ * Here we do not want to call ContainerResultNode::OnRemoving since our own
+ * ClearChildren will do the same thing and more (unregister the observers).
+ * The base ResultNode::OnRemoving will clear some regular node stats, so it
+ * is OK.
+ */
+void
+nsNavHistoryQueryResultNode::OnRemoving()
+{
+ nsNavHistoryResultNode::OnRemoving();
+ ClearChildren(true);
+ mResult = nullptr;
+}
+
+
+/**
+ * Marks the container as open, rebuilding results if they are invalid. We
+ * may still have valid results if the container was previously open and
+ * nothing happened since closing it.
+ *
+ * We do not handle CloseContainer specially. The default one just marks the
+ * container as closed, but doesn't actually mark the results as invalid.
+ * The results will be invalidated by the next history or bookmark
+ * notification that comes in. This means if you open and close the item
+ * without anything happening in between, it will be fast (this actually
+ * happens when results are used as menus).
+ */
+nsresult
+nsNavHistoryQueryResultNode::OpenContainer()
+{
+ NS_ASSERTION(!mExpanded, "Container must be closed to open it");
+ mExpanded = true;
+
+ nsresult rv;
+
+ if (!CanExpand())
+ return NS_OK;
+ if (!mContentsValid) {
+ rv = FillChildren();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ rv = NotifyOnStateChange(STATE_CLOSED);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+/**
+ * When we have valid results we can always give an exact answer. When we
+ * don't we just assume we'll have results, since actually doing the query
+ * might be hard. This is used to draw twisties on the tree, so precise results
+ * don't matter.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetHasChildren(bool* aHasChildren)
+{
+ *aHasChildren = false;
+
+ if (!CanExpand()) {
+ return NS_OK;
+ }
+
+ uint16_t resultType = mOptions->ResultType();
+
+ // Tags are always populated, otherwise they are removed.
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS) {
+ *aHasChildren = true;
+ return NS_OK;
+ }
+
+ // For tag containers query we must check if we have any tag
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_TAG_QUERY) {
+ nsCOMPtr<nsITaggingService> tagging =
+ do_GetService(NS_TAGGINGSERVICE_CONTRACTID);
+ if (tagging) {
+ bool hasTags;
+ *aHasChildren = NS_SUCCEEDED(tagging->GetHasTags(&hasTags)) && hasTags;
+ }
+ return NS_OK;
+ }
+
+ // For history containers query we must check if we have any history
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_SITE_QUERY) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ return history->GetHasHistoryEntries(aHasChildren);
+ }
+
+ //XXX: For other containers queries we must:
+ // 1. If it's open, just check mChildren for containers
+ // 2. Else null the view (keep it in a var), open container, check mChildren
+ // for containers, close container, reset the view
+
+ if (mContentsValid) {
+ *aHasChildren = (mChildren.Count() > 0);
+ return NS_OK;
+ }
+ *aHasChildren = true;
+ return NS_OK;
+}
+
+
+/**
+ * This doesn't just return mURI because in the case of queries that may
+ * be lazily constructed from the query objects.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetUri(nsACString& aURI)
+{
+ nsresult rv = VerifyQueriesSerialized();
+ NS_ENSURE_SUCCESS(rv, rv);
+ aURI = mURI;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetFolderItemId(int64_t* aItemId)
+{
+ *aItemId = -1;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetTargetFolderGuid(nsACString& aGuid) {
+ aGuid = EmptyCString();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetQueries(uint32_t* queryCount,
+ nsINavHistoryQuery*** queries)
+{
+ nsresult rv = VerifyQueriesParsed();
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ASSERTION(mQueries.Count() > 0, "Must have >= 1 query");
+
+ *queries = static_cast<nsINavHistoryQuery**>
+ (moz_xmalloc(mQueries.Count() * sizeof(nsINavHistoryQuery*)));
+ NS_ENSURE_TRUE(*queries, NS_ERROR_OUT_OF_MEMORY);
+
+ for (int32_t i = 0; i < mQueries.Count(); ++i)
+ NS_ADDREF((*queries)[i] = mQueries[i]);
+ *queryCount = mQueries.Count();
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetQueryOptions(
+ nsINavHistoryQueryOptions** aQueryOptions)
+{
+ *aQueryOptions = Options();
+ NS_ADDREF(*aQueryOptions);
+ return NS_OK;
+}
+
+/**
+ * Safe options getter, ensures queries are parsed first.
+ */
+nsNavHistoryQueryOptions*
+nsNavHistoryQueryResultNode::Options()
+{
+ nsresult rv = VerifyQueriesParsed();
+ if (NS_FAILED(rv))
+ return nullptr;
+ NS_ASSERTION(mOptions, "Options invalid, cannot generate from URI");
+ return mOptions;
+}
+
+
+nsresult
+nsNavHistoryQueryResultNode::VerifyQueriesParsed()
+{
+ if (mQueries.Count() > 0) {
+ NS_ASSERTION(mOptions, "If a result has queries, it also needs options");
+ return NS_OK;
+ }
+ NS_ASSERTION(!mURI.IsEmpty(),
+ "Query nodes must have either a URI or query/options");
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ nsresult rv = history->QueryStringToQueryArray(mURI, &mQueries,
+ getter_AddRefs(mOptions));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mLiveUpdate = history->GetUpdateRequirements(mQueries, mOptions,
+ &mHasSearchTerms);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavHistoryQueryResultNode::VerifyQueriesSerialized()
+{
+ if (!mURI.IsEmpty()) {
+ return NS_OK;
+ }
+ NS_ASSERTION(mQueries.Count() > 0 && mOptions,
+ "Query nodes must have either a URI or query/options");
+
+ nsTArray<nsINavHistoryQuery*> flatQueries;
+ flatQueries.SetCapacity(mQueries.Count());
+ for (int32_t i = 0; i < mQueries.Count(); ++i)
+ flatQueries.AppendElement(static_cast<nsINavHistoryQuery*>
+ (mQueries.ObjectAt(i)));
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ nsresult rv = history->QueriesToQueryString(flatQueries.Elements(),
+ flatQueries.Length(),
+ mOptions, mURI);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_STATE(!mURI.IsEmpty());
+ return NS_OK;
+}
+
+
+nsresult
+nsNavHistoryQueryResultNode::FillChildren()
+{
+ NS_ASSERTION(!mContentsValid,
+ "Don't call FillChildren when contents are valid");
+ NS_ASSERTION(mChildren.Count() == 0,
+ "We are trying to fill children when there already are some");
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ // get the results from the history service
+ nsresult rv = VerifyQueriesParsed();
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = history->GetQueryResults(this, mQueries, mOptions, &mChildren);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // it is important to call FillStats to fill in the parents on all
+ // nodes and the result node pointers on the containers
+ FillStats();
+
+ uint16_t sortType = GetSortType();
+
+ if (mResult && mResult->mNeedsToApplySortingMode) {
+ // We should repopulate container and then apply sortingMode. To avoid
+ // sorting 2 times we simply do that here.
+ mResult->SetSortingMode(mResult->mSortingMode);
+ }
+ else if (mOptions->QueryType() != nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY ||
+ sortType != nsINavHistoryQueryOptions::SORT_BY_NONE) {
+ // The default SORT_BY_NONE sorts by the bookmark index (position),
+ // which we do not have for history queries.
+ // Once we've computed all tree stats, we can sort, because containers will
+ // then have proper visit counts and dates.
+ SortComparator comparator = GetSortingComparator(GetSortType());
+ if (comparator) {
+ nsAutoCString sortingAnnotation;
+ GetSortingAnnotation(sortingAnnotation);
+ // Usually containers queries results comes already sorted from the
+ // database, but some locales could have special rules to sort by title.
+ // RecursiveSort won't apply these rules to containers in containers
+ // queries because when setting sortingMode on the result we want to sort
+ // contained items (bug 473157).
+ // Base container RecursiveSort will sort both our children and all
+ // descendants, and is used in this case because we have to do manual
+ // title sorting.
+ // Query RecursiveSort will instead only sort descendants if we are a
+ // constinaersQuery, e.g. a grouped query that will return other queries.
+ // For other type of queries it will act as the base one.
+ if (IsContainersQuery() &&
+ sortType == mOptions->SortingMode() &&
+ (sortType == nsINavHistoryQueryOptions::SORT_BY_TITLE_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_TITLE_DESCENDING))
+ nsNavHistoryContainerResultNode::RecursiveSort(sortingAnnotation.get(), comparator);
+ else
+ RecursiveSort(sortingAnnotation.get(), comparator);
+ }
+ }
+
+ // if we are limiting our results remove items from the end of the
+ // mChildren array after sorting. This is done for root node only.
+ // note, if count < max results, we won't do anything.
+ if (!mParent && mOptions->MaxResults()) {
+ while ((uint32_t)mChildren.Count() > mOptions->MaxResults())
+ mChildren.RemoveObjectAt(mChildren.Count() - 1);
+ }
+
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+
+ if (mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY ||
+ mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_UNIFIED) {
+ // Date containers that contain site containers have no reason to observe
+ // history, if the inside site container is expanded it will update,
+ // otherwise we are going to refresh the parent query.
+ if (!mParent || mParent->mOptions->ResultType() != nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY) {
+ // register with the result for history updates
+ result->AddHistoryObserver(this);
+ }
+ }
+
+ if (mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_BOOKMARKS ||
+ mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_UNIFIED ||
+ mLiveUpdate == QUERYUPDATE_COMPLEX_WITH_BOOKMARKS ||
+ mHasSearchTerms) {
+ // register with the result for bookmark updates
+ result->AddAllBookmarksObserver(this);
+ }
+
+ mContentsValid = true;
+ return NS_OK;
+}
+
+
+/**
+ * Call with unregister = false when we are going to update the children (for
+ * example, when the container is open). This will clear the list and notify
+ * all the children that they are going away.
+ *
+ * When the results are becoming invalid and we are not going to refresh them,
+ * set unregister = true, which will unregister the listener from the
+ * result if any. We use unregister = false when we are refreshing the list
+ * immediately so want to stay a notifier.
+ */
+void
+nsNavHistoryQueryResultNode::ClearChildren(bool aUnregister)
+{
+ for (int32_t i = 0; i < mChildren.Count(); ++i)
+ mChildren[i]->OnRemoving();
+ mChildren.Clear();
+
+ if (aUnregister && mContentsValid) {
+ nsNavHistoryResult* result = GetResult();
+ if (result) {
+ result->RemoveHistoryObserver(this);
+ result->RemoveAllBookmarksObserver(this);
+ }
+ }
+ mContentsValid = false;
+}
+
+
+/**
+ * This is called to update the result when something has changed that we
+ * can not incrementally update.
+ */
+nsresult
+nsNavHistoryQueryResultNode::Refresh()
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mBatchInProgress) {
+ result->requestRefresh(this);
+ return NS_OK;
+ }
+
+ // This is not a root node but it does not have a parent - this means that
+ // the node has already been cleared and it is now called, because it was
+ // left in a local copy of the observers array.
+ if (mIndentLevel > -1 && !mParent)
+ return NS_OK;
+
+ // Do not refresh if we are not expanded or if we are child of a query
+ // containing other queries. In this case calling Refresh for each child
+ // query could cause a major slowdown. We should not refresh nested
+ // queries, since we will already refresh the parent one.
+ if (!mExpanded ||
+ (mParent && mParent->IsQuery() &&
+ mParent->GetAsQuery()->IsContainersQuery())) {
+ // Don't update, just invalidate and unhook
+ ClearChildren(true);
+ return NS_OK; // no updates in tree state
+ }
+
+ if (mLiveUpdate == QUERYUPDATE_COMPLEX_WITH_BOOKMARKS)
+ ClearChildren(true);
+ else
+ ClearChildren(false);
+
+ // Ignore errors from FillChildren, since we will still want to refresh
+ // the tree (there just might not be anything in it on error).
+ (void)FillChildren();
+
+ NOTIFY_RESULT_OBSERVERS(result, InvalidateContainer(TO_CONTAINER(this)));
+ return NS_OK;
+}
+
+
+/**
+ * Here, we override GetSortType to return the current sorting for this
+ * query. GetSortType is used when dynamically inserting query results so we
+ * can see which comparator we should use to find the proper insertion point
+ * (it shouldn't be called from folder containers which maintain their own
+ * sorting).
+ *
+ * Normally, the container just forwards it up the chain. This is what we want
+ * for host groups, for example. For queries, we often want to use the query's
+ * sorting mode.
+ *
+ * However, we only use this query node's sorting when it is not the root.
+ * When it is the root, we use the result's sorting mode. This is because
+ * there are two cases:
+ * - You are looking at a bookmark hierarchy that contains an embedded
+ * result. We should always use the query's sort ordering since the result
+ * node's headers have nothing to do with us (and are disabled).
+ * - You are looking at a query in the tree. In this case, we want the
+ * result sorting to override ours (it should be initialized to the same
+ * sorting mode).
+ */
+uint16_t
+nsNavHistoryQueryResultNode::GetSortType()
+{
+ if (mParent)
+ return mOptions->SortingMode();
+ if (mResult)
+ return mResult->mSortingMode;
+
+ // This is a detached container, just use natural order.
+ return nsINavHistoryQueryOptions::SORT_BY_NONE;
+}
+
+
+void
+nsNavHistoryQueryResultNode::GetSortingAnnotation(nsACString& aAnnotation) {
+ if (mParent) {
+ // use our sorting, we are not the root
+ mOptions->GetSortingAnnotation(aAnnotation);
+ }
+ else if (mResult) {
+ aAnnotation.Assign(mResult->mSortingAnnotation);
+ }
+}
+
+void
+nsNavHistoryQueryResultNode::RecursiveSort(
+ const char* aData, SortComparator aComparator)
+{
+ void* data = const_cast<void*>(static_cast<const void*>(aData));
+
+ if (!IsContainersQuery())
+ mChildren.Sort(aComparator, data);
+
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->IsContainer())
+ mChildren[i]->GetAsContainer()->RecursiveSort(aData, aComparator);
+ }
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetSkipTags(bool *aSkipTags)
+{
+ *aSkipTags = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::GetSkipDescendantsOnItemRemoval(bool *aSkipDescendantsOnItemRemoval)
+{
+ *aSkipDescendantsOnItemRemoval = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnBeginUpdateBatch()
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnEndUpdateBatch()
+{
+ // If the query has no children it's possible it's not yet listening to
+ // bookmarks changes, in such a case it's safer to force a refresh to gather
+ // eventual new nodes matching query options.
+ if (mChildren.Count() == 0) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ mBatchChanges = 0;
+ return NS_OK;
+}
+
+static nsresult setHistoryDetailsCallback(nsNavHistoryResultNode* aNode,
+ const void* aClosure,
+ const nsNavHistoryResult* aResult)
+{
+ const nsNavHistoryResultNode* updatedNode =
+ static_cast<const nsNavHistoryResultNode*>(aClosure);
+
+ aNode->mAccessCount = updatedNode->mAccessCount;
+ aNode->mTime = updatedNode->mTime;
+ aNode->mFrecency = updatedNode->mFrecency;
+ aNode->mHidden = updatedNode->mHidden;
+
+ return NS_OK;
+}
+
+/**
+ * Here we need to update all copies of the URI we have with the new visit
+ * count, and potentially add a new entry in our query. This is the most
+ * common update operation and it is important that it be as efficient as
+ * possible.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnVisit(nsIURI* aURI, int64_t aVisitId,
+ PRTime aTime, int64_t aSessionId,
+ int64_t aReferringId,
+ uint32_t aTransitionType,
+ const nsACString& aGUID,
+ bool aHidden,
+ uint32_t* aAdded)
+{
+ if (aHidden && !mOptions->IncludeHidden())
+ return NS_OK;
+
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mBatchInProgress &&
+ ++mBatchChanges > MAX_BATCH_CHANGES_BEFORE_REFRESH) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ switch(mLiveUpdate) {
+ case QUERYUPDATE_HOST: {
+ // For these simple yet common cases we can check the host ourselves
+ // before doing the overhead of creating a new result node.
+ MOZ_ASSERT(mQueries.Count() == 1,
+ "Host updated queries can have only one object");
+ RefPtr<nsNavHistoryQuery> query = do_QueryObject(mQueries[0]);
+
+ bool hasDomain;
+ query->GetHasDomain(&hasDomain);
+ if (!hasDomain)
+ return NS_OK;
+
+ nsAutoCString host;
+ if (NS_FAILED(aURI->GetAsciiHost(host)))
+ return NS_OK;
+
+ if (!query->Domain().Equals(host))
+ return NS_OK;
+
+ // Fall through to check the time, if the time is not present it will
+ // still match.
+ MOZ_FALLTHROUGH;
+ }
+
+ case QUERYUPDATE_TIME: {
+ // For these simple yet common cases we can check the time ourselves
+ // before doing the overhead of creating a new result node.
+ MOZ_ASSERT(mQueries.Count() == 1,
+ "Time updated queries can have only one object");
+ RefPtr<nsNavHistoryQuery> query = do_QueryObject(mQueries[0]);
+
+ bool hasIt;
+ query->GetHasBeginTime(&hasIt);
+ if (hasIt) {
+ PRTime beginTime = history->NormalizeTime(query->BeginTimeReference(),
+ query->BeginTime());
+ if (aTime < beginTime)
+ return NS_OK; // before our time range
+ }
+ query->GetHasEndTime(&hasIt);
+ if (hasIt) {
+ PRTime endTime = history->NormalizeTime(query->EndTimeReference(),
+ query->EndTime());
+ if (aTime > endTime)
+ return NS_OK; // after our time range
+ }
+ // Now we know that our visit satisfies the time range, fall through to
+ // the QUERYUPDATE_SIMPLE case below.
+ MOZ_FALLTHROUGH;
+ }
+
+ case QUERYUPDATE_SIMPLE: {
+ // If all of the queries are filtered by some transitions, skip the
+ // update if aTransitionType doesn't match any of them.
+ if (mTransitions.Length() > 0 && !mTransitions.Contains(aTransitionType))
+ return NS_OK;
+
+ // The history service can tell us whether the new item should appear
+ // in the result. We first have to construct a node for it to check.
+ RefPtr<nsNavHistoryResultNode> addition;
+ nsresult rv = history->VisitIdToResultNode(aVisitId, mOptions,
+ getter_AddRefs(addition));
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_STATE(addition);
+ addition->mTransitionType = aTransitionType;
+ if (!history->EvaluateQueryForNode(mQueries, mOptions, addition))
+ return NS_OK; // don't need to include in our query
+
+ if (mOptions->ResultType() == nsNavHistoryQueryOptions::RESULTS_AS_VISIT) {
+ // If this is a visit type query, just insert the new visit. We never
+ // update visits, only add or remove them.
+ rv = InsertSortedChild(addition);
+ NS_ENSURE_SUCCESS(rv, rv);
+ } else {
+ uint16_t sortType = GetSortType();
+ bool updateSorting =
+ sortType == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_DATE_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_FRECENCY_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING;
+
+ if (!UpdateURIs(false, true, updateSorting, addition->mURI,
+ setHistoryDetailsCallback,
+ const_cast<void*>(static_cast<void*>(addition.get())))) {
+ // Couldn't find a node to update.
+ rv = InsertSortedChild(addition);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ if (aAdded)
+ ++(*aAdded);
+
+ break;
+ }
+
+ case QUERYUPDATE_COMPLEX:
+ case QUERYUPDATE_COMPLEX_WITH_BOOKMARKS:
+ // need to requery in complex cases
+ return Refresh();
+
+ default:
+ MOZ_ASSERT(false, "Invalid value for mLiveUpdate");
+ return Refresh();
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * Find every node that matches this URI and rename it. We try to do
+ * incremental updates here, even when we are closed, because changing titles
+ * is easier than requerying if we are invalid.
+ *
+ * This actually gets called a lot. Typically, we will get an AddURI message
+ * when the user visits the page, and then the title will be set asynchronously
+ * when the title element of the page is parsed.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnTitleChanged(nsIURI* aURI,
+ const nsAString& aPageTitle,
+ const nsACString& aGUID)
+{
+ if (!mExpanded) {
+ // When we are not expanded, we don't update, just invalidate and unhook.
+ // It would still be pretty easy to traverse the results and update the
+ // titles, but when a title changes, its unlikely that it will be the only
+ // thing. Therefore, we just give up.
+ ClearChildren(true);
+ return NS_OK; // no updates in tree state
+ }
+
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mBatchInProgress &&
+ ++mBatchChanges > MAX_BATCH_CHANGES_BEFORE_REFRESH) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ // compute what the new title should be
+ NS_ConvertUTF16toUTF8 newTitle(aPageTitle);
+
+ bool onlyOneEntry =
+ mOptions->ResultType() == nsINavHistoryQueryOptions::RESULTS_AS_URI ||
+ mOptions->ResultType() == nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS;
+
+ // See if our queries have any search term matching.
+ if (mHasSearchTerms) {
+ // Find all matching URI nodes.
+ nsCOMArray<nsNavHistoryResultNode> matches;
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ RecursiveFindURIs(onlyOneEntry, this, spec, &matches);
+ if (matches.Count() == 0) {
+ // This could be a new node matching the query, thus we could need
+ // to add it to the result.
+ RefPtr<nsNavHistoryResultNode> node;
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->URIToResultNode(aURI, mOptions, getter_AddRefs(node));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (history->EvaluateQueryForNode(mQueries, mOptions, node)) {
+ rv = InsertSortedChild(node);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+ for (int32_t i = 0; i < matches.Count(); ++i) {
+ // For each matched node we check if it passes the query filter, if not
+ // we remove the node from the result, otherwise we'll update the title
+ // later.
+ nsNavHistoryResultNode* node = matches[i];
+ // We must check the node with the new title.
+ node->mTitle = newTitle;
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ if (!history->EvaluateQueryForNode(mQueries, mOptions, node)) {
+ nsNavHistoryContainerResultNode* parent = node->mParent;
+ // URI nodes should always have parents
+ NS_ENSURE_TRUE(parent, NS_ERROR_UNEXPECTED);
+ int32_t childIndex = parent->FindChild(node);
+ NS_ASSERTION(childIndex >= 0, "Child not found in parent");
+ parent->RemoveChildAt(childIndex);
+ }
+ }
+ }
+
+ return ChangeTitles(aURI, newTitle, true, onlyOneEntry);
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnFrecencyChanged(nsIURI* aURI,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnManyFrecenciesChanged()
+{
+ return NS_OK;
+}
+
+
+/**
+ * Here, we can always live update by just deleting all occurrences of
+ * the given URI.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnDeleteURI(nsIURI* aURI,
+ const nsACString& aGUID,
+ uint16_t aReason)
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mBatchInProgress &&
+ ++mBatchChanges > MAX_BATCH_CHANGES_BEFORE_REFRESH) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ if (IsContainersQuery()) {
+ // Incremental updates of query returning queries are pretty much
+ // complicated. In this case it's possible one of the child queries has
+ // no more children and it should be removed. Unfortunately there is no
+ // way to know that without executing the child query and counting results.
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+ }
+
+ bool onlyOneEntry = (mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_URI ||
+ mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS);
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMArray<nsNavHistoryResultNode> matches;
+ RecursiveFindURIs(onlyOneEntry, this, spec, &matches);
+ for (int32_t i = 0; i < matches.Count(); ++i) {
+ nsNavHistoryResultNode* node = matches[i];
+ nsNavHistoryContainerResultNode* parent = node->mParent;
+ // URI nodes should always have parents
+ NS_ENSURE_TRUE(parent, NS_ERROR_UNEXPECTED);
+
+ int32_t childIndex = parent->FindChild(node);
+ NS_ASSERTION(childIndex >= 0, "Child not found in parent");
+ parent->RemoveChildAt(childIndex);
+ if (parent->mChildren.Count() == 0 && parent->IsQuery() &&
+ parent->mIndentLevel > -1) {
+ // When query subcontainers (like hosts) get empty we should remove them
+ // as well. If the parent is not the root node, append it to our list
+ // and it will get evaluated later in the loop.
+ matches.AppendObject(parent);
+ }
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnClearHistory()
+{
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ return NS_OK;
+}
+
+
+static nsresult setFaviconCallback(nsNavHistoryResultNode* aNode,
+ const void* aClosure,
+ const nsNavHistoryResult* aResult)
+{
+ const nsCString* newFavicon = static_cast<const nsCString*>(aClosure);
+ aNode->mFaviconURI = *newFavicon;
+
+ if (aResult && (!aNode->mParent || aNode->mParent->AreChildrenVisible()))
+ NOTIFY_RESULT_OBSERVERS(aResult, NodeIconChanged(aNode));
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnPageChanged(nsIURI* aURI,
+ uint32_t aChangedAttribute,
+ const nsAString& aNewValue,
+ const nsACString& aGUID)
+{
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ switch (aChangedAttribute) {
+ case nsINavHistoryObserver::ATTRIBUTE_FAVICON: {
+ NS_ConvertUTF16toUTF8 newFavicon(aNewValue);
+ bool onlyOneEntry = (mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_URI ||
+ mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS);
+ UpdateURIs(true, onlyOneEntry, false, spec, setFaviconCallback,
+ &newFavicon);
+ break;
+ }
+ default:
+ NS_WARNING("Unknown page changed notification");
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnDeleteVisits(nsIURI* aURI,
+ PRTime aVisitTime,
+ const nsACString& aGUID,
+ uint16_t aReason,
+ uint32_t aTransitionType)
+{
+ NS_PRECONDITION(mOptions->QueryType() == nsINavHistoryQueryOptions::QUERY_TYPE_HISTORY,
+ "Bookmarks queries should not get a OnDeleteVisits notification");
+ if (aVisitTime == 0) {
+ // All visits for this uri have been removed, but the uri won't be removed
+ // from the databse, most likely because it's a bookmark. For a history
+ // query this is equivalent to a onDeleteURI notification.
+ nsresult rv = OnDeleteURI(aURI, aGUID, aReason);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ if (aTransitionType > 0) {
+ // All visits for aTransitionType have been removed, if the query is
+ // filtering on such transition type, this is equivalent to an onDeleteURI
+ // notification.
+ if (mTransitions.Length() > 0 && mTransitions.Contains(aTransitionType)) {
+ nsresult rv = OnDeleteURI(aURI, aGUID, aReason);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult
+nsNavHistoryQueryResultNode::NotifyIfTagsChanged(nsIURI* aURI)
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ nsAutoCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ bool onlyOneEntry = (mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_URI ||
+ mOptions->ResultType() ==
+ nsINavHistoryQueryOptions::RESULTS_AS_TAG_CONTENTS
+ );
+
+ // Find matching URI nodes.
+ RefPtr<nsNavHistoryResultNode> node;
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+
+ nsCOMArray<nsNavHistoryResultNode> matches;
+ RecursiveFindURIs(onlyOneEntry, this, spec, &matches);
+
+ if (matches.Count() == 0 && mHasSearchTerms) {
+ // A new tag has been added, it's possible it matches our query.
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->URIToResultNode(aURI, mOptions, getter_AddRefs(node));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (history->EvaluateQueryForNode(mQueries, mOptions, node)) {
+ rv = InsertSortedChild(node);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ for (int32_t i = 0; i < matches.Count(); ++i) {
+ nsNavHistoryResultNode* node = matches[i];
+ // Force a tags update before checking the node.
+ node->mTags.SetIsVoid(true);
+ nsAutoString tags;
+ rv = node->GetTags(tags);
+ NS_ENSURE_SUCCESS(rv, rv);
+ // It's possible now this node does not respect anymore the conditions.
+ // In such a case it should be removed.
+ if (mHasSearchTerms &&
+ !history->EvaluateQueryForNode(mQueries, mOptions, node)) {
+ nsNavHistoryContainerResultNode* parent = node->mParent;
+ // URI nodes should always have parents
+ NS_ENSURE_TRUE(parent, NS_ERROR_UNEXPECTED);
+ int32_t childIndex = parent->FindChild(node);
+ NS_ASSERTION(childIndex >= 0, "Child not found in parent");
+ parent->RemoveChildAt(childIndex);
+ }
+ else {
+ NOTIFY_RESULT_OBSERVERS(result, NodeTagsChanged(node));
+ }
+ }
+
+ return NS_OK;
+}
+
+/**
+ * These are the bookmark observer functions for query nodes. They listen
+ * for bookmark events and refresh the results if we have any dependence on
+ * the bookmark system.
+ */
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnItemAdded(int64_t aItemId,
+ int64_t aParentId,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aTitle,
+ PRTime aDateAdded,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK &&
+ mLiveUpdate != QUERYUPDATE_SIMPLE && mLiveUpdate != QUERYUPDATE_TIME) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnItemRemoved(int64_t aItemId,
+ int64_t aParentId,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK &&
+ mLiveUpdate != QUERYUPDATE_SIMPLE && mLiveUpdate != QUERYUPDATE_TIME) {
+ nsresult rv = Refresh();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnItemChanged(int64_t aItemId,
+ const nsACString& aProperty,
+ bool aIsAnnotationProperty,
+ const nsACString& aNewValue,
+ PRTime aLastModified,
+ uint16_t aItemType,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ const nsACString& aOldValue,
+ uint16_t aSource)
+{
+ // History observers should not get OnItemChanged
+ // but should get the corresponding history notifications instead.
+ // For bookmark queries, "all bookmark" observers should get OnItemChanged.
+ // For example, when a title of a bookmark changes, we want that to refresh.
+
+ if (mLiveUpdate == QUERYUPDATE_COMPLEX_WITH_BOOKMARKS) {
+ switch (aItemType) {
+ case nsINavBookmarksService::TYPE_SEPARATOR:
+ // No separators in queries.
+ return NS_OK;
+ case nsINavBookmarksService::TYPE_FOLDER:
+ // Queries never result as "folders", but the tags-query results as
+ // special "tag" containers, which should follow their corresponding
+ // folders titles.
+ if (mOptions->ResultType() != nsINavHistoryQueryOptions::RESULTS_AS_TAG_QUERY)
+ return NS_OK;
+ MOZ_FALLTHROUGH;
+ default:
+ (void)Refresh();
+ }
+ }
+ else {
+ // Some node could observe both bookmarks and history. But a node observing
+ // only history should never get a bookmark notification.
+ NS_WARNING_ASSERTION(
+ mResult && (mResult->mIsAllBookmarksObserver ||
+ mResult->mIsBookmarkFolderObserver),
+ "history observers should not get OnItemChanged, but should get the "
+ "corresponding history notifications instead");
+
+ // Tags in history queries are a special case since tags are per uri and
+ // we filter tags based on searchterms.
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK &&
+ aProperty.EqualsLiteral("tags")) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = bookmarks->GetBookmarkURI(aItemId, getter_AddRefs(uri));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = NotifyIfTagsChanged(uri);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ }
+
+ return nsNavHistoryResultNode::OnItemChanged(aItemId, aProperty,
+ aIsAnnotationProperty,
+ aNewValue, aLastModified,
+ aItemType, aParentId, aGUID,
+ aParentGUID, aOldValue, aSource);
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnItemVisited(int64_t aItemId,
+ int64_t aVisitId,
+ PRTime aTime,
+ uint32_t aTransitionType,
+ nsIURI* aURI,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID)
+{
+ // for bookmark queries, "all bookmark" observer should get OnItemVisited
+ // but it is ignored.
+ if (mLiveUpdate != QUERYUPDATE_COMPLEX_WITH_BOOKMARKS)
+ NS_WARNING_ASSERTION(
+ mResult && (mResult->mIsAllBookmarksObserver ||
+ mResult->mIsBookmarkFolderObserver),
+ "history observers should not get OnItemVisited, but should get OnVisit "
+ "instead");
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryQueryResultNode::OnItemMoved(int64_t aFolder,
+ int64_t aOldParent,
+ int32_t aOldIndex,
+ int64_t aNewParent,
+ int32_t aNewIndex,
+ uint16_t aItemType,
+ const nsACString& aGUID,
+ const nsACString& aOldParentGUID,
+ const nsACString& aNewParentGUID,
+ uint16_t aSource)
+{
+ // 1. The query cannot be affected by the item's position
+ // 2. For the time being, we cannot optimize this not to update
+ // queries which are not restricted to some folders, due to way
+ // sub-queries are updated (see Refresh)
+ if (mLiveUpdate == QUERYUPDATE_COMPLEX_WITH_BOOKMARKS &&
+ aItemType != nsINavBookmarksService::TYPE_SEPARATOR &&
+ aOldParent != aNewParent) {
+ return Refresh();
+ }
+ return NS_OK;
+}
+
+/**
+ * HOW DYNAMIC FOLDER UPDATING WORKS
+ *
+ * When you create a result, it will automatically keep itself in sync with
+ * stuff that happens in the system. For folder nodes, this means changes to
+ * bookmarks.
+ *
+ * A folder will fill its children "when necessary." This means it is being
+ * opened or whether we need to see if it is empty for twisty drawing. It will
+ * then register its ID with the main result object that owns it. This result
+ * object will listen for all bookmark notifications and pass those
+ * notifications to folder nodes that have registered for that specific folder
+ * ID.
+ *
+ * When a bookmark folder is closed, it will not clear its children. Instead,
+ * it will keep them and also stay registered as a listener. This means that
+ * you can more quickly re-open the same folder without doing any work. This
+ * happens a lot for menus, and bookmarks don't change very often.
+ *
+ * When a message comes in and the folder is open, we will do the correct
+ * operations to keep ourselves in sync with the bookmark service. If the
+ * folder is closed, we just clear our list to mark it as invalid and
+ * unregister as a listener. This means we do not have to keep maintaining
+ * an up-to-date list for the entire bookmark menu structure in every place
+ * it is used.
+ */
+NS_IMPL_ISUPPORTS_INHERITED(nsNavHistoryFolderResultNode,
+ nsNavHistoryContainerResultNode,
+ nsINavHistoryQueryResultNode,
+ mozIStorageStatementCallback)
+
+nsNavHistoryFolderResultNode::nsNavHistoryFolderResultNode(
+ const nsACString& aTitle, nsNavHistoryQueryOptions* aOptions,
+ int64_t aFolderId) :
+ nsNavHistoryContainerResultNode(EmptyCString(), aTitle, EmptyCString(),
+ nsNavHistoryResultNode::RESULT_TYPE_FOLDER,
+ aOptions),
+ mContentsValid(false),
+ mTargetFolderItemId(aFolderId),
+ mIsRegisteredFolderObserver(false)
+{
+ mItemId = aFolderId;
+}
+
+nsNavHistoryFolderResultNode::~nsNavHistoryFolderResultNode()
+{
+ if (mIsRegisteredFolderObserver && mResult)
+ mResult->RemoveBookmarkFolderObserver(this, mTargetFolderItemId);
+}
+
+
+/**
+ * Here we do not want to call ContainerResultNode::OnRemoving since our own
+ * ClearChildren will do the same thing and more (unregister the observers).
+ * The base ResultNode::OnRemoving will clear some regular node stats, so it is
+ * OK.
+ */
+void
+nsNavHistoryFolderResultNode::OnRemoving()
+{
+ nsNavHistoryResultNode::OnRemoving();
+ ClearChildren(true);
+ mResult = nullptr;
+}
+
+
+nsresult
+nsNavHistoryFolderResultNode::OpenContainer()
+{
+ NS_ASSERTION(!mExpanded, "Container must be expanded to close it");
+ nsresult rv;
+
+ if (!mContentsValid) {
+ rv = FillChildren();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ mExpanded = true;
+
+ rv = NotifyOnStateChange(STATE_CLOSED);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+/**
+ * The async version of OpenContainer.
+ */
+nsresult
+nsNavHistoryFolderResultNode::OpenContainerAsync()
+{
+ NS_ASSERTION(!mExpanded, "Container already expanded when opening it");
+
+ // If the children are valid, open the container synchronously. This will be
+ // the case when the container has already been opened and any other time
+ // FillChildren or FillChildrenAsync has previously been called.
+ if (mContentsValid)
+ return OpenContainer();
+
+ nsresult rv = FillChildrenAsync();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = NotifyOnStateChange(STATE_CLOSED);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
+/**
+ * @see nsNavHistoryQueryResultNode::HasChildren. The semantics here are a
+ * little different. Querying the contents of a bookmark folder is relatively
+ * fast and it is common to have empty folders. Therefore, we always want to
+ * return the correct result so that twisties are drawn properly.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetHasChildren(bool* aHasChildren)
+{
+ if (!mContentsValid) {
+ nsresult rv = FillChildren();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ *aHasChildren = (mChildren.Count() > 0);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetFolderItemId(int64_t* aItemId)
+{
+ *aItemId = mTargetFolderItemId;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetTargetFolderGuid(nsACString& aGuid) {
+ aGuid = mTargetFolderGuid;
+ return NS_OK;
+}
+
+/**
+ * Lazily computes the URI for this specific folder query with the current
+ * options.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetUri(nsACString& aURI)
+{
+ if (!mURI.IsEmpty()) {
+ aURI = mURI;
+ return NS_OK;
+ }
+
+ uint32_t queryCount;
+ nsINavHistoryQuery** queries;
+ nsresult rv = GetQueries(&queryCount, &queries);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+
+ rv = history->QueriesToQueryString(queries, queryCount, mOptions, aURI);
+ for (uint32_t queryIndex = 0; queryIndex < queryCount; ++queryIndex) {
+ NS_RELEASE(queries[queryIndex]);
+ }
+ free(queries);
+ return rv;
+}
+
+
+/**
+ * @return the queries that give you this bookmarks folder
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetQueries(uint32_t* queryCount,
+ nsINavHistoryQuery*** queries)
+{
+ // get the query object
+ nsCOMPtr<nsINavHistoryQuery> query;
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ nsresult rv = history->GetNewQuery(getter_AddRefs(query));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // query just has the folder ID set and nothing else
+ rv = query->SetFolders(&mTargetFolderItemId, 1);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // make array of our 1 query
+ *queries = static_cast<nsINavHistoryQuery**>
+ (moz_xmalloc(sizeof(nsINavHistoryQuery*)));
+ if (!*queries)
+ return NS_ERROR_OUT_OF_MEMORY;
+ (*queries)[0] = query.forget().take();
+ *queryCount = 1;
+ return NS_OK;
+}
+
+
+/**
+ * Options for the query that gives you this bookmarks folder. This is just
+ * the options for the folder with the current folder ID set.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetQueryOptions(
+ nsINavHistoryQueryOptions** aQueryOptions)
+{
+ NS_ASSERTION(mOptions, "Options invalid");
+
+ *aQueryOptions = mOptions;
+ NS_ADDREF(*aQueryOptions);
+ return NS_OK;
+}
+
+
+nsresult
+nsNavHistoryFolderResultNode::FillChildren()
+{
+ NS_ASSERTION(!mContentsValid,
+ "Don't call FillChildren when contents are valid");
+ NS_ASSERTION(mChildren.Count() == 0,
+ "We are trying to fill children when there already are some");
+
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+
+ // Actually get the folder children from the bookmark service.
+ nsresult rv = bookmarks->QueryFolderChildren(mTargetFolderItemId, mOptions, &mChildren);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // PERFORMANCE: it may be better to also fill any child folders at this point
+ // so that we can draw tree twisties without doing a separate query later.
+ // If we don't end up drawing twisties a lot, it doesn't matter. If we do
+ // this, we should wrap everything in a transaction here on the bookmark
+ // service's connection.
+
+ return OnChildrenFilled();
+}
+
+
+/**
+ * Performs some tasks after all the children of the container have been added.
+ * The container's contents are not valid until this method has been called.
+ */
+nsresult
+nsNavHistoryFolderResultNode::OnChildrenFilled()
+{
+ // It is important to call FillStats to fill in the parents on all
+ // nodes and the result node pointers on the containers.
+ FillStats();
+
+ if (mResult && mResult->mNeedsToApplySortingMode) {
+ // We should repopulate container and then apply sortingMode. To avoid
+ // sorting 2 times we simply do that here.
+ mResult->SetSortingMode(mResult->mSortingMode);
+ }
+ else {
+ // Once we've computed all tree stats, we can sort, because containers will
+ // then have proper visit counts and dates.
+ SortComparator comparator = GetSortingComparator(GetSortType());
+ if (comparator) {
+ nsAutoCString sortingAnnotation;
+ GetSortingAnnotation(sortingAnnotation);
+ RecursiveSort(sortingAnnotation.get(), comparator);
+ }
+ }
+
+ // If we are limiting our results remove items from the end of the
+ // mChildren array after sorting. This is done for root node only.
+ // Note, if count < max results, we won't do anything.
+ if (!mParent && mOptions->MaxResults()) {
+ while ((uint32_t)mChildren.Count() > mOptions->MaxResults())
+ mChildren.RemoveObjectAt(mChildren.Count() - 1);
+ }
+
+ // Register with the result for updates.
+ EnsureRegisteredAsFolderObserver();
+
+ mContentsValid = true;
+ return NS_OK;
+}
+
+
+/**
+ * Registers the node with its result as a folder observer if it is not already
+ * registered.
+ */
+void
+nsNavHistoryFolderResultNode::EnsureRegisteredAsFolderObserver()
+{
+ if (!mIsRegisteredFolderObserver && mResult) {
+ mResult->AddBookmarkFolderObserver(this, mTargetFolderItemId);
+ mIsRegisteredFolderObserver = true;
+ }
+}
+
+
+/**
+ * The async version of FillChildren. This begins asynchronous execution by
+ * calling nsNavBookmarks::QueryFolderChildrenAsync. During execution, this
+ * node's async Storage callbacks, HandleResult and HandleCompletion, will be
+ * called.
+ */
+nsresult
+nsNavHistoryFolderResultNode::FillChildrenAsync()
+{
+ NS_ASSERTION(!mContentsValid, "FillChildrenAsync when contents are valid");
+ NS_ASSERTION(mChildren.Count() == 0, "FillChildrenAsync when children exist");
+
+ // ProcessFolderNodeChild, called in HandleResult, increments this for every
+ // result row it processes. Initialize it here as we begin async execution.
+ mAsyncBookmarkIndex = -1;
+
+ nsNavBookmarks* bmSvc = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bmSvc, NS_ERROR_OUT_OF_MEMORY);
+ nsresult rv =
+ bmSvc->QueryFolderChildrenAsync(this, mTargetFolderItemId,
+ getter_AddRefs(mAsyncPendingStmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Register with the result for updates. All updates during async execution
+ // will cause it to be restarted.
+ EnsureRegisteredAsFolderObserver();
+
+ return NS_OK;
+}
+
+
+/**
+ * A mozIStorageStatementCallback method. Called during the async execution
+ * begun by FillChildrenAsync.
+ *
+ * @param aResultSet
+ * The result set containing the data from the database.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::HandleResult(mozIStorageResultSet* aResultSet)
+{
+ NS_ENSURE_ARG_POINTER(aResultSet);
+
+ nsNavBookmarks* bmSvc = nsNavBookmarks::GetBookmarksService();
+ if (!bmSvc) {
+ CancelAsyncOpen(false);
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ // Consume all the currently available rows of the result set.
+ nsCOMPtr<mozIStorageRow> row;
+ while (NS_SUCCEEDED(aResultSet->GetNextRow(getter_AddRefs(row))) && row) {
+ nsresult rv = bmSvc->ProcessFolderNodeRow(row, mOptions, &mChildren,
+ mAsyncBookmarkIndex);
+ if (NS_FAILED(rv)) {
+ CancelAsyncOpen(false);
+ return rv;
+ }
+ }
+
+ return NS_OK;
+}
+
+
+/**
+ * A mozIStorageStatementCallback method. Called during the async execution
+ * begun by FillChildrenAsync.
+ *
+ * @param aReason
+ * Indicates the final state of execution.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::HandleCompletion(uint16_t aReason)
+{
+ if (aReason == mozIStorageStatementCallback::REASON_FINISHED &&
+ mAsyncCanceledState == NOT_CANCELED) {
+ // Async execution successfully completed. The container is ready to open.
+
+ nsresult rv = OnChildrenFilled();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mExpanded = true;
+ mAsyncPendingStmt = nullptr;
+
+ // Notify observers only after mExpanded and mAsyncPendingStmt are set.
+ rv = NotifyOnStateChange(STATE_LOADING);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ else if (mAsyncCanceledState == CANCELED_RESTART_NEEDED) {
+ // Async execution was canceled and needs to be restarted.
+ mAsyncCanceledState = NOT_CANCELED;
+ ClearChildren(false);
+ FillChildrenAsync();
+ }
+
+ else {
+ // Async execution failed or was canceled without restart. Remove all
+ // children and close the container, notifying observers.
+ mAsyncCanceledState = NOT_CANCELED;
+ ClearChildren(true);
+ CloseContainer();
+ }
+
+ return NS_OK;
+}
+
+
+void
+nsNavHistoryFolderResultNode::ClearChildren(bool unregister)
+{
+ for (int32_t i = 0; i < mChildren.Count(); ++i)
+ mChildren[i]->OnRemoving();
+ mChildren.Clear();
+
+ bool needsUnregister = unregister && (mContentsValid || mAsyncPendingStmt);
+ if (needsUnregister && mResult && mIsRegisteredFolderObserver) {
+ mResult->RemoveBookmarkFolderObserver(this, mTargetFolderItemId);
+ mIsRegisteredFolderObserver = false;
+ }
+ mContentsValid = false;
+}
+
+
+/**
+ * This is called to update the result when something has changed that we
+ * can not incrementally update.
+ */
+nsresult
+nsNavHistoryFolderResultNode::Refresh()
+{
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+ if (result->mBatchInProgress) {
+ result->requestRefresh(this);
+ return NS_OK;
+ }
+
+ ClearChildren(true);
+
+ if (!mExpanded) {
+ // When we are not expanded, we don't update, just invalidate and unhook.
+ return NS_OK;
+ }
+
+ // Ignore errors from FillChildren, since we will still want to refresh
+ // the tree (there just might not be anything in it on error). ClearChildren
+ // has unregistered us as an observer since FillChildren will try to
+ // re-register us.
+ (void)FillChildren();
+
+ NOTIFY_RESULT_OBSERVERS(result, InvalidateContainer(TO_CONTAINER(this)));
+ return NS_OK;
+}
+
+
+/**
+ * Implements the logic described above the constructor. This sees if we
+ * should do an incremental update and returns true if so. If not, it
+ * invalidates our children, unregisters us an observer, and returns false.
+ */
+bool
+nsNavHistoryFolderResultNode::StartIncrementalUpdate()
+{
+ // if any items are excluded, we can not do incremental updates since the
+ // indices from the bookmark service will not be valid
+
+ if (!mOptions->ExcludeItems() &&
+ !mOptions->ExcludeQueries() &&
+ !mOptions->ExcludeReadOnlyFolders()) {
+ // easy case: we are visible, always do incremental update
+ if (mExpanded || AreChildrenVisible())
+ return true;
+
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_TRUE(result, false);
+
+ // When any observers are attached also do incremental updates if our
+ // parent is visible, so that twisties are drawn correctly.
+ if (mParent)
+ return result->mObservers.Length() > 0;
+ }
+
+ // otherwise, we don't do incremental updates, invalidate and unregister
+ (void)Refresh();
+ return false;
+}
+
+
+/**
+ * This function adds aDelta to all bookmark indices between the two endpoints,
+ * inclusive. It is used when items are added or removed from the bookmark
+ * folder.
+ */
+void
+nsNavHistoryFolderResultNode::ReindexRange(int32_t aStartIndex,
+ int32_t aEndIndex,
+ int32_t aDelta)
+{
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ nsNavHistoryResultNode* node = mChildren[i];
+ if (node->mBookmarkIndex >= aStartIndex &&
+ node->mBookmarkIndex <= aEndIndex)
+ node->mBookmarkIndex += aDelta;
+ }
+}
+
+
+/**
+ * Searches this folder for a node with the given id/target-folder-id.
+ *
+ * @return the node if found, null otherwise.
+ * @note Does not addref the node!
+ */
+nsNavHistoryResultNode*
+nsNavHistoryFolderResultNode::FindChildById(int64_t aItemId,
+ uint32_t* aNodeIndex)
+{
+ for (int32_t i = 0; i < mChildren.Count(); ++i) {
+ if (mChildren[i]->mItemId == aItemId ||
+ (mChildren[i]->IsFolder() &&
+ mChildren[i]->GetAsFolder()->mTargetFolderItemId == aItemId)) {
+ *aNodeIndex = i;
+ return mChildren[i];
+ }
+ }
+ return nullptr;
+}
+
+
+// Used by nsNavHistoryFolderResultNode's nsINavBookmarkObserver methods below.
+// If the container is notified of a bookmark event while asynchronous execution
+// is pending, this restarts it and returns.
+#define RESTART_AND_RETURN_IF_ASYNC_PENDING() \
+ if (mAsyncPendingStmt) { \
+ CancelAsyncOpen(true); \
+ return NS_OK; \
+ }
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetSkipTags(bool *aSkipTags)
+{
+ *aSkipTags = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::GetSkipDescendantsOnItemRemoval(bool *aSkipDescendantsOnItemRemoval)
+{
+ *aSkipDescendantsOnItemRemoval = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnBeginUpdateBatch()
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnEndUpdateBatch()
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnItemAdded(int64_t aItemId,
+ int64_t aParentFolder,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aTitle,
+ PRTime aDateAdded,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ MOZ_ASSERT(aParentFolder == mTargetFolderItemId, "Got wrong bookmark update");
+
+ RESTART_AND_RETURN_IF_ASYNC_PENDING();
+
+ {
+ uint32_t index;
+ nsNavHistoryResultNode* node = FindChildById(aItemId, &index);
+ // Bug 1097528.
+ // It's possible our result registered due to a previous notification, for
+ // example the Library left pane could have refreshed and replaced the
+ // right pane as a consequence. In such a case our contents are already
+ // up-to-date. That's OK.
+ if (node)
+ return NS_OK;
+ }
+
+ bool excludeItems = (mResult && mResult->mRootNode->mOptions->ExcludeItems()) ||
+ (mParent && mParent->mOptions->ExcludeItems()) ||
+ mOptions->ExcludeItems();
+
+ // here, try to do something reasonable if the bookmark service gives us
+ // a bogus index.
+ if (aIndex < 0) {
+ NS_NOTREACHED("Invalid index for item adding: <0");
+ aIndex = 0;
+ }
+ else if (aIndex > mChildren.Count()) {
+ if (!excludeItems) {
+ // Something wrong happened while updating indexes.
+ NS_NOTREACHED("Invalid index for item adding: greater than count");
+ }
+ aIndex = mChildren.Count();
+ }
+
+ nsresult rv;
+
+ // Check for query URIs, which are bookmarks, but treated as containers
+ // in results and views.
+ bool isQuery = false;
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK) {
+ NS_ASSERTION(aURI, "Got a null URI when we are a bookmark?!");
+ nsAutoCString itemURISpec;
+ rv = aURI->GetSpec(itemURISpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+ isQuery = IsQueryURI(itemURISpec);
+ }
+
+ if (aItemType != nsINavBookmarksService::TYPE_FOLDER &&
+ !isQuery && excludeItems) {
+ // don't update items when we aren't displaying them, but we still need
+ // to adjust bookmark indices to account for the insertion
+ ReindexRange(aIndex, INT32_MAX, 1);
+ return NS_OK;
+ }
+
+ if (!StartIncrementalUpdate())
+ return NS_OK; // folder was completely refreshed for us
+
+ // adjust indices to account for insertion
+ ReindexRange(aIndex, INT32_MAX, 1);
+
+ RefPtr<nsNavHistoryResultNode> node;
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ rv = history->BookmarkIdToResultNode(aItemId, mOptions, getter_AddRefs(node));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else if (aItemType == nsINavBookmarksService::TYPE_FOLDER) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ rv = bookmarks->ResultNodeForContainer(aItemId, mOptions, getter_AddRefs(node));
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ else if (aItemType == nsINavBookmarksService::TYPE_SEPARATOR) {
+ node = new nsNavHistorySeparatorResultNode();
+ NS_ENSURE_TRUE(node, NS_ERROR_OUT_OF_MEMORY);
+ node->mItemId = aItemId;
+ node->mBookmarkGuid = aGUID;
+ node->mDateAdded = aDateAdded;
+ node->mLastModified = aDateAdded;
+ }
+
+ node->mBookmarkIndex = aIndex;
+
+ if (aItemType == nsINavBookmarksService::TYPE_SEPARATOR ||
+ GetSortType() == nsINavHistoryQueryOptions::SORT_BY_NONE) {
+ // insert at natural bookmarks position
+ return InsertChildAt(node, aIndex);
+ }
+
+ // insert at sorted position
+ return InsertSortedChild(node);
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnItemRemoved(int64_t aItemId,
+ int64_t aParentFolder,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ // Folder shortcuts should not be notified removal of the target folder.
+ MOZ_ASSERT_IF(mItemId != mTargetFolderItemId, aItemId != mTargetFolderItemId);
+ // Concrete folders should not be notified their own removal.
+ // Note aItemId may equal mItemId for recursive folder shortcuts.
+ MOZ_ASSERT_IF(mItemId == mTargetFolderItemId, aItemId != mItemId);
+
+ // In any case though, here we only care about the children removal.
+ if (mTargetFolderItemId == aItemId || mItemId == aItemId)
+ return NS_OK;
+
+ MOZ_ASSERT(aParentFolder == mTargetFolderItemId, "Got wrong bookmark update");
+
+ RESTART_AND_RETURN_IF_ASYNC_PENDING();
+
+ // don't trust the index from the bookmark service, find it ourselves. The
+ // sorting could be different, or the bookmark services indices and ours might
+ // be out of sync somehow.
+ uint32_t index;
+ nsNavHistoryResultNode* node = FindChildById(aItemId, &index);
+ // Bug 1097528.
+ // It's possible our result registered due to a previous notification, for
+ // example the Library left pane could have refreshed and replaced the
+ // right pane as a consequence. In such a case our contents are already
+ // up-to-date. That's OK.
+ if (!node) {
+ return NS_OK;
+ }
+
+ bool excludeItems = (mResult && mResult->mRootNode->mOptions->ExcludeItems()) ||
+ (mParent && mParent->mOptions->ExcludeItems()) ||
+ mOptions->ExcludeItems();
+ if ((node->IsURI() || node->IsSeparator()) && excludeItems) {
+ // don't update items when we aren't displaying them, but we do need to
+ // adjust everybody's bookmark indices to account for the removal
+ ReindexRange(aIndex, INT32_MAX, -1);
+ return NS_OK;
+ }
+
+ if (!StartIncrementalUpdate())
+ return NS_OK; // we are completely refreshed
+
+ // shift all following indices down
+ ReindexRange(aIndex + 1, INT32_MAX, -1);
+
+ return RemoveChildAt(index);
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResultNode::OnItemChanged(int64_t aItemId,
+ const nsACString& aProperty,
+ bool aIsAnnotationProperty,
+ const nsACString& aNewValue,
+ PRTime aLastModified,
+ uint16_t aItemType,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ const nsACString& aOldValue,
+ uint16_t aSource)
+{
+ if (aItemId != mItemId)
+ return NS_OK;
+
+ mLastModified = aLastModified;
+
+ nsNavHistoryResult* result = GetResult();
+ NS_ENSURE_STATE(result);
+
+ bool shouldNotify = !mParent || mParent->AreChildrenVisible();
+
+ if (aIsAnnotationProperty) {
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeAnnotationChanged(this, aProperty));
+ }
+ else if (aProperty.EqualsLiteral("title")) {
+ // XXX: what should we do if the new title is void?
+ mTitle = aNewValue;
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeTitleChanged(this, mTitle));
+ }
+ else if (aProperty.EqualsLiteral("uri")) {
+ // clear the tags string as well
+ mTags.SetIsVoid(true);
+ mURI = aNewValue;
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeURIChanged(this, mURI));
+ }
+ else if (aProperty.EqualsLiteral("favicon")) {
+ mFaviconURI = aNewValue;
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeIconChanged(this));
+ }
+ else if (aProperty.EqualsLiteral("cleartime")) {
+ mTime = 0;
+ if (shouldNotify) {
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeHistoryDetailsChanged(this, 0, mAccessCount));
+ }
+ }
+ else if (aProperty.EqualsLiteral("tags")) {
+ mTags.SetIsVoid(true);
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeTagsChanged(this));
+ }
+ else if (aProperty.EqualsLiteral("dateAdded")) {
+ // aNewValue has the date as a string, but we can use aLastModified,
+ // because it's set to the same value when dateAdded is changed.
+ mDateAdded = aLastModified;
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeDateAddedChanged(this, mDateAdded));
+ }
+ else if (aProperty.EqualsLiteral("lastModified")) {
+ if (shouldNotify) {
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeLastModifiedChanged(this, aLastModified));
+ }
+ }
+ else if (aProperty.EqualsLiteral("keyword")) {
+ if (shouldNotify)
+ NOTIFY_RESULT_OBSERVERS(result, NodeKeywordChanged(this, aNewValue));
+ }
+ else
+ NS_NOTREACHED("Unknown bookmark property changing.");
+
+ if (!mParent)
+ return NS_OK;
+
+ // DO NOT OPTIMIZE THIS TO CHECK aProperty
+ // The sorting methods fall back to each other so we need to re-sort the
+ // result even if it's not set to sort by the given property.
+ int32_t ourIndex = mParent->FindChild(this);
+ NS_ASSERTION(ourIndex >= 0, "Could not find self in parent");
+ if (ourIndex >= 0)
+ mParent->EnsureItemPosition(ourIndex);
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnItemChanged(int64_t aItemId,
+ const nsACString& aProperty,
+ bool aIsAnnotationProperty,
+ const nsACString& aNewValue,
+ PRTime aLastModified,
+ uint16_t aItemType,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ const nsACString& aOldValue,
+ uint16_t aSource)
+{
+ RESTART_AND_RETURN_IF_ASYNC_PENDING();
+
+ return nsNavHistoryResultNode::OnItemChanged(aItemId, aProperty,
+ aIsAnnotationProperty,
+ aNewValue, aLastModified,
+ aItemType, aParentId, aGUID,
+ aParentGUID, aOldValue, aSource);
+}
+
+/**
+ * Updates visit count and last visit time and refreshes.
+ */
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnItemVisited(int64_t aItemId,
+ int64_t aVisitId,
+ PRTime aTime,
+ uint32_t aTransitionType,
+ nsIURI* aURI,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID)
+{
+ bool excludeItems = (mResult && mResult->mRootNode->mOptions->ExcludeItems()) ||
+ (mParent && mParent->mOptions->ExcludeItems()) ||
+ mOptions->ExcludeItems();
+ if (excludeItems)
+ return NS_OK; // don't update items when we aren't displaying them
+
+ RESTART_AND_RETURN_IF_ASYNC_PENDING();
+
+ if (!StartIncrementalUpdate())
+ return NS_OK;
+
+ uint32_t nodeIndex;
+ nsNavHistoryResultNode* node = FindChildById(aItemId, &nodeIndex);
+ if (!node)
+ return NS_ERROR_FAILURE;
+
+ // Update node.
+ node->mTime = aTime;
+ ++node->mAccessCount;
+
+ // Update us.
+ int32_t oldAccessCount = mAccessCount;
+ ++mAccessCount;
+ if (aTime > mTime)
+ mTime = aTime;
+ nsresult rv = ReverseUpdateStats(mAccessCount - oldAccessCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Update frecency for proper frecency ordering.
+ // TODO (bug 832617): we may avoid one query here, by providing the new
+ // frecency value in the notification.
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_OK);
+ RefPtr<nsNavHistoryResultNode> visitNode;
+ rv = history->VisitIdToResultNode(aVisitId, mOptions,
+ getter_AddRefs(visitNode));
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_STATE(visitNode);
+ node->mFrecency = visitNode->mFrecency;
+
+ if (AreChildrenVisible()) {
+ // Sorting has not changed, just redraw the row if it's visible.
+ nsNavHistoryResult* result = GetResult();
+ NOTIFY_RESULT_OBSERVERS(result,
+ NodeHistoryDetailsChanged(node, mTime, mAccessCount));
+ }
+
+ // Update sorting if necessary.
+ uint32_t sortType = GetSortType();
+ if (sortType == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_VISITCOUNT_DESCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_DATE_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_DATE_DESCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_FRECENCY_ASCENDING ||
+ sortType == nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING) {
+ int32_t childIndex = FindChild(node);
+ NS_ASSERTION(childIndex >= 0, "Could not find child we just got a reference to");
+ if (childIndex >= 0) {
+ EnsureItemPosition(childIndex);
+ }
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryFolderResultNode::OnItemMoved(int64_t aItemId,
+ int64_t aOldParent,
+ int32_t aOldIndex,
+ int64_t aNewParent,
+ int32_t aNewIndex,
+ uint16_t aItemType,
+ const nsACString& aGUID,
+ const nsACString& aOldParentGUID,
+ const nsACString& aNewParentGUID,
+ uint16_t aSource)
+{
+ NS_ASSERTION(aOldParent == mTargetFolderItemId || aNewParent == mTargetFolderItemId,
+ "Got a bookmark message that doesn't belong to us");
+
+ RESTART_AND_RETURN_IF_ASYNC_PENDING();
+
+ uint32_t index;
+ nsNavHistoryResultNode* node = FindChildById(aItemId, &index);
+ // Bug 1097528.
+ // It's possible our result registered due to a previous notification, for
+ // example the Library left pane could have refreshed and replaced the
+ // right pane as a consequence. In such a case our contents are already
+ // up-to-date. That's OK.
+ if (node && aNewParent == mTargetFolderItemId && index == static_cast<uint32_t>(aNewIndex))
+ return NS_OK;
+ if (!node && aOldParent == mTargetFolderItemId)
+ return NS_OK;
+
+ bool excludeItems = (mResult && mResult->mRootNode->mOptions->ExcludeItems()) ||
+ (mParent && mParent->mOptions->ExcludeItems()) ||
+ mOptions->ExcludeItems();
+ if (node && excludeItems && (node->IsURI() || node->IsSeparator())) {
+ // Don't update items when we aren't displaying them.
+ return NS_OK;
+ }
+
+ if (!StartIncrementalUpdate())
+ return NS_OK; // entire container was refreshed for us
+
+ if (aOldParent == aNewParent) {
+ // getting moved within the same folder, we don't want to do a remove and
+ // an add because that will lose your tree state.
+
+ // adjust bookmark indices
+ ReindexRange(aOldIndex + 1, INT32_MAX, -1);
+ ReindexRange(aNewIndex, INT32_MAX, 1);
+
+ MOZ_ASSERT(node, "Can't find folder that is moving!");
+ if (!node) {
+ return NS_ERROR_FAILURE;
+ }
+ MOZ_ASSERT(index < uint32_t(mChildren.Count()), "Invalid index!");
+ node->mBookmarkIndex = aNewIndex;
+
+ // adjust position
+ EnsureItemPosition(index);
+ return NS_OK;
+ } else {
+ // moving between two different folders, just do a remove and an add
+ nsCOMPtr<nsIURI> itemURI;
+ nsAutoCString itemTitle;
+ if (aItemType == nsINavBookmarksService::TYPE_BOOKMARK) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ NS_ENSURE_TRUE(bookmarks, NS_ERROR_OUT_OF_MEMORY);
+ nsresult rv = bookmarks->GetBookmarkURI(aItemId, getter_AddRefs(itemURI));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = bookmarks->GetItemTitle(aItemId, itemTitle);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+ if (aOldParent == mTargetFolderItemId) {
+ OnItemRemoved(aItemId, aOldParent, aOldIndex, aItemType, itemURI,
+ aGUID, aOldParentGUID, aSource);
+ }
+ if (aNewParent == mTargetFolderItemId) {
+ OnItemAdded(aItemId, aNewParent, aNewIndex, aItemType, itemURI, itemTitle,
+ RoundedPRNow(), // This is a dummy dateAdded, not the real value.
+ aGUID, aNewParentGUID, aSource);
+ }
+ }
+ return NS_OK;
+}
+
+
+/**
+ * Separator nodes do not hold any data.
+ */
+nsNavHistorySeparatorResultNode::nsNavHistorySeparatorResultNode()
+ : nsNavHistoryResultNode(EmptyCString(), EmptyCString(),
+ 0, 0, EmptyCString())
+{
+}
+
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(nsNavHistoryResult)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(nsNavHistoryResult)
+ tmp->StopObserving();
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mRootNode)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mObservers)
+ for (auto it = tmp->mBookmarkFolderObservers.Iter(); !it.Done(); it.Next()) {
+ delete it.Data();
+ it.Remove();
+ }
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mAllBookmarksObservers)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mHistoryObservers)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(nsNavHistoryResult)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mRootNode)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mObservers)
+ for (auto it = tmp->mBookmarkFolderObservers.Iter(); !it.Done(); it.Next()) {
+ nsNavHistoryResult::FolderObserverList*& list = it.Data();
+ for (uint32_t i = 0; i < list->Length(); ++i) {
+ NS_CYCLE_COLLECTION_NOTE_EDGE_NAME(cb,
+ "mBookmarkFolderObservers value[i]");
+ nsNavHistoryResultNode* node = list->ElementAt(i);
+ cb.NoteXPCOMChild(node);
+ }
+ }
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mAllBookmarksObservers)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mHistoryObservers)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(nsNavHistoryResult)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(nsNavHistoryResult)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(nsNavHistoryResult)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsINavHistoryResult)
+ NS_INTERFACE_MAP_STATIC_AMBIGUOUS(nsNavHistoryResult)
+ NS_INTERFACE_MAP_ENTRY(nsINavHistoryResult)
+ NS_INTERFACE_MAP_ENTRY(nsINavBookmarkObserver)
+ NS_INTERFACE_MAP_ENTRY(nsINavHistoryObserver)
+ NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference)
+NS_INTERFACE_MAP_END
+
+nsNavHistoryResult::nsNavHistoryResult(nsNavHistoryContainerResultNode* aRoot)
+ : mRootNode(aRoot)
+ , mNeedsToApplySortingMode(false)
+ , mIsHistoryObserver(false)
+ , mIsBookmarkFolderObserver(false)
+ , mIsAllBookmarksObserver(false)
+ , mBookmarkFolderObservers(64)
+ , mBatchInProgress(false)
+ , mSuppressNotifications(false)
+{
+ mRootNode->mResult = this;
+}
+
+nsNavHistoryResult::~nsNavHistoryResult()
+{
+ // Delete all heap-allocated bookmark folder observer arrays.
+ for (auto it = mBookmarkFolderObservers.Iter(); !it.Done(); it.Next()) {
+ delete it.Data();
+ it.Remove();
+ }
+}
+
+void
+nsNavHistoryResult::StopObserving()
+{
+ if (mIsBookmarkFolderObserver || mIsAllBookmarksObserver) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ if (bookmarks) {
+ bookmarks->RemoveObserver(this);
+ mIsBookmarkFolderObserver = false;
+ mIsAllBookmarksObserver = false;
+ }
+ }
+ if (mIsHistoryObserver) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ if (history) {
+ history->RemoveObserver(this);
+ mIsHistoryObserver = false;
+ }
+ }
+}
+
+/**
+ * @note you must call AddRef before this, since we may do things like
+ * register ourselves.
+ */
+nsresult
+nsNavHistoryResult::Init(nsINavHistoryQuery** aQueries,
+ uint32_t aQueryCount,
+ nsNavHistoryQueryOptions *aOptions)
+{
+ nsresult rv;
+ NS_ASSERTION(aOptions, "Must have valid options");
+ NS_ASSERTION(aQueries && aQueryCount > 0, "Must have >1 query in result");
+
+ // Fill saved source queries with copies of the original (the caller might
+ // change their original objects, and we always want to reflect the source
+ // parameters).
+ for (uint32_t i = 0; i < aQueryCount; ++i) {
+ nsCOMPtr<nsINavHistoryQuery> queryClone;
+ rv = aQueries[i]->Clone(getter_AddRefs(queryClone));
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (!mQueries.AppendObject(queryClone))
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ rv = aOptions->Clone(getter_AddRefs(mOptions));
+ NS_ENSURE_SUCCESS(rv, rv);
+ mSortingMode = aOptions->SortingMode();
+ rv = aOptions->GetSortingAnnotation(mSortingAnnotation);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ NS_ASSERTION(mRootNode->mIndentLevel == -1,
+ "Root node's indent level initialized wrong");
+ mRootNode->FillStats();
+
+ return NS_OK;
+}
+
+
+/**
+ * Constructs a new history result object.
+ */
+nsresult // static
+nsNavHistoryResult::NewHistoryResult(nsINavHistoryQuery** aQueries,
+ uint32_t aQueryCount,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryContainerResultNode* aRoot,
+ bool aBatchInProgress,
+ nsNavHistoryResult** result)
+{
+ *result = new nsNavHistoryResult(aRoot);
+ if (!*result)
+ return NS_ERROR_OUT_OF_MEMORY;
+ NS_ADDREF(*result); // must happen before Init
+ // Correctly set mBatchInProgress for the result based on the root node value.
+ (*result)->mBatchInProgress = aBatchInProgress;
+ nsresult rv = (*result)->Init(aQueries, aQueryCount, aOptions);
+ if (NS_FAILED(rv)) {
+ NS_RELEASE(*result);
+ *result = nullptr;
+ return rv;
+ }
+
+ return NS_OK;
+}
+
+
+void
+nsNavHistoryResult::AddHistoryObserver(nsNavHistoryQueryResultNode* aNode)
+{
+ if (!mIsHistoryObserver) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ASSERTION(history, "Can't create history service");
+ history->AddObserver(this, true);
+ mIsHistoryObserver = true;
+ }
+ // Don't add duplicate observers. In some case we don't unregister when
+ // children are cleared (see ClearChildren) and the next FillChildren call
+ // will try to add the observer again.
+ if (mHistoryObservers.IndexOf(aNode) == mHistoryObservers.NoIndex) {
+ mHistoryObservers.AppendElement(aNode);
+ }
+}
+
+
+void
+nsNavHistoryResult::AddAllBookmarksObserver(nsNavHistoryQueryResultNode* aNode)
+{
+ if (!mIsAllBookmarksObserver && !mIsBookmarkFolderObserver) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ if (!bookmarks) {
+ NS_NOTREACHED("Can't create bookmark service");
+ return;
+ }
+ bookmarks->AddObserver(this, true);
+ mIsAllBookmarksObserver = true;
+ }
+ // Don't add duplicate observers. In some case we don't unregister when
+ // children are cleared (see ClearChildren) and the next FillChildren call
+ // will try to add the observer again.
+ if (mAllBookmarksObservers.IndexOf(aNode) == mAllBookmarksObservers.NoIndex) {
+ mAllBookmarksObservers.AppendElement(aNode);
+ }
+}
+
+
+void
+nsNavHistoryResult::AddBookmarkFolderObserver(nsNavHistoryFolderResultNode* aNode,
+ int64_t aFolder)
+{
+ if (!mIsBookmarkFolderObserver && !mIsAllBookmarksObserver) {
+ nsNavBookmarks* bookmarks = nsNavBookmarks::GetBookmarksService();
+ if (!bookmarks) {
+ NS_NOTREACHED("Can't create bookmark service");
+ return;
+ }
+ bookmarks->AddObserver(this, true);
+ mIsBookmarkFolderObserver = true;
+ }
+ // Don't add duplicate observers. In some case we don't unregister when
+ // children are cleared (see ClearChildren) and the next FillChildren call
+ // will try to add the observer again.
+ FolderObserverList* list = BookmarkFolderObserversForId(aFolder, true);
+ if (list->IndexOf(aNode) == list->NoIndex) {
+ list->AppendElement(aNode);
+ }
+}
+
+
+void
+nsNavHistoryResult::RemoveHistoryObserver(nsNavHistoryQueryResultNode* aNode)
+{
+ mHistoryObservers.RemoveElement(aNode);
+}
+
+
+void
+nsNavHistoryResult::RemoveAllBookmarksObserver(nsNavHistoryQueryResultNode* aNode)
+{
+ mAllBookmarksObservers.RemoveElement(aNode);
+}
+
+
+void
+nsNavHistoryResult::RemoveBookmarkFolderObserver(nsNavHistoryFolderResultNode* aNode,
+ int64_t aFolder)
+{
+ FolderObserverList* list = BookmarkFolderObserversForId(aFolder, false);
+ if (!list)
+ return; // we don't even have an entry for that folder
+ list->RemoveElement(aNode);
+}
+
+
+nsNavHistoryResult::FolderObserverList*
+nsNavHistoryResult::BookmarkFolderObserversForId(int64_t aFolderId, bool aCreate)
+{
+ FolderObserverList* list;
+ if (mBookmarkFolderObservers.Get(aFolderId, &list))
+ return list;
+ if (!aCreate)
+ return nullptr;
+
+ // need to create a new list
+ list = new FolderObserverList;
+ mBookmarkFolderObservers.Put(aFolderId, list);
+ return list;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetSortingMode(uint16_t* aSortingMode)
+{
+ *aSortingMode = mSortingMode;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::SetSortingMode(uint16_t aSortingMode)
+{
+ NS_ENSURE_STATE(mRootNode);
+
+ if (aSortingMode > nsINavHistoryQueryOptions::SORT_BY_FRECENCY_DESCENDING)
+ return NS_ERROR_INVALID_ARG;
+
+ // Keep everything in sync.
+ NS_ASSERTION(mOptions, "Options should always be present for a root query");
+
+ mSortingMode = aSortingMode;
+
+ if (!mRootNode->mExpanded) {
+ // Need to do this later when node will be expanded.
+ mNeedsToApplySortingMode = true;
+ return NS_OK;
+ }
+
+ // Actually do sorting.
+ nsNavHistoryContainerResultNode::SortComparator comparator =
+ nsNavHistoryContainerResultNode::GetSortingComparator(aSortingMode);
+ if (comparator) {
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_ERROR_OUT_OF_MEMORY);
+ mRootNode->RecursiveSort(mSortingAnnotation.get(), comparator);
+ }
+
+ NOTIFY_RESULT_OBSERVERS(this, SortingChanged(aSortingMode));
+ NOTIFY_RESULT_OBSERVERS(this, InvalidateContainer(mRootNode));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetSortingAnnotation(nsACString& _result) {
+ _result.Assign(mSortingAnnotation);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::SetSortingAnnotation(const nsACString& aSortingAnnotation) {
+ mSortingAnnotation.Assign(aSortingAnnotation);
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::AddObserver(nsINavHistoryResultObserver* aObserver,
+ bool aOwnsWeak)
+{
+ NS_ENSURE_ARG(aObserver);
+ nsresult rv = mObservers.AppendWeakElement(aObserver, aOwnsWeak);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = aObserver->SetResult(this);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // If we are batching, notify a fake batch start to the observers.
+ // Not doing so would then notify a not coupled batch end.
+ if (mBatchInProgress) {
+ NOTIFY_RESULT_OBSERVERS(this, Batching(true));
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::RemoveObserver(nsINavHistoryResultObserver* aObserver)
+{
+ NS_ENSURE_ARG(aObserver);
+ return mObservers.RemoveWeakElement(aObserver);
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetSuppressNotifications(bool* _retval)
+{
+ *_retval = mSuppressNotifications;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::SetSuppressNotifications(bool aSuppressNotifications)
+{
+ mSuppressNotifications = aSuppressNotifications;
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetRoot(nsINavHistoryContainerResultNode** aRoot)
+{
+ if (!mRootNode) {
+ NS_NOTREACHED("Root is null");
+ *aRoot = nullptr;
+ return NS_ERROR_FAILURE;
+ }
+ RefPtr<nsNavHistoryContainerResultNode> node(mRootNode);
+ node.forget(aRoot);
+ return NS_OK;
+}
+
+
+void
+nsNavHistoryResult::requestRefresh(nsNavHistoryContainerResultNode* aContainer)
+{
+ // Don't add twice the same container.
+ if (mRefreshParticipants.IndexOf(aContainer) == mRefreshParticipants.NoIndex)
+ mRefreshParticipants.AppendElement(aContainer);
+}
+
+// nsINavBookmarkObserver implementation
+
+// Here, it is important that we create a COPY of the observer array. Some
+// observers will requery themselves, which may cause the observer array to
+// be modified or added to.
+#define ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(_folderId, _functionCall) \
+ PR_BEGIN_MACRO \
+ FolderObserverList* _fol = BookmarkFolderObserversForId(_folderId, false); \
+ if (_fol) { \
+ FolderObserverList _listCopy(*_fol); \
+ for (uint32_t _fol_i = 0; _fol_i < _listCopy.Length(); ++_fol_i) { \
+ if (_listCopy[_fol_i]) \
+ _listCopy[_fol_i]->_functionCall; \
+ } \
+ } \
+ PR_END_MACRO
+#define ENUMERATE_LIST_OBSERVERS(_listType, _functionCall, _observersList, _conditionCall) \
+ PR_BEGIN_MACRO \
+ _listType _listCopy(_observersList); \
+ for (uint32_t _obs_i = 0; _obs_i < _listCopy.Length(); ++_obs_i) { \
+ if (_listCopy[_obs_i] && _listCopy[_obs_i]->_conditionCall) \
+ _listCopy[_obs_i]->_functionCall; \
+ } \
+ PR_END_MACRO
+#define ENUMERATE_QUERY_OBSERVERS(_functionCall, _observersList, _conditionCall) \
+ ENUMERATE_LIST_OBSERVERS(QueryObserverList, _functionCall, _observersList, _conditionCall)
+#define ENUMERATE_ALL_BOOKMARKS_OBSERVERS(_functionCall) \
+ ENUMERATE_QUERY_OBSERVERS(_functionCall, mAllBookmarksObservers, IsQuery())
+#define ENUMERATE_HISTORY_OBSERVERS(_functionCall) \
+ ENUMERATE_QUERY_OBSERVERS(_functionCall, mHistoryObservers, IsQuery())
+
+#define NOTIFY_REFRESH_PARTICIPANTS() \
+ PR_BEGIN_MACRO \
+ ENUMERATE_LIST_OBSERVERS(ContainerObserverList, Refresh(), mRefreshParticipants, IsContainer()); \
+ mRefreshParticipants.Clear(); \
+ PR_END_MACRO
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetSkipTags(bool *aSkipTags)
+{
+ *aSkipTags = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryResult::GetSkipDescendantsOnItemRemoval(bool *aSkipDescendantsOnItemRemoval)
+{
+ *aSkipDescendantsOnItemRemoval = false;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnBeginUpdateBatch()
+{
+ // Since we could be observing both history and bookmarks, it's possible both
+ // notify the batch. We can safely ignore nested calls.
+ if (!mBatchInProgress) {
+ mBatchInProgress = true;
+ ENUMERATE_HISTORY_OBSERVERS(OnBeginUpdateBatch());
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(OnBeginUpdateBatch());
+
+ NOTIFY_RESULT_OBSERVERS(this, Batching(true));
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnEndUpdateBatch()
+{
+ // Since we could be observing both history and bookmarks, it's possible both
+ // notify the batch. We can safely ignore nested calls.
+ // Notice it's possible we are notified OnEndUpdateBatch more times than
+ // onBeginUpdateBatch, since the result could be created in the middle of
+ // nested batches.
+ if (mBatchInProgress) {
+ ENUMERATE_HISTORY_OBSERVERS(OnEndUpdateBatch());
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(OnEndUpdateBatch());
+
+ // Setting mBatchInProgress before notifying the end of the batch to
+ // observers would make evantual calls to Refresh() directly handled rather
+ // than enqueued. Thus set it just before handling refreshes.
+ mBatchInProgress = false;
+ NOTIFY_REFRESH_PARTICIPANTS();
+ NOTIFY_RESULT_OBSERVERS(this, Batching(false));
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnItemAdded(int64_t aItemId,
+ int64_t aParentId,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aTitle,
+ PRTime aDateAdded,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG(aItemType != nsINavBookmarksService::TYPE_BOOKMARK ||
+ aURI);
+
+ ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(aParentId,
+ OnItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle, aDateAdded,
+ aGUID, aParentGUID, aSource)
+ );
+ ENUMERATE_HISTORY_OBSERVERS(
+ OnItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle, aDateAdded,
+ aGUID, aParentGUID, aSource)
+ );
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(
+ OnItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle, aDateAdded,
+ aGUID, aParentGUID, aSource)
+ );
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnItemRemoved(int64_t aItemId,
+ int64_t aParentId,
+ int32_t aIndex,
+ uint16_t aItemType,
+ nsIURI* aURI,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ uint16_t aSource)
+{
+ NS_ENSURE_ARG(aItemType != nsINavBookmarksService::TYPE_BOOKMARK ||
+ aURI);
+
+ ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(aParentId,
+ OnItemRemoved(aItemId, aParentId, aIndex, aItemType, aURI, aGUID,
+ aParentGUID, aSource));
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(
+ OnItemRemoved(aItemId, aParentId, aIndex, aItemType, aURI, aGUID,
+ aParentGUID, aSource));
+ ENUMERATE_HISTORY_OBSERVERS(
+ OnItemRemoved(aItemId, aParentId, aIndex, aItemType, aURI, aGUID,
+ aParentGUID, aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnItemChanged(int64_t aItemId,
+ const nsACString &aProperty,
+ bool aIsAnnotationProperty,
+ const nsACString &aNewValue,
+ PRTime aLastModified,
+ uint16_t aItemType,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ const nsACString& aOldValue,
+ uint16_t aSource)
+{
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(
+ OnItemChanged(aItemId, aProperty, aIsAnnotationProperty, aNewValue,
+ aLastModified, aItemType, aParentId, aGUID, aParentGUID,
+ aOldValue, aSource));
+
+ // Note: folder-nodes set their own bookmark observer only once they're
+ // opened, meaning we cannot optimize this code path for changes done to
+ // folder-nodes.
+
+ FolderObserverList* list = BookmarkFolderObserversForId(aParentId, false);
+ if (!list)
+ return NS_OK;
+
+ for (uint32_t i = 0; i < list->Length(); ++i) {
+ RefPtr<nsNavHistoryFolderResultNode> folder = list->ElementAt(i);
+ if (folder) {
+ uint32_t nodeIndex;
+ RefPtr<nsNavHistoryResultNode> node =
+ folder->FindChildById(aItemId, &nodeIndex);
+ // if ExcludeItems is true we don't update non visible items
+ bool excludeItems = (mRootNode->mOptions->ExcludeItems()) ||
+ folder->mOptions->ExcludeItems();
+ if (node &&
+ (!excludeItems || !(node->IsURI() || node->IsSeparator())) &&
+ folder->StartIncrementalUpdate()) {
+ node->OnItemChanged(aItemId, aProperty, aIsAnnotationProperty,
+ aNewValue, aLastModified, aItemType, aParentId,
+ aGUID, aParentGUID, aOldValue, aSource);
+ }
+ }
+ }
+
+ // Note: we do NOT call history observers in this case. This notification is
+ // the same as other history notification, except that here we know the item
+ // is a bookmark. History observers will handle the history notification
+ // instead.
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnItemVisited(int64_t aItemId,
+ int64_t aVisitId,
+ PRTime aVisitTime,
+ uint32_t aTransitionType,
+ nsIURI* aURI,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID)
+{
+ NS_ENSURE_ARG(aURI);
+
+ ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(aParentId,
+ OnItemVisited(aItemId, aVisitId, aVisitTime, aTransitionType, aURI,
+ aParentId, aGUID, aParentGUID));
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(
+ OnItemVisited(aItemId, aVisitId, aVisitTime, aTransitionType, aURI,
+ aParentId, aGUID, aParentGUID));
+ // Note: we do NOT call history observers in this case. This notification is
+ // the same as OnVisit, except that here we know the item is a bookmark.
+ // History observers will handle the history notification instead.
+ return NS_OK;
+}
+
+
+/**
+ * Need to notify both the source and the destination folders (if they are
+ * different).
+ */
+NS_IMETHODIMP
+nsNavHistoryResult::OnItemMoved(int64_t aItemId,
+ int64_t aOldParent,
+ int32_t aOldIndex,
+ int64_t aNewParent,
+ int32_t aNewIndex,
+ uint16_t aItemType,
+ const nsACString& aGUID,
+ const nsACString& aOldParentGUID,
+ const nsACString& aNewParentGUID,
+ uint16_t aSource)
+{
+ ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(aOldParent,
+ OnItemMoved(aItemId, aOldParent, aOldIndex, aNewParent, aNewIndex,
+ aItemType, aGUID, aOldParentGUID, aNewParentGUID, aSource));
+ if (aNewParent != aOldParent) {
+ ENUMERATE_BOOKMARK_FOLDER_OBSERVERS(aNewParent,
+ OnItemMoved(aItemId, aOldParent, aOldIndex, aNewParent, aNewIndex,
+ aItemType, aGUID, aOldParentGUID, aNewParentGUID, aSource));
+ }
+ ENUMERATE_ALL_BOOKMARKS_OBSERVERS(OnItemMoved(aItemId, aOldParent, aOldIndex,
+ aNewParent, aNewIndex,
+ aItemType, aGUID,
+ aOldParentGUID,
+ aNewParentGUID, aSource));
+ ENUMERATE_HISTORY_OBSERVERS(OnItemMoved(aItemId, aOldParent, aOldIndex,
+ aNewParent, aNewIndex, aItemType,
+ aGUID, aOldParentGUID,
+ aNewParentGUID, aSource));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnVisit(nsIURI* aURI, int64_t aVisitId, PRTime aTime,
+ int64_t aSessionId, int64_t aReferringId,
+ uint32_t aTransitionType, const nsACString& aGUID,
+ bool aHidden, uint32_t aVisitCount, uint32_t aTyped)
+{
+ NS_ENSURE_ARG(aURI);
+
+ // Embed visits are never shown in our views.
+ if (aTransitionType == nsINavHistoryService::TRANSITION_EMBED) {
+ return NS_OK;
+ }
+
+ uint32_t added = 0;
+
+ ENUMERATE_HISTORY_OBSERVERS(OnVisit(aURI, aVisitId, aTime, aSessionId,
+ aReferringId, aTransitionType, aGUID,
+ aHidden, &added));
+
+ if (!mRootNode->mExpanded)
+ return NS_OK;
+
+ // If this visit is accepted by an overlapped container, and not all
+ // overlapped containers are visible, we should still call Refresh if the
+ // visit falls into any of them.
+ bool todayIsMissing = false;
+ uint32_t resultType = mRootNode->mOptions->ResultType();
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY) {
+ uint32_t childCount;
+ nsresult rv = mRootNode->GetChildCount(&childCount);
+ NS_ENSURE_SUCCESS(rv, rv);
+ if (childCount) {
+ nsCOMPtr<nsINavHistoryResultNode> firstChild;
+ rv = mRootNode->GetChild(0, getter_AddRefs(firstChild));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsAutoCString title;
+ rv = firstChild->GetTitle(title);
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsNavHistory* history = nsNavHistory::GetHistoryService();
+ NS_ENSURE_TRUE(history, NS_OK);
+ nsAutoCString todayLabel;
+ history->GetStringFromName(
+ u"finduri-AgeInDays-is-0", todayLabel);
+ todayIsMissing = !todayLabel.Equals(title);
+ }
+ }
+
+ if (!added || todayIsMissing) {
+ // None of registered query observers has accepted our URI. This means,
+ // that a matching query either was not expanded or it does not exist.
+ uint32_t resultType = mRootNode->mOptions->ResultType();
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_QUERY ||
+ resultType == nsINavHistoryQueryOptions::RESULTS_AS_DATE_SITE_QUERY) {
+ // If the visit falls into the Today bucket and the bucket exists, it was
+ // just not expanded, thus there's no reason to update.
+ int64_t beginOfToday =
+ nsNavHistory::NormalizeTime(nsINavHistoryQuery::TIME_RELATIVE_TODAY, 0);
+ if (todayIsMissing || aTime < beginOfToday) {
+ (void)mRootNode->GetAsQuery()->Refresh();
+ }
+ return NS_OK;
+ }
+
+ if (resultType == nsINavHistoryQueryOptions::RESULTS_AS_SITE_QUERY) {
+ (void)mRootNode->GetAsQuery()->Refresh();
+ return NS_OK;
+ }
+
+ // We are result of a folder node, then we should run through history
+ // observers that are containers queries and refresh them.
+ // We use a copy of the observers array since requerying could potentially
+ // cause changes to the array.
+ ENUMERATE_QUERY_OBSERVERS(Refresh(), mHistoryObservers, IsContainersQuery());
+ }
+
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnTitleChanged(nsIURI* aURI,
+ const nsAString& aPageTitle,
+ const nsACString& aGUID)
+{
+ NS_ENSURE_ARG(aURI);
+
+ ENUMERATE_HISTORY_OBSERVERS(OnTitleChanged(aURI, aPageTitle, aGUID));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnFrecencyChanged(nsIURI* aURI,
+ int32_t aNewFrecency,
+ const nsACString& aGUID,
+ bool aHidden,
+ PRTime aLastVisitDate)
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnManyFrecenciesChanged()
+{
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnDeleteURI(nsIURI *aURI,
+ const nsACString& aGUID,
+ uint16_t aReason)
+{
+ NS_ENSURE_ARG(aURI);
+
+ ENUMERATE_HISTORY_OBSERVERS(OnDeleteURI(aURI, aGUID, aReason));
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnClearHistory()
+{
+ ENUMERATE_HISTORY_OBSERVERS(OnClearHistory());
+ return NS_OK;
+}
+
+
+NS_IMETHODIMP
+nsNavHistoryResult::OnPageChanged(nsIURI* aURI,
+ uint32_t aChangedAttribute,
+ const nsAString& aValue,
+ const nsACString& aGUID)
+{
+ NS_ENSURE_ARG(aURI);
+
+ ENUMERATE_HISTORY_OBSERVERS(OnPageChanged(aURI, aChangedAttribute, aValue, aGUID));
+ return NS_OK;
+}
+
+
+/**
+ * Don't do anything when visits expire.
+ */
+NS_IMETHODIMP
+nsNavHistoryResult::OnDeleteVisits(nsIURI* aURI,
+ PRTime aVisitTime,
+ const nsACString& aGUID,
+ uint16_t aReason,
+ uint32_t aTransitionType)
+{
+ NS_ENSURE_ARG(aURI);
+
+ ENUMERATE_HISTORY_OBSERVERS(OnDeleteVisits(aURI, aVisitTime, aGUID, aReason,
+ aTransitionType));
+ return NS_OK;
+}
diff --git a/toolkit/components/places/nsNavHistoryResult.h b/toolkit/components/places/nsNavHistoryResult.h
new file mode 100644
index 000000000..fffe2bf13
--- /dev/null
+++ b/toolkit/components/places/nsNavHistoryResult.h
@@ -0,0 +1,782 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The definitions of objects that make up a history query result set. This file
+ * should only be included by nsNavHistory.h, include that if you want these
+ * classes.
+ */
+
+#ifndef nsNavHistoryResult_h_
+#define nsNavHistoryResult_h_
+
+#include "nsTArray.h"
+#include "nsInterfaceHashtable.h"
+#include "nsDataHashtable.h"
+#include "nsCycleCollectionParticipant.h"
+#include "mozilla/storage.h"
+#include "Helpers.h"
+
+class nsNavHistory;
+class nsNavHistoryQuery;
+class nsNavHistoryQueryOptions;
+
+class nsNavHistoryContainerResultNode;
+class nsNavHistoryFolderResultNode;
+class nsNavHistoryQueryResultNode;
+
+/**
+ * hashkey wrapper using int64_t KeyType
+ *
+ * @see nsTHashtable::EntryType for specification
+ *
+ * This just truncates the 64-bit int to a 32-bit one for using a hash number.
+ * It is used for bookmark folder IDs, which should be way less than 2^32.
+ */
+class nsTrimInt64HashKey : public PLDHashEntryHdr
+{
+public:
+ typedef const int64_t& KeyType;
+ typedef const int64_t* KeyTypePointer;
+
+ explicit nsTrimInt64HashKey(KeyTypePointer aKey) : mValue(*aKey) { }
+ nsTrimInt64HashKey(const nsTrimInt64HashKey& toCopy) : mValue(toCopy.mValue) { }
+ ~nsTrimInt64HashKey() { }
+
+ KeyType GetKey() const { return mValue; }
+ bool KeyEquals(KeyTypePointer aKey) const { return *aKey == mValue; }
+
+ static KeyTypePointer KeyToPointer(KeyType aKey) { return &aKey; }
+ static PLDHashNumber HashKey(KeyTypePointer aKey)
+ { return static_cast<uint32_t>((*aKey) & UINT32_MAX); }
+ enum { ALLOW_MEMMOVE = true };
+
+private:
+ const int64_t mValue;
+};
+
+
+// Declare methods for implementing nsINavBookmarkObserver
+// and nsINavHistoryObserver (some methods, such as BeginUpdateBatch overlap)
+#define NS_DECL_BOOKMARK_HISTORY_OBSERVER_BASE(...) \
+ NS_DECL_NSINAVBOOKMARKOBSERVER \
+ NS_IMETHOD OnTitleChanged(nsIURI* aURI, const nsAString& aPageTitle, \
+ const nsACString& aGUID) __VA_ARGS__; \
+ NS_IMETHOD OnFrecencyChanged(nsIURI* aURI, int32_t aNewFrecency, \
+ const nsACString& aGUID, bool aHidden, \
+ PRTime aLastVisitDate) __VA_ARGS__; \
+ NS_IMETHOD OnManyFrecenciesChanged() __VA_ARGS__; \
+ NS_IMETHOD OnDeleteURI(nsIURI *aURI, const nsACString& aGUID, \
+ uint16_t aReason) __VA_ARGS__; \
+ NS_IMETHOD OnClearHistory() __VA_ARGS__; \
+ NS_IMETHOD OnPageChanged(nsIURI *aURI, uint32_t aChangedAttribute, \
+ const nsAString &aNewValue, \
+ const nsACString &aGUID) __VA_ARGS__; \
+ NS_IMETHOD OnDeleteVisits(nsIURI* aURI, PRTime aVisitTime, \
+ const nsACString& aGUID, uint16_t aReason, \
+ uint32_t aTransitionType) __VA_ARGS__;
+
+// The internal version has an output aAdded parameter, it is incremented by
+// query nodes when the visited uri belongs to them. If no such query exists,
+// the history result creates a new query node dynamically.
+#define NS_DECL_BOOKMARK_HISTORY_OBSERVER_INTERNAL \
+ NS_DECL_BOOKMARK_HISTORY_OBSERVER_BASE() \
+ NS_IMETHOD OnVisit(nsIURI* aURI, int64_t aVisitId, PRTime aTime, \
+ int64_t aSessionId, int64_t aReferringId, \
+ uint32_t aTransitionType, const nsACString& aGUID, \
+ bool aHidden, uint32_t* aAdded);
+
+// The external version is used by results.
+#define NS_DECL_BOOKMARK_HISTORY_OBSERVER_EXTERNAL(...) \
+ NS_DECL_BOOKMARK_HISTORY_OBSERVER_BASE(__VA_ARGS__) \
+ NS_IMETHOD OnVisit(nsIURI* aURI, int64_t aVisitId, PRTime aTime, \
+ int64_t aSessionId, int64_t aReferringId, \
+ uint32_t aTransitionType, const nsACString& aGUID, \
+ bool aHidden, uint32_t aVisitCount, uint32_t aTyped) __VA_ARGS__;
+
+// nsNavHistoryResult
+//
+// nsNavHistory creates this object and fills in mChildren (by getting
+// it through GetTopLevel()). Then FilledAllResults() is called to finish
+// object initialization.
+
+#define NS_NAVHISTORYRESULT_IID \
+ { 0x455d1d40, 0x1b9b, 0x40e6, { 0xa6, 0x41, 0x8b, 0xb7, 0xe8, 0x82, 0x23, 0x87 } }
+
+class nsNavHistoryResult final : public nsSupportsWeakReference,
+ public nsINavHistoryResult,
+ public nsINavBookmarkObserver,
+ public nsINavHistoryObserver
+{
+public:
+ static nsresult NewHistoryResult(nsINavHistoryQuery** aQueries,
+ uint32_t aQueryCount,
+ nsNavHistoryQueryOptions* aOptions,
+ nsNavHistoryContainerResultNode* aRoot,
+ bool aBatchInProgress,
+ nsNavHistoryResult** result);
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_NAVHISTORYRESULT_IID)
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_NSINAVHISTORYRESULT
+ NS_DECL_BOOKMARK_HISTORY_OBSERVER_EXTERNAL(override)
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(nsNavHistoryResult, nsINavHistoryResult)
+
+ void AddHistoryObserver(nsNavHistoryQueryResultNode* aNode);
+ void AddBookmarkFolderObserver(nsNavHistoryFolderResultNode* aNode, int64_t aFolder);
+ void AddAllBookmarksObserver(nsNavHistoryQueryResultNode* aNode);
+ void RemoveHistoryObserver(nsNavHistoryQueryResultNode* aNode);
+ void RemoveBookmarkFolderObserver(nsNavHistoryFolderResultNode* aNode, int64_t aFolder);
+ void RemoveAllBookmarksObserver(nsNavHistoryQueryResultNode* aNode);
+ void StopObserving();
+
+public:
+ // two-stage init, use NewHistoryResult to construct
+ explicit nsNavHistoryResult(nsNavHistoryContainerResultNode* mRoot);
+ nsresult Init(nsINavHistoryQuery** aQueries,
+ uint32_t aQueryCount,
+ nsNavHistoryQueryOptions *aOptions);
+
+ RefPtr<nsNavHistoryContainerResultNode> mRootNode;
+
+ nsCOMArray<nsINavHistoryQuery> mQueries;
+ nsCOMPtr<nsNavHistoryQueryOptions> mOptions;
+
+ // One of nsNavHistoryQueryOptions.SORY_BY_* This is initialized to mOptions.sortingMode,
+ // but may be overridden if the user clicks on one of the columns.
+ uint16_t mSortingMode;
+ // If root node is closed and we try to apply a sortingMode, it would not
+ // work. So we will apply it when the node will be reopened and populated.
+ // This var states the fact we need to apply sortingMode in such a situation.
+ bool mNeedsToApplySortingMode;
+
+ // The sorting annotation to be used for in SORT_BY_ANNOTATION_* modes
+ nsCString mSortingAnnotation;
+
+ // node observers
+ bool mIsHistoryObserver;
+ bool mIsBookmarkFolderObserver;
+ bool mIsAllBookmarksObserver;
+
+ typedef nsTArray< RefPtr<nsNavHistoryQueryResultNode> > QueryObserverList;
+ QueryObserverList mHistoryObservers;
+ QueryObserverList mAllBookmarksObservers;
+
+ typedef nsTArray< RefPtr<nsNavHistoryFolderResultNode> > FolderObserverList;
+ nsDataHashtable<nsTrimInt64HashKey, FolderObserverList*> mBookmarkFolderObservers;
+ FolderObserverList* BookmarkFolderObserversForId(int64_t aFolderId, bool aCreate);
+
+ typedef nsTArray< RefPtr<nsNavHistoryContainerResultNode> > ContainerObserverList;
+
+ void RecursiveExpandCollapse(nsNavHistoryContainerResultNode* aContainer,
+ bool aExpand);
+
+ void InvalidateTree();
+
+ bool mBatchInProgress;
+
+ nsMaybeWeakPtrArray<nsINavHistoryResultObserver> mObservers;
+ bool mSuppressNotifications;
+
+ ContainerObserverList mRefreshParticipants;
+ void requestRefresh(nsNavHistoryContainerResultNode* aContainer);
+
+protected:
+ virtual ~nsNavHistoryResult();
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(nsNavHistoryResult, NS_NAVHISTORYRESULT_IID)
+
+// nsNavHistoryResultNode
+//
+// This is the base class for every node in a result set. The result itself
+// is a node (nsNavHistoryResult inherits from this), as well as every
+// leaf and branch on the tree.
+
+#define NS_NAVHISTORYRESULTNODE_IID \
+ {0x54b61d38, 0x57c1, 0x11da, {0x95, 0xb8, 0x00, 0x13, 0x21, 0xc9, 0xf6, 0x9e}}
+
+// These are all the simple getters, they can be used for the result node
+// implementation and all subclasses. More complex are GetIcon, GetParent
+// (which depends on the definition of container result node), and GetUri
+// (which is overridded for lazy construction for some containers).
+#define NS_IMPLEMENT_SIMPLE_RESULTNODE \
+ NS_IMETHOD GetTitle(nsACString& aTitle) override \
+ { aTitle = mTitle; return NS_OK; } \
+ NS_IMETHOD GetAccessCount(uint32_t* aAccessCount) override \
+ { *aAccessCount = mAccessCount; return NS_OK; } \
+ NS_IMETHOD GetTime(PRTime* aTime) override \
+ { *aTime = mTime; return NS_OK; } \
+ NS_IMETHOD GetIndentLevel(int32_t* aIndentLevel) override \
+ { *aIndentLevel = mIndentLevel; return NS_OK; } \
+ NS_IMETHOD GetBookmarkIndex(int32_t* aIndex) override \
+ { *aIndex = mBookmarkIndex; return NS_OK; } \
+ NS_IMETHOD GetDateAdded(PRTime* aDateAdded) override \
+ { *aDateAdded = mDateAdded; return NS_OK; } \
+ NS_IMETHOD GetLastModified(PRTime* aLastModified) override \
+ { *aLastModified = mLastModified; return NS_OK; } \
+ NS_IMETHOD GetItemId(int64_t* aId) override \
+ { *aId = mItemId; return NS_OK; }
+
+// This is used by the base classes instead of
+// NS_FORWARD_NSINAVHISTORYRESULTNODE(nsNavHistoryResultNode) because they
+// need to redefine GetType and GetUri rather than forwarding them. This
+// implements all the simple getters instead of forwarding because they are so
+// short and we can save a virtual function call.
+//
+// (GetUri is redefined only by QueryResultNode and FolderResultNode because
+// the queries might not necessarily be parsed. The rest just return the node's
+// buffer.)
+#define NS_FORWARD_COMMON_RESULTNODE_TO_BASE \
+ NS_IMPLEMENT_SIMPLE_RESULTNODE \
+ NS_IMETHOD GetIcon(nsACString& aIcon) override \
+ { return nsNavHistoryResultNode::GetIcon(aIcon); } \
+ NS_IMETHOD GetParent(nsINavHistoryContainerResultNode** aParent) override \
+ { return nsNavHistoryResultNode::GetParent(aParent); } \
+ NS_IMETHOD GetParentResult(nsINavHistoryResult** aResult) override \
+ { return nsNavHistoryResultNode::GetParentResult(aResult); } \
+ NS_IMETHOD GetTags(nsAString& aTags) override \
+ { return nsNavHistoryResultNode::GetTags(aTags); } \
+ NS_IMETHOD GetPageGuid(nsACString& aPageGuid) override \
+ { return nsNavHistoryResultNode::GetPageGuid(aPageGuid); } \
+ NS_IMETHOD GetBookmarkGuid(nsACString& aBookmarkGuid) override \
+ { return nsNavHistoryResultNode::GetBookmarkGuid(aBookmarkGuid); } \
+ NS_IMETHOD GetVisitId(int64_t* aVisitId) override \
+ { return nsNavHistoryResultNode::GetVisitId(aVisitId); } \
+ NS_IMETHOD GetFromVisitId(int64_t* aFromVisitId) override \
+ { return nsNavHistoryResultNode::GetFromVisitId(aFromVisitId); } \
+ NS_IMETHOD GetVisitType(uint32_t* aVisitType) override \
+ { return nsNavHistoryResultNode::GetVisitType(aVisitType); }
+
+class nsNavHistoryResultNode : public nsINavHistoryResultNode
+{
+public:
+ nsNavHistoryResultNode(const nsACString& aURI, const nsACString& aTitle,
+ uint32_t aAccessCount, PRTime aTime,
+ const nsACString& aIconURI);
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_NAVHISTORYRESULTNODE_IID)
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(nsNavHistoryResultNode)
+
+ NS_IMPLEMENT_SIMPLE_RESULTNODE
+ NS_IMETHOD GetIcon(nsACString& aIcon) override;
+ NS_IMETHOD GetParent(nsINavHistoryContainerResultNode** aParent) override;
+ NS_IMETHOD GetParentResult(nsINavHistoryResult** aResult) override;
+ NS_IMETHOD GetType(uint32_t* type) override
+ { *type = nsNavHistoryResultNode::RESULT_TYPE_URI; return NS_OK; }
+ NS_IMETHOD GetUri(nsACString& aURI) override
+ { aURI = mURI; return NS_OK; }
+ NS_IMETHOD GetTags(nsAString& aTags) override;
+ NS_IMETHOD GetPageGuid(nsACString& aPageGuid) override;
+ NS_IMETHOD GetBookmarkGuid(nsACString& aBookmarkGuid) override;
+ NS_IMETHOD GetVisitId(int64_t* aVisitId) override;
+ NS_IMETHOD GetFromVisitId(int64_t* aFromVisitId) override;
+ NS_IMETHOD GetVisitType(uint32_t* aVisitType) override;
+
+ virtual void OnRemoving();
+
+ // Called from result's onItemChanged, see also bookmark observer declaration in
+ // nsNavHistoryFolderResultNode
+ NS_IMETHOD OnItemChanged(int64_t aItemId,
+ const nsACString &aProperty,
+ bool aIsAnnotationProperty,
+ const nsACString &aValue,
+ PRTime aNewLastModified,
+ uint16_t aItemType,
+ int64_t aParentId,
+ const nsACString& aGUID,
+ const nsACString& aParentGUID,
+ const nsACString &aOldValue,
+ uint16_t aSource);
+
+protected:
+ virtual ~nsNavHistoryResultNode() {}
+
+public:
+
+ nsNavHistoryResult* GetResult();
+ nsNavHistoryQueryOptions* GetGeneratingOptions();
+
+ // These functions test the type. We don't use a virtual function since that
+ // would take a vtable slot for every one of (potentially very many) nodes.
+ // Note that GetType() already has a vtable slot because its on the iface.
+ bool IsTypeContainer(uint32_t type) {
+ return type == nsINavHistoryResultNode::RESULT_TYPE_QUERY ||
+ type == nsINavHistoryResultNode::RESULT_TYPE_FOLDER ||
+ type == nsINavHistoryResultNode::RESULT_TYPE_FOLDER_SHORTCUT;
+ }
+ bool IsContainer() {
+ uint32_t type;
+ GetType(&type);
+ return IsTypeContainer(type);
+ }
+ static bool IsTypeURI(uint32_t type) {
+ return type == nsINavHistoryResultNode::RESULT_TYPE_URI;
+ }
+ bool IsURI() {
+ uint32_t type;
+ GetType(&type);
+ return IsTypeURI(type);
+ }
+ static bool IsTypeFolder(uint32_t type) {
+ return type == nsINavHistoryResultNode::RESULT_TYPE_FOLDER ||
+ type == nsINavHistoryResultNode::RESULT_TYPE_FOLDER_SHORTCUT;
+ }
+ bool IsFolder() {
+ uint32_t type;
+ GetType(&type);
+ return IsTypeFolder(type);
+ }
+ static bool IsTypeQuery(uint32_t type) {
+ return type == nsINavHistoryResultNode::RESULT_TYPE_QUERY;
+ }
+ bool IsQuery() {
+ uint32_t type;
+ GetType(&type);
+ return IsTypeQuery(type);
+ }
+ bool IsSeparator() {
+ uint32_t type;
+ GetType(&type);
+ return type == nsINavHistoryResultNode::RESULT_TYPE_SEPARATOR;
+ }
+ nsNavHistoryContainerResultNode* GetAsContainer() {
+ NS_ASSERTION(IsContainer(), "Not a container");
+ return reinterpret_cast<nsNavHistoryContainerResultNode*>(this);
+ }
+ nsNavHistoryFolderResultNode* GetAsFolder() {
+ NS_ASSERTION(IsFolder(), "Not a folder");
+ return reinterpret_cast<nsNavHistoryFolderResultNode*>(this);
+ }
+ nsNavHistoryQueryResultNode* GetAsQuery() {
+ NS_ASSERTION(IsQuery(), "Not a query");
+ return reinterpret_cast<nsNavHistoryQueryResultNode*>(this);
+ }
+
+ RefPtr<nsNavHistoryContainerResultNode> mParent;
+ nsCString mURI; // not necessarily valid for containers, call GetUri
+ nsCString mTitle;
+ nsString mTags;
+ bool mAreTagsSorted;
+ uint32_t mAccessCount;
+ int64_t mTime;
+ nsCString mFaviconURI;
+ int32_t mBookmarkIndex;
+ int64_t mItemId;
+ int64_t mFolderId;
+ int64_t mVisitId;
+ int64_t mFromVisitId;
+ PRTime mDateAdded;
+ PRTime mLastModified;
+
+ // The indent level of this node. The root node will have a value of -1. The
+ // root's children will have a value of 0, and so on.
+ int32_t mIndentLevel;
+
+ // Frecency of the page. Valid only for URI nodes.
+ int32_t mFrecency;
+
+ // Hidden status of the page. Valid only for URI nodes.
+ bool mHidden;
+
+ // Transition type used when this node represents a single visit.
+ uint32_t mTransitionType;
+
+ // Unique Id of the page.
+ nsCString mPageGuid;
+
+ // Unique Id of the bookmark.
+ nsCString mBookmarkGuid;
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(nsNavHistoryResultNode, NS_NAVHISTORYRESULTNODE_IID)
+
+
+// nsNavHistoryContainerResultNode
+//
+// This is the base class for all nodes that can have children. It is
+// overridden for nodes that are dynamically populated such as queries and
+// folders. It is used directly for simple containers such as host groups
+// in history views.
+
+// derived classes each provide their own implementation of has children and
+// forward the rest to us using this macro
+#define NS_FORWARD_CONTAINERNODE_EXCEPT_HASCHILDREN \
+ NS_IMETHOD GetState(uint16_t* _state) override \
+ { return nsNavHistoryContainerResultNode::GetState(_state); } \
+ NS_IMETHOD GetContainerOpen(bool *aContainerOpen) override \
+ { return nsNavHistoryContainerResultNode::GetContainerOpen(aContainerOpen); } \
+ NS_IMETHOD SetContainerOpen(bool aContainerOpen) override \
+ { return nsNavHistoryContainerResultNode::SetContainerOpen(aContainerOpen); } \
+ NS_IMETHOD GetChildCount(uint32_t *aChildCount) override \
+ { return nsNavHistoryContainerResultNode::GetChildCount(aChildCount); } \
+ NS_IMETHOD GetChild(uint32_t index, nsINavHistoryResultNode **_retval) override \
+ { return nsNavHistoryContainerResultNode::GetChild(index, _retval); } \
+ NS_IMETHOD GetChildIndex(nsINavHistoryResultNode* aNode, uint32_t* _retval) override \
+ { return nsNavHistoryContainerResultNode::GetChildIndex(aNode, _retval); } \
+ NS_IMETHOD FindNodeByDetails(const nsACString& aURIString, PRTime aTime, \
+ int64_t aItemId, bool aRecursive, \
+ nsINavHistoryResultNode** _retval) override \
+ { return nsNavHistoryContainerResultNode::FindNodeByDetails(aURIString, aTime, aItemId, \
+ aRecursive, _retval); }
+
+#define NS_NAVHISTORYCONTAINERRESULTNODE_IID \
+ { 0x6e3bf8d3, 0x22aa, 0x4065, { 0x86, 0xbc, 0x37, 0x46, 0xb5, 0xb3, 0x2c, 0xe8 } }
+
+class nsNavHistoryContainerResultNode : public nsNavHistoryResultNode,
+ public nsINavHistoryContainerResultNode
+{
+public:
+ nsNavHistoryContainerResultNode(
+ const nsACString& aURI, const nsACString& aTitle,
+ const nsACString& aIconURI, uint32_t aContainerType,
+ nsNavHistoryQueryOptions* aOptions);
+ nsNavHistoryContainerResultNode(
+ const nsACString& aURI, const nsACString& aTitle,
+ PRTime aTime,
+ const nsACString& aIconURI, uint32_t aContainerType,
+ nsNavHistoryQueryOptions* aOptions);
+
+ virtual nsresult Refresh();
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_NAVHISTORYCONTAINERRESULTNODE_IID)
+
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(nsNavHistoryContainerResultNode, nsNavHistoryResultNode)
+ NS_FORWARD_COMMON_RESULTNODE_TO_BASE
+ NS_IMETHOD GetType(uint32_t* type) override
+ { *type = mContainerType; return NS_OK; }
+ NS_IMETHOD GetUri(nsACString& aURI) override
+ { aURI = mURI; return NS_OK; }
+ NS_DECL_NSINAVHISTORYCONTAINERRESULTNODE
+
+public:
+
+ virtual void OnRemoving() override;
+
+ bool AreChildrenVisible();
+
+ // Overridded by descendents to populate.
+ virtual nsresult OpenContainer();
+ nsresult CloseContainer(bool aSuppressNotifications = false);
+
+ virtual nsresult OpenContainerAsync();
+
+ // This points to the result that owns this container. All containers have
+ // their result pointer set so we can quickly get to the result without having
+ // to walk the tree. Yet, this also saves us from storing a million pointers
+ // for every leaf node to the result.
+ RefPtr<nsNavHistoryResult> mResult;
+
+ // For example, RESULT_TYPE_QUERY. Query and Folder results override GetType
+ // so this is not used, but is still kept in sync.
+ uint32_t mContainerType;
+
+ // When there are children, this stores the open state in the tree
+ // this is set to the default in the constructor.
+ bool mExpanded;
+
+ // Filled in by the result type generator in nsNavHistory.
+ nsCOMArray<nsNavHistoryResultNode> mChildren;
+
+ nsCOMPtr<nsNavHistoryQueryOptions> mOptions;
+
+ void FillStats();
+ nsresult ReverseUpdateStats(int32_t aAccessCountChange);
+
+ // Sorting methods.
+ typedef nsCOMArray<nsNavHistoryResultNode>::nsCOMArrayComparatorFunc SortComparator;
+ virtual uint16_t GetSortType();
+ virtual void GetSortingAnnotation(nsACString& aSortingAnnotation);
+
+ static SortComparator GetSortingComparator(uint16_t aSortType);
+ virtual void RecursiveSort(const char* aData,
+ SortComparator aComparator);
+ uint32_t FindInsertionPoint(nsNavHistoryResultNode* aNode, SortComparator aComparator,
+ const char* aData, bool* aItemExists);
+ bool DoesChildNeedResorting(uint32_t aIndex, SortComparator aComparator,
+ const char* aData);
+
+ static int32_t SortComparison_StringLess(const nsAString& a, const nsAString& b);
+
+ static int32_t SortComparison_Bookmark(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_TitleLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_TitleGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_DateLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_DateGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_URILess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_URIGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_VisitCountLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_VisitCountGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_KeywordLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_KeywordGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_AnnotationLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_AnnotationGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_DateAddedLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_DateAddedGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_LastModifiedLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_LastModifiedGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_TagsLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_TagsGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_FrecencyLess(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+ static int32_t SortComparison_FrecencyGreater(nsNavHistoryResultNode* a,
+ nsNavHistoryResultNode* b,
+ void* closure);
+
+ // finding children: THESE DO NOT ADDREF
+ nsNavHistoryResultNode* FindChildURI(const nsACString& aSpec,
+ uint32_t* aNodeIndex);
+ // returns the index of the given node, -1 if not found
+ int32_t FindChild(nsNavHistoryResultNode* aNode)
+ { return mChildren.IndexOf(aNode); }
+
+ nsresult InsertChildAt(nsNavHistoryResultNode* aNode, int32_t aIndex);
+ nsresult InsertSortedChild(nsNavHistoryResultNode* aNode,
+ bool aIgnoreDuplicates = false);
+ bool EnsureItemPosition(uint32_t aIndex);
+
+ nsresult RemoveChildAt(int32_t aIndex);
+
+ void RecursiveFindURIs(bool aOnlyOne,
+ nsNavHistoryContainerResultNode* aContainer,
+ const nsCString& aSpec,
+ nsCOMArray<nsNavHistoryResultNode>* aMatches);
+ bool UpdateURIs(bool aRecursive, bool aOnlyOne, bool aUpdateSort,
+ const nsCString& aSpec,
+ nsresult (*aCallback)(nsNavHistoryResultNode*, const void*,
+ const nsNavHistoryResult*),
+ const void* aClosure);
+ nsresult ChangeTitles(nsIURI* aURI, const nsACString& aNewTitle,
+ bool aRecursive, bool aOnlyOne);
+
+protected:
+ virtual ~nsNavHistoryContainerResultNode();
+
+ enum AsyncCanceledState {
+ NOT_CANCELED, CANCELED, CANCELED_RESTART_NEEDED
+ };
+
+ void CancelAsyncOpen(bool aRestart);
+ nsresult NotifyOnStateChange(uint16_t aOldState);
+
+ nsCOMPtr<mozIStoragePendingStatement> mAsyncPendingStmt;
+ AsyncCanceledState mAsyncCanceledState;
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(nsNavHistoryContainerResultNode,
+ NS_NAVHISTORYCONTAINERRESULTNODE_IID)
+
+// nsNavHistoryQueryResultNode
+//
+// Overridden container type for complex queries over history and/or
+// bookmarks. This keeps itself in sync by listening to history and
+// bookmark notifications.
+
+class nsNavHistoryQueryResultNode final : public nsNavHistoryContainerResultNode,
+ public nsINavHistoryQueryResultNode,
+ public nsINavBookmarkObserver
+{
+public:
+ nsNavHistoryQueryResultNode(const nsACString& aTitle,
+ const nsACString& aIconURI,
+ const nsACString& aQueryURI);
+ nsNavHistoryQueryResultNode(const nsACString& aTitle,
+ const nsACString& aIconURI,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions);
+ nsNavHistoryQueryResultNode(const nsACString& aTitle,
+ const nsACString& aIconURI,
+ PRTime aTime,
+ const nsCOMArray<nsNavHistoryQuery>& aQueries,
+ nsNavHistoryQueryOptions* aOptions);
+
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_FORWARD_COMMON_RESULTNODE_TO_BASE
+ NS_IMETHOD GetType(uint32_t* type) override
+ { *type = nsNavHistoryResultNode::RESULT_TYPE_QUERY; return NS_OK; }
+ NS_IMETHOD GetUri(nsACString& aURI) override; // does special lazy creation
+ NS_FORWARD_CONTAINERNODE_EXCEPT_HASCHILDREN
+ NS_IMETHOD GetHasChildren(bool* aHasChildren) override;
+ NS_DECL_NSINAVHISTORYQUERYRESULTNODE
+
+ bool CanExpand();
+ bool IsContainersQuery();
+
+ virtual nsresult OpenContainer() override;
+
+ NS_DECL_BOOKMARK_HISTORY_OBSERVER_INTERNAL
+ virtual void OnRemoving() override;
+
+public:
+ // this constructs lazily mURI from mQueries and mOptions, call
+ // VerifyQueriesSerialized either this or mQueries/mOptions should be valid
+ nsresult VerifyQueriesSerialized();
+
+ // these may be constructed lazily from mURI, call VerifyQueriesParsed
+ // either this or mURI should be valid
+ nsCOMArray<nsNavHistoryQuery> mQueries;
+ uint32_t mLiveUpdate; // one of QUERYUPDATE_* in nsNavHistory.h
+ bool mHasSearchTerms;
+ nsresult VerifyQueriesParsed();
+
+ // safe options getter, ensures queries are parsed
+ nsNavHistoryQueryOptions* Options();
+
+ // this indicates whether the query contents are valid, they don't go away
+ // after the container is closed until a notification comes in
+ bool mContentsValid;
+
+ nsresult FillChildren();
+ void ClearChildren(bool unregister);
+ nsresult Refresh() override;
+
+ virtual uint16_t GetSortType() override;
+ virtual void GetSortingAnnotation(nsACString& aSortingAnnotation) override;
+ virtual void RecursiveSort(const char* aData,
+ SortComparator aComparator) override;
+
+ nsresult NotifyIfTagsChanged(nsIURI* aURI);
+
+ uint32_t mBatchChanges;
+
+ // Tracks transition type filters shared by all mQueries.
+ nsTArray<uint32_t> mTransitions;
+
+protected:
+ virtual ~nsNavHistoryQueryResultNode();
+};
+
+
+// nsNavHistoryFolderResultNode
+//
+// Overridden container type for bookmark folders. It will keep the contents
+// of the folder in sync with the bookmark service.
+
+class nsNavHistoryFolderResultNode final : public nsNavHistoryContainerResultNode,
+ public nsINavHistoryQueryResultNode,
+ public nsINavBookmarkObserver,
+ public mozilla::places::WeakAsyncStatementCallback
+{
+public:
+ nsNavHistoryFolderResultNode(const nsACString& aTitle,
+ nsNavHistoryQueryOptions* options,
+ int64_t aFolderId);
+
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_FORWARD_COMMON_RESULTNODE_TO_BASE
+ NS_IMETHOD GetType(uint32_t* type) override {
+ if (mTargetFolderItemId != mItemId) {
+ *type = nsNavHistoryResultNode::RESULT_TYPE_FOLDER_SHORTCUT;
+ } else {
+ *type = nsNavHistoryResultNode::RESULT_TYPE_FOLDER;
+ }
+ return NS_OK;
+ }
+ NS_IMETHOD GetUri(nsACString& aURI) override;
+ NS_FORWARD_CONTAINERNODE_EXCEPT_HASCHILDREN
+ NS_IMETHOD GetHasChildren(bool* aHasChildren) override;
+ NS_DECL_NSINAVHISTORYQUERYRESULTNODE
+
+ virtual nsresult OpenContainer() override;
+
+ virtual nsresult OpenContainerAsync() override;
+ NS_DECL_ASYNCSTATEMENTCALLBACK
+
+ // This object implements a bookmark observer interface. This is called from the
+ // result's actual observer and it knows all observers are FolderResultNodes
+ NS_DECL_NSINAVBOOKMARKOBSERVER
+
+ virtual void OnRemoving() override;
+
+ // this indicates whether the folder contents are valid, they don't go away
+ // after the container is closed until a notification comes in
+ bool mContentsValid;
+
+ // If the node is generated from a place:folder=X query, this is the target
+ // folder id and GUID. For regular folder nodes, they are set to the same
+ // values as mItemId and mBookmarkGuid. For more complex queries, they are set
+ // to -1/an empty string.
+ int64_t mTargetFolderItemId;
+ nsCString mTargetFolderGuid;
+
+ nsresult FillChildren();
+ void ClearChildren(bool aUnregister);
+ nsresult Refresh() override;
+
+ bool StartIncrementalUpdate();
+ void ReindexRange(int32_t aStartIndex, int32_t aEndIndex, int32_t aDelta);
+
+ nsNavHistoryResultNode* FindChildById(int64_t aItemId,
+ uint32_t* aNodeIndex);
+
+protected:
+ virtual ~nsNavHistoryFolderResultNode();
+
+private:
+
+ nsresult OnChildrenFilled();
+ void EnsureRegisteredAsFolderObserver();
+ nsresult FillChildrenAsync();
+
+ bool mIsRegisteredFolderObserver;
+ int32_t mAsyncBookmarkIndex;
+};
+
+// nsNavHistorySeparatorResultNode
+//
+// Separator result nodes do not hold any data.
+class nsNavHistorySeparatorResultNode : public nsNavHistoryResultNode
+{
+public:
+ nsNavHistorySeparatorResultNode();
+
+ NS_IMETHOD GetType(uint32_t* type)
+ { *type = nsNavHistoryResultNode::RESULT_TYPE_SEPARATOR; return NS_OK; }
+};
+
+#endif // nsNavHistoryResult_h_
diff --git a/toolkit/components/places/nsPIPlacesDatabase.idl b/toolkit/components/places/nsPIPlacesDatabase.idl
new file mode 100644
index 000000000..5511b1be6
--- /dev/null
+++ b/toolkit/components/places/nsPIPlacesDatabase.idl
@@ -0,0 +1,52 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 sts=2
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsISupports.idl"
+
+interface mozIStorageConnection;
+interface nsINavHistoryQuery;
+interface nsINavHistoryQueryOptions;
+interface mozIStorageStatementCallback;
+interface mozIStoragePendingStatement;
+interface nsIAsyncShutdownClient;
+
+/**
+ * This is a private interface used by Places components to get access to the
+ * database. If outside consumers wish to use this, they should only read from
+ * the database so they do not break any internal invariants.
+ */
+[scriptable, uuid(366ee63e-a413-477d-9ad6-8d6863e89401)]
+interface nsPIPlacesDatabase : nsISupports
+{
+ /**
+ * The database connection used by Places.
+ */
+ readonly attribute mozIStorageConnection DBConnection;
+
+ /**
+ * Asynchronously executes the statement created from queries.
+ *
+ * @see nsINavHistoryService::executeQueries
+ * @note THIS IS A TEMPORARY API. Don't rely on it, since it will be replaced
+ * in future versions by a real async querying API.
+ * @note Results obtained from this method differ from results obtained from
+ * executeQueries, because there is additional filtering and sorting
+ * done by the latter. Thus you should use executeQueries, unless you
+ * are absolutely sure that the returned results are fine for
+ * your use-case.
+ */
+ mozIStoragePendingStatement asyncExecuteLegacyQueries(
+ [array, size_is(aQueryCount)] in nsINavHistoryQuery aQueries,
+ in unsigned long aQueryCount,
+ in nsINavHistoryQueryOptions aOptions,
+ in mozIStorageStatementCallback aCallback);
+
+ /**
+ * Hook for clients who need to perform actions during/by the end of
+ * the shutdown of the database.
+ */
+ readonly attribute nsIAsyncShutdownClient shutdownClient;
+};
diff --git a/toolkit/components/places/nsPlacesExpiration.js b/toolkit/components/places/nsPlacesExpiration.js
new file mode 100644
index 000000000..499934362
--- /dev/null
+++ b/toolkit/components/places/nsPlacesExpiration.js
@@ -0,0 +1,1105 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 expandtab
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This component handles history and orphans expiration through asynchronous
+ * Storage statements.
+ * Expiration runs:
+ * - At idle, but just once, we stop any other kind of expiration during idle
+ * to preserve batteries in portable devices.
+ * - At shutdown, only if the database is dirty, we should still avoid to
+ * expire too heavily on shutdown.
+ * - On ClearHistory we run a full expiration for privacy reasons.
+ * - On a repeating timer we expire in small chunks.
+ *
+ * Expiration algorithm will adapt itself based on:
+ * - Memory size of the device.
+ * - Status of the database (clean or dirty).
+ */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+
+// Constants
+
+// Last expiration step should run before the final sync.
+const TOPIC_SHUTDOWN = "places-will-close-connection";
+const TOPIC_PREF_CHANGED = "nsPref:changed";
+const TOPIC_DEBUG_START_EXPIRATION = "places-debug-start-expiration";
+const TOPIC_EXPIRATION_FINISHED = "places-expiration-finished";
+const TOPIC_IDLE_BEGIN = "idle";
+const TOPIC_IDLE_END = "active";
+const TOPIC_IDLE_DAILY = "idle-daily";
+const TOPIC_TESTING_MODE = "testing-mode";
+const TOPIC_TEST_INTERVAL_CHANGED = "test-interval-changed";
+
+// Branch for all expiration preferences.
+const PREF_BRANCH = "places.history.expiration.";
+
+// Max number of unique URIs to retain in history.
+// Notice this is a lazy limit. This means we will start to expire if we will
+// go over it, but we won't ensure that we will stop exactly when we reach it,
+// instead we will stop after the next expiration step that will bring us
+// below it.
+// If this preference does not exist or has a negative value, we will calculate
+// a limit based on current hardware.
+const PREF_MAX_URIS = "max_pages";
+const PREF_MAX_URIS_NOTSET = -1; // Use our internally calculated limit.
+
+// We save the current unique URIs limit to this pref, to make it available to
+// other components without having to duplicate the full logic.
+const PREF_READONLY_CALCULATED_MAX_URIS = "transient_current_max_pages";
+
+// Seconds between each expiration step.
+const PREF_INTERVAL_SECONDS = "interval_seconds";
+const PREF_INTERVAL_SECONDS_NOTSET = 3 * 60;
+
+// We calculate an optimal database size, based on hardware specs.
+// This percentage of memory size is used to protect against calculating a too
+// large database size on systems with small memory.
+const DATABASE_TO_MEMORY_PERC = 4;
+// This percentage of disk size is used to protect against calculating a too
+// large database size on disks with tiny quota or available space.
+const DATABASE_TO_DISK_PERC = 2;
+// Maximum size of the optimal database. High-end hardware has plenty of
+// memory and disk space, but performances don't grow linearly.
+const DATABASE_MAX_SIZE = 73400320; // 70MiB
+// If the physical memory size is bogus, fallback to this.
+const MEMSIZE_FALLBACK_BYTES = 268435456; // 256 MiB
+// If the disk available space is bogus, fallback to this.
+const DISKSIZE_FALLBACK_BYTES = 268435456; // 256 MiB
+
+// Max number of entries to expire at each expiration step.
+// This value is globally used for different kind of data we expire, can be
+// tweaked based on data type. See below in getBoundStatement.
+const EXPIRE_LIMIT_PER_STEP = 6;
+// When we run a large expiration step, the above limit is multiplied by this.
+const EXPIRE_LIMIT_PER_LARGE_STEP_MULTIPLIER = 10;
+
+// When history is clean or dirty enough we will adapt the expiration algorithm
+// to be more lazy or more aggressive.
+// This is done acting on the interval between expiration steps and the number
+// of expirable items.
+// 1. Clean history:
+// We expire at (default interval * EXPIRE_AGGRESSIVITY_MULTIPLIER) the
+// default number of entries.
+// 2. Dirty history:
+// We expire at the default interval, but a greater number of entries
+// (default number of entries * EXPIRE_AGGRESSIVITY_MULTIPLIER).
+const EXPIRE_AGGRESSIVITY_MULTIPLIER = 3;
+
+// This is the average size in bytes of an URI entry in the database.
+// Magic numbers are determined through analysis of the distribution of a ratio
+// between number of unique URIs and database size among our users.
+// Used as a fall back value when it's not possible to calculate the real value.
+const URIENTRY_AVG_SIZE = 600;
+
+// Seconds of idle time before starting a larger expiration step.
+// Notice during idle we stop the expiration timer since we don't want to hurt
+// stand-by or mobile devices batteries.
+const IDLE_TIMEOUT_SECONDS = 5 * 60;
+
+// If a clear history ran just before we shutdown, we will skip most of the
+// expiration at shutdown. This is maximum number of seconds from last
+// clearHistory to decide to skip expiration at shutdown.
+const SHUTDOWN_WITH_RECENT_CLEARHISTORY_TIMEOUT_SECONDS = 10;
+
+// If the pages delta from the last ANALYZE is over this threashold, the tables
+// should be analyzed again.
+const ANALYZE_PAGES_THRESHOLD = 100;
+
+// If the number of pages over history limit is greater than this threshold,
+// expiration will be more aggressive, to bring back history to a saner size.
+const OVERLIMIT_PAGES_THRESHOLD = 1000;
+
+const MSECS_PER_DAY = 86400000;
+const ANNOS_EXPIRE_POLICIES = [
+ { bind: "expire_days",
+ type: Ci.nsIAnnotationService.EXPIRE_DAYS,
+ time: 7 * 1000 * MSECS_PER_DAY },
+ { bind: "expire_weeks",
+ type: Ci.nsIAnnotationService.EXPIRE_WEEKS,
+ time: 30 * 1000 * MSECS_PER_DAY },
+ { bind: "expire_months",
+ type: Ci.nsIAnnotationService.EXPIRE_MONTHS,
+ time: 180 * 1000 * MSECS_PER_DAY },
+];
+
+// When we expire we can use these limits:
+// - SMALL for usual partial expirations, will expire a small chunk.
+// - LARGE for idle or shutdown expirations, will expire a large chunk.
+// - UNLIMITED for clearHistory, will expire everything.
+// - DEBUG will use a known limit, passed along with the debug notification.
+const LIMIT = {
+ SMALL: 0,
+ LARGE: 1,
+ UNLIMITED: 2,
+ DEBUG: 3,
+};
+
+// Represents the status of history database.
+const STATUS = {
+ CLEAN: 0,
+ DIRTY: 1,
+ UNKNOWN: 2,
+};
+
+// Represents actions on which a query will run.
+const ACTION = {
+ TIMED: 1 << 0, // happens every this._interval
+ TIMED_OVERLIMIT: 1 << 1, // like TIMED but only when history is over limits
+ TIMED_ANALYZE: 1 << 2, // happens when ANALYZE statistics should be updated
+ CLEAR_HISTORY: 1 << 3, // happens when history is cleared
+ SHUTDOWN_DIRTY: 1 << 4, // happens at shutdown for DIRTY state
+ IDLE_DIRTY: 1 << 5, // happens on idle for DIRTY state
+ IDLE_DAILY: 1 << 6, // happens once a day on idle
+ DEBUG: 1 << 7, // happens on TOPIC_DEBUG_START_EXPIRATION
+};
+
+// The queries we use to expire.
+const EXPIRATION_QUERIES = {
+
+ // Some visits can be expired more often than others, cause they are less
+ // useful to the user and can pollute awesomebar results:
+ // 1. urls over 255 chars
+ // 2. redirect sources and downloads
+ // Note: due to the REPLACE option, this should be executed before
+ // QUERY_FIND_VISITS_TO_EXPIRE, that has a more complete result.
+ QUERY_FIND_EXOTIC_VISITS_TO_EXPIRE: {
+ sql: `INSERT INTO expiration_notify (v_id, url, guid, visit_date, reason)
+ SELECT v.id, h.url, h.guid, v.visit_date, "exotic"
+ FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = v.place_id
+ WHERE visit_date < strftime('%s','now','localtime','start of day','-60 days','utc') * 1000000
+ AND ( LENGTH(h.url) > 255 OR v.visit_type = 7 )
+ ORDER BY v.visit_date ASC
+ LIMIT :limit_visits`,
+ actions: ACTION.TIMED_OVERLIMIT | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Finds visits to be expired when history is over the unique pages limit,
+ // otherwise will return nothing.
+ // This explicitly excludes any visits added in the last 7 days, to protect
+ // users with thousands of bookmarks from constantly losing history.
+ QUERY_FIND_VISITS_TO_EXPIRE: {
+ sql: `INSERT INTO expiration_notify
+ (v_id, url, guid, visit_date, expected_results)
+ SELECT v.id, h.url, h.guid, v.visit_date, :limit_visits
+ FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = v.place_id
+ WHERE (SELECT COUNT(*) FROM moz_places) > :max_uris
+ AND visit_date < strftime('%s','now','localtime','start of day','-7 days','utc') * 1000000
+ ORDER BY v.visit_date ASC
+ LIMIT :limit_visits`,
+ actions: ACTION.TIMED_OVERLIMIT | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Removes the previously found visits.
+ QUERY_EXPIRE_VISITS: {
+ sql: `DELETE FROM moz_historyvisits WHERE id IN (
+ SELECT v_id FROM expiration_notify WHERE v_id NOTNULL
+ )`,
+ actions: ACTION.TIMED_OVERLIMIT | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Finds orphan URIs in the database.
+ // Notice we won't notify single removed URIs on History.clear(), so we don't
+ // run this query in such a case, but just delete URIs.
+ // This could run in the middle of adding a visit or bookmark to a new page.
+ // In such a case since it is async, could end up expiring the orphan page
+ // before it actually gets the new visit or bookmark.
+ // Thus, since new pages get frecency -1, we filter on that.
+ QUERY_FIND_URIS_TO_EXPIRE: {
+ sql: `INSERT INTO expiration_notify (p_id, url, guid, visit_date)
+ SELECT h.id, h.url, h.guid, h.last_visit_date
+ FROM moz_places h
+ LEFT JOIN moz_historyvisits v ON h.id = v.place_id
+ WHERE h.last_visit_date IS NULL
+ AND h.foreign_count = 0
+ AND v.id IS NULL
+ AND frecency <> -1
+ LIMIT :limit_uris`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.SHUTDOWN_DIRTY |
+ ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // Expire found URIs from the database.
+ QUERY_EXPIRE_URIS: {
+ sql: `DELETE FROM moz_places WHERE id IN (
+ SELECT p_id FROM expiration_notify WHERE p_id NOTNULL
+ ) AND foreign_count = 0 AND last_visit_date ISNULL`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.SHUTDOWN_DIRTY |
+ ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // Expire orphan URIs from the database.
+ QUERY_SILENT_EXPIRE_ORPHAN_URIS: {
+ sql: `DELETE FROM moz_places WHERE id IN (
+ SELECT h.id
+ FROM moz_places h
+ LEFT JOIN moz_historyvisits v ON h.id = v.place_id
+ WHERE h.last_visit_date IS NULL
+ AND h.foreign_count = 0
+ AND v.id IS NULL
+ LIMIT :limit_uris
+ )`,
+ actions: ACTION.CLEAR_HISTORY
+ },
+
+ // Hosts accumulated during the places delete are updated through a trigger
+ // (see nsPlacesTriggers.h).
+ QUERY_UPDATE_HOSTS: {
+ sql: `DELETE FROM moz_updatehosts_temp`,
+ actions: ACTION.CLEAR_HISTORY | ACTION.TIMED | ACTION.TIMED_OVERLIMIT |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire orphan icons from the database.
+ QUERY_EXPIRE_FAVICONS: {
+ sql: `DELETE FROM moz_favicons WHERE id IN (
+ SELECT f.id FROM moz_favicons f
+ LEFT JOIN moz_places h ON f.id = h.favicon_id
+ WHERE h.favicon_id IS NULL
+ LIMIT :limit_favicons
+ )`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire orphan page annotations from the database.
+ QUERY_EXPIRE_ANNOS: {
+ sql: `DELETE FROM moz_annos WHERE id in (
+ SELECT a.id FROM moz_annos a
+ LEFT JOIN moz_places h ON a.place_id = h.id
+ WHERE h.id IS NULL
+ LIMIT :limit_annos
+ )`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire page annotations based on expiration policy.
+ QUERY_EXPIRE_ANNOS_WITH_POLICY: {
+ sql: `DELETE FROM moz_annos
+ WHERE (expiration = :expire_days
+ AND :expire_days_time > MAX(lastModified, dateAdded))
+ OR (expiration = :expire_weeks
+ AND :expire_weeks_time > MAX(lastModified, dateAdded))
+ OR (expiration = :expire_months
+ AND :expire_months_time > MAX(lastModified, dateAdded))`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire items annotations based on expiration policy.
+ QUERY_EXPIRE_ITEMS_ANNOS_WITH_POLICY: {
+ sql: `DELETE FROM moz_items_annos
+ WHERE (expiration = :expire_days
+ AND :expire_days_time > MAX(lastModified, dateAdded))
+ OR (expiration = :expire_weeks
+ AND :expire_weeks_time > MAX(lastModified, dateAdded))
+ OR (expiration = :expire_months
+ AND :expire_months_time > MAX(lastModified, dateAdded))`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire page annotations based on expiration policy.
+ QUERY_EXPIRE_ANNOS_WITH_HISTORY: {
+ sql: `DELETE FROM moz_annos
+ WHERE expiration = :expire_with_history
+ AND NOT EXISTS (SELECT id FROM moz_historyvisits
+ WHERE place_id = moz_annos.place_id LIMIT 1)`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire item annos without a corresponding item id.
+ QUERY_EXPIRE_ITEMS_ANNOS: {
+ sql: `DELETE FROM moz_items_annos WHERE id IN (
+ SELECT a.id FROM moz_items_annos a
+ LEFT JOIN moz_bookmarks b ON a.item_id = b.id
+ WHERE b.id IS NULL
+ LIMIT :limit_annos
+ )`,
+ actions: ACTION.CLEAR_HISTORY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // Expire all annotation names without a corresponding annotation.
+ QUERY_EXPIRE_ANNO_ATTRIBUTES: {
+ sql: `DELETE FROM moz_anno_attributes WHERE id IN (
+ SELECT n.id FROM moz_anno_attributes n
+ LEFT JOIN moz_annos a ON n.id = a.anno_attribute_id
+ LEFT JOIN moz_items_annos t ON n.id = t.anno_attribute_id
+ WHERE a.anno_attribute_id IS NULL
+ AND t.anno_attribute_id IS NULL
+ LIMIT :limit_annos
+ )`,
+ actions: ACTION.CLEAR_HISTORY | ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY |
+ ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // Expire orphan inputhistory.
+ QUERY_EXPIRE_INPUTHISTORY: {
+ sql: `DELETE FROM moz_inputhistory WHERE place_id IN (
+ SELECT i.place_id FROM moz_inputhistory i
+ LEFT JOIN moz_places h ON h.id = i.place_id
+ WHERE h.id IS NULL
+ LIMIT :limit_inputhistory
+ )`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.CLEAR_HISTORY |
+ ACTION.SHUTDOWN_DIRTY | ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY |
+ ACTION.DEBUG
+ },
+
+ // Expire all session annotations. Should only be called at shutdown.
+ QUERY_EXPIRE_ANNOS_SESSION: {
+ sql: "DELETE FROM moz_annos WHERE expiration = :expire_session",
+ actions: ACTION.CLEAR_HISTORY | ACTION.DEBUG
+ },
+
+ // Expire all session item annotations. Should only be called at shutdown.
+ QUERY_EXPIRE_ITEMS_ANNOS_SESSION: {
+ sql: "DELETE FROM moz_items_annos WHERE expiration = :expire_session",
+ actions: ACTION.CLEAR_HISTORY | ACTION.DEBUG
+ },
+
+ // Select entries for notifications.
+ // If p_id is set whole_entry = 1, then we have expired the full page.
+ // Either p_id or v_id are always set.
+ QUERY_SELECT_NOTIFICATIONS: {
+ sql: `SELECT url, guid, MAX(visit_date) AS visit_date,
+ MAX(IFNULL(MIN(p_id, 1), MIN(v_id, 0))) AS whole_entry,
+ MAX(expected_results) AS expected_results,
+ (SELECT MAX(visit_date) FROM expiration_notify
+ WHERE reason = "expired" AND url = n.url AND p_id ISNULL
+ ) AS most_recent_expired_visit
+ FROM expiration_notify n
+ GROUP BY url`,
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.SHUTDOWN_DIRTY |
+ ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // Empty the notifications table.
+ QUERY_DELETE_NOTIFICATIONS: {
+ sql: "DELETE FROM expiration_notify",
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.SHUTDOWN_DIRTY |
+ ACTION.IDLE_DIRTY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+
+ // The following queries are used to adjust the sqlite_stat1 table to help the
+ // query planner create better queries. These should always be run LAST, and
+ // are therefore at the end of the object.
+ // Since also nsNavHistory.cpp executes ANALYZE, the analyzed tables
+ // must be the same in both components. So ensure they are in sync.
+
+ QUERY_ANALYZE_MOZ_PLACES: {
+ sql: "ANALYZE moz_places",
+ actions: ACTION.TIMED_OVERLIMIT | ACTION.TIMED_ANALYZE |
+ ACTION.CLEAR_HISTORY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+ QUERY_ANALYZE_MOZ_BOOKMARKS: {
+ sql: "ANALYZE moz_bookmarks",
+ actions: ACTION.TIMED_ANALYZE | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+ QUERY_ANALYZE_MOZ_HISTORYVISITS: {
+ sql: "ANALYZE moz_historyvisits",
+ actions: ACTION.TIMED_OVERLIMIT | ACTION.TIMED_ANALYZE |
+ ACTION.CLEAR_HISTORY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+ QUERY_ANALYZE_MOZ_INPUTHISTORY: {
+ sql: "ANALYZE moz_inputhistory",
+ actions: ACTION.TIMED | ACTION.TIMED_OVERLIMIT | ACTION.TIMED_ANALYZE |
+ ACTION.CLEAR_HISTORY | ACTION.IDLE_DAILY | ACTION.DEBUG
+ },
+};
+
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ * array of nsINavBookmarkObserver objects.
+ * @param notification
+ * the notification name.
+ * @param args
+ * array of arguments to pass to the notification.
+ */
+function notify(observers, notification, args = []) {
+ for (let observer of observers) {
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+}
+
+// nsPlacesExpiration definition
+
+function nsPlacesExpiration()
+{
+ // Smart Getters
+
+ XPCOMUtils.defineLazyGetter(this, "_db", function () {
+ let db = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsPIPlacesDatabase).
+ DBConnection;
+
+ // Create the temporary notifications table.
+ let stmt = db.createAsyncStatement(
+ `CREATE TEMP TABLE expiration_notify (
+ id INTEGER PRIMARY KEY
+ , v_id INTEGER
+ , p_id INTEGER
+ , url TEXT NOT NULL
+ , guid TEXT NOT NULL
+ , visit_date INTEGER
+ , expected_results INTEGER NOT NULL DEFAULT 0
+ , reason TEXT NOT NULL DEFAULT "expired"
+ )`);
+ stmt.executeAsync();
+ stmt.finalize();
+
+ return db;
+ });
+
+ XPCOMUtils.defineLazyServiceGetter(this, "_idle",
+ "@mozilla.org/widget/idleservice;1",
+ "nsIIdleService");
+
+ this._prefBranch = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefService).
+ getBranch(PREF_BRANCH);
+
+ this._loadPrefs().then(() => {
+ // Observe our preferences branch for changes.
+ this._prefBranch.addObserver("", this, true);
+
+ // Create our expiration timer.
+ this._newTimer();
+ }, Cu.reportError);
+
+ // Register topic observers.
+ Services.obs.addObserver(this, TOPIC_SHUTDOWN, true);
+ Services.obs.addObserver(this, TOPIC_DEBUG_START_EXPIRATION, true);
+ Services.obs.addObserver(this, TOPIC_IDLE_DAILY, true);
+}
+
+nsPlacesExpiration.prototype = {
+
+ // nsIObserver
+
+ observe: function PEX_observe(aSubject, aTopic, aData)
+ {
+ if (this._shuttingDown) {
+ return;
+ }
+
+ if (aTopic == TOPIC_SHUTDOWN) {
+ this._shuttingDown = true;
+ this.expireOnIdle = false;
+
+ if (this._timer) {
+ this._timer.cancel();
+ this._timer = null;
+ }
+
+ // If we didn't ran a clearHistory recently and database is dirty, we
+ // want to expire some entries, to speed up the expiration process.
+ let hasRecentClearHistory =
+ Date.now() - this._lastClearHistoryTime <
+ SHUTDOWN_WITH_RECENT_CLEARHISTORY_TIMEOUT_SECONDS * 1000;
+ if (!hasRecentClearHistory && this.status == STATUS.DIRTY) {
+ this._expireWithActionAndLimit(ACTION.SHUTDOWN_DIRTY, LIMIT.LARGE);
+ }
+
+ this._finalizeInternalStatements();
+ }
+ else if (aTopic == TOPIC_PREF_CHANGED) {
+ this._loadPrefs().then(() => {
+ if (aData == PREF_INTERVAL_SECONDS) {
+ // Renew the timer with the new interval value.
+ this._newTimer();
+ }
+ }, Cu.reportError);
+ }
+ else if (aTopic == TOPIC_DEBUG_START_EXPIRATION) {
+ // The passed-in limit is the maximum number of visits to expire when
+ // history is over capacity. Mind to correctly handle the NaN value.
+ let limit = parseInt(aData);
+ if (limit == -1) {
+ // Everything should be expired without any limit. If history is over
+ // capacity then all existing visits will be expired.
+ // Should only be used in tests, since may cause dataloss.
+ this._expireWithActionAndLimit(ACTION.DEBUG, LIMIT.UNLIMITED);
+ }
+ else if (limit > 0) {
+ // The number of expired visits is limited by this amount. It may be
+ // used for testing purposes, like checking that limited queries work.
+ this._debugLimit = limit;
+ this._expireWithActionAndLimit(ACTION.DEBUG, LIMIT.DEBUG);
+ }
+ else {
+ // Any other value is intended as a 0 limit, that means no visits
+ // will be expired. Even if this doesn't touch visits, it will remove
+ // any orphan pages, icons, annotations and similar from the database,
+ // so it may be used for cleanup purposes.
+ this._debugLimit = -1;
+ this._expireWithActionAndLimit(ACTION.DEBUG, LIMIT.DEBUG);
+ }
+ }
+ else if (aTopic == TOPIC_IDLE_BEGIN) {
+ // Stop the expiration timer. We don't want to keep up expiring on idle
+ // to preserve batteries on mobile devices and avoid killing stand-by.
+ if (this._timer) {
+ this._timer.cancel();
+ this._timer = null;
+ }
+ if (this.expireOnIdle)
+ this._expireWithActionAndLimit(ACTION.IDLE_DIRTY, LIMIT.LARGE);
+ }
+ else if (aTopic == TOPIC_IDLE_END) {
+ // Restart the expiration timer.
+ if (!this._timer)
+ this._newTimer();
+ }
+ else if (aTopic == TOPIC_IDLE_DAILY) {
+ this._expireWithActionAndLimit(ACTION.IDLE_DAILY, LIMIT.LARGE);
+ }
+ else if (aTopic == TOPIC_TESTING_MODE) {
+ this._testingMode = true;
+ }
+ },
+
+ // nsINavHistoryObserver
+
+ _inBatchMode: false,
+ onBeginUpdateBatch: function PEX_onBeginUpdateBatch()
+ {
+ this._inBatchMode = true;
+
+ // We do not want to expire while we are doing batch work.
+ if (this._timer) {
+ this._timer.cancel();
+ this._timer = null;
+ }
+ },
+
+ onEndUpdateBatch: function PEX_onEndUpdateBatch()
+ {
+ this._inBatchMode = false;
+
+ // Restore timer.
+ if (!this._timer)
+ this._newTimer();
+ },
+
+ _lastClearHistoryTime: 0,
+ onClearHistory: function PEX_onClearHistory() {
+ this._lastClearHistoryTime = Date.now();
+ // Expire orphans. History status is clean after a clear history.
+ this.status = STATUS.CLEAN;
+ this._expireWithActionAndLimit(ACTION.CLEAR_HISTORY, LIMIT.UNLIMITED);
+ },
+
+ onVisit: function() {},
+ onTitleChanged: function() {},
+ onDeleteURI: function() {},
+ onPageChanged: function() {},
+ onDeleteVisits: function() {},
+
+ // nsITimerCallback
+
+ notify: function PEX_timerCallback()
+ {
+ // Check if we are over history capacity, if so visits must be expired.
+ this._getPagesStats((function onPagesCount(aPagesCount, aStatsCount) {
+ let overLimitPages = aPagesCount - this._urisLimit;
+ this._overLimit = overLimitPages > 0;
+
+ let action = this._overLimit ? ACTION.TIMED_OVERLIMIT : ACTION.TIMED;
+ // If the number of pages changed significantly from the last ANALYZE
+ // update SQLite statistics.
+ if (Math.abs(aPagesCount - aStatsCount) >= ANALYZE_PAGES_THRESHOLD) {
+ action = action | ACTION.TIMED_ANALYZE;
+ }
+
+ // Adapt expiration aggressivity to the number of pages over the limit.
+ let limit = overLimitPages > OVERLIMIT_PAGES_THRESHOLD ? LIMIT.LARGE
+ : LIMIT.SMALL;
+
+ this._expireWithActionAndLimit(action, limit);
+ }).bind(this));
+ },
+
+ // mozIStorageStatementCallback
+
+ handleResult: function PEX_handleResult(aResultSet)
+ {
+ // We don't want to notify after shutdown.
+ if (this._shuttingDown)
+ return;
+
+ let row;
+ while ((row = aResultSet.getNextRow())) {
+ // expected_results is set to the number of expected visits by
+ // QUERY_FIND_VISITS_TO_EXPIRE. We decrease that counter for each found
+ // visit and if it reaches zero we mark the database as dirty, since all
+ // the expected visits were expired, so it's likely the next run will
+ // find more.
+ let expectedResults = row.getResultByName("expected_results");
+ if (expectedResults > 0) {
+ if (!("_expectedResultsCount" in this)) {
+ this._expectedResultsCount = expectedResults;
+ }
+ if (this._expectedResultsCount > 0) {
+ this._expectedResultsCount--;
+ }
+ }
+
+ let uri = Services.io.newURI(row.getResultByName("url"), null, null);
+ let guid = row.getResultByName("guid");
+ let visitDate = row.getResultByName("visit_date");
+ let wholeEntry = row.getResultByName("whole_entry");
+ let mostRecentExpiredVisit = row.getResultByName("most_recent_expired_visit");
+ let reason = Ci.nsINavHistoryObserver.REASON_EXPIRED;
+ let observers = PlacesUtils.history.getObservers();
+
+ if (mostRecentExpiredVisit) {
+ let days = parseInt((Date.now() - (mostRecentExpiredVisit / 1000)) / MSECS_PER_DAY);
+ if (!this._mostRecentExpiredVisitDays) {
+ this._mostRecentExpiredVisitDays = days;
+ }
+ else if (days < this._mostRecentExpiredVisitDays) {
+ this._mostRecentExpiredVisitDays = days;
+ }
+ }
+
+ // Dispatch expiration notifications to history.
+ if (wholeEntry) {
+ notify(observers, "onDeleteURI", [uri, guid, reason]);
+ } else {
+ notify(observers, "onDeleteVisits", [uri, visitDate, guid, reason, 0]);
+ }
+ }
+ },
+
+ handleError: function PEX_handleError(aError)
+ {
+ Cu.reportError("Async statement execution returned with '" +
+ aError.result + "', '" + aError.message + "'");
+ },
+
+ // Number of expiration steps needed to reach a CLEAN status.
+ _telemetrySteps: 1,
+ handleCompletion: function PEX_handleCompletion(aReason)
+ {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+
+ if (this._mostRecentExpiredVisitDays) {
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_MOST_RECENT_EXPIRED_VISIT_DAYS")
+ .add(this._mostRecentExpiredVisitDays);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ } finally {
+ delete this._mostRecentExpiredVisitDays;
+ }
+ }
+
+ if ("_expectedResultsCount" in this) {
+ // Adapt the aggressivity of steps based on the status of history.
+ // A dirty history will return all the entries we are expecting bringing
+ // our countdown to zero, while a clean one will not.
+ let oldStatus = this.status;
+ this.status = this._expectedResultsCount == 0 ? STATUS.DIRTY
+ : STATUS.CLEAN;
+
+ // Collect or send telemetry data.
+ if (this.status == STATUS.DIRTY) {
+ this._telemetrySteps++;
+ }
+ else {
+ // Avoid reporting the common cases where the database is clean, or
+ // a single step is needed.
+ if (oldStatus == STATUS.DIRTY) {
+ try {
+ Services.telemetry
+ .getHistogramById("PLACES_EXPIRATION_STEPS_TO_CLEAN2")
+ .add(this._telemetrySteps);
+ } catch (ex) {
+ Components.utils.reportError("Unable to report telemetry.");
+ }
+ }
+ this._telemetrySteps = 1;
+ }
+
+ delete this._expectedResultsCount;
+ }
+
+ // Dispatch a notification that expiration has finished.
+ Services.obs.notifyObservers(null, TOPIC_EXPIRATION_FINISHED, null);
+ }
+ },
+
+ // nsPlacesExpiration
+
+ _urisLimit: PREF_MAX_URIS_NOTSET,
+ _interval: PREF_INTERVAL_SECONDS_NOTSET,
+ _shuttingDown: false,
+
+ _status: STATUS.UNKNOWN,
+ set status(aNewStatus) {
+ if (aNewStatus != this._status) {
+ // If status changes we should restart the timer.
+ this._status = aNewStatus;
+ this._newTimer();
+ // If needed add/remove the cleanup step on idle. We want to expire on
+ // idle only if history is dirty, to preserve mobile devices batteries.
+ this.expireOnIdle = aNewStatus == STATUS.DIRTY;
+ }
+ return aNewStatus;
+ },
+ get status() {
+ return this._status;
+ },
+
+ _isIdleObserver: false,
+ _expireOnIdle: false,
+ set expireOnIdle(aExpireOnIdle) {
+ // Observe idle regardless aExpireOnIdle, since we always want to stop
+ // timed expiration on idle, to preserve mobile battery life.
+ if (!this._isIdleObserver && !this._shuttingDown) {
+ this._idle.addIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._isIdleObserver = true;
+ }
+ else if (this._isIdleObserver && this._shuttingDown) {
+ this._idle.removeIdleObserver(this, IDLE_TIMEOUT_SECONDS);
+ this._isIdleObserver = false;
+ }
+
+ // If running a debug expiration we need full control of what happens
+ // but idle cleanup could activate in the middle, since tinderboxes are
+ // permanently idle. That would cause unexpected oranges, so disable it.
+ if (this._debugLimit !== undefined)
+ this._expireOnIdle = false;
+ else
+ this._expireOnIdle = aExpireOnIdle;
+ return this._expireOnIdle;
+ },
+ get expireOnIdle() {
+ return this._expireOnIdle;
+ },
+
+ _loadPrefs: Task.async(function* () {
+ // Get the user's limit, if it was set.
+ try {
+ // We want to silently fail since getIntPref throws if it does not exist,
+ // and use a default to fallback to.
+ this._urisLimit = this._prefBranch.getIntPref(PREF_MAX_URIS);
+ } catch (ex) { /* User limit not set */ }
+
+ if (this._urisLimit < 0) {
+ // Some testing code expects a pref change to be synchronous, so
+ // temporarily set this to a large value, while we asynchronously update
+ // to the correct value.
+ this._urisLimit = 300000;
+
+ // The user didn't specify a custom limit, so we calculate the number of
+ // unique places that may fit an optimal database size on this hardware.
+ // Oldest pages over this threshold will be expired.
+ let memSizeBytes = MEMSIZE_FALLBACK_BYTES;
+ try {
+ // Limit the size on systems with small memory.
+ memSizeBytes = Services.sysinfo.getProperty("memsize");
+ } catch (ex) {}
+ if (memSizeBytes <= 0) {
+ memsize = MEMSIZE_FALLBACK_BYTES;
+ }
+
+ let diskAvailableBytes = DISKSIZE_FALLBACK_BYTES;
+ try {
+ // Protect against a full disk or tiny quota.
+ let dbFile = this._db.databaseFile;
+ dbFile.QueryInterface(Ci.nsILocalFile);
+ diskAvailableBytes = dbFile.diskSpaceAvailable;
+ } catch (ex) {}
+ if (diskAvailableBytes <= 0) {
+ diskAvailableBytes = DISKSIZE_FALLBACK_BYTES;
+ }
+
+ let optimalDatabaseSize = Math.min(
+ memSizeBytes * DATABASE_TO_MEMORY_PERC / 100,
+ diskAvailableBytes * DATABASE_TO_DISK_PERC / 100,
+ DATABASE_MAX_SIZE
+ );
+
+ // Calculate avg size of a URI in the database.
+ let db = yield PlacesUtils.promiseDBConnection();
+ let pageSize = (yield db.execute(`PRAGMA page_size`))[0].getResultByIndex(0);
+ let pageCount = (yield db.execute(`PRAGMA page_count`))[0].getResultByIndex(0);
+ let freelistCount = (yield db.execute(`PRAGMA freelist_count`))[0].getResultByIndex(0);
+ let dbSize = (pageCount - freelistCount) * pageSize;
+ let uriCount = (yield db.execute(`SELECT count(*) FROM moz_places`))[0].getResultByIndex(0);
+ let avgURISize = Math.ceil(dbSize / uriCount);
+ // For new profiles this value may be too large, due to the Sqlite header,
+ // or Infinity when there are no pages. Thus we must limit it.
+ if (avgURISize > (URIENTRY_AVG_SIZE * 3)) {
+ avgURISize = URIENTRY_AVG_SIZE;
+ }
+ this._urisLimit = Math.ceil(optimalDatabaseSize / avgURISize);
+ }
+
+ // Expose the calculated limit to other components.
+ this._prefBranch.setIntPref(PREF_READONLY_CALCULATED_MAX_URIS,
+ this._urisLimit);
+
+ // Get the expiration interval value.
+ try {
+ // We want to silently fail since getIntPref throws if it does not exist,
+ // and use a default to fallback to.
+ this._interval = this._prefBranch.getIntPref(PREF_INTERVAL_SECONDS);
+ } catch (ex) { /* User interval not set */ }
+ if (this._interval <= 0) {
+ this._interval = PREF_INTERVAL_SECONDS_NOTSET;
+ }
+ }),
+
+ /**
+ * Evaluates the real number of pages in the database and the value currently
+ * used by the SQLite query planner.
+ *
+ * @param aCallback
+ * invoked on success, function (aPagesCount, aStatsCount).
+ */
+ _getPagesStats: function PEX__getPagesStats(aCallback) {
+ if (!this._cachedStatements["LIMIT_COUNT"]) {
+ this._cachedStatements["LIMIT_COUNT"] = this._db.createAsyncStatement(
+ `SELECT (SELECT COUNT(*) FROM moz_places),
+ (SELECT SUBSTR(stat,1,LENGTH(stat)-2) FROM sqlite_stat1
+ WHERE idx = 'moz_places_url_uniqueindex')`
+ );
+ }
+ this._cachedStatements["LIMIT_COUNT"].executeAsync({
+ _pagesCount: 0,
+ _statsCount: 0,
+ handleResult: function(aResults) {
+ let row = aResults.getNextRow();
+ this._pagesCount = row.getResultByIndex(0);
+ this._statsCount = row.getResultByIndex(1);
+ },
+ handleCompletion: function (aReason) {
+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
+ aCallback(this._pagesCount, this._statsCount);
+ }
+ },
+ handleError: function(aError) {
+ Cu.reportError("Async statement execution returned with '" +
+ aError.result + "', '" + aError.message + "'");
+ }
+ });
+ },
+
+ /**
+ * Execute async statements to expire with the specified queries.
+ *
+ * @param aAction
+ * The ACTION we are expiring for. See the ACTION const for values.
+ * @param aLimit
+ * Whether to use small, large or no limits when expiring. See the
+ * LIMIT const for values.
+ */
+ _expireWithActionAndLimit:
+ function PEX__expireWithActionAndLimit(aAction, aLimit)
+ {
+ // Skip expiration during batch mode.
+ if (this._inBatchMode)
+ return;
+ // Don't try to further expire after shutdown.
+ if (this._shuttingDown && aAction != ACTION.SHUTDOWN_DIRTY) {
+ return;
+ }
+
+ let boundStatements = [];
+ for (let queryType in EXPIRATION_QUERIES) {
+ if (EXPIRATION_QUERIES[queryType].actions & aAction)
+ boundStatements.push(this._getBoundStatement(queryType, aLimit, aAction));
+ }
+
+ // Execute statements asynchronously in a transaction.
+ this._db.executeAsync(boundStatements, boundStatements.length, this);
+ },
+
+ /**
+ * Finalizes all of our mozIStorageStatements so we can properly close the
+ * database.
+ */
+ _finalizeInternalStatements: function PEX__finalizeInternalStatements()
+ {
+ for (let queryType in this._cachedStatements) {
+ let stmt = this._cachedStatements[queryType];
+ stmt.finalize();
+ }
+ },
+
+ /**
+ * Generate the statement used for expiration.
+ *
+ * @param aQueryType
+ * Type of the query to build statement for.
+ * @param aLimit
+ * Whether to use small, large or no limits when expiring. See the
+ * LIMIT const for values.
+ * @param aAction
+ * Current action causing the expiration. See the ACTION const.
+ */
+ _cachedStatements: {},
+ _getBoundStatement: function PEX__getBoundStatement(aQueryType, aLimit, aAction)
+ {
+ // Statements creation can be expensive, so we want to cache them.
+ let stmt = this._cachedStatements[aQueryType];
+ if (stmt === undefined) {
+ stmt = this._cachedStatements[aQueryType] =
+ this._db.createAsyncStatement(EXPIRATION_QUERIES[aQueryType].sql);
+ }
+
+ let baseLimit;
+ switch (aLimit) {
+ case LIMIT.UNLIMITED:
+ baseLimit = -1;
+ break;
+ case LIMIT.SMALL:
+ baseLimit = EXPIRE_LIMIT_PER_STEP;
+ break;
+ case LIMIT.LARGE:
+ baseLimit = EXPIRE_LIMIT_PER_STEP * EXPIRE_LIMIT_PER_LARGE_STEP_MULTIPLIER;
+ break;
+ case LIMIT.DEBUG:
+ baseLimit = this._debugLimit;
+ break;
+ }
+ if (this.status == STATUS.DIRTY && aAction != ACTION.DEBUG &&
+ baseLimit > 0) {
+ baseLimit *= EXPIRE_AGGRESSIVITY_MULTIPLIER;
+ }
+
+ // Bind the appropriate parameters.
+ let params = stmt.params;
+ switch (aQueryType) {
+ case "QUERY_FIND_EXOTIC_VISITS_TO_EXPIRE":
+ // Avoid expiring all visits in case of an unlimited debug expiration,
+ // just remove orphans instead.
+ params.limit_visits =
+ aLimit == LIMIT.DEBUG && baseLimit == -1 ? 0 : baseLimit;
+ break;
+ case "QUERY_FIND_VISITS_TO_EXPIRE":
+ params.max_uris = this._urisLimit;
+ // Avoid expiring all visits in case of an unlimited debug expiration,
+ // just remove orphans instead.
+ params.limit_visits =
+ aLimit == LIMIT.DEBUG && baseLimit == -1 ? 0 : baseLimit;
+ break;
+ case "QUERY_FIND_URIS_TO_EXPIRE":
+ params.limit_uris = baseLimit;
+ break;
+ case "QUERY_SILENT_EXPIRE_ORPHAN_URIS":
+ params.limit_uris = baseLimit;
+ break;
+ case "QUERY_EXPIRE_FAVICONS":
+ params.limit_favicons = baseLimit;
+ break;
+ case "QUERY_EXPIRE_ANNOS":
+ // Each page may have multiple annos.
+ params.limit_annos = baseLimit * EXPIRE_AGGRESSIVITY_MULTIPLIER;
+ break;
+ case "QUERY_EXPIRE_ANNOS_WITH_POLICY":
+ case "QUERY_EXPIRE_ITEMS_ANNOS_WITH_POLICY":
+ let microNow = Date.now() * 1000;
+ ANNOS_EXPIRE_POLICIES.forEach(function(policy) {
+ params[policy.bind] = policy.type;
+ params[policy.bind + "_time"] = microNow - policy.time;
+ });
+ break;
+ case "QUERY_EXPIRE_ANNOS_WITH_HISTORY":
+ params.expire_with_history = Ci.nsIAnnotationService.EXPIRE_WITH_HISTORY;
+ break;
+ case "QUERY_EXPIRE_ITEMS_ANNOS":
+ params.limit_annos = baseLimit;
+ break;
+ case "QUERY_EXPIRE_ANNO_ATTRIBUTES":
+ params.limit_annos = baseLimit;
+ break;
+ case "QUERY_EXPIRE_INPUTHISTORY":
+ params.limit_inputhistory = baseLimit;
+ break;
+ case "QUERY_EXPIRE_ANNOS_SESSION":
+ case "QUERY_EXPIRE_ITEMS_ANNOS_SESSION":
+ params.expire_session = Ci.nsIAnnotationService.EXPIRE_SESSION;
+ break;
+ }
+
+ return stmt;
+ },
+
+ /**
+ * Creates a new timer based on this._interval.
+ *
+ * @return a REPEATING_SLACK nsITimer that runs every this._interval.
+ */
+ _newTimer: function PEX__newTimer()
+ {
+ if (this._timer)
+ this._timer.cancel();
+ if (this._shuttingDown)
+ return undefined;
+ let interval = this.status != STATUS.DIRTY ?
+ this._interval * EXPIRE_AGGRESSIVITY_MULTIPLIER : this._interval;
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(this, interval * 1000,
+ Ci.nsITimer.TYPE_REPEATING_SLACK);
+ if (this._testingMode) {
+ Services.obs.notifyObservers(null, TOPIC_TEST_INTERVAL_CHANGED,
+ interval);
+ }
+ return this._timer = timer;
+ },
+
+ // nsISupports
+
+ classID: Components.ID("705a423f-2f69-42f3-b9fe-1517e0dee56f"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(nsPlacesExpiration),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver
+ , Ci.nsINavHistoryObserver
+ , Ci.nsITimerCallback
+ , Ci.mozIStorageStatementCallback
+ , Ci.nsISupportsWeakReference
+ ])
+};
+
+// Module Registration
+
+var components = [nsPlacesExpiration];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
diff --git a/toolkit/components/places/nsPlacesIndexes.h b/toolkit/components/places/nsPlacesIndexes.h
new file mode 100644
index 000000000..9cce5a0aa
--- /dev/null
+++ b/toolkit/components/places/nsPlacesIndexes.h
@@ -0,0 +1,124 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef nsPlacesIndexes_h__
+#define nsPlacesIndexes_h__
+
+#define CREATE_PLACES_IDX(__name, __table, __columns, __type) \
+ NS_LITERAL_CSTRING( \
+ "CREATE " __type " INDEX IF NOT EXISTS " __table "_" __name \
+ " ON " __table " (" __columns ")" \
+ )
+
+/**
+ * moz_places
+ */
+#define CREATE_IDX_MOZ_PLACES_URL_HASH \
+ CREATE_PLACES_IDX( \
+ "url_hashindex", "moz_places", "url_hash", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_FAVICON \
+ CREATE_PLACES_IDX( \
+ "faviconindex", "moz_places", "favicon_id", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_REVHOST \
+ CREATE_PLACES_IDX( \
+ "hostindex", "moz_places", "rev_host", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_VISITCOUNT \
+ CREATE_PLACES_IDX( \
+ "visitcount", "moz_places", "visit_count", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_FRECENCY \
+ CREATE_PLACES_IDX( \
+ "frecencyindex", "moz_places", "frecency", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_LASTVISITDATE \
+ CREATE_PLACES_IDX( \
+ "lastvisitdateindex", "moz_places", "last_visit_date", "" \
+ )
+
+#define CREATE_IDX_MOZ_PLACES_GUID \
+ CREATE_PLACES_IDX( \
+ "guid_uniqueindex", "moz_places", "guid", "UNIQUE" \
+ )
+
+/**
+ * moz_historyvisits
+ */
+
+#define CREATE_IDX_MOZ_HISTORYVISITS_PLACEDATE \
+ CREATE_PLACES_IDX( \
+ "placedateindex", "moz_historyvisits", "place_id, visit_date", "" \
+ )
+
+#define CREATE_IDX_MOZ_HISTORYVISITS_FROMVISIT \
+ CREATE_PLACES_IDX( \
+ "fromindex", "moz_historyvisits", "from_visit", "" \
+ )
+
+#define CREATE_IDX_MOZ_HISTORYVISITS_VISITDATE \
+ CREATE_PLACES_IDX( \
+ "dateindex", "moz_historyvisits", "visit_date", "" \
+ )
+
+/**
+ * moz_bookmarks
+ */
+
+#define CREATE_IDX_MOZ_BOOKMARKS_PLACETYPE \
+ CREATE_PLACES_IDX( \
+ "itemindex", "moz_bookmarks", "fk, type", "" \
+ )
+
+#define CREATE_IDX_MOZ_BOOKMARKS_PARENTPOSITION \
+ CREATE_PLACES_IDX( \
+ "parentindex", "moz_bookmarks", "parent, position", "" \
+ )
+
+#define CREATE_IDX_MOZ_BOOKMARKS_PLACELASTMODIFIED \
+ CREATE_PLACES_IDX( \
+ "itemlastmodifiedindex", "moz_bookmarks", "fk, lastModified", "" \
+ )
+
+#define CREATE_IDX_MOZ_BOOKMARKS_GUID \
+ CREATE_PLACES_IDX( \
+ "guid_uniqueindex", "moz_bookmarks", "guid", "UNIQUE" \
+ )
+
+/**
+ * moz_annos
+ */
+
+#define CREATE_IDX_MOZ_ANNOS_PLACEATTRIBUTE \
+ CREATE_PLACES_IDX( \
+ "placeattributeindex", "moz_annos", "place_id, anno_attribute_id", "UNIQUE" \
+ )
+
+/**
+ * moz_items_annos
+ */
+
+#define CREATE_IDX_MOZ_ITEMSANNOS_PLACEATTRIBUTE \
+ CREATE_PLACES_IDX( \
+ "itemattributeindex", "moz_items_annos", "item_id, anno_attribute_id", "UNIQUE" \
+ )
+
+/**
+ * moz_keywords
+ */
+
+#define CREATE_IDX_MOZ_KEYWORDS_PLACEPOSTDATA \
+ CREATE_PLACES_IDX( \
+ "placepostdata_uniqueindex", "moz_keywords", "place_id, post_data", "UNIQUE" \
+ )
+
+#endif // nsPlacesIndexes_h__
diff --git a/toolkit/components/places/nsPlacesMacros.h b/toolkit/components/places/nsPlacesMacros.h
new file mode 100644
index 000000000..47ebe17ac
--- /dev/null
+++ b/toolkit/components/places/nsPlacesMacros.h
@@ -0,0 +1,82 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsIConsoleService.h"
+#include "nsIScriptError.h"
+
+#ifndef __FUNCTION__
+#define __FUNCTION__ __func__
+#endif
+
+// Call a method on each observer in a category cache, then call the same
+// method on the observer array.
+#define NOTIFY_OBSERVERS(canFire, cache, array, type, method) \
+ PR_BEGIN_MACRO \
+ if (canFire) { \
+ nsCOMArray<type> entries; \
+ cache.GetEntries(entries); \
+ for (int32_t idx = 0; idx < entries.Count(); ++idx) \
+ entries[idx]->method; \
+ ENUMERATE_WEAKARRAY(array, type, method) \
+ } \
+ PR_END_MACRO;
+
+#define NOTIFY_BOOKMARKS_OBSERVERS(canFire, cache, array, skipIf, method) \
+ PR_BEGIN_MACRO \
+ if (canFire) { \
+ nsCOMArray<nsINavBookmarkObserver> entries; \
+ cache.GetEntries(entries); \
+ for (int32_t idx = 0; idx < entries.Count(); ++idx) { \
+ if (skipIf(entries[idx])) \
+ continue; \
+ entries[idx]->method; \
+ } \
+ for (uint32_t idx = 0; idx < array.Length(); ++idx) { \
+ const nsCOMPtr<nsINavBookmarkObserver> &e = array.ElementAt(idx).GetValue(); \
+ if (e) { \
+ if (skipIf(e)) \
+ continue; \
+ e->method; \
+ } \
+ } \
+ } \
+ PR_END_MACRO;
+
+#define PLACES_FACTORY_SINGLETON_IMPLEMENTATION(_className, _sInstance) \
+ _className * _className::_sInstance = nullptr; \
+ \
+ already_AddRefed<_className> \
+ _className::GetSingleton() \
+ { \
+ if (_sInstance) { \
+ RefPtr<_className> ret = _sInstance; \
+ return ret.forget(); \
+ } \
+ _sInstance = new _className(); \
+ RefPtr<_className> ret = _sInstance; \
+ if (NS_FAILED(_sInstance->Init())) { \
+ /* Null out ret before _sInstance so the destructor doesn't assert */ \
+ ret = nullptr; \
+ _sInstance = nullptr; \
+ return nullptr; \
+ } \
+ return ret.forget(); \
+ }
+
+#define PLACES_WARN_DEPRECATED() \
+ PR_BEGIN_MACRO \
+ nsCString msg(__FUNCTION__); \
+ msg.AppendLiteral(" is deprecated and will be removed in the next version.");\
+ NS_WARNING(msg.get()); \
+ nsCOMPtr<nsIConsoleService> cs = do_GetService(NS_CONSOLESERVICE_CONTRACTID);\
+ if (cs) { \
+ nsCOMPtr<nsIScriptError> e = do_CreateInstance(NS_SCRIPTERROR_CONTRACTID); \
+ if (e && NS_SUCCEEDED(e->Init(NS_ConvertUTF8toUTF16(msg), EmptyString(), \
+ EmptyString(), 0, 0, \
+ nsIScriptError::errorFlag, "Places"))) { \
+ cs->LogMessage(e); \
+ } \
+ } \
+ PR_END_MACRO
diff --git a/toolkit/components/places/nsPlacesModule.cpp b/toolkit/components/places/nsPlacesModule.cpp
new file mode 100644
index 000000000..679d460b4
--- /dev/null
+++ b/toolkit/components/places/nsPlacesModule.cpp
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/ModuleUtils.h"
+#include "nsIClassInfoImpl.h"
+
+#include "nsAnnoProtocolHandler.h"
+#include "nsAnnotationService.h"
+#include "nsNavHistory.h"
+#include "nsNavBookmarks.h"
+#include "nsFaviconService.h"
+#include "History.h"
+#include "nsDocShellCID.h"
+
+using namespace mozilla::places;
+
+NS_GENERIC_FACTORY_SINGLETON_CONSTRUCTOR(nsNavHistory,
+ nsNavHistory::GetSingleton)
+NS_GENERIC_FACTORY_SINGLETON_CONSTRUCTOR(nsAnnotationService,
+ nsAnnotationService::GetSingleton)
+NS_GENERIC_FACTORY_SINGLETON_CONSTRUCTOR(nsNavBookmarks,
+ nsNavBookmarks::GetSingleton)
+NS_GENERIC_FACTORY_SINGLETON_CONSTRUCTOR(nsFaviconService,
+ nsFaviconService::GetSingleton)
+NS_GENERIC_FACTORY_SINGLETON_CONSTRUCTOR(History, History::GetSingleton)
+
+NS_GENERIC_FACTORY_CONSTRUCTOR(nsAnnoProtocolHandler)
+NS_DEFINE_NAMED_CID(NS_NAVHISTORYSERVICE_CID);
+NS_DEFINE_NAMED_CID(NS_ANNOTATIONSERVICE_CID);
+NS_DEFINE_NAMED_CID(NS_ANNOPROTOCOLHANDLER_CID);
+NS_DEFINE_NAMED_CID(NS_NAVBOOKMARKSSERVICE_CID);
+NS_DEFINE_NAMED_CID(NS_FAVICONSERVICE_CID);
+NS_DEFINE_NAMED_CID(NS_HISTORYSERVICE_CID);
+
+const mozilla::Module::CIDEntry kPlacesCIDs[] = {
+ { &kNS_NAVHISTORYSERVICE_CID, false, nullptr, nsNavHistoryConstructor },
+ { &kNS_ANNOTATIONSERVICE_CID, false, nullptr, nsAnnotationServiceConstructor },
+ { &kNS_ANNOPROTOCOLHANDLER_CID, false, nullptr, nsAnnoProtocolHandlerConstructor },
+ { &kNS_NAVBOOKMARKSSERVICE_CID, false, nullptr, nsNavBookmarksConstructor },
+ { &kNS_FAVICONSERVICE_CID, false, nullptr, nsFaviconServiceConstructor },
+ { &kNS_HISTORYSERVICE_CID, false, nullptr, HistoryConstructor },
+ { nullptr }
+};
+
+const mozilla::Module::ContractIDEntry kPlacesContracts[] = {
+ { NS_NAVHISTORYSERVICE_CONTRACTID, &kNS_NAVHISTORYSERVICE_CID },
+ { NS_ANNOTATIONSERVICE_CONTRACTID, &kNS_ANNOTATIONSERVICE_CID },
+ { NS_NETWORK_PROTOCOL_CONTRACTID_PREFIX "moz-anno", &kNS_ANNOPROTOCOLHANDLER_CID },
+ { NS_NAVBOOKMARKSSERVICE_CONTRACTID, &kNS_NAVBOOKMARKSSERVICE_CID },
+ { NS_FAVICONSERVICE_CONTRACTID, &kNS_FAVICONSERVICE_CID },
+ { "@mozilla.org/embeddor.implemented/bookmark-charset-resolver;1", &kNS_NAVHISTORYSERVICE_CID },
+ { NS_IHISTORY_CONTRACTID, &kNS_HISTORYSERVICE_CID },
+ { NS_DOWNLOADHISTORY_CONTRACTID, &kNS_HISTORYSERVICE_CID },
+ { nullptr }
+};
+
+const mozilla::Module::CategoryEntry kPlacesCategories[] = {
+ { "vacuum-participant", "Places", NS_NAVHISTORYSERVICE_CONTRACTID },
+ { nullptr }
+};
+
+const mozilla::Module kPlacesModule = {
+ mozilla::Module::kVersion,
+ kPlacesCIDs,
+ kPlacesContracts,
+ kPlacesCategories
+};
+
+NSMODULE_DEFN(nsPlacesModule) = &kPlacesModule;
diff --git a/toolkit/components/places/nsPlacesTables.h b/toolkit/components/places/nsPlacesTables.h
new file mode 100644
index 000000000..aca92735e
--- /dev/null
+++ b/toolkit/components/places/nsPlacesTables.h
@@ -0,0 +1,154 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef __nsPlacesTables_h__
+#define __nsPlacesTables_h__
+
+
+#define CREATE_MOZ_PLACES NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_places ( " \
+ " id INTEGER PRIMARY KEY" \
+ ", url LONGVARCHAR" \
+ ", title LONGVARCHAR" \
+ ", rev_host LONGVARCHAR" \
+ ", visit_count INTEGER DEFAULT 0" \
+ ", hidden INTEGER DEFAULT 0 NOT NULL" \
+ ", typed INTEGER DEFAULT 0 NOT NULL" \
+ ", favicon_id INTEGER" \
+ ", frecency INTEGER DEFAULT -1 NOT NULL" \
+ ", last_visit_date INTEGER " \
+ ", guid TEXT" \
+ ", foreign_count INTEGER DEFAULT 0 NOT NULL" \
+ ", url_hash INTEGER DEFAULT 0 NOT NULL " \
+ ")" \
+)
+
+#define CREATE_MOZ_HISTORYVISITS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_historyvisits (" \
+ " id INTEGER PRIMARY KEY" \
+ ", from_visit INTEGER" \
+ ", place_id INTEGER" \
+ ", visit_date INTEGER" \
+ ", visit_type INTEGER" \
+ ", session INTEGER" \
+ ")" \
+)
+
+
+#define CREATE_MOZ_INPUTHISTORY NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_inputhistory (" \
+ " place_id INTEGER NOT NULL" \
+ ", input LONGVARCHAR NOT NULL" \
+ ", use_count INTEGER" \
+ ", PRIMARY KEY (place_id, input)" \
+ ")" \
+)
+
+#define CREATE_MOZ_ANNOS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_annos (" \
+ " id INTEGER PRIMARY KEY" \
+ ", place_id INTEGER NOT NULL" \
+ ", anno_attribute_id INTEGER" \
+ ", mime_type VARCHAR(32) DEFAULT NULL" \
+ ", content LONGVARCHAR" \
+ ", flags INTEGER DEFAULT 0" \
+ ", expiration INTEGER DEFAULT 0" \
+ ", type INTEGER DEFAULT 0" \
+ ", dateAdded INTEGER DEFAULT 0" \
+ ", lastModified INTEGER DEFAULT 0" \
+ ")" \
+)
+
+#define CREATE_MOZ_ANNO_ATTRIBUTES NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_anno_attributes (" \
+ " id INTEGER PRIMARY KEY" \
+ ", name VARCHAR(32) UNIQUE NOT NULL" \
+ ")" \
+)
+
+#define CREATE_MOZ_ITEMS_ANNOS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_items_annos (" \
+ " id INTEGER PRIMARY KEY" \
+ ", item_id INTEGER NOT NULL" \
+ ", anno_attribute_id INTEGER" \
+ ", mime_type VARCHAR(32) DEFAULT NULL" \
+ ", content LONGVARCHAR" \
+ ", flags INTEGER DEFAULT 0" \
+ ", expiration INTEGER DEFAULT 0" \
+ ", type INTEGER DEFAULT 0" \
+ ", dateAdded INTEGER DEFAULT 0" \
+ ", lastModified INTEGER DEFAULT 0" \
+ ")" \
+)
+
+#define CREATE_MOZ_FAVICONS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_favicons (" \
+ " id INTEGER PRIMARY KEY" \
+ ", url LONGVARCHAR UNIQUE" \
+ ", data BLOB" \
+ ", mime_type VARCHAR(32)" \
+ ", expiration LONG" \
+ ")" \
+)
+
+#define CREATE_MOZ_BOOKMARKS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_bookmarks (" \
+ " id INTEGER PRIMARY KEY" \
+ ", type INTEGER" \
+ ", fk INTEGER DEFAULT NULL" /* place_id */ \
+ ", parent INTEGER" \
+ ", position INTEGER" \
+ ", title LONGVARCHAR" \
+ ", keyword_id INTEGER" \
+ ", folder_type TEXT" \
+ ", dateAdded INTEGER" \
+ ", lastModified INTEGER" \
+ ", guid TEXT" \
+ ")" \
+)
+
+#define CREATE_MOZ_KEYWORDS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_keywords (" \
+ " id INTEGER PRIMARY KEY AUTOINCREMENT" \
+ ", keyword TEXT UNIQUE" \
+ ", place_id INTEGER" \
+ ", post_data TEXT" \
+ ")" \
+)
+
+#define CREATE_MOZ_HOSTS NS_LITERAL_CSTRING( \
+ "CREATE TABLE moz_hosts (" \
+ " id INTEGER PRIMARY KEY" \
+ ", host TEXT NOT NULL UNIQUE" \
+ ", frecency INTEGER" \
+ ", typed INTEGER NOT NULL DEFAULT 0" \
+ ", prefix TEXT" \
+ ")" \
+)
+
+// Note: this should be kept up-to-date with the definition in
+// nsPlacesAutoComplete.js.
+#define CREATE_MOZ_OPENPAGES_TEMP NS_LITERAL_CSTRING( \
+ "CREATE TEMP TABLE moz_openpages_temp (" \
+ " url TEXT" \
+ ", userContextId INTEGER" \
+ ", open_count INTEGER" \
+ ", PRIMARY KEY (url, userContextId)" \
+ ")" \
+)
+
+// This table is used, along with moz_places_afterdelete_trigger, to update
+// hosts after places removals. During a DELETE FROM moz_places, hosts are
+// accumulated into this table, then a DELETE FROM moz_updatehosts_temp will
+// take care of updating the moz_hosts table for every modified host.
+// See CREATE_PLACES_AFTERDELETE_TRIGGER in nsPlacestriggers.h for details.
+#define CREATE_UPDATEHOSTS_TEMP NS_LITERAL_CSTRING( \
+ "CREATE TEMP TABLE moz_updatehosts_temp (" \
+ " host TEXT PRIMARY KEY " \
+ ") WITHOUT ROWID " \
+)
+
+#endif // __nsPlacesTables_h__
diff --git a/toolkit/components/places/nsPlacesTriggers.h b/toolkit/components/places/nsPlacesTriggers.h
new file mode 100644
index 000000000..d5b45ff5e
--- /dev/null
+++ b/toolkit/components/places/nsPlacesTriggers.h
@@ -0,0 +1,267 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsPlacesTables.h"
+
+#ifndef __nsPlacesTriggers_h__
+#define __nsPlacesTriggers_h__
+
+/**
+ * Exclude these visit types:
+ * 0 - invalid
+ * 4 - EMBED
+ * 7 - DOWNLOAD
+ * 8 - FRAMED_LINK
+ * 9 - RELOAD
+ **/
+#define EXCLUDED_VISIT_TYPES "0, 4, 7, 8, 9"
+
+/**
+ * This triggers update visit_count and last_visit_date based on historyvisits
+ * table changes.
+ */
+#define CREATE_HISTORYVISITS_AFTERINSERT_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_historyvisits_afterinsert_v2_trigger " \
+ "AFTER INSERT ON moz_historyvisits FOR EACH ROW " \
+ "BEGIN " \
+ "SELECT store_last_inserted_id('moz_historyvisits', NEW.id); " \
+ "UPDATE moz_places SET " \
+ "visit_count = visit_count + (SELECT NEW.visit_type NOT IN (" EXCLUDED_VISIT_TYPES ")), "\
+ "last_visit_date = MAX(IFNULL(last_visit_date, 0), NEW.visit_date) " \
+ "WHERE id = NEW.place_id;" \
+ "END" \
+)
+
+#define CREATE_HISTORYVISITS_AFTERDELETE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_historyvisits_afterdelete_v2_trigger " \
+ "AFTER DELETE ON moz_historyvisits FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places SET " \
+ "visit_count = visit_count - (SELECT OLD.visit_type NOT IN (" EXCLUDED_VISIT_TYPES ")), "\
+ "last_visit_date = (SELECT visit_date FROM moz_historyvisits " \
+ "WHERE place_id = OLD.place_id " \
+ "ORDER BY visit_date DESC LIMIT 1) " \
+ "WHERE id = OLD.place_id;" \
+ "END" \
+)
+
+/**
+ * A predicate matching pages on rev_host, based on a given host value.
+ * 'host' may be either the moz_hosts.host column or an alias representing an
+ * equivalent value.
+ */
+#define HOST_TO_REVHOST_PREDICATE \
+ "rev_host = get_unreversed_host(host || '.') || '.' " \
+ "OR rev_host = get_unreversed_host(host || '.') || '.www.'"
+
+/**
+ * Select the best prefix for a host, based on existing pages registered for it.
+ * Prefixes have a priority, from the top to the bottom, so that secure pages
+ * have higher priority, and more generically "www." prefixed hosts come before
+ * unprefixed ones.
+ * Given a host, examine associated pages and:
+ * - if all of the typed pages start with https://www. return https://www.
+ * - if all of the typed pages start with https:// return https://
+ * - if all of the typed pages start with ftp: return ftp://
+ * - if all of the typed pages start with www. return www.
+ * - otherwise don't use any prefix
+ */
+#define HOSTS_PREFIX_PRIORITY_FRAGMENT \
+ "SELECT CASE " \
+ "WHEN 1 = ( " \
+ "SELECT min(substr(url,1,12) = 'https://www.') FROM moz_places h " \
+ "WHERE (" HOST_TO_REVHOST_PREDICATE ") AND +h.typed = 1 " \
+ ") THEN 'https://www.' " \
+ "WHEN 1 = ( " \
+ "SELECT min(substr(url,1,8) = 'https://') FROM moz_places h " \
+ "WHERE (" HOST_TO_REVHOST_PREDICATE ") AND +h.typed = 1 " \
+ ") THEN 'https://' " \
+ "WHEN 1 = ( " \
+ "SELECT min(substr(url,1,4) = 'ftp:') FROM moz_places h " \
+ "WHERE (" HOST_TO_REVHOST_PREDICATE ") AND +h.typed = 1 " \
+ ") THEN 'ftp://' " \
+ "WHEN 1 = ( " \
+ "SELECT min(substr(url,1,11) = 'http://www.') FROM moz_places h " \
+ "WHERE (" HOST_TO_REVHOST_PREDICATE ") AND +h.typed = 1 " \
+ ") THEN 'www.' " \
+ "END "
+
+/**
+ * These triggers update the hostnames table whenever moz_places changes.
+ */
+#define CREATE_PLACES_AFTERINSERT_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_places_afterinsert_trigger " \
+ "AFTER INSERT ON moz_places FOR EACH ROW " \
+ "BEGIN " \
+ "SELECT store_last_inserted_id('moz_places', NEW.id); " \
+ "INSERT OR REPLACE INTO moz_hosts (id, host, frecency, typed, prefix) " \
+ "SELECT " \
+ "(SELECT id FROM moz_hosts WHERE host = fixup_url(get_unreversed_host(NEW.rev_host))), " \
+ "fixup_url(get_unreversed_host(NEW.rev_host)), " \
+ "MAX(IFNULL((SELECT frecency FROM moz_hosts WHERE host = fixup_url(get_unreversed_host(NEW.rev_host))), -1), NEW.frecency), " \
+ "MAX(IFNULL((SELECT typed FROM moz_hosts WHERE host = fixup_url(get_unreversed_host(NEW.rev_host))), 0), NEW.typed), " \
+ "(" HOSTS_PREFIX_PRIORITY_FRAGMENT \
+ "FROM ( " \
+ "SELECT fixup_url(get_unreversed_host(NEW.rev_host)) AS host " \
+ ") AS match " \
+ ") " \
+ " WHERE LENGTH(NEW.rev_host) > 1; " \
+ "END" \
+)
+
+// This is a hack to workaround the lack of FOR EACH STATEMENT in Sqlite, until
+// bug 871908 can be fixed properly.
+// We store the modified hosts in a temp table, and after every DELETE FROM
+// moz_places, we issue a DELETE FROM moz_updatehosts_temp. The AFTER DELETE
+// trigger will then take care of updating the moz_hosts table.
+// Note this way we lose atomicity, crashing between the 2 queries may break the
+// hosts table coherency. So it's better to run those DELETE queries in a single
+// transaction.
+// Regardless, this is still better than hanging the browser for several minutes
+// on a fast machine.
+#define CREATE_PLACES_AFTERDELETE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_places_afterdelete_trigger " \
+ "AFTER DELETE ON moz_places FOR EACH ROW " \
+ "BEGIN " \
+ "INSERT OR IGNORE INTO moz_updatehosts_temp (host)" \
+ "VALUES (fixup_url(get_unreversed_host(OLD.rev_host)));" \
+ "END" \
+)
+
+#define CREATE_UPDATEHOSTS_AFTERDELETE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_updatehosts_afterdelete_trigger " \
+ "AFTER DELETE ON moz_updatehosts_temp FOR EACH ROW " \
+ "BEGIN " \
+ "DELETE FROM moz_hosts " \
+ "WHERE host = OLD.host " \
+ "AND NOT EXISTS(" \
+ "SELECT 1 FROM moz_places " \
+ "WHERE rev_host = get_unreversed_host(host || '.') || '.' " \
+ "OR rev_host = get_unreversed_host(host || '.') || '.www.' " \
+ "); " \
+ "UPDATE moz_hosts " \
+ "SET prefix = (" HOSTS_PREFIX_PRIORITY_FRAGMENT ") " \
+ "WHERE host = OLD.host; " \
+ "END" \
+)
+
+// For performance reasons the host frecency is updated only when the page
+// frecency changes by a meaningful percentage. This is because the frecency
+// decay algorithm requires to update all the frecencies at once, causing a
+// too high overhead, while leaving the ordering unchanged.
+#define CREATE_PLACES_AFTERUPDATE_FRECENCY_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_places_afterupdate_frecency_trigger " \
+ "AFTER UPDATE OF frecency ON moz_places FOR EACH ROW " \
+ "WHEN NEW.frecency >= 0 " \
+ "AND ABS(" \
+ "IFNULL((NEW.frecency - OLD.frecency) / CAST(NEW.frecency AS REAL), " \
+ "(NEW.frecency - OLD.frecency))" \
+ ") > .05 " \
+ "BEGIN " \
+ "UPDATE moz_hosts " \
+ "SET frecency = (SELECT MAX(frecency) FROM moz_places " \
+ "WHERE rev_host = get_unreversed_host(host || '.') || '.' " \
+ "OR rev_host = get_unreversed_host(host || '.') || '.www.') " \
+ "WHERE host = fixup_url(get_unreversed_host(NEW.rev_host)); " \
+ "END" \
+)
+
+#define CREATE_PLACES_AFTERUPDATE_TYPED_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_places_afterupdate_typed_trigger " \
+ "AFTER UPDATE OF typed ON moz_places FOR EACH ROW " \
+ "WHEN NEW.typed = 1 " \
+ "BEGIN " \
+ "UPDATE moz_hosts " \
+ "SET typed = 1 " \
+ "WHERE host = fixup_url(get_unreversed_host(NEW.rev_host)); " \
+ "END" \
+)
+
+/**
+ * This trigger removes a row from moz_openpages_temp when open_count reaches 0.
+ *
+ * @note this should be kept up-to-date with the definition in
+ * nsPlacesAutoComplete.js
+ */
+#define CREATE_REMOVEOPENPAGE_CLEANUP_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMPORARY TRIGGER moz_openpages_temp_afterupdate_trigger " \
+ "AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW " \
+ "WHEN NEW.open_count = 0 " \
+ "BEGIN " \
+ "DELETE FROM moz_openpages_temp " \
+ "WHERE url = NEW.url " \
+ "AND userContextId = NEW.userContextId;" \
+ "END" \
+)
+
+#define CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERDELETE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_bookmarks_foreign_count_afterdelete_trigger " \
+ "AFTER DELETE ON moz_bookmarks FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count - 1 " \
+ "WHERE id = OLD.fk;" \
+ "END" \
+)
+
+#define CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERINSERT_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_bookmarks_foreign_count_afterinsert_trigger " \
+ "AFTER INSERT ON moz_bookmarks FOR EACH ROW " \
+ "BEGIN " \
+ "SELECT store_last_inserted_id('moz_bookmarks', NEW.id); " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count + 1 " \
+ "WHERE id = NEW.fk;" \
+ "END" \
+)
+
+#define CREATE_BOOKMARKS_FOREIGNCOUNT_AFTERUPDATE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_bookmarks_foreign_count_afterupdate_trigger " \
+ "AFTER UPDATE OF fk ON moz_bookmarks FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count + 1 " \
+ "WHERE id = NEW.fk;" \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count - 1 " \
+ "WHERE id = OLD.fk;" \
+ "END" \
+)
+
+#define CREATE_KEYWORDS_FOREIGNCOUNT_AFTERDELETE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_keywords_foreign_count_afterdelete_trigger " \
+ "AFTER DELETE ON moz_keywords FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count - 1 " \
+ "WHERE id = OLD.place_id;" \
+ "END" \
+)
+
+#define CREATE_KEYWORDS_FOREIGNCOUNT_AFTERINSERT_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_keyords_foreign_count_afterinsert_trigger " \
+ "AFTER INSERT ON moz_keywords FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count + 1 " \
+ "WHERE id = NEW.place_id;" \
+ "END" \
+)
+
+#define CREATE_KEYWORDS_FOREIGNCOUNT_AFTERUPDATE_TRIGGER NS_LITERAL_CSTRING( \
+ "CREATE TEMP TRIGGER moz_keywords_foreign_count_afterupdate_trigger " \
+ "AFTER UPDATE OF place_id ON moz_keywords FOR EACH ROW " \
+ "BEGIN " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count + 1 " \
+ "WHERE id = NEW.place_id; " \
+ "UPDATE moz_places " \
+ "SET foreign_count = foreign_count - 1 " \
+ "WHERE id = OLD.place_id; " \
+ "END" \
+)
+
+#endif // __nsPlacesTriggers_h__
diff --git a/toolkit/components/places/nsTaggingService.js b/toolkit/components/places/nsTaggingService.js
new file mode 100644
index 000000000..1fad67a82
--- /dev/null
+++ b/toolkit/components/places/nsTaggingService.js
@@ -0,0 +1,709 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
+ "resource://gre/modules/Deprecated.jsm");
+
+const TOPIC_SHUTDOWN = "places-shutdown";
+
+/**
+ * The Places Tagging Service
+ */
+function TaggingService() {
+ // Observe bookmarks changes.
+ PlacesUtils.bookmarks.addObserver(this, false);
+
+ // Cleanup on shutdown.
+ Services.obs.addObserver(this, TOPIC_SHUTDOWN, false);
+}
+
+TaggingService.prototype = {
+ /**
+ * Creates a tag container under the tags-root with the given name.
+ *
+ * @param aTagName
+ * the name for the new tag.
+ * @param aSource
+ * a change source constant from nsINavBookmarksService::SOURCE_*.
+ * @returns the id of the new tag container.
+ */
+ _createTag: function TS__createTag(aTagName, aSource) {
+ var newFolderId = PlacesUtils.bookmarks.createFolder(
+ PlacesUtils.tagsFolderId, aTagName, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ /* aGuid */ null, aSource
+ );
+ // Add the folder to our local cache, so we can avoid doing this in the
+ // observer that would have to check itemType.
+ this._tagFolders[newFolderId] = aTagName;
+
+ return newFolderId;
+ },
+
+ /**
+ * Checks whether the given uri is tagged with the given tag.
+ *
+ * @param [in] aURI
+ * url to check for
+ * @param [in] aTagName
+ * the tag to check for
+ * @returns the item id if the URI is tagged with the given tag, -1
+ * otherwise.
+ */
+ _getItemIdForTaggedURI: function TS__getItemIdForTaggedURI(aURI, aTagName) {
+ var tagId = this._getItemIdForTag(aTagName);
+ if (tagId == -1)
+ return -1;
+ // Using bookmarks service API for this would be a pain.
+ // Until tags implementation becomes sane, go the query way.
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ let stmt = db.createStatement(
+ `SELECT id FROM moz_bookmarks
+ WHERE parent = :tag_id
+ AND fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)`
+ );
+ stmt.params.tag_id = tagId;
+ stmt.params.page_url = aURI.spec;
+ try {
+ if (stmt.executeStep()) {
+ return stmt.row.id;
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+ return -1;
+ },
+
+ /**
+ * Returns the folder id for a tag, or -1 if not found.
+ * @param [in] aTag
+ * string tag to search for
+ * @returns integer id for the bookmark folder for the tag
+ */
+ _getItemIdForTag: function TS_getItemIdForTag(aTagName) {
+ for (var i in this._tagFolders) {
+ if (aTagName.toLowerCase() == this._tagFolders[i].toLowerCase())
+ return parseInt(i);
+ }
+ return -1;
+ },
+
+ /**
+ * Makes a proper array of tag objects like { id: number, name: string }.
+ *
+ * @param aTags
+ * Array of tags. Entries can be tag names or concrete item id.
+ * @param trim [optional]
+ * Whether to trim passed-in named tags. Defaults to false.
+ * @return Array of tag objects like { id: number, name: string }.
+ *
+ * @throws Cr.NS_ERROR_INVALID_ARG if any element of the input array is not
+ * a valid tag.
+ */
+ _convertInputMixedTagsArray(aTags, trim=false) {
+ // Handle sparse array with a .filter.
+ return aTags.filter(tag => tag !== undefined)
+ .map(idOrName => {
+ let tag = {};
+ if (typeof(idOrName) == "number" && this._tagFolders[idOrName]) {
+ // This is a tag folder id.
+ tag.id = idOrName;
+ // We can't know the name at this point, since a previous tag could
+ // want to change it.
+ tag.__defineGetter__("name", () => this._tagFolders[tag.id]);
+ }
+ else if (typeof(idOrName) == "string" && idOrName.length > 0 &&
+ idOrName.length <= Ci.nsITaggingService.MAX_TAG_LENGTH) {
+ // This is a tag name.
+ tag.name = trim ? idOrName.trim() : idOrName;
+ // We can't know the id at this point, since a previous tag could
+ // have created it.
+ tag.__defineGetter__("id", () => this._getItemIdForTag(tag.name));
+ }
+ else {
+ throw Cr.NS_ERROR_INVALID_ARG;
+ }
+ return tag;
+ });
+ },
+
+ // nsITaggingService
+ tagURI: function TS_tagURI(aURI, aTags, aSource)
+ {
+ if (!aURI || !aTags || !Array.isArray(aTags)) {
+ throw Cr.NS_ERROR_INVALID_ARG;
+ }
+
+ // This also does some input validation.
+ let tags = this._convertInputMixedTagsArray(aTags, true);
+
+ let taggingFunction = () => {
+ for (let tag of tags) {
+ if (tag.id == -1) {
+ // Tag does not exist yet, create it.
+ this._createTag(tag.name, aSource);
+ }
+
+ if (this._getItemIdForTaggedURI(aURI, tag.name) == -1) {
+ // The provided URI is not yet tagged, add a tag for it.
+ // Note that bookmarks under tag containers must have null titles.
+ PlacesUtils.bookmarks.insertBookmark(
+ tag.id, aURI, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ /* aTitle */ null, /* aGuid */ null, aSource
+ );
+ }
+
+ // Try to preserve user's tag name casing.
+ // Rename the tag container so the Places view matches the most-recent
+ // user-typed value.
+ if (PlacesUtils.bookmarks.getItemTitle(tag.id) != tag.name) {
+ // this._tagFolders is updated by the bookmarks observer.
+ PlacesUtils.bookmarks.setItemTitle(tag.id, tag.name, aSource);
+ }
+ }
+ };
+
+ // Use a batch only if creating more than 2 tags.
+ if (tags.length < 3) {
+ taggingFunction();
+ } else {
+ PlacesUtils.bookmarks.runInBatchMode(taggingFunction, null);
+ }
+ },
+
+ /**
+ * Removes the tag container from the tags root if the given tag is empty.
+ *
+ * @param aTagId
+ * the itemId of the tag element under the tags root
+ * @param aSource
+ * a change source constant from nsINavBookmarksService::SOURCE_*
+ */
+ _removeTagIfEmpty: function TS__removeTagIfEmpty(aTagId, aSource) {
+ let count = 0;
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ let stmt = db.createStatement(
+ `SELECT count(*) AS count FROM moz_bookmarks
+ WHERE parent = :tag_id`
+ );
+ stmt.params.tag_id = aTagId;
+ try {
+ if (stmt.executeStep()) {
+ count = stmt.row.count;
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+
+ if (count == 0) {
+ PlacesUtils.bookmarks.removeItem(aTagId, aSource);
+ }
+ },
+
+ // nsITaggingService
+ untagURI: function TS_untagURI(aURI, aTags, aSource)
+ {
+ if (!aURI || (aTags && !Array.isArray(aTags))) {
+ throw Cr.NS_ERROR_INVALID_ARG;
+ }
+
+ if (!aTags) {
+ // Passing null should clear all tags for aURI, see the IDL.
+ // XXXmano: write a perf-sensitive version of this code path...
+ aTags = this.getTagsForURI(aURI);
+ }
+
+ // This also does some input validation.
+ let tags = this._convertInputMixedTagsArray(aTags);
+
+ let isAnyTagNotTrimmed = tags.some(tag => /^\s|\s$/.test(tag.name));
+ if (isAnyTagNotTrimmed) {
+ Deprecated.warning("At least one tag passed to untagURI was not trimmed",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=967196");
+ }
+
+ let untaggingFunction = () => {
+ for (let tag of tags) {
+ if (tag.id != -1) {
+ // A tag could exist.
+ let itemId = this._getItemIdForTaggedURI(aURI, tag.name);
+ if (itemId != -1) {
+ // There is a tagged item.
+ PlacesUtils.bookmarks.removeItem(itemId, aSource);
+ }
+ }
+ }
+ };
+
+ // Use a batch only if creating more than 2 tags.
+ if (tags.length < 3) {
+ untaggingFunction();
+ } else {
+ PlacesUtils.bookmarks.runInBatchMode(untaggingFunction, null);
+ }
+ },
+
+ // nsITaggingService
+ getURIsForTag: function TS_getURIsForTag(aTagName) {
+ if (!aTagName || aTagName.length == 0)
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ if (/^\s|\s$/.test(aTagName)) {
+ Deprecated.warning("Tag passed to getURIsForTag was not trimmed",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=967196");
+ }
+
+ let uris = [];
+ let tagId = this._getItemIdForTag(aTagName);
+ if (tagId == -1)
+ return uris;
+
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ let stmt = db.createStatement(
+ `SELECT h.url FROM moz_places h
+ JOIN moz_bookmarks b ON b.fk = h.id
+ WHERE b.parent = :tag_id`
+ );
+ stmt.params.tag_id = tagId;
+ try {
+ while (stmt.executeStep()) {
+ try {
+ uris.push(Services.io.newURI(stmt.row.url, null, null));
+ } catch (ex) {}
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+
+ return uris;
+ },
+
+ // nsITaggingService
+ getTagsForURI: function TS_getTagsForURI(aURI, aCount) {
+ if (!aURI)
+ throw Cr.NS_ERROR_INVALID_ARG;
+
+ var tags = [];
+ var bookmarkIds = PlacesUtils.bookmarks.getBookmarkIdsForURI(aURI);
+ for (var i=0; i < bookmarkIds.length; i++) {
+ var folderId = PlacesUtils.bookmarks.getFolderIdForItem(bookmarkIds[i]);
+ if (this._tagFolders[folderId])
+ tags.push(this._tagFolders[folderId]);
+ }
+
+ // sort the tag list
+ tags.sort(function(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ });
+ if (aCount)
+ aCount.value = tags.length;
+ return tags;
+ },
+
+ __tagFolders: null,
+ get _tagFolders() {
+ if (!this.__tagFolders) {
+ this.__tagFolders = [];
+
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ let stmt = db.createStatement(
+ "SELECT id, title FROM moz_bookmarks WHERE parent = :tags_root "
+ );
+ stmt.params.tags_root = PlacesUtils.tagsFolderId;
+ try {
+ while (stmt.executeStep()) {
+ this.__tagFolders[stmt.row.id] = stmt.row.title;
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+ }
+
+ return this.__tagFolders;
+ },
+
+ // nsITaggingService
+ get allTags() {
+ var allTags = [];
+ for (var i in this._tagFolders)
+ allTags.push(this._tagFolders[i]);
+ // sort the tag list
+ allTags.sort(function(a, b) {
+ return a.toLowerCase().localeCompare(b.toLowerCase());
+ });
+ return allTags;
+ },
+
+ // nsITaggingService
+ get hasTags() {
+ return this._tagFolders.length > 0;
+ },
+
+ // nsIObserver
+ observe: function TS_observe(aSubject, aTopic, aData) {
+ if (aTopic == TOPIC_SHUTDOWN) {
+ PlacesUtils.bookmarks.removeObserver(this);
+ Services.obs.removeObserver(this, TOPIC_SHUTDOWN);
+ }
+ },
+
+ /**
+ * If the only bookmark items associated with aURI are contained in tag
+ * folders, returns the IDs of those items. This can be the case if
+ * the URI was bookmarked and tagged at some point, but the bookmark was
+ * removed, leaving only the bookmark items in tag folders. If the URI is
+ * either properly bookmarked or not tagged just returns and empty array.
+ *
+ * @param aURI
+ * A URI (string) that may or may not be bookmarked
+ * @returns an array of item ids
+ */
+ _getTaggedItemIdsIfUnbookmarkedURI:
+ function TS__getTaggedItemIdsIfUnbookmarkedURI(aURI) {
+ var itemIds = [];
+ var isBookmarked = false;
+
+ // Using bookmarks service API for this would be a pain.
+ // Until tags implementation becomes sane, go the query way.
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ let stmt = db.createStatement(
+ `SELECT id, parent
+ FROM moz_bookmarks
+ WHERE fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)`
+ );
+ stmt.params.page_url = aURI.spec;
+ try {
+ while (stmt.executeStep() && !isBookmarked) {
+ if (this._tagFolders[stmt.row.parent]) {
+ // This is a tag entry.
+ itemIds.push(stmt.row.id);
+ }
+ else {
+ // This is a real bookmark, so the bookmarked URI is not an orphan.
+ isBookmarked = true;
+ }
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+
+ return isBookmarked ? [] : itemIds;
+ },
+
+ // nsINavBookmarkObserver
+ onItemAdded: function TS_onItemAdded(aItemId, aFolderId, aIndex, aItemType,
+ aURI, aTitle) {
+ // Nothing to do if this is not a tag.
+ if (aFolderId != PlacesUtils.tagsFolderId ||
+ aItemType != PlacesUtils.bookmarks.TYPE_FOLDER)
+ return;
+
+ this._tagFolders[aItemId] = aTitle;
+ },
+
+ onItemRemoved: function TS_onItemRemoved(aItemId, aFolderId, aIndex,
+ aItemType, aURI, aGuid, aParentGuid,
+ aSource) {
+ // Item is a tag folder.
+ if (aFolderId == PlacesUtils.tagsFolderId && this._tagFolders[aItemId]) {
+ delete this._tagFolders[aItemId];
+ }
+ // Item is a bookmark that was removed from a non-tag folder.
+ else if (aURI && !this._tagFolders[aFolderId]) {
+ // If the only bookmark items now associated with the bookmark's URI are
+ // contained in tag folders, the URI is no longer properly bookmarked, so
+ // untag it.
+ let itemIds = this._getTaggedItemIdsIfUnbookmarkedURI(aURI);
+ for (let i = 0; i < itemIds.length; i++) {
+ try {
+ PlacesUtils.bookmarks.removeItem(itemIds[i], aSource);
+ } catch (ex) {}
+ }
+ }
+ // Item is a tag entry. If this was the last entry for this tag, remove it.
+ else if (aURI && this._tagFolders[aFolderId]) {
+ this._removeTagIfEmpty(aFolderId, aSource);
+ }
+ },
+
+ onItemChanged: function TS_onItemChanged(aItemId, aProperty,
+ aIsAnnotationProperty, aNewValue,
+ aLastModified, aItemType) {
+ if (aProperty == "title" && this._tagFolders[aItemId])
+ this._tagFolders[aItemId] = aNewValue;
+ },
+
+ onItemMoved: function TS_onItemMoved(aItemId, aOldParent, aOldIndex,
+ aNewParent, aNewIndex, aItemType) {
+ if (this._tagFolders[aItemId] && PlacesUtils.tagsFolderId == aOldParent &&
+ PlacesUtils.tagsFolderId != aNewParent)
+ delete this._tagFolders[aItemId];
+ },
+
+ onItemVisited: function () {},
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+
+ // nsISupports
+
+ classID: Components.ID("{bbc23860-2553-479d-8b78-94d9038334f7}"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(TaggingService),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsITaggingService
+ , Ci.nsINavBookmarkObserver
+ , Ci.nsIObserver
+ ])
+};
+
+
+function TagAutoCompleteResult(searchString, searchResult,
+ defaultIndex, errorDescription,
+ results, comments) {
+ this._searchString = searchString;
+ this._searchResult = searchResult;
+ this._defaultIndex = defaultIndex;
+ this._errorDescription = errorDescription;
+ this._results = results;
+ this._comments = comments;
+}
+
+TagAutoCompleteResult.prototype = {
+
+ /**
+ * The original search string
+ */
+ get searchString() {
+ return this._searchString;
+ },
+
+ /**
+ * The result code of this result object, either:
+ * RESULT_IGNORED (invalid searchString)
+ * RESULT_FAILURE (failure)
+ * RESULT_NOMATCH (no matches found)
+ * RESULT_SUCCESS (matches found)
+ */
+ get searchResult() {
+ return this._searchResult;
+ },
+
+ /**
+ * Index of the default item that should be entered if none is selected
+ */
+ get defaultIndex() {
+ return this._defaultIndex;
+ },
+
+ /**
+ * A string describing the cause of a search failure
+ */
+ get errorDescription() {
+ return this._errorDescription;
+ },
+
+ /**
+ * The number of matches
+ */
+ get matchCount() {
+ return this._results.length;
+ },
+
+ /**
+ * Get the value of the result at the given index
+ */
+ getValueAt: function PTACR_getValueAt(index) {
+ return this._results[index];
+ },
+
+ getLabelAt: function PTACR_getLabelAt(index) {
+ return this.getValueAt(index);
+ },
+
+ /**
+ * Get the comment of the result at the given index
+ */
+ getCommentAt: function PTACR_getCommentAt(index) {
+ return this._comments[index];
+ },
+
+ /**
+ * Get the style hint for the result at the given index
+ */
+ getStyleAt: function PTACR_getStyleAt(index) {
+ if (!this._comments[index])
+ return null; // not a category label, so no special styling
+
+ if (index == 0)
+ return "suggestfirst"; // category label on first line of results
+
+ return "suggesthint"; // category label on any other line of results
+ },
+
+ /**
+ * Get the image for the result at the given index
+ */
+ getImageAt: function PTACR_getImageAt(index) {
+ return null;
+ },
+
+ /**
+ * Get the image for the result at the given index
+ */
+ getFinalCompleteValueAt: function PTACR_getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ },
+
+ /**
+ * Remove the value at the given index from the autocomplete results.
+ * If removeFromDb is set to true, the value should be removed from
+ * persistent storage as well.
+ */
+ removeValueAt: function PTACR_removeValueAt(index, removeFromDb) {
+ this._results.splice(index, 1);
+ this._comments.splice(index, 1);
+ },
+
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIAutoCompleteResult
+ ])
+};
+
+// Implements nsIAutoCompleteSearch
+function TagAutoCompleteSearch() {
+ XPCOMUtils.defineLazyServiceGetter(this, "tagging",
+ "@mozilla.org/browser/tagging-service;1",
+ "nsITaggingService");
+}
+
+TagAutoCompleteSearch.prototype = {
+ _stopped : false,
+
+ /*
+ * Search for a given string and notify a listener (either synchronously
+ * or asynchronously) of the result
+ *
+ * @param searchString - The string to search for
+ * @param searchParam - An extra parameter
+ * @param previousResult - A previous result to use for faster searching
+ * @param listener - A listener to notify when the search is complete
+ */
+ startSearch: function PTACS_startSearch(searchString, searchParam, result, listener) {
+ var searchResults = this.tagging.allTags;
+ var results = [];
+ var comments = [];
+ this._stopped = false;
+
+ // only search on characters for the last tag
+ var index = Math.max(searchString.lastIndexOf(","),
+ searchString.lastIndexOf(";"));
+ var before = '';
+ if (index != -1) {
+ before = searchString.slice(0, index+1);
+ searchString = searchString.slice(index+1);
+ // skip past whitespace
+ var m = searchString.match(/\s+/);
+ if (m) {
+ before += m[0];
+ searchString = searchString.slice(m[0].length);
+ }
+ }
+
+ if (!searchString.length) {
+ var newResult = new TagAutoCompleteResult(searchString,
+ Ci.nsIAutoCompleteResult.RESULT_NOMATCH, 0, "", results, comments);
+ listener.onSearchResult(self, newResult);
+ return;
+ }
+
+ var self = this;
+ // generator: if yields true, not done
+ function* doSearch() {
+ var i = 0;
+ while (i < searchResults.length) {
+ if (self._stopped)
+ yield false;
+ // for each match, prepend what the user has typed so far
+ if (searchResults[i].toLowerCase()
+ .indexOf(searchString.toLowerCase()) == 0 &&
+ !comments.includes(searchResults[i])) {
+ results.push(before + searchResults[i]);
+ comments.push(searchResults[i]);
+ }
+
+ ++i;
+
+ /* TODO: bug 481451
+ * For each yield we pass a new result to the autocomplete
+ * listener. The listener appends instead of replacing previous results,
+ * causing invalid matchCount values.
+ *
+ * As a workaround, all tags are searched through in a single batch,
+ * making this synchronous until the above issue is fixed.
+ */
+
+ /*
+ // 100 loops per yield
+ if ((i % 100) == 0) {
+ var newResult = new TagAutoCompleteResult(searchString,
+ Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING, 0, "", results, comments);
+ listener.onSearchResult(self, newResult);
+ yield true;
+ }
+ */
+ }
+
+ let searchResult = results.length > 0 ?
+ Ci.nsIAutoCompleteResult.RESULT_SUCCESS :
+ Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ var newResult = new TagAutoCompleteResult(searchString, searchResult, 0,
+ "", results, comments);
+ listener.onSearchResult(self, newResult);
+ yield false;
+ }
+
+ // chunk the search results via the generator
+ var gen = doSearch();
+ while (gen.next().value);
+ },
+
+ /**
+ * Stop an asynchronous search that is in progress
+ */
+ stopSearch: function PTACS_stopSearch() {
+ this._stopped = true;
+ },
+
+ // nsISupports
+
+ classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}"),
+
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(TagAutoCompleteSearch),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIAutoCompleteSearch
+ ])
+};
+
+var component = [TaggingService, TagAutoCompleteSearch];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);
diff --git a/toolkit/components/places/tests/.eslintrc.js b/toolkit/components/places/tests/.eslintrc.js
new file mode 100644
index 000000000..d5283c966
--- /dev/null
+++ b/toolkit/components/places/tests/.eslintrc.js
@@ -0,0 +1,9 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../testing/mochitest/mochitest.eslintrc.js",
+ "../../../../testing/mochitest/chrome.eslintrc.js",
+ "../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/PlacesTestUtils.jsm b/toolkit/components/places/tests/PlacesTestUtils.jsm
new file mode 100644
index 000000000..36e425cae
--- /dev/null
+++ b/toolkit/components/places/tests/PlacesTestUtils.jsm
@@ -0,0 +1,163 @@
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "PlacesTestUtils",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+
+this.PlacesTestUtils = Object.freeze({
+ /**
+ * Asynchronously adds visits to a page.
+ *
+ * @param aPlaceInfo
+ * Can be an nsIURI, in such a case a single LINK visit will be added.
+ * Otherwise can be an object describing the visit to add, or an array
+ * of these objects:
+ * { uri: nsIURI of the page,
+ * [optional] transition: one of the TRANSITION_* from nsINavHistoryService,
+ * [optional] title: title of the page,
+ * [optional] visitDate: visit date, either in microseconds from the epoch or as a date object
+ * [optional] referrer: nsIURI of the referrer for this visit
+ * }
+ *
+ * @return {Promise}
+ * @resolves When all visits have been added successfully.
+ * @rejects JavaScript exception.
+ */
+ addVisits: Task.async(function* (placeInfo) {
+ let places = [];
+ let infos = [];
+
+ if (placeInfo instanceof Ci.nsIURI ||
+ placeInfo instanceof URL ||
+ typeof placeInfo == "string") {
+ places.push({ uri: placeInfo });
+ }
+ else if (Array.isArray(placeInfo)) {
+ places = places.concat(placeInfo);
+ } else if (typeof placeInfo == "object" && placeInfo.uri) {
+ places.push(placeInfo)
+ } else {
+ throw new Error("Unsupported type passed to addVisits");
+ }
+
+ // Create a PageInfo for each entry.
+ for (let place of places) {
+ let info = {url: place.uri};
+ info.title = (typeof place.title === "string") ? place.title : "test visit for " + info.url.spec ;
+ if (typeof place.referrer == "string") {
+ place.referrer = NetUtil.newURI(place.referrer);
+ } else if (place.referrer && place.referrer instanceof URL) {
+ place.referrer = NetUtil.newURI(place.referrer.href);
+ }
+ let visitDate = place.visitDate;
+ if (visitDate) {
+ if (!(visitDate instanceof Date)) {
+ visitDate = PlacesUtils.toDate(visitDate);
+ }
+ } else {
+ visitDate = new Date();
+ }
+ info.visits = [{
+ transition: place.transition,
+ date: visitDate,
+ referrer: place.referrer
+ }];
+ infos.push(info);
+ }
+ return PlacesUtils.history.insertMany(infos);
+ }),
+
+ /**
+ * Clear all history.
+ *
+ * @return {Promise}
+ * @resolves When history was cleared successfully.
+ * @rejects JavaScript exception.
+ */
+ clearHistory() {
+ let expirationFinished = new Promise(resolve => {
+ Services.obs.addObserver(function observe(subj, topic, data) {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false);
+ });
+
+ return Promise.all([expirationFinished, PlacesUtils.history.clear()]);
+ },
+
+ /**
+ * Waits for all pending async statements on the default connection.
+ *
+ * @return {Promise}
+ * @resolves When all pending async statements finished.
+ * @rejects Never.
+ *
+ * @note The result is achieved by asynchronously executing a query requiring
+ * a write lock. Since all statements on the same connection are
+ * serialized, the end of this write operation means that all writes are
+ * complete. Note that WAL makes so that writers don't block readers, but
+ * this is a problem only across different connections.
+ */
+ promiseAsyncUpdates() {
+ return PlacesUtils.withConnectionWrapper("promiseAsyncUpdates", Task.async(function* (db) {
+ try {
+ yield db.executeCached("BEGIN EXCLUSIVE");
+ yield db.executeCached("COMMIT");
+ } catch (ex) {
+ // If we fail to start a transaction, it's because there is already one.
+ // In such a case we should not try to commit the existing transaction.
+ }
+ }));
+ },
+
+ /**
+ * Asynchronously checks if an address is found in the database.
+ * @param aURI
+ * nsIURI or address to look for.
+ *
+ * @return {Promise}
+ * @resolves Returns true if the page is found.
+ * @rejects JavaScript exception.
+ */
+ isPageInDB: Task.async(function* (aURI) {
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(
+ "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
+ { url });
+ return rows.length > 0;
+ }),
+
+ /**
+ * Asynchronously checks how many visits exist for a specified page.
+ * @param aURI
+ * nsIURI or address to look for.
+ *
+ * @return {Promise}
+ * @resolves Returns the number of visits found.
+ * @rejects JavaScript exception.
+ */
+ visitsInDB: Task.async(function* (aURI) {
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(
+ `SELECT count(*) FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = v.place_id
+ WHERE url_hash = hash(:url) AND url = :url`,
+ { url });
+ return rows[0].getResultByIndex(0);
+ })
+});
diff --git a/toolkit/components/places/tests/bookmarks/.eslintrc.js b/toolkit/components/places/tests/bookmarks/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/bookmarks/head_bookmarks.js b/toolkit/components/places/tests/bookmarks/head_bookmarks.js
new file mode 100644
index 000000000..842a66b31
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/head_bookmarks.js
@@ -0,0 +1,20 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
diff --git a/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js b/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js
new file mode 100644
index 000000000..b6982987b
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_1016953-renaming-uncompressed.js
@@ -0,0 +1,103 @@
+function run_test() {
+ run_next_test();
+}
+
+/* Bug 1016953 - When a previous bookmark backup exists with the same hash
+regardless of date, an automatic backup should attempt to either rename it to
+today's date if the backup was for an old date or leave it alone if it was for
+the same date. However if the file ext was json it will accidentally rename it
+to jsonlz4 while keeping the json contents
+*/
+
+add_task(function* test_same_date_same_hash() {
+ // If old file has been created on the same date and has the same hash
+ // the file should be left alone
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+ // Save to profile dir to obtain hash and nodeCount to append to filename
+ let tempPath = OS.Path.join(OS.Constants.Path.profileDir,
+ "bug10169583_bookmarks.json");
+ let {count, hash} = yield BookmarkJSONUtils.exportToFile(tempPath);
+
+ // Save JSON file in backup folder with hash appended
+ let dateObj = new Date();
+ let filename = "bookmarks-" + PlacesBackups.toISODateString(dateObj) + "_" +
+ count + "_" + hash + ".json";
+ let backupFile = OS.Path.join(backupFolder, filename);
+ yield OS.File.move(tempPath, backupFile);
+
+ // Force a compressed backup which fallbacks to rename
+ yield PlacesBackups.create();
+ let mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+ // check to ensure not renamed to jsonlz4
+ Assert.equal(mostRecentBackupFile, backupFile);
+ // inspect contents and check if valid json
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ let result = yield OS.File.read(mostRecentBackupFile);
+ let jsonString = converter.convertFromByteArray(result, result.length);
+ do_print("Check is valid JSON");
+ JSON.parse(jsonString);
+
+ // Cleanup
+ yield OS.File.remove(backupFile);
+ yield OS.File.remove(tempPath);
+ PlacesBackups._backupFiles = null; // To force re-cache of backupFiles
+});
+
+add_task(function* test_same_date_diff_hash() {
+ // If the old file has been created on the same date, but has a different hash
+ // the existing file should be overwritten with the newer compressed version
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+ let tempPath = OS.Path.join(OS.Constants.Path.profileDir,
+ "bug10169583_bookmarks.json");
+ let {count} = yield BookmarkJSONUtils.exportToFile(tempPath);
+ let dateObj = new Date();
+ let filename = "bookmarks-" + PlacesBackups.toISODateString(dateObj) + "_" +
+ count + "_" + "differentHash==" + ".json";
+ let backupFile = OS.Path.join(backupFolder, filename);
+ yield OS.File.move(tempPath, backupFile);
+ yield PlacesBackups.create(); // Force compressed backup
+ mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+
+ // Decode lz4 compressed file to json and check if json is valid
+ let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
+ createInstance(Ci.nsIScriptableUnicodeConverter);
+ converter.charset = "UTF-8";
+ let result = yield OS.File.read(mostRecentBackupFile, { compression: "lz4" });
+ let jsonString = converter.convertFromByteArray(result, result.length);
+ do_print("Check is valid JSON");
+ JSON.parse(jsonString);
+
+ // Cleanup
+ yield OS.File.remove(mostRecentBackupFile);
+ yield OS.File.remove(tempPath);
+ PlacesBackups._backupFiles = null; // To force re-cache of backupFiles
+});
+
+add_task(function* test_diff_date_same_hash() {
+ // If the old file has been created on an older day but has the same hash
+ // it should be renamed with today's date without altering the contents.
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+ let tempPath = OS.Path.join(OS.Constants.Path.profileDir,
+ "bug10169583_bookmarks.json");
+ let {count, hash} = yield BookmarkJSONUtils.exportToFile(tempPath);
+ let oldDate = new Date(2014, 1, 1);
+ let curDate = new Date();
+ let oldFilename = "bookmarks-" + PlacesBackups.toISODateString(oldDate) + "_" +
+ count + "_" + hash + ".json";
+ let newFilename = "bookmarks-" + PlacesBackups.toISODateString(curDate) + "_" +
+ count + "_" + hash + ".json";
+ let backupFile = OS.Path.join(backupFolder, oldFilename);
+ let newBackupFile = OS.Path.join(backupFolder, newFilename);
+ yield OS.File.move(tempPath, backupFile);
+
+ // Ensure file has been renamed correctly
+ yield PlacesBackups.create();
+ let mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+ Assert.equal(mostRecentBackupFile, newBackupFile);
+
+ // Cleanup
+ yield OS.File.remove(mostRecentBackupFile);
+ yield OS.File.remove(tempPath);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js b/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js
new file mode 100644
index 000000000..13755e576
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_1017502-bookmarks_foreign_count.js
@@ -0,0 +1,112 @@
+/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Bug 1017502 - Add a foreign_count column to moz_places
+This tests, tests the triggers that adjust the foreign_count when a bookmark is
+added or removed and also the maintenance task to fix wrong counts.
+*/
+
+const T_URI = NetUtil.newURI("https://www.mozilla.org/firefox/nightly/firstrun/");
+
+function* getForeignCountForURL(conn, url) {
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ url = url instanceof Ci.nsIURI ? url.spec : url;
+ let rows = yield conn.executeCached(
+ `SELECT foreign_count FROM moz_places WHERE url_hash = hash(:t_url)
+ AND url = :t_url`, { t_url: url });
+ return rows[0].getResultByName("foreign_count");
+}
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* add_remove_change_bookmark_test() {
+ let conn = yield PlacesUtils.promiseDBConnection();
+
+ // Simulate a visit to the url
+ yield PlacesTestUtils.addVisits(T_URI);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 0);
+
+ // Add 1st bookmark which should increment foreign_count by 1
+ let id1 = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ T_URI, PlacesUtils.bookmarks.DEFAULT_INDEX, "First Run");
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 1);
+
+ // Add 2nd bookmark
+ let id2 = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarksMenuFolderId,
+ T_URI, PlacesUtils.bookmarks.DEFAULT_INDEX, "First Run");
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 2);
+
+ // Remove 2nd bookmark which should decrement foreign_count by 1
+ PlacesUtils.bookmarks.removeItem(id2);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 1);
+
+ // Change first bookmark's URI
+ const URI2 = NetUtil.newURI("http://www.mozilla.org");
+ PlacesUtils.bookmarks.changeBookmarkURI(id1, URI2);
+ // Check foreign count for original URI
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 0);
+ // Check foreign count for new URI
+ Assert.equal((yield getForeignCountForURL(conn, URI2)), 1);
+
+ // Cleanup - Remove changed bookmark
+ let id = PlacesUtils.bookmarks.getBookmarkIdsForURI(URI2);
+ PlacesUtils.bookmarks.removeItem(id);
+ Assert.equal((yield getForeignCountForURL(conn, URI2)), 0);
+
+});
+
+add_task(function* maintenance_foreign_count_test() {
+ let conn = yield PlacesUtils.promiseDBConnection();
+
+ // Simulate a visit to the url
+ yield PlacesTestUtils.addVisits(T_URI);
+
+ // Adjust the foreign_count for the added entry to an incorrect value
+ let deferred = Promise.defer();
+ let stmt = DBConn().createAsyncStatement(
+ `UPDATE moz_places SET foreign_count = 10 WHERE url_hash = hash(:t_url)
+ AND url = :t_url `);
+ stmt.params.t_url = T_URI.spec;
+ stmt.executeAsync({
+ handleCompletion: function() {
+ deferred.resolve();
+ }
+ });
+ stmt.finalize();
+ yield deferred.promise;
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 10);
+
+ // Run maintenance
+ Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
+ let promiseMaintenanceFinished =
+ promiseTopicObserved("places-maintenance-finished");
+ PlacesDBUtils.maintenanceOnIdle();
+ yield promiseMaintenanceFinished;
+
+ // Check if the foreign_count has been adjusted to the correct value
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 0);
+});
+
+add_task(function* add_remove_tags_test() {
+ let conn = yield PlacesUtils.promiseDBConnection();
+
+ yield PlacesTestUtils.addVisits(T_URI);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 0);
+
+ // Check foreign count incremented by 1 for a single tag
+ PlacesUtils.tagging.tagURI(T_URI, ["test tag"]);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 1);
+
+ // Check foreign count is incremented by 2 for two tags
+ PlacesUtils.tagging.tagURI(T_URI, ["one", "two"]);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 3);
+
+ // Check foreign count is set to 0 when all tags are removed
+ PlacesUtils.tagging.untagURI(T_URI, ["test tag", "one", "two"]);
+ Assert.equal((yield getForeignCountForURL(conn, T_URI)), 0);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_1129529.js b/toolkit/components/places/tests/bookmarks/test_1129529.js
new file mode 100644
index 000000000..da1ff708f
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_1129529.js
@@ -0,0 +1,76 @@
+var now = Date.now() * 1000;
+
+// Test that importing bookmark data where a bookmark has a tag longer than 100
+// chars imports everything except the tags for that bookmark.
+add_task(function* () {
+ let aData = {
+ guid: "root________",
+ index: 0,
+ id: 1,
+ type: "text/x-moz-place-container",
+ dateAdded: now,
+ lastModified: now,
+ root: "placesRoot",
+ children: [{
+ guid: "unfiled_____",
+ index: 0,
+ id: 2,
+ type: "text/x-moz-place-container",
+ dateAdded: now,
+ lastModified: now,
+ root: "unfiledBookmarksFolder",
+ children: [
+ {
+ guid: "___guid1____",
+ index: 0,
+ id: 3,
+ charset: "UTF-8",
+ tags: "tag0",
+ type: "text/x-moz-place",
+ dateAdded: now,
+ lastModified: now,
+ uri: "http://test0.com/"
+ },
+ {
+ guid: "___guid2____",
+ index: 1,
+ id: 4,
+ charset: "UTF-8",
+ tags: "tag1," + "a" + "0123456789".repeat(10), // 101 chars
+ type: "text/x-moz-place",
+ dateAdded: now,
+ lastModified: now,
+ uri: "http://test1.com/"
+ },
+ {
+ guid: "___guid3____",
+ index: 2,
+ id: 5,
+ charset: "UTF-8",
+ tags: "tag2",
+ type: "text/x-moz-place",
+ dateAdded: now,
+ lastModified: now,
+ uri: "http://test2.com/"
+ }
+ ]
+ }]
+ };
+
+ let contentType = "application/json";
+ let uri = "data:" + contentType + "," + JSON.stringify(aData);
+ yield BookmarkJSONUtils.importFromURL(uri, false);
+
+ let [bookmarks] = yield PlacesBackups.getBookmarksTree();
+ let unsortedBookmarks = bookmarks.children[2].children;
+ Assert.equal(unsortedBookmarks.length, 3);
+
+ for (let i = 0; i < unsortedBookmarks.length; ++i) {
+ let bookmark = unsortedBookmarks[i];
+ Assert.equal(bookmark.charset, "UTF-8");
+ Assert.equal(bookmark.dateAdded, now);
+ Assert.equal(bookmark.lastModified, now);
+ Assert.equal(bookmark.uri, "http://test" + i + ".com/");
+ Assert.equal(bookmark.tags, "tag" + i);
+ }
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_384228.js b/toolkit/components/places/tests/bookmarks/test_384228.js
new file mode 100644
index 000000000..9a52c9746
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_384228.js
@@ -0,0 +1,98 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * test querying for bookmarks in multiple folders.
+ */
+add_task(function* search_bookmark_in_folder() {
+ let testFolder1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bug 384228 test folder 1"
+ });
+ Assert.equal(testFolder1.index, 0);
+
+ let testFolder2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bug 384228 test folder 2"
+ });
+ Assert.equal(testFolder2.index, 1);
+
+ let testFolder3 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bug 384228 test folder 3"
+ });
+ Assert.equal(testFolder3.index, 2);
+
+ let b1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder1.guid,
+ url: "http://foo.tld/",
+ title: "title b1 (folder 1)"
+ });
+ Assert.equal(b1.index, 0);
+
+ let b2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder1.guid,
+ url: "http://foo.tld/",
+ title: "title b2 (folder 1)"
+ });
+ Assert.equal(b2.index, 1);
+
+ let b3 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder2.guid,
+ url: "http://foo.tld/",
+ title: "title b3 (folder 2)"
+ });
+ Assert.equal(b3.index, 0);
+
+ let b4 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder3.guid,
+ url: "http://foo.tld/",
+ title: "title b4 (folder 3)"
+ });
+ Assert.equal(b4.index, 0);
+
+ // also test recursive search
+ let testFolder1_1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder1.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bug 384228 test folder 1.1"
+ });
+ Assert.equal(testFolder1_1.index, 2);
+
+ let b5 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: testFolder1_1.guid,
+ url: "http://foo.tld/",
+ title: "title b5 (folder 1.1)"
+ });
+ Assert.equal(b5.index, 0);
+
+
+ // query folder 1, folder 2 and get 4 bookmarks
+ let folderIds = [];
+ folderIds.push(yield PlacesUtils.promiseItemId(testFolder1.guid));
+ folderIds.push(yield PlacesUtils.promiseItemId(testFolder2.guid));
+
+ let hs = PlacesUtils.history;
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.searchTerms = "title";
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ query.setFolders(folderIds, folderIds.length);
+ let rootNode = hs.executeQuery(query, options).root;
+ rootNode.containerOpen = true;
+
+ // should not match item from folder 3
+ Assert.equal(rootNode.childCount, 4);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid);
+ Assert.equal(rootNode.getChild(3).bookmarkGuid, b5.guid);
+
+ rootNode.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_385829.js b/toolkit/components/places/tests/bookmarks/test_385829.js
new file mode 100644
index 000000000..63beee5f3
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_385829.js
@@ -0,0 +1,182 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* search_bookmark_by_lastModified_dateDated() {
+ // test search on folder with various sorts and max results
+ // see bug #385829 for more details
+ let folder = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bug 385829 test"
+ });
+
+ let now = new Date();
+ // ensure some unique values for date added and last modified
+ // for date added: b1 < b2 < b3 < b4
+ // for last modified: b1 > b2 > b3 > b4
+ let b1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ url: "http://a1.com/",
+ title: "1 title",
+ dateAdded: new Date(now.getTime() + 1000)
+ });
+ let b2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ url: "http://a2.com/",
+ title: "2 title",
+ dateAdded: new Date(now.getTime() + 2000)
+ });
+ let b3 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ url: "http://a3.com/",
+ title: "3 title",
+ dateAdded: new Date(now.getTime() + 3000)
+ });
+ let b4 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ url: "http://a4.com/",
+ title: "4 title",
+ dateAdded: new Date(now.getTime() + 4000)
+ });
+
+ // make sure lastModified is larger than dateAdded
+ let modifiedTime = new Date(now.getTime() + 5000);
+ yield PlacesUtils.bookmarks.update({
+ guid: b1.guid,
+ lastModified: new Date(modifiedTime.getTime() + 4000)
+ });
+ yield PlacesUtils.bookmarks.update({
+ guid: b2.guid,
+ lastModified: new Date(modifiedTime.getTime() + 3000)
+ });
+ yield PlacesUtils.bookmarks.update({
+ guid: b3.guid,
+ lastModified: new Date(modifiedTime.getTime() + 2000)
+ });
+ yield PlacesUtils.bookmarks.update({
+ guid: b4.guid,
+ lastModified: new Date(modifiedTime.getTime() + 1000)
+ });
+
+ let hs = PlacesUtils.history;
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ options.maxResults = 3;
+ let folderIds = [];
+ folderIds.push(yield PlacesUtils.promiseItemId(folder.guid));
+ query.setFolders(folderIds, 1);
+
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+
+ // test SORT_BY_DATEADDED_ASCENDING (live update)
+ result.sortingMode = options.SORT_BY_DATEADDED_ASCENDING;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid);
+ Assert.ok(rootNode.getChild(0).dateAdded <
+ rootNode.getChild(1).dateAdded);
+ Assert.ok(rootNode.getChild(1).dateAdded <
+ rootNode.getChild(2).dateAdded);
+
+ // test SORT_BY_DATEADDED_DESCENDING (live update)
+ result.sortingMode = options.SORT_BY_DATEADDED_DESCENDING;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b3.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b1.guid);
+ Assert.ok(rootNode.getChild(0).dateAdded >
+ rootNode.getChild(1).dateAdded);
+ Assert.ok(rootNode.getChild(1).dateAdded >
+ rootNode.getChild(2).dateAdded);
+
+ // test SORT_BY_LASTMODIFIED_ASCENDING (live update)
+ result.sortingMode = options.SORT_BY_LASTMODIFIED_ASCENDING;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b3.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b1.guid);
+ Assert.ok(rootNode.getChild(0).lastModified <
+ rootNode.getChild(1).lastModified);
+ Assert.ok(rootNode.getChild(1).lastModified <
+ rootNode.getChild(2).lastModified);
+
+ // test SORT_BY_LASTMODIFIED_DESCENDING (live update)
+ result.sortingMode = options.SORT_BY_LASTMODIFIED_DESCENDING;
+
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid);
+ Assert.ok(rootNode.getChild(0).lastModified >
+ rootNode.getChild(1).lastModified);
+ Assert.ok(rootNode.getChild(1).lastModified >
+ rootNode.getChild(2).lastModified);
+ rootNode.containerOpen = false;
+
+ // test SORT_BY_DATEADDED_ASCENDING
+ options.sortingMode = options.SORT_BY_DATEADDED_ASCENDING;
+ result = hs.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid);
+ Assert.ok(rootNode.getChild(0).dateAdded <
+ rootNode.getChild(1).dateAdded);
+ Assert.ok(rootNode.getChild(1).dateAdded <
+ rootNode.getChild(2).dateAdded);
+ rootNode.containerOpen = false;
+
+ // test SORT_BY_DATEADDED_DESCENDING
+ options.sortingMode = options.SORT_BY_DATEADDED_DESCENDING;
+ result = hs.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b4.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b3.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b2.guid);
+ Assert.ok(rootNode.getChild(0).dateAdded >
+ rootNode.getChild(1).dateAdded);
+ Assert.ok(rootNode.getChild(1).dateAdded >
+ rootNode.getChild(2).dateAdded);
+ rootNode.containerOpen = false;
+
+ // test SORT_BY_LASTMODIFIED_ASCENDING
+ options.sortingMode = options.SORT_BY_LASTMODIFIED_ASCENDING;
+ result = hs.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b4.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b3.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b2.guid);
+ Assert.ok(rootNode.getChild(0).lastModified <
+ rootNode.getChild(1).lastModified);
+ Assert.ok(rootNode.getChild(1).lastModified <
+ rootNode.getChild(2).lastModified);
+ rootNode.containerOpen = false;
+
+ // test SORT_BY_LASTMODIFIED_DESCENDING
+ options.sortingMode = options.SORT_BY_LASTMODIFIED_DESCENDING;
+ result = hs.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ Assert.equal(rootNode.childCount, 3);
+ Assert.equal(rootNode.getChild(0).bookmarkGuid, b1.guid);
+ Assert.equal(rootNode.getChild(1).bookmarkGuid, b2.guid);
+ Assert.equal(rootNode.getChild(2).bookmarkGuid, b3.guid);
+ Assert.ok(rootNode.getChild(0).lastModified >
+ rootNode.getChild(1).lastModified);
+ Assert.ok(rootNode.getChild(1).lastModified >
+ rootNode.getChild(2).lastModified);
+ rootNode.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_388695.js b/toolkit/components/places/tests/bookmarks/test_388695.js
new file mode 100644
index 000000000..4e313c52f
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_388695.js
@@ -0,0 +1,52 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get bookmark service
+try {
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+} catch (ex) {
+ do_throw("Could not get nav-bookmarks-service\n");
+}
+
+var gTestRoot;
+var gURI;
+var gItemId1;
+var gItemId2;
+
+// main
+function run_test() {
+ gURI = uri("http://foo.tld.com/");
+ gTestRoot = bmsvc.createFolder(bmsvc.placesRoot, "test folder",
+ bmsvc.DEFAULT_INDEX);
+
+ // test getBookmarkIdsForURI
+ // getBookmarkIdsForURI sorts by the most recently added/modified (descending)
+ //
+ // we cannot rely on dateAdded growing when doing so in a simple iteration,
+ // see PR_Now() documentation
+ do_test_pending();
+
+ gItemId1 = bmsvc.insertBookmark(gTestRoot, gURI, bmsvc.DEFAULT_INDEX, "");
+ do_timeout(100, phase2);
+}
+
+function phase2() {
+ gItemId2 = bmsvc.insertBookmark(gTestRoot, gURI, bmsvc.DEFAULT_INDEX, "");
+ var b = bmsvc.getBookmarkIdsForURI(gURI);
+ do_check_eq(b[0], gItemId2);
+ do_check_eq(b[1], gItemId1);
+ do_timeout(100, phase3);
+}
+
+function phase3() {
+ // trigger last modified change
+ bmsvc.setItemTitle(gItemId1, "");
+ var b = bmsvc.getBookmarkIdsForURI(gURI);
+ do_check_eq(b[0], gItemId1);
+ do_check_eq(b[1], gItemId2);
+ do_test_finished();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_393498.js b/toolkit/components/places/tests/bookmarks/test_393498.js
new file mode 100644
index 000000000..601f77a0a
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_393498.js
@@ -0,0 +1,102 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var observer = {
+ __proto__: NavBookmarkObserver.prototype,
+
+ onItemAdded: function (id, folder, index) {
+ this._itemAddedId = id;
+ this._itemAddedParent = folder;
+ this._itemAddedIndex = index;
+ },
+ onItemChanged: function (id, property, isAnnotationProperty, value) {
+ this._itemChangedId = id;
+ this._itemChangedProperty = property;
+ this._itemChanged_isAnnotationProperty = isAnnotationProperty;
+ this._itemChangedValue = value;
+ }
+};
+PlacesUtils.bookmarks.addObserver(observer, false);
+
+do_register_cleanup(function () {
+ PlacesUtils.bookmarks.removeObserver(observer);
+});
+
+function run_test() {
+ // We set times in the past to workaround a timing bug due to virtual
+ // machines and the skew between PR_Now() and Date.now(), see bug 427142 and
+ // bug 858377 for details.
+ const PAST_PRTIME = (Date.now() - 86400000) * 1000;
+
+ // Insert a new bookmark.
+ let testFolder = PlacesUtils.bookmarks.createFolder(
+ PlacesUtils.placesRootId, "test Folder",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let bookmarkId = PlacesUtils.bookmarks.insertBookmark(
+ testFolder, uri("http://google.com/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "");
+
+ // Sanity check.
+ do_check_true(observer.itemChangedProperty === undefined);
+
+ // Set dateAdded in the past and verify the bookmarks cache.
+ PlacesUtils.bookmarks.setItemDateAdded(bookmarkId, PAST_PRTIME);
+ do_check_eq(observer._itemChangedProperty, "dateAdded");
+ do_check_eq(observer._itemChangedValue, PAST_PRTIME);
+ let dateAdded = PlacesUtils.bookmarks.getItemDateAdded(bookmarkId);
+ do_check_eq(dateAdded, PAST_PRTIME);
+
+ // After just inserting, modified should be the same as dateAdded.
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(bookmarkId), dateAdded);
+
+ // Set lastModified in the past and verify the bookmarks cache.
+ PlacesUtils.bookmarks.setItemLastModified(bookmarkId, PAST_PRTIME);
+ do_check_eq(observer._itemChangedProperty, "lastModified");
+ do_check_eq(observer._itemChangedValue, PAST_PRTIME);
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(bookmarkId),
+ PAST_PRTIME);
+
+ // Set bookmark title
+ PlacesUtils.bookmarks.setItemTitle(bookmarkId, "Google");
+
+ // Test notifications.
+ do_check_eq(observer._itemChangedId, bookmarkId);
+ do_check_eq(observer._itemChangedProperty, "title");
+ do_check_eq(observer._itemChangedValue, "Google");
+
+ // Check lastModified has been updated.
+ is_time_ordered(PAST_PRTIME,
+ PlacesUtils.bookmarks.getItemLastModified(bookmarkId));
+
+ // Check that node properties are updated.
+ let root = PlacesUtils.getFolderContents(testFolder).root;
+ do_check_eq(root.childCount, 1);
+ let childNode = root.getChild(0);
+
+ // confirm current dates match node properties
+ do_check_eq(PlacesUtils.bookmarks.getItemDateAdded(bookmarkId),
+ childNode.dateAdded);
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(bookmarkId),
+ childNode.lastModified);
+
+ // Test live update of lastModified when setting title.
+ PlacesUtils.bookmarks.setItemLastModified(bookmarkId, PAST_PRTIME);
+ PlacesUtils.bookmarks.setItemTitle(bookmarkId, "Google");
+
+ // Check lastModified has been updated.
+ is_time_ordered(PAST_PRTIME, childNode.lastModified);
+ // Test that node value matches db value.
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(bookmarkId),
+ childNode.lastModified);
+
+ // Test live update of the exposed date apis.
+ PlacesUtils.bookmarks.setItemDateAdded(bookmarkId, PAST_PRTIME);
+ do_check_eq(childNode.dateAdded, PAST_PRTIME);
+ PlacesUtils.bookmarks.setItemLastModified(bookmarkId, PAST_PRTIME);
+ do_check_eq(childNode.lastModified, PAST_PRTIME);
+
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_395101.js b/toolkit/components/places/tests/bookmarks/test_395101.js
new file mode 100644
index 000000000..a507e7361
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_395101.js
@@ -0,0 +1,87 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get bookmark service
+try {
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].getService(Ci.nsINavBookmarksService);
+} catch (ex) {
+ do_throw("Could not get nav-bookmarks-service\n");
+}
+
+// Get history service
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService(Ci.nsINavHistoryService);
+} catch (ex) {
+ do_throw("Could not get history service\n");
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+// get bookmarks root id
+var root = bmsvc.bookmarksMenuFolder;
+
+// main
+function run_test() {
+ // test searching for tagged bookmarks
+
+ // test folder
+ var folder = bmsvc.createFolder(root, "bug 395101 test", bmsvc.DEFAULT_INDEX);
+
+ // create a bookmark
+ var testURI = uri("http://a1.com");
+ var b1 = bmsvc.insertBookmark(folder, testURI,
+ bmsvc.DEFAULT_INDEX, "1 title");
+
+ // tag the bookmarked URI
+ tagssvc.tagURI(testURI, ["elephant", "walrus", "giraffe", "turkey", "hiPPo", "BABOON", "alf"]);
+
+ // search for the bookmark, using a tag
+ var query = histsvc.getNewQuery();
+ query.searchTerms = "elephant";
+ var options = histsvc.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ query.setFolders([folder], 1);
+
+ var result = histsvc.executeQuery(query, options);
+ var rootNode = result.root;
+ rootNode.containerOpen = true;
+
+ do_check_eq(rootNode.childCount, 1);
+ do_check_eq(rootNode.getChild(0).itemId, b1);
+ rootNode.containerOpen = false;
+
+ // partial matches are okay
+ query.searchTerms = "wal";
+ result = histsvc.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 1);
+ rootNode.containerOpen = false;
+
+ // case insensitive search term
+ query.searchTerms = "WALRUS";
+ result = histsvc.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 1);
+ do_check_eq(rootNode.getChild(0).itemId, b1);
+ rootNode.containerOpen = false;
+
+ // case insensitive tag
+ query.searchTerms = "baboon";
+ result = histsvc.executeQuery(query, options);
+ rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 1);
+ do_check_eq(rootNode.getChild(0).itemId, b1);
+ rootNode.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_395593.js b/toolkit/components/places/tests/bookmarks/test_395593.js
new file mode 100644
index 000000000..46d8f5b80
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_395593.js
@@ -0,0 +1,69 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+function check_queries_results(aQueries, aOptions, aExpectedItemIds) {
+ var result = hs.executeQueries(aQueries, aQueries.length, aOptions);
+ var root = result.root;
+ root.containerOpen = true;
+
+ // Dump found nodes.
+ for (let i = 0; i < root.childCount; i++) {
+ dump("nodes[" + i + "]: " + root.getChild(0).title + "\n");
+ }
+
+ do_check_eq(root.childCount, aExpectedItemIds.length);
+ for (let i = 0; i < root.childCount; i++) {
+ do_check_eq(root.getChild(i).itemId, aExpectedItemIds[i]);
+ }
+
+ root.containerOpen = false;
+}
+
+// main
+function run_test() {
+ var id1 = bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://foo.tld"),
+ bs.DEFAULT_INDEX, "123 0");
+ var id2 = bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://foo.tld"),
+ bs.DEFAULT_INDEX, "456");
+ var id3 = bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://foo.tld"),
+ bs.DEFAULT_INDEX, "123 456");
+ var id4 = bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://foo.tld"),
+ bs.DEFAULT_INDEX, "789 456");
+
+ /**
+ * All of the query objects are ORed together. Within a query, all the terms
+ * are ANDed together. See nsINavHistory.idl.
+ */
+ var queries = [];
+ queries.push(hs.getNewQuery());
+ queries.push(hs.getNewQuery());
+ var options = hs.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+
+ // Test 1
+ dump("Test searching for 123 OR 789\n");
+ queries[0].searchTerms = "123";
+ queries[1].searchTerms = "789";
+ check_queries_results(queries, options, [id1, id3, id4]);
+
+ // Test 2
+ dump("Test searching for 123 OR 456\n");
+ queries[0].searchTerms = "123";
+ queries[1].searchTerms = "456";
+ check_queries_results(queries, options, [id1, id2, id3, id4]);
+
+ // Test 3
+ dump("Test searching for 00 OR 789\n");
+ queries[0].searchTerms = "00";
+ queries[1].searchTerms = "789";
+ check_queries_results(queries, options, [id4]);
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js b/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js
new file mode 100644
index 000000000..e317cc2e9
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_405938_restore_queries.js
@@ -0,0 +1,221 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+/*
+
+Backup/restore tests example:
+
+var myTest = {
+ populate: function () { ... add bookmarks ... },
+ validate: function () { ... query for your bookmarks ... }
+}
+
+this.push(myTest);
+
+*/
+
+/*
+
+test summary:
+- create folders with content
+- create a query bookmark for those folders
+- backs up bookmarks
+- restores bookmarks
+- confirms that the query has the new ids for the same folders
+
+scenarios:
+- 1 folder (folder shortcut)
+- n folders (single query)
+- n folders (multiple queries)
+
+*/
+
+const DEFAULT_INDEX = PlacesUtils.bookmarks.DEFAULT_INDEX;
+
+var test = {
+ _testRootId: null,
+ _testRootTitle: "test root",
+ _folderIds: [],
+ _bookmarkURIs: [],
+ _count: 3,
+
+ populate: function populate() {
+ // folder to hold this test
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.toolbarFolderId);
+ this._testRootId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.toolbarFolderId,
+ this._testRootTitle, DEFAULT_INDEX);
+
+ // create test folders each with a bookmark
+ for (var i = 0; i < this._count; i++) {
+ var folderId =
+ PlacesUtils.bookmarks.createFolder(this._testRootId, "folder" + i, DEFAULT_INDEX);
+ this._folderIds.push(folderId)
+
+ var bookmarkURI = uri("http://" + i);
+ PlacesUtils.bookmarks.insertBookmark(folderId, bookmarkURI,
+ DEFAULT_INDEX, "bookmark" + i);
+ this._bookmarkURIs.push(bookmarkURI);
+ }
+
+ // create a query URI with 1 folder (ie: folder shortcut)
+ this._queryURI1 = uri("place:folder=" + this._folderIds[0] + "&queryType=1");
+ this._queryTitle1 = "query1";
+ PlacesUtils.bookmarks.insertBookmark(this._testRootId, this._queryURI1,
+ DEFAULT_INDEX, this._queryTitle1);
+
+ // create a query URI with _count folders
+ this._queryURI2 = uri("place:folder=" + this._folderIds.join("&folder=") + "&queryType=1");
+ this._queryTitle2 = "query2";
+ PlacesUtils.bookmarks.insertBookmark(this._testRootId, this._queryURI2,
+ DEFAULT_INDEX, this._queryTitle2);
+
+ // create a query URI with _count queries (each with a folder)
+ // first get a query object for each folder
+ var queries = this._folderIds.map(function(aFolderId) {
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ return query;
+ });
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ this._queryURI3 =
+ uri(PlacesUtils.history.queriesToQueryString(queries, queries.length, options));
+ this._queryTitle3 = "query3";
+ PlacesUtils.bookmarks.insertBookmark(this._testRootId, this._queryURI3,
+ DEFAULT_INDEX, this._queryTitle3);
+ },
+
+ clean: function () {},
+
+ validate: function validate() {
+ // Throw a wrench in the works by inserting some new bookmarks,
+ // ensuring folder ids won't be the same, when restoring.
+ for (let i = 0; i < 10; i++) {
+ PlacesUtils.bookmarks.
+ insertBookmark(PlacesUtils.bookmarksMenuFolderId, uri("http://aaaa"+i), DEFAULT_INDEX, "");
+ }
+
+ var toolbar =
+ PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId,
+ false, true).root;
+ do_check_true(toolbar.childCount, 1);
+
+ var folderNode = toolbar.getChild(0);
+ do_check_eq(folderNode.type, folderNode.RESULT_TYPE_FOLDER);
+ do_check_eq(folderNode.title, this._testRootTitle);
+ folderNode.QueryInterface(Ci.nsINavHistoryQueryResultNode);
+ folderNode.containerOpen = true;
+
+ // |_count| folders + the query node
+ do_check_eq(folderNode.childCount, this._count+3);
+
+ for (let i = 0; i < this._count; i++) {
+ var subFolder = folderNode.getChild(i);
+ do_check_eq(subFolder.title, "folder"+i);
+ subFolder.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ subFolder.containerOpen = true;
+ do_check_eq(subFolder.childCount, 1);
+ var child = subFolder.getChild(0);
+ do_check_eq(child.title, "bookmark"+i);
+ do_check_true(uri(child.uri).equals(uri("http://" + i)))
+ }
+
+ // validate folder shortcut
+ this.validateQueryNode1(folderNode.getChild(this._count));
+
+ // validate folders query
+ this.validateQueryNode2(folderNode.getChild(this._count + 1));
+
+ // validate multiple queries query
+ this.validateQueryNode3(folderNode.getChild(this._count + 2));
+
+ // clean up
+ folderNode.containerOpen = false;
+ toolbar.containerOpen = false;
+ },
+
+ validateQueryNode1: function validateQueryNode1(aNode) {
+ do_check_eq(aNode.title, this._queryTitle1);
+ do_check_true(PlacesUtils.nodeIsFolder(aNode));
+
+ aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ aNode.containerOpen = true;
+ do_check_eq(aNode.childCount, 1);
+ var child = aNode.getChild(0);
+ do_check_true(uri(child.uri).equals(uri("http://0")))
+ do_check_eq(child.title, "bookmark0")
+ aNode.containerOpen = false;
+ },
+
+ validateQueryNode2: function validateQueryNode2(aNode) {
+ do_check_eq(aNode.title, this._queryTitle2);
+ do_check_true(PlacesUtils.nodeIsQuery(aNode));
+
+ aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ aNode.containerOpen = true;
+ do_check_eq(aNode.childCount, this._count);
+ for (var i = 0; i < aNode.childCount; i++) {
+ var child = aNode.getChild(i);
+ do_check_true(uri(child.uri).equals(uri("http://" + i)))
+ do_check_eq(child.title, "bookmark" + i)
+ }
+ aNode.containerOpen = false;
+ },
+
+ validateQueryNode3: function validateQueryNode3(aNode) {
+ do_check_eq(aNode.title, this._queryTitle3);
+ do_check_true(PlacesUtils.nodeIsQuery(aNode));
+
+ aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ aNode.containerOpen = true;
+ do_check_eq(aNode.childCount, this._count);
+ for (var i = 0; i < aNode.childCount; i++) {
+ var child = aNode.getChild(i);
+ do_check_true(uri(child.uri).equals(uri("http://" + i)))
+ do_check_eq(child.title, "bookmark" + i)
+ }
+ aNode.containerOpen = false;
+ }
+}
+tests.push(test);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ // make json file
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ tests.forEach(function(aTest) {
+ aTest.populate();
+ // sanity
+ aTest.validate();
+ });
+
+ // export json to file
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ // clean
+ tests.forEach(function(aTest) {
+ aTest.clean();
+ });
+
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate
+ tests.forEach(function(aTest) {
+ aTest.validate();
+ });
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_417228-exclude-from-backup.js b/toolkit/components/places/tests/bookmarks/test_417228-exclude-from-backup.js
new file mode 100644
index 000000000..858496856
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_417228-exclude-from-backup.js
@@ -0,0 +1,141 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const EXCLUDE_FROM_BACKUP_ANNO = "places/excludeFromBackup";
+// Menu, Toolbar, Unsorted, Tags, Mobile
+const PLACES_ROOTS_COUNT = 5;
+var tests = [];
+
+/*
+
+Backup/restore tests example:
+
+var myTest = {
+ populate: function () { ... add bookmarks ... },
+ validate: function () { ... query for your bookmarks ... }
+}
+
+this.push(myTest);
+
+*/
+
+var test = {
+ populate: function populate() {
+ // check initial size
+ var rootNode = PlacesUtils.getFolderContents(PlacesUtils.placesRootId,
+ false, false).root;
+ do_check_eq(rootNode.childCount, PLACES_ROOTS_COUNT );
+ rootNode.containerOpen = false;
+
+ var idx = PlacesUtils.bookmarks.DEFAULT_INDEX;
+
+ // create a root to be restore
+ this._restoreRootTitle = "restore root";
+ var restoreRootId = PlacesUtils.bookmarks
+ .createFolder(PlacesUtils.placesRootId,
+ this._restoreRootTitle, idx);
+ // add a test bookmark
+ this._restoreRootURI = uri("http://restore.uri");
+ PlacesUtils.bookmarks.insertBookmark(restoreRootId, this._restoreRootURI,
+ idx, "restore uri");
+ // add a test bookmark to be exclude
+ this._restoreRootExcludeURI = uri("http://exclude.uri");
+ var exItemId = PlacesUtils.bookmarks
+ .insertBookmark(restoreRootId,
+ this._restoreRootExcludeURI,
+ idx, "exclude uri");
+ // Annotate the bookmark for exclusion.
+ PlacesUtils.annotations.setItemAnnotation(exItemId,
+ EXCLUDE_FROM_BACKUP_ANNO, 1, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+
+ // create a root to be exclude
+ this._excludeRootTitle = "exclude root";
+ this._excludeRootId = PlacesUtils.bookmarks
+ .createFolder(PlacesUtils.placesRootId,
+ this._excludeRootTitle, idx);
+ // Annotate the root for exclusion.
+ PlacesUtils.annotations.setItemAnnotation(this._excludeRootId,
+ EXCLUDE_FROM_BACKUP_ANNO, 1, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+ // add a test bookmark exclude by exclusion of its parent
+ PlacesUtils.bookmarks.insertBookmark(this._excludeRootId,
+ this._restoreRootExcludeURI,
+ idx, "exclude uri");
+ },
+
+ validate: function validate(aEmptyBookmarks) {
+ var rootNode = PlacesUtils.getFolderContents(PlacesUtils.placesRootId,
+ false, false).root;
+
+ if (!aEmptyBookmarks) {
+ // since restore does not remove backup exclude items both
+ // roots should still exist.
+ do_check_eq(rootNode.childCount, PLACES_ROOTS_COUNT + 2);
+ // open exclude root and check it still contains one item
+ var restoreRootIndex = PLACES_ROOTS_COUNT;
+ var excludeRootIndex = PLACES_ROOTS_COUNT+1;
+ var excludeRootNode = rootNode.getChild(excludeRootIndex);
+ do_check_eq(this._excludeRootTitle, excludeRootNode.title);
+ excludeRootNode.QueryInterface(Ci.nsINavHistoryQueryResultNode);
+ excludeRootNode.containerOpen = true;
+ do_check_eq(excludeRootNode.childCount, 1);
+ var excludeRootChildNode = excludeRootNode.getChild(0);
+ do_check_eq(excludeRootChildNode.uri, this._restoreRootExcludeURI.spec);
+ excludeRootNode.containerOpen = false;
+ }
+ else {
+ // exclude root should not exist anymore
+ do_check_eq(rootNode.childCount, PLACES_ROOTS_COUNT + 1);
+ restoreRootIndex = PLACES_ROOTS_COUNT;
+ }
+
+ var restoreRootNode = rootNode.getChild(restoreRootIndex);
+ do_check_eq(this._restoreRootTitle, restoreRootNode.title);
+ restoreRootNode.QueryInterface(Ci.nsINavHistoryQueryResultNode);
+ restoreRootNode.containerOpen = true;
+ do_check_eq(restoreRootNode.childCount, 1);
+ var restoreRootChildNode = restoreRootNode.getChild(0);
+ do_check_eq(restoreRootChildNode.uri, this._restoreRootURI.spec);
+ restoreRootNode.containerOpen = false;
+
+ rootNode.containerOpen = false;
+ }
+}
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function*() {
+ // make json file
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ test.populate();
+
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate without removing all bookmarks
+ // restore do not remove backup exclude entries
+ test.validate(false);
+
+ // cleanup
+ yield PlacesUtils.bookmarks.eraseEverything();
+ // manually remove the excluded root
+ PlacesUtils.bookmarks.removeItem(test._excludeRootId);
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate after a complete bookmarks cleanup
+ test.validate(true);
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_417228-other-roots.js b/toolkit/components/places/tests/bookmarks/test_417228-other-roots.js
new file mode 100644
index 000000000..1def75d2d
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_417228-other-roots.js
@@ -0,0 +1,158 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+/*
+
+Backup/restore tests example:
+
+var myTest = {
+ populate: function () { ... add bookmarks ... },
+ validate: function () { ... query for your bookmarks ... }
+}
+
+this.push(myTest);
+
+*/
+
+tests.push({
+ excludeItemsFromRestore: [],
+ populate: function populate() {
+ // check initial size
+ var rootNode = PlacesUtils.getFolderContents(PlacesUtils.placesRootId,
+ false, false).root;
+ do_check_eq(rootNode.childCount, 5);
+
+ // create a test root
+ this._folderTitle = "test folder";
+ this._folderId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.placesRootId,
+ this._folderTitle,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ do_check_eq(rootNode.childCount, 6);
+
+ // add a tag
+ this._testURI = PlacesUtils._uri("http://test");
+ this._tags = ["a", "b"];
+ PlacesUtils.tagging.tagURI(this._testURI, this._tags);
+
+ // add a child to each root, including our test root
+ this._roots = [PlacesUtils.bookmarksMenuFolderId, PlacesUtils.toolbarFolderId,
+ PlacesUtils.unfiledBookmarksFolderId, PlacesUtils.mobileFolderId,
+ this._folderId];
+ this._roots.forEach(function(aRootId) {
+ // clean slate
+ PlacesUtils.bookmarks.removeFolderChildren(aRootId);
+ // add a test bookmark
+ PlacesUtils.bookmarks.insertBookmark(aRootId, this._testURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "test");
+ }, this);
+
+ // add a folder to exclude from replacing during restore
+ // this will still be present post-restore
+ var excludedFolderId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.placesRootId,
+ "excluded",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ do_check_eq(rootNode.childCount, 7);
+ this.excludeItemsFromRestore.push(excludedFolderId);
+
+ // add a test bookmark to it
+ PlacesUtils.bookmarks.insertBookmark(excludedFolderId, this._testURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "test");
+ },
+
+ inbetween: function inbetween() {
+ // add some items that should be removed by the restore
+
+ // add a folder
+ this._litterTitle = "otter";
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.placesRootId,
+ this._litterTitle, 0);
+
+ // add some tags
+ PlacesUtils.tagging.tagURI(this._testURI, ["c", "d"]);
+ },
+
+ validate: function validate() {
+ // validate tags restored
+ var tags = PlacesUtils.tagging.getTagsForURI(this._testURI);
+ // also validates that litter tags are gone
+ do_check_eq(this._tags.toString(), tags.toString());
+
+ var rootNode = PlacesUtils.getFolderContents(PlacesUtils.placesRootId,
+ false, false).root;
+
+ // validate litter is gone
+ do_check_neq(rootNode.getChild(0).title, this._litterTitle);
+
+ // test root count is the same
+ do_check_eq(rootNode.childCount, 7);
+
+ var foundTestFolder = 0;
+ for (var i = 0; i < rootNode.childCount; i++) {
+ var node = rootNode.getChild(i);
+
+ do_print("validating " + node.title);
+ if (node.itemId != PlacesUtils.tagsFolderId) {
+ if (node.title == this._folderTitle) {
+ // check the test folder's properties
+ do_check_eq(node.type, node.RESULT_TYPE_FOLDER);
+ do_check_eq(node.title, this._folderTitle);
+ foundTestFolder++;
+ }
+
+ // test contents
+ node.QueryInterface(Ci.nsINavHistoryContainerResultNode).containerOpen = true;
+ do_check_eq(node.childCount, 1);
+ var child = node.getChild(0);
+ do_check_true(PlacesUtils._uri(child.uri).equals(this._testURI));
+
+ // clean up
+ node.containerOpen = false;
+ }
+ }
+ do_check_eq(foundTestFolder, 1);
+ rootNode.containerOpen = false;
+ }
+});
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ // make json file
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ tests.forEach(function(aTest) {
+ aTest.populate();
+ // sanity
+ aTest.validate();
+
+ if (aTest.excludedItemsFromRestore)
+ excludedItemsFromRestore = excludedItems.concat(aTest.excludedItemsFromRestore);
+ });
+
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ tests.forEach(function(aTest) {
+ aTest.inbetween();
+ });
+
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate
+ tests.forEach(function(aTest) {
+ aTest.validate();
+ });
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js b/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js
new file mode 100644
index 000000000..7da1146cf
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_424958-json-quoted-folders.js
@@ -0,0 +1,91 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+/*
+
+Backup/restore tests example:
+
+var myTest = {
+ populate: function () { ... add bookmarks ... },
+ validate: function () { ... query for your bookmarks ... }
+}
+
+this.push(myTest);
+
+*/
+
+var quotesTest = {
+ _folderTitle: '"quoted folder"',
+ _folderId: null,
+
+ populate: function () {
+ this._folderId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.toolbarFolderId,
+ this._folderTitle,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ },
+
+ clean: function () {
+ PlacesUtils.bookmarks.removeItem(this._folderId);
+ },
+
+ validate: function () {
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ var result = PlacesUtils.history.executeQuery(query, PlacesUtils.history.getNewQueryOptions());
+
+ var toolbar = result.root;
+ toolbar.containerOpen = true;
+
+ // test for our quoted folder
+ do_check_true(toolbar.childCount, 1);
+ var folderNode = toolbar.getChild(0);
+ do_check_eq(folderNode.type, folderNode.RESULT_TYPE_FOLDER);
+ do_check_eq(folderNode.title, this._folderTitle);
+
+ // clean up
+ toolbar.containerOpen = false;
+ }
+}
+tests.push(quotesTest);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ // make json file
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ tests.forEach(function(aTest) {
+ aTest.populate();
+ // sanity
+ aTest.validate();
+ });
+
+ // export json to file
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ // clean
+ tests.forEach(function(aTest) {
+ aTest.clean();
+ });
+
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate
+ tests.forEach(function(aTest) {
+ aTest.validate();
+ });
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_448584.js b/toolkit/components/places/tests/bookmarks/test_448584.js
new file mode 100644
index 000000000..6e58bd83a
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_448584.js
@@ -0,0 +1,113 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+// Get database connection
+try {
+ var mDBConn = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+}
+catch (ex) {
+ do_throw("Could not get database connection\n");
+}
+
+/*
+ This test is:
+ - don't try to add invalid uri nodes to a JSON backup
+*/
+
+var invalidURITest = {
+ _itemTitle: "invalid uri",
+ _itemUrl: "http://test.mozilla.org/",
+ _itemId: null,
+
+ populate: function () {
+ // add a valid bookmark
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.toolbarFolderId,
+ PlacesUtils._uri(this._itemUrl),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ this._itemTitle);
+ // this bookmark will go corrupt
+ this._itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.toolbarFolderId,
+ PlacesUtils._uri(this._itemUrl),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ this._itemTitle);
+ },
+
+ clean: function () {
+ PlacesUtils.bookmarks.removeItem(this._itemId);
+ },
+
+ validate: function (aExpectValidItemsCount) {
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ var options = PlacesUtils.history.getNewQueryOptions();
+ var result = PlacesUtils.history.executeQuery(query, options);
+
+ var toolbar = result.root;
+ toolbar.containerOpen = true;
+
+ // test for our bookmark
+ do_check_eq(toolbar.childCount, aExpectValidItemsCount);
+ for (var i = 0; i < toolbar.childCount; i++) {
+ var folderNode = toolbar.getChild(0);
+ do_check_eq(folderNode.type, folderNode.RESULT_TYPE_URI);
+ do_check_eq(folderNode.title, this._itemTitle);
+ }
+
+ // clean up
+ toolbar.containerOpen = false;
+ }
+}
+tests.push(invalidURITest);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function*() {
+ // make json file
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ tests.forEach(function(aTest) {
+ aTest.populate();
+ // sanity
+ aTest.validate(2);
+ // Something in the code went wrong and we finish up losing the place, so
+ // the bookmark uri becomes null.
+ var sql = "UPDATE moz_bookmarks SET fk = 1337 WHERE id = ?1";
+ var stmt = mDBConn.createStatement(sql);
+ stmt.bindByIndex(0, aTest._itemId);
+ try {
+ stmt.execute();
+ } finally {
+ stmt.finalize();
+ }
+ });
+
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ // clean
+ tests.forEach(function(aTest) {
+ aTest.clean();
+ });
+
+ // restore json file
+ try {
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+ } catch (ex) { do_throw("couldn't import the exported file: " + ex); }
+
+ // validate
+ tests.forEach(function(aTest) {
+ aTest.validate(1);
+ });
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_458683.js b/toolkit/components/places/tests/bookmarks/test_458683.js
new file mode 100644
index 000000000..c3722aab5
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_458683.js
@@ -0,0 +1,131 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+// Get database connection
+try {
+ var mDBConn = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+}
+catch (ex) {
+ do_throw("Could not get database connection\n");
+}
+
+/*
+ This test is:
+ - don't block while doing backup and restore if tag containers contain
+ bogus items (separators, folders)
+*/
+
+var invalidTagChildTest = {
+ _itemTitle: "invalid uri",
+ _itemUrl: "http://test.mozilla.org/",
+ _itemId: -1,
+ _tag: "testTag",
+ _tagItemId: -1,
+
+ populate: function () {
+ // add a valid bookmark
+ this._itemId = PlacesUtils.bookmarks
+ .insertBookmark(PlacesUtils.toolbarFolderId,
+ PlacesUtils._uri(this._itemUrl),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ this._itemTitle);
+
+ // create a tag
+ PlacesUtils.tagging.tagURI(PlacesUtils._uri(this._itemUrl), [this._tag]);
+ // get tag folder id
+ var options = PlacesUtils.history.getNewQueryOptions();
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.tagsFolder], 1);
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var tagRoot = result.root;
+ tagRoot.containerOpen = true;
+ do_check_eq(tagRoot.childCount, 1);
+ var tagNode = tagRoot.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ this._tagItemId = tagNode.itemId;
+ tagRoot.containerOpen = false;
+
+ // add a separator and a folder inside tag folder
+ PlacesUtils.bookmarks.insertSeparator(this._tagItemId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.createFolder(this._tagItemId,
+ "test folder",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+
+ // add a separator and a folder inside tag root
+ PlacesUtils.bookmarks.insertSeparator(PlacesUtils.bookmarks.tagsFolder,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.bookmarks.tagsFolder,
+ "test tags root folder",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ },
+
+ clean: function () {
+ PlacesUtils.tagging.untagURI(PlacesUtils._uri(this._itemUrl), [this._tag]);
+ PlacesUtils.bookmarks.removeItem(this._itemId);
+ },
+
+ validate: function () {
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ var options = PlacesUtils.history.getNewQueryOptions();
+ var result = PlacesUtils.history.executeQuery(query, options);
+
+ var toolbar = result.root;
+ toolbar.containerOpen = true;
+
+ // test for our bookmark
+ do_check_eq(toolbar.childCount, 1);
+ for (var i = 0; i < toolbar.childCount; i++) {
+ var folderNode = toolbar.getChild(0);
+ do_check_eq(folderNode.type, folderNode.RESULT_TYPE_URI);
+ do_check_eq(folderNode.title, this._itemTitle);
+ }
+ toolbar.containerOpen = false;
+
+ // test for our tag
+ var tags = PlacesUtils.tagging.getTagsForURI(PlacesUtils._uri(this._itemUrl));
+ do_check_eq(tags.length, 1);
+ do_check_eq(tags[0], this._tag);
+ }
+}
+tests.push(invalidTagChildTest);
+
+function run_test() {
+ run_next_test()
+}
+
+add_task(function* () {
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+
+ // populate db
+ tests.forEach(function(aTest) {
+ aTest.populate();
+ // sanity
+ aTest.validate();
+ });
+
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+
+ // clean
+ tests.forEach(function(aTest) {
+ aTest.clean();
+ });
+
+ // restore json file
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+
+ // validate
+ tests.forEach(function(aTest) {
+ aTest.validate();
+ });
+
+ // clean up
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js b/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js
new file mode 100644
index 000000000..3ce0e6ad7
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_466303-json-remove-backups.js
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Since PlacesBackups.getbackupFiles() is a lazy getter, these tests must
+// run in the given order, to avoid making it out-of-sync.
+
+add_task(function* check_max_backups_is_respected() {
+ // Get bookmarkBackups directory
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+
+ // Create 2 json dummy backups in the past.
+ let oldJsonPath = OS.Path.join(backupFolder, "bookmarks-2008-01-01.json");
+ let oldJsonFile = yield OS.File.open(oldJsonPath, { truncate: true });
+ oldJsonFile.close();
+ do_check_true(yield OS.File.exists(oldJsonPath));
+
+ let jsonPath = OS.Path.join(backupFolder, "bookmarks-2008-01-31.json");
+ let jsonFile = yield OS.File.open(jsonPath, { truncate: true });
+ jsonFile.close();
+ do_check_true(yield OS.File.exists(jsonPath));
+
+ // Export bookmarks to JSON.
+ // Allow 2 backups, the older one should be removed.
+ yield PlacesBackups.create(2);
+
+ let count = 0;
+ let lastBackupPath = null;
+ let iterator = new OS.File.DirectoryIterator(backupFolder);
+ try {
+ yield iterator.forEach(aEntry => {
+ count++;
+ if (PlacesBackups.filenamesRegex.test(aEntry.name))
+ lastBackupPath = aEntry.path;
+ });
+ } finally {
+ iterator.close();
+ }
+
+ do_check_eq(count, 2);
+ do_check_neq(lastBackupPath, null);
+ do_check_false(yield OS.File.exists(oldJsonPath));
+ do_check_true(yield OS.File.exists(jsonPath));
+});
+
+add_task(function* check_max_backups_greater_than_backups() {
+ // Get bookmarkBackups directory
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+
+ // Export bookmarks to JSON.
+ // Allow 3 backups, none should be removed.
+ yield PlacesBackups.create(3);
+
+ let count = 0;
+ let lastBackupPath = null;
+ let iterator = new OS.File.DirectoryIterator(backupFolder);
+ try {
+ yield iterator.forEach(aEntry => {
+ count++;
+ if (PlacesBackups.filenamesRegex.test(aEntry.name))
+ lastBackupPath = aEntry.path;
+ });
+ } finally {
+ iterator.close();
+ }
+ do_check_eq(count, 2);
+ do_check_neq(lastBackupPath, null);
+});
+
+add_task(function* check_max_backups_null() {
+ // Get bookmarkBackups directory
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+
+ // Export bookmarks to JSON.
+ // Allow infinite backups, none should be removed, a new one is not created
+ // since one for today already exists.
+ yield PlacesBackups.create(null);
+
+ let count = 0;
+ let lastBackupPath = null;
+ let iterator = new OS.File.DirectoryIterator(backupFolder);
+ try {
+ yield iterator.forEach(aEntry => {
+ count++;
+ if (PlacesBackups.filenamesRegex.test(aEntry.name))
+ lastBackupPath = aEntry.path;
+ });
+ } finally {
+ iterator.close();
+ }
+ do_check_eq(count, 2);
+ do_check_neq(lastBackupPath, null);
+});
+
+add_task(function* check_max_backups_undefined() {
+ // Get bookmarkBackups directory
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+
+ // Export bookmarks to JSON.
+ // Allow infinite backups, none should be removed, a new one is not created
+ // since one for today already exists.
+ yield PlacesBackups.create();
+
+ let count = 0;
+ let lastBackupPath = null;
+ let iterator = new OS.File.DirectoryIterator(backupFolder);
+ try {
+ yield iterator.forEach(aEntry => {
+ count++;
+ if (PlacesBackups.filenamesRegex.test(aEntry.name))
+ lastBackupPath = aEntry.path;
+ });
+ } finally {
+ iterator.close();
+ }
+ do_check_eq(count, 2);
+ do_check_neq(lastBackupPath, null);
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js b/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js
new file mode 100644
index 000000000..116352666
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_477583_json-backup-in-future.js
@@ -0,0 +1,56 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ do_test_pending();
+
+ Task.spawn(function*() {
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+ let bookmarksBackupDir = new FileUtils.File(backupFolder);
+ // Remove all files from backups folder.
+ let files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ entry.remove(false);
+ }
+
+ // Create a json dummy backup in the future.
+ let dateObj = new Date();
+ dateObj.setYear(dateObj.getFullYear() + 1);
+ let name = PlacesBackups.getFilenameForDate(dateObj);
+ do_check_eq(name, "bookmarks-" + PlacesBackups.toISODateString(dateObj) + ".json");
+ files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ if (PlacesBackups.filenamesRegex.test(entry.leafName))
+ entry.remove(false);
+ }
+
+ let futureBackupFile = bookmarksBackupDir.clone();
+ futureBackupFile.append(name);
+ futureBackupFile.create(Ci.nsILocalFile.NORMAL_FILE_TYPE, 0o600);
+ do_check_true(futureBackupFile.exists());
+
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 0);
+
+ yield PlacesBackups.create();
+ // Check that a backup for today has been created.
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 1);
+ let mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+ do_check_neq(mostRecentBackupFile, null);
+ do_check_true(PlacesBackups.filenamesRegex.test(OS.Path.basename(mostRecentBackupFile)));
+
+ // Check that future backup has been removed.
+ do_check_false(futureBackupFile.exists());
+
+ // Cleanup.
+ mostRecentBackupFile = new FileUtils.File(mostRecentBackupFile);
+ mostRecentBackupFile.remove(false);
+ do_check_false(mostRecentBackupFile.exists());
+
+ do_test_finished()
+ });
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_675416.js b/toolkit/components/places/tests/bookmarks/test_675416.js
new file mode 100644
index 000000000..08b1c3620
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_675416.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ /**
+ * Requests information to the service, so that bookmark's data is cached.
+ * @param aItemId
+ * Id of the bookmark to be cached.
+ */
+ function forceBookmarkCaching(aItemId) {
+ PlacesUtils.bookmarks.getFolderIdForItem(aItemId);
+ }
+
+ let observer = {
+ onBeginUpdateBatch: () => forceBookmarkCaching(itemId1),
+ onEndUpdateBatch: () => forceBookmarkCaching(itemId1),
+ onItemAdded: forceBookmarkCaching,
+ onItemChanged: forceBookmarkCaching,
+ onItemMoved: forceBookmarkCaching,
+ onItemRemoved: function(id) {
+ try {
+ forceBookmarkCaching(id);
+ do_throw("trying to fetch a removed bookmark should throw");
+ } catch (ex) {}
+ },
+ onItemVisited: forceBookmarkCaching,
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver])
+ };
+ PlacesUtils.bookmarks.addObserver(observer, false);
+
+ let folderId1 = PlacesUtils.bookmarks
+ .createFolder(PlacesUtils.bookmarksMenuFolderId,
+ "Bookmarks",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let itemId1 = PlacesUtils.bookmarks
+ .insertBookmark(folderId1,
+ NetUtil.newURI("http:/www.wired.com/wiredscience"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "Wired Science");
+
+ PlacesUtils.bookmarks.removeItem(folderId1);
+
+ let folderId2 = PlacesUtils.bookmarks
+ .createFolder(PlacesUtils.bookmarksMenuFolderId,
+ "Science",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let folderId3 = PlacesUtils.bookmarks
+ .createFolder(folderId2,
+ "Blogs",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ // Check title is correctly reported.
+ do_check_eq(PlacesUtils.bookmarks.getItemTitle(folderId3), "Blogs");
+ do_check_eq(PlacesUtils.bookmarks.getItemTitle(folderId2), "Science");
+
+ PlacesUtils.bookmarks.removeObserver(observer, false);
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_711914.js b/toolkit/components/places/tests/bookmarks/test_711914.js
new file mode 100644
index 000000000..3712c8a77
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_711914.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ /**
+ * Requests information to the service, so that bookmark's data is cached.
+ * @param aItemId
+ * Id of the bookmark to be cached.
+ */
+ function forceBookmarkCaching(aItemId) {
+ let parent = PlacesUtils.bookmarks.getFolderIdForItem(aItemId);
+ PlacesUtils.bookmarks.getFolderIdForItem(parent);
+ }
+
+ let observer = {
+ onBeginUpdateBatch: () => forceBookmarkCaching(itemId1),
+ onEndUpdateBatch: () => forceBookmarkCaching(itemId1),
+ onItemAdded: forceBookmarkCaching,
+ onItemChanged: forceBookmarkCaching,
+ onItemMoved: forceBookmarkCaching,
+ onItemRemoved: function (id) {
+ try {
+ forceBookmarkCaching(id);
+ do_throw("trying to fetch a removed bookmark should throw");
+ } catch (ex) {}
+ },
+ onItemVisited: forceBookmarkCaching,
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver])
+ };
+ PlacesUtils.bookmarks.addObserver(observer, false);
+
+ let folder1 = PlacesUtils.bookmarks
+ .createFolder(PlacesUtils.bookmarksMenuFolderId,
+ "Folder1",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let folder2 = PlacesUtils.bookmarks
+ .createFolder(folder1,
+ "Folder2",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.insertBookmark(folder2,
+ NetUtil.newURI("http://mozilla.org/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "Mozilla");
+
+ PlacesUtils.bookmarks.removeFolderChildren(folder1);
+
+ // Check title is correctly reported.
+ do_check_eq(PlacesUtils.bookmarks.getItemTitle(folder1), "Folder1");
+ try {
+ PlacesUtils.bookmarks.getItemTitle(folder2);
+ do_throw("trying to fetch a removed bookmark should throw");
+ } catch (ex) {}
+
+ PlacesUtils.bookmarks.removeObserver(observer, false);
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js b/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js
new file mode 100644
index 000000000..c88323478
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_818584-discard-duplicate-backups.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Checks that automatically created bookmark backups are discarded if they are
+ * duplicate of an existing ones.
+ */
+function run_test() {
+ run_next_test();
+}
+
+add_task(function*() {
+ // Create a backup for yesterday in the backups folder.
+ let backupFolder = yield PlacesBackups.getBackupFolder();
+ let dateObj = new Date();
+ dateObj.setDate(dateObj.getDate() - 1);
+ let oldBackupName = PlacesBackups.getFilenameForDate(dateObj);
+ let oldBackup = OS.Path.join(backupFolder, oldBackupName);
+ let {count: count, hash: hash} = yield BookmarkJSONUtils.exportToFile(oldBackup);
+ do_check_true(count > 0);
+ do_check_eq(hash.length, 24);
+ oldBackupName = oldBackupName.replace(/\.json/, "_" + count + "_" + hash + ".json");
+ yield OS.File.move(oldBackup, OS.Path.join(backupFolder, oldBackupName));
+
+ // Create a backup.
+ // This should just rename the existing backup, so in the end there should be
+ // only one backup with today's date.
+ yield PlacesBackups.create();
+
+ // Get the hash of the generated backup
+ let backupFiles = yield PlacesBackups.getBackupFiles();
+ do_check_eq(backupFiles.length, 1);
+
+ let matches = OS.Path.basename(backupFiles[0]).match(PlacesBackups.filenamesRegex);
+ do_check_eq(matches[1], PlacesBackups.toISODateString(new Date()));
+ do_check_eq(matches[2], count);
+ do_check_eq(matches[3], hash);
+
+ // Add a bookmark and create another backup.
+ let bookmarkId = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarks.bookmarksMenuFolder,
+ uri("http://foo.com"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "foo");
+ // We must enforce a backup since one for today already exists. The forced
+ // backup will replace the existing one.
+ yield PlacesBackups.create(undefined, true);
+ do_check_eq(backupFiles.length, 1);
+ recentBackup = yield PlacesBackups.getMostRecentBackup();
+ do_check_neq(recentBackup, OS.Path.join(backupFolder, oldBackupName));
+ matches = OS.Path.basename(recentBackup).match(PlacesBackups.filenamesRegex);
+ do_check_eq(matches[1], PlacesBackups.toISODateString(new Date()));
+ do_check_eq(matches[2], count + 1);
+ do_check_neq(matches[3], hash);
+
+ // Clean up
+ PlacesUtils.bookmarks.removeItem(bookmarkId);
+ yield PlacesBackups.create(0);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js b/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js
new file mode 100644
index 000000000..2c84990b3
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_818587_compress-bookmarks-backups.js
@@ -0,0 +1,57 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* compress_bookmark_backups_test() {
+ // Check for jsonlz4 extension
+ let todayFilename = PlacesBackups.getFilenameForDate(new Date(2014, 4, 15), true);
+ do_check_eq(todayFilename, "bookmarks-2014-05-15.jsonlz4");
+
+ yield PlacesBackups.create();
+
+ // Check that a backup for today has been created and the regex works fine for lz4.
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 1);
+ let mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+ do_check_neq(mostRecentBackupFile, null);
+ do_check_true(PlacesBackups.filenamesRegex.test(OS.Path.basename(mostRecentBackupFile)));
+
+ // The most recent backup file has to be removed since saveBookmarksToJSONFile
+ // will otherwise over-write the current backup, since it will be made on the
+ // same date
+ yield OS.File.remove(mostRecentBackupFile);
+ do_check_false((yield OS.File.exists(mostRecentBackupFile)));
+
+ // Check that, if the user created a custom backup out of the default
+ // backups folder, it gets copied (compressed) into it.
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.json");
+ yield PlacesBackups.saveBookmarksToJSONFile(jsonFile);
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 1);
+
+ // Check if import works from lz4 compressed json
+ let uri = NetUtil.newURI("http://www.mozilla.org/en-US/");
+ let bm = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark");
+
+ // Force create a compressed backup, Remove the bookmark, the restore the backup
+ yield PlacesBackups.create(undefined, true);
+ let recentBackup = yield PlacesBackups.getMostRecentBackup();
+ PlacesUtils.bookmarks.removeItem(bm);
+ yield BookmarkJSONUtils.importFromFile(recentBackup, true);
+ let root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let node = root.getChild(0);
+ do_check_eq(node.uri, uri.spec);
+
+ root.containerOpen = false;
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId);
+
+ // Cleanup.
+ yield OS.File.remove(jsonFile);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js b/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js
new file mode 100644
index 000000000..4ea07fb39
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_818593-store-backup-metadata.js
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * To confirm that metadata i.e. bookmark count is set and retrieved for
+ * automatic backups.
+ */
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_saveBookmarksToJSONFile_and_create()
+{
+ // Add a bookmark
+ let uri = NetUtil.newURI("http://getfirefox.com/");
+ let bookmarkId =
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Firefox!");
+
+ // Test saveBookmarksToJSONFile()
+ let backupFile = FileUtils.getFile("TmpD", ["bookmarks.json"]);
+ backupFile.create(Ci.nsILocalFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ let nodeCount = yield PlacesBackups.saveBookmarksToJSONFile(backupFile, true);
+ do_check_true(nodeCount > 0);
+ do_check_true(backupFile.exists());
+ do_check_eq(backupFile.leafName, "bookmarks.json");
+
+ // Ensure the backup would be copied to our backups folder when the original
+ // backup is saved somewhere else.
+ let recentBackup = yield PlacesBackups.getMostRecentBackup();
+ let matches = OS.Path.basename(recentBackup).match(PlacesBackups.filenamesRegex);
+ do_check_eq(matches[2], nodeCount);
+ do_check_eq(matches[3].length, 24);
+
+ // Clear all backups in our backups folder.
+ yield PlacesBackups.create(0);
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 0);
+
+ // Test create() which saves bookmarks with metadata on the filename.
+ yield PlacesBackups.create();
+ do_check_eq((yield PlacesBackups.getBackupFiles()).length, 1);
+
+ mostRecentBackupFile = yield PlacesBackups.getMostRecentBackup();
+ do_check_neq(mostRecentBackupFile, null);
+ matches = OS.Path.basename(recentBackup).match(PlacesBackups.filenamesRegex);
+ do_check_eq(matches[2], nodeCount);
+ do_check_eq(matches[3].length, 24);
+
+ // Cleanup
+ backupFile.remove(false);
+ yield PlacesBackups.create(0);
+ PlacesUtils.bookmarks.removeItem(bookmarkId);
+});
+
diff --git a/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js b/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js
new file mode 100644
index 000000000..f5e9f8187
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_992901-backup-unsorted-hierarchy.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Checks that backups properly include all of the bookmarks if the hierarchy
+ * in the database is unordered so that a hierarchy is defined before its
+ * ancestor in the bookmarks table.
+ */
+function run_test() {
+ run_next_test();
+}
+
+add_task(function*() {
+ let bm = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ NetUtil.newURI("http://mozilla.org/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark");
+ let f2 = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, "f2",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.moveItem(bm, f2, PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let f1 = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, "f1",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.moveItem(f2, f1, PlacesUtils.bookmarks.DEFAULT_INDEX);
+
+ // Create a backup.
+ yield PlacesBackups.create();
+
+ // Remove the bookmarks, then restore the backup.
+ PlacesUtils.bookmarks.removeItem(f1);
+ yield BookmarkJSONUtils.importFromFile((yield PlacesBackups.getMostRecentBackup()), true);
+
+ do_print("Checking first level");
+ let root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let level1 = root.getChild(0);
+ do_check_eq(level1.title, "f1");
+ do_print("Checking second level");
+ PlacesUtils.asContainer(level1).containerOpen = true
+ let level2 = level1.getChild(0);
+ do_check_eq(level2.title, "f2");
+ do_print("Checking bookmark");
+ PlacesUtils.asContainer(level2).containerOpen = true
+ let bookmark = level2.getChild(0);
+ do_check_eq(bookmark.title, "bookmark");
+ level2.containerOpen = false;
+ level1.containerOpen = false;
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js b/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js
new file mode 100644
index 000000000..b900887b5
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_997030-bookmarks-html-encode.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Checks that we don't encodeURI twice when creating bookmarks.html.
+ */
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ let uri = NetUtil.newURI("http://bt.ktxp.com/search.php?keyword=%E5%A6%84%E6%83%B3%E5%AD%A6%E7%94%9F%E4%BC%9A");
+ let bm = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark");
+
+ let file = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.exported.997030.html");
+ if ((yield OS.File.exists(file))) {
+ yield OS.File.remove(file);
+ }
+ yield BookmarkHTMLUtils.exportToFile(file);
+
+ // Remove the bookmarks, then restore the backup.
+ PlacesUtils.bookmarks.removeItem(bm);
+ yield BookmarkHTMLUtils.importFromFile(file, true);
+
+ do_print("Checking first level");
+ let root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let node = root.getChild(0);
+ do_check_eq(node.uri, uri.spec);
+
+ root.containerOpen = false;
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_async_observers.js b/toolkit/components/places/tests/bookmarks/test_async_observers.js
new file mode 100644
index 000000000..86d48ac24
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_async_observers.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test checks that bookmarks service is correctly forwarding async
+ * events like visit or favicon additions. */
+
+const NOW = Date.now() * 1000;
+
+var observer = {
+ bookmarks: [],
+ observedBookmarks: 0,
+ observedVisitId: 0,
+ deferred: null,
+
+ /**
+ * Returns a promise that is resolved when the observer determines that the
+ * test can continue. This is required rather than calling run_next_test
+ * directly in the observer because there are cases where we must wait for
+ * other asynchronous events to be completed in addition to this.
+ */
+ setupCompletionPromise: function ()
+ {
+ this.observedBookmarks = 0;
+ this.deferred = Promise.defer();
+ return this.deferred.promise;
+ },
+
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onItemAdded: function () {},
+ onItemRemoved: function () {},
+ onItemMoved: function () {},
+ onItemChanged: function(aItemId, aProperty, aIsAnnotation, aNewValue,
+ aLastModified, aItemType)
+ {
+ do_print("Check that we got the correct change information.");
+ do_check_neq(this.bookmarks.indexOf(aItemId), -1);
+ if (aProperty == "favicon") {
+ do_check_false(aIsAnnotation);
+ do_check_eq(aNewValue, SMALLPNG_DATA_URI.spec);
+ do_check_eq(aLastModified, 0);
+ do_check_eq(aItemType, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ }
+ else if (aProperty == "cleartime") {
+ do_check_false(aIsAnnotation);
+ do_check_eq(aNewValue, "");
+ do_check_eq(aLastModified, 0);
+ do_check_eq(aItemType, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ }
+ else {
+ do_throw("Unexpected property change " + aProperty);
+ }
+
+ if (++this.observedBookmarks == this.bookmarks.length) {
+ this.deferred.resolve();
+ }
+ },
+ onItemVisited: function(aItemId, aVisitId, aTime)
+ {
+ do_print("Check that we got the correct visit information.");
+ do_check_neq(this.bookmarks.indexOf(aItemId), -1);
+ this.observedVisitId = aVisitId;
+ do_check_eq(aTime, NOW);
+ if (++this.observedBookmarks == this.bookmarks.length) {
+ this.deferred.resolve();
+ }
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver,
+ ])
+};
+PlacesUtils.bookmarks.addObserver(observer, false);
+
+add_task(function* test_add_visit()
+{
+ let observerPromise = observer.setupCompletionPromise();
+
+ // Add a visit to the bookmark and wait for the observer.
+ let visitId;
+ let deferUpdatePlaces = Promise.defer();
+ PlacesUtils.asyncHistory.updatePlaces({
+ uri: NetUtil.newURI("http://book.ma.rk/"),
+ visits: [{ transitionType: TRANSITION_TYPED, visitDate: NOW }]
+ }, {
+ handleError: function TAV_handleError() {
+ deferUpdatePlaces.reject(new Error("Unexpected error in adding visit."));
+ },
+ handleResult: function (aPlaceInfo) {
+ visitId = aPlaceInfo.visits[0].visitId;
+ },
+ handleCompletion: function TAV_handleCompletion() {
+ deferUpdatePlaces.resolve();
+ }
+ });
+
+ // Wait for both the observer and the asynchronous update, in any order.
+ yield deferUpdatePlaces.promise;
+ yield observerPromise;
+
+ // Check that both asynchronous results are consistent.
+ do_check_eq(observer.observedVisitId, visitId);
+});
+
+add_task(function* test_add_icon()
+{
+ let observerPromise = observer.setupCompletionPromise();
+ PlacesUtils.favicons.setAndFetchFaviconForPage(NetUtil.newURI("http://book.ma.rk/"),
+ SMALLPNG_DATA_URI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ yield observerPromise;
+});
+
+add_task(function* test_remove_page()
+{
+ let observerPromise = observer.setupCompletionPromise();
+ PlacesUtils.history.removePage(NetUtil.newURI("http://book.ma.rk/"));
+ yield observerPromise;
+});
+
+add_task(function cleanup()
+{
+ PlacesUtils.bookmarks.removeObserver(observer, false);
+});
+
+add_task(function* shutdown()
+{
+ // Check that async observers don't try to create async statements after
+ // shutdown. That would cause assertions, since the async thread is gone
+ // already. Note that in such a case the notifications are not fired, so we
+ // cannot test for them.
+ // Put an history notification that triggers AsyncGetBookmarksForURI between
+ // asyncClose() and the actual connection closing. Enqueuing a main-thread
+ // event just after places-will-close-connection should ensure it runs before
+ // places-connection-closed.
+ // Notice this code is not using helpers cause it depends on a very specific
+ // order, a change in the helpers code could make this test useless.
+ let deferred = Promise.defer();
+
+ Services.obs.addObserver(function onNotification() {
+ Services.obs.removeObserver(onNotification, "places-will-close-connection");
+ do_check_true(true, "Observed fake places shutdown");
+
+ Services.tm.mainThread.dispatch(() => {
+ // WARNING: this is very bad, never use out of testing code.
+ PlacesUtils.bookmarks.QueryInterface(Ci.nsINavHistoryObserver)
+ .onPageChanged(NetUtil.newURI("http://book.ma.rk/"),
+ Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON,
+ "test", "test");
+ deferred.resolve(promiseTopicObserved("places-connection-closed"));
+ }, Ci.nsIThread.DISPATCH_NORMAL);
+ }, "places-will-close-connection", false);
+ shutdownPlaces();
+
+ yield deferred.promise;
+});
+
+function run_test()
+{
+ // Add multiple bookmarks to the same uri.
+ observer.bookmarks.push(
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ NetUtil.newURI("http://book.ma.rk/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "Bookmark")
+ );
+ observer.bookmarks.push(
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.toolbarFolderId,
+ NetUtil.newURI("http://book.ma.rk/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "Bookmark")
+ );
+
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bmindex.js b/toolkit/components/places/tests/bookmarks/test_bmindex.js
new file mode 100644
index 000000000..c764e4310
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bmindex.js
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const NUM_BOOKMARKS = 20;
+const NUM_SEPARATORS = 5;
+const NUM_FOLDERS = 10;
+const NUM_ITEMS = NUM_BOOKMARKS + NUM_SEPARATORS + NUM_FOLDERS;
+const MIN_RAND = -5;
+const MAX_RAND = 40;
+
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+function check_contiguous_indexes(aBookmarks) {
+ var indexes = [];
+ aBookmarks.forEach(function(aBookmarkId) {
+ let bmIndex = bs.getItemIndex(aBookmarkId);
+ dump("Index: " + bmIndex + "\n");
+ dump("Checking duplicates\n");
+ do_check_eq(indexes.indexOf(bmIndex), -1);
+ dump("Checking out of range, found " + aBookmarks.length + " items\n");
+ do_check_true(bmIndex >= 0 && bmIndex < aBookmarks.length);
+ indexes.push(bmIndex);
+ });
+ dump("Checking all valid indexes have been used\n");
+ do_check_eq(indexes.length, aBookmarks.length);
+}
+
+// main
+function run_test() {
+ var bookmarks = [];
+ // Insert bookmarks with random indexes.
+ for (let i = 0; bookmarks.length < NUM_BOOKMARKS; i++) {
+ let randIndex = Math.round(MIN_RAND + (Math.random() * (MAX_RAND - MIN_RAND)));
+ try {
+ let id = bs.insertBookmark(bs.unfiledBookmarksFolder,
+ uri("http://" + i + ".mozilla.org/"),
+ randIndex, "Test bookmark " + i);
+ if (randIndex < -1)
+ do_throw("Creating a bookmark at an invalid index should throw");
+ bookmarks.push(id);
+ }
+ catch (ex) {
+ if (randIndex >= -1)
+ do_throw("Creating a bookmark at a valid index should not throw");
+ }
+ }
+ check_contiguous_indexes(bookmarks);
+
+ // Insert separators with random indexes.
+ for (let i = 0; bookmarks.length < NUM_BOOKMARKS + NUM_SEPARATORS; i++) {
+ let randIndex = Math.round(MIN_RAND + (Math.random() * (MAX_RAND - MIN_RAND)));
+ try {
+ let id = bs.insertSeparator(bs.unfiledBookmarksFolder, randIndex);
+ if (randIndex < -1)
+ do_throw("Creating a separator at an invalid index should throw");
+ bookmarks.push(id);
+ }
+ catch (ex) {
+ if (randIndex >= -1)
+ do_throw("Creating a separator at a valid index should not throw");
+ }
+ }
+ check_contiguous_indexes(bookmarks);
+
+ // Insert folders with random indexes.
+ for (let i = 0; bookmarks.length < NUM_ITEMS; i++) {
+ let randIndex = Math.round(MIN_RAND + (Math.random() * (MAX_RAND - MIN_RAND)));
+ try {
+ let id = bs.createFolder(bs.unfiledBookmarksFolder,
+ "Test folder " + i, randIndex);
+ if (randIndex < -1)
+ do_throw("Creating a folder at an invalid index should throw");
+ bookmarks.push(id);
+ }
+ catch (ex) {
+ if (randIndex >= -1)
+ do_throw("Creating a folder at a valid index should not throw");
+ }
+ }
+ check_contiguous_indexes(bookmarks);
+
+ // Execute some random bookmark delete.
+ for (let i = 0; i < Math.ceil(NUM_ITEMS / 4); i++) {
+ let id = bookmarks.splice(Math.floor(Math.random() * bookmarks.length), 1);
+ dump("Removing item with id " + id + "\n");
+ bs.removeItem(id);
+ }
+ check_contiguous_indexes(bookmarks);
+
+ // Execute some random bookmark move. This will also try to move it to
+ // invalid index values.
+ for (let i = 0; i < Math.ceil(NUM_ITEMS / 4); i++) {
+ let randIndex = Math.floor(Math.random() * bookmarks.length);
+ let id = bookmarks[randIndex];
+ let newIndex = Math.round(MIN_RAND + (Math.random() * (MAX_RAND - MIN_RAND)));
+ dump("Moving item with id " + id + " to index " + newIndex + "\n");
+ try {
+ bs.moveItem(id, bs.unfiledBookmarksFolder, newIndex);
+ if (newIndex < -1)
+ do_throw("Moving an item to a negative index should throw\n");
+ }
+ catch (ex) {
+ if (newIndex >= -1)
+ do_throw("Moving an item to a valid index should not throw\n");
+ }
+
+ }
+ check_contiguous_indexes(bookmarks);
+
+ // Ensure setItemIndex throws if we pass it a negative index.
+ try {
+ bs.setItemIndex(bookmarks[0], -1);
+ do_throw("setItemIndex should throw for a negative index");
+ } catch (ex) {}
+ // Ensure setItemIndex throws if we pass it a bad itemId.
+ try {
+ bs.setItemIndex(0, 5);
+ do_throw("setItemIndex should throw for a bad itemId");
+ } catch (ex) {}
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks.js b/toolkit/components/places/tests/bookmarks/test_bookmarks.js
new file mode 100644
index 000000000..b67482223
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks.js
@@ -0,0 +1,718 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var bs = PlacesUtils.bookmarks;
+var hs = PlacesUtils.history;
+var anno = PlacesUtils.annotations;
+
+
+var bookmarksObserver = {
+ onBeginUpdateBatch: function() {
+ this._beginUpdateBatch = true;
+ },
+ onEndUpdateBatch: function() {
+ this._endUpdateBatch = true;
+ },
+ onItemAdded: function(id, folder, index, itemType, uri, title, dateAdded,
+ guid) {
+ this._itemAddedId = id;
+ this._itemAddedParent = folder;
+ this._itemAddedIndex = index;
+ this._itemAddedURI = uri;
+ this._itemAddedTitle = title;
+
+ // Ensure that we've created a guid for this item.
+ let stmt = DBConn().createStatement(
+ `SELECT guid
+ FROM moz_bookmarks
+ WHERE id = :item_id`
+ );
+ stmt.params.item_id = id;
+ do_check_true(stmt.executeStep());
+ do_check_false(stmt.getIsNull(0));
+ do_check_valid_places_guid(stmt.row.guid);
+ do_check_eq(stmt.row.guid, guid);
+ stmt.finalize();
+ },
+ onItemRemoved: function(id, folder, index, itemType) {
+ this._itemRemovedId = id;
+ this._itemRemovedFolder = folder;
+ this._itemRemovedIndex = index;
+ },
+ onItemChanged: function(id, property, isAnnotationProperty, value,
+ lastModified, itemType, parentId, guid, parentGuid,
+ oldValue) {
+ this._itemChangedId = id;
+ this._itemChangedProperty = property;
+ this._itemChanged_isAnnotationProperty = isAnnotationProperty;
+ this._itemChangedValue = value;
+ this._itemChangedOldValue = oldValue;
+ },
+ onItemVisited: function(id, visitID, time) {
+ this._itemVisitedId = id;
+ this._itemVisitedVistId = visitID;
+ this._itemVisitedTime = time;
+ },
+ onItemMoved: function(id, oldParent, oldIndex, newParent, newIndex,
+ itemType) {
+ this._itemMovedId = id
+ this._itemMovedOldParent = oldParent;
+ this._itemMovedOldIndex = oldIndex;
+ this._itemMovedNewParent = newParent;
+ this._itemMovedNewIndex = newIndex;
+ },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver,
+ ])
+};
+
+
+// Get bookmarks menu folder id.
+var root = bs.bookmarksMenuFolder;
+// Index at which items should begin.
+var bmStartIndex = 0;
+
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_bookmarks() {
+ bs.addObserver(bookmarksObserver, false);
+
+ // test special folders
+ do_check_true(bs.placesRoot > 0);
+ do_check_true(bs.bookmarksMenuFolder > 0);
+ do_check_true(bs.tagsFolder > 0);
+ do_check_true(bs.toolbarFolder > 0);
+ do_check_true(bs.unfiledBookmarksFolder > 0);
+
+ // test getFolderIdForItem() with bogus item id will throw
+ try {
+ bs.getFolderIdForItem(0);
+ do_throw("getFolderIdForItem accepted bad input");
+ } catch (ex) {}
+
+ // test getFolderIdForItem() with bogus item id will throw
+ try {
+ bs.getFolderIdForItem(-1);
+ do_throw("getFolderIdForItem accepted bad input");
+ } catch (ex) {}
+
+ // test root parentage
+ do_check_eq(bs.getFolderIdForItem(bs.bookmarksMenuFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.tagsFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.toolbarFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.unfiledBookmarksFolder), bs.placesRoot);
+
+ // create a folder to hold all the tests
+ // this makes the tests more tolerant of changes to default_places.html
+ let testRoot = bs.createFolder(root, "places bookmarks xpcshell tests",
+ bs.DEFAULT_INDEX);
+ do_check_eq(bookmarksObserver._itemAddedId, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedParent, root);
+ do_check_eq(bookmarksObserver._itemAddedIndex, bmStartIndex);
+ do_check_eq(bookmarksObserver._itemAddedURI, null);
+ let testStartIndex = 0;
+
+ // test getItemIndex for folders
+ do_check_eq(bs.getItemIndex(testRoot), bmStartIndex);
+
+ // test getItemType for folders
+ do_check_eq(bs.getItemType(testRoot), bs.TYPE_FOLDER);
+
+ // insert a bookmark.
+ // the time before we insert, in microseconds
+ let beforeInsert = Date.now() * 1000;
+ do_check_true(beforeInsert > 0);
+
+ let newId = bs.insertBookmark(testRoot, uri("http://google.com/"),
+ bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId);
+ do_check_eq(bookmarksObserver._itemAddedParent, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedIndex, testStartIndex);
+ do_check_true(bookmarksObserver._itemAddedURI.equals(uri("http://google.com/")));
+ do_check_eq(bs.getBookmarkURI(newId).spec, "http://google.com/");
+
+ let dateAdded = bs.getItemDateAdded(newId);
+ // dateAdded can equal beforeInsert
+ do_check_true(is_time_ordered(beforeInsert, dateAdded));
+
+ // after just inserting, modified should not be set
+ let lastModified = bs.getItemLastModified(newId);
+ do_check_eq(lastModified, dateAdded);
+
+ // The time before we set the title, in microseconds.
+ let beforeSetTitle = Date.now() * 1000;
+ do_check_true(beforeSetTitle >= beforeInsert);
+
+ // Workaround possible VM timers issues moving lastModified and dateAdded
+ // to the past.
+ lastModified -= 1000;
+ bs.setItemLastModified(newId, lastModified);
+ dateAdded -= 1000;
+ bs.setItemDateAdded(newId, dateAdded);
+
+ // set bookmark title
+ bs.setItemTitle(newId, "Google");
+ do_check_eq(bookmarksObserver._itemChangedId, newId);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+ do_check_eq(bookmarksObserver._itemChangedValue, "Google");
+
+ // check that dateAdded hasn't changed
+ let dateAdded2 = bs.getItemDateAdded(newId);
+ do_check_eq(dateAdded2, dateAdded);
+
+ // check lastModified after we set the title
+ let lastModified2 = bs.getItemLastModified(newId);
+ do_print("test setItemTitle");
+ do_print("dateAdded = " + dateAdded);
+ do_print("beforeSetTitle = " + beforeSetTitle);
+ do_print("lastModified = " + lastModified);
+ do_print("lastModified2 = " + lastModified2);
+ do_check_true(is_time_ordered(lastModified, lastModified2));
+ do_check_true(is_time_ordered(dateAdded, lastModified2));
+
+ // get item title
+ let title = bs.getItemTitle(newId);
+ do_check_eq(title, "Google");
+
+ // test getItemType for bookmarks
+ do_check_eq(bs.getItemType(newId), bs.TYPE_BOOKMARK);
+
+ // get item title bad input
+ try {
+ bs.getItemTitle(-3);
+ do_throw("getItemTitle accepted bad input");
+ } catch (ex) {}
+
+ // get the folder that the bookmark is in
+ let folderId = bs.getFolderIdForItem(newId);
+ do_check_eq(folderId, testRoot);
+
+ // test getItemIndex for bookmarks
+ do_check_eq(bs.getItemIndex(newId), testStartIndex);
+
+ // create a folder at a specific index
+ let workFolder = bs.createFolder(testRoot, "Work", 0);
+ do_check_eq(bookmarksObserver._itemAddedId, workFolder);
+ do_check_eq(bookmarksObserver._itemAddedParent, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 0);
+ do_check_eq(bookmarksObserver._itemAddedURI, null);
+
+ do_check_eq(bs.getItemTitle(workFolder), "Work");
+ bs.setItemTitle(workFolder, "Work #");
+ do_check_eq(bs.getItemTitle(workFolder), "Work #");
+
+ // add item into subfolder, specifying index
+ let newId2 = bs.insertBookmark(workFolder,
+ uri("http://developer.mozilla.org/"),
+ 0, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId2);
+ do_check_eq(bookmarksObserver._itemAddedParent, workFolder);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 0);
+
+ // change item
+ bs.setItemTitle(newId2, "DevMo");
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+
+ // insert item into subfolder
+ let newId3 = bs.insertBookmark(workFolder,
+ uri("http://msdn.microsoft.com/"),
+ bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId3);
+ do_check_eq(bookmarksObserver._itemAddedParent, workFolder);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 1);
+
+ // change item
+ bs.setItemTitle(newId3, "MSDN");
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+
+ // remove item
+ bs.removeItem(newId2);
+ do_check_eq(bookmarksObserver._itemRemovedId, newId2);
+ do_check_eq(bookmarksObserver._itemRemovedFolder, workFolder);
+ do_check_eq(bookmarksObserver._itemRemovedIndex, 0);
+
+ // insert item into subfolder
+ let newId4 = bs.insertBookmark(workFolder,
+ uri("http://developer.mozilla.org/"),
+ bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId4);
+ do_check_eq(bookmarksObserver._itemAddedParent, workFolder);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 1);
+
+ // create folder
+ let homeFolder = bs.createFolder(testRoot, "Home", bs.DEFAULT_INDEX);
+ do_check_eq(bookmarksObserver._itemAddedId, homeFolder);
+ do_check_eq(bookmarksObserver._itemAddedParent, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 2);
+
+ // insert item
+ let newId5 = bs.insertBookmark(homeFolder, uri("http://espn.com/"),
+ bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId5);
+ do_check_eq(bookmarksObserver._itemAddedParent, homeFolder);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 0);
+
+ // change item
+ bs.setItemTitle(newId5, "ESPN");
+ do_check_eq(bookmarksObserver._itemChangedId, newId5);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+
+ // insert query item
+ let uri6 = uri("place:domain=google.com&type="+
+ Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY);
+ let newId6 = bs.insertBookmark(testRoot, uri6, bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedParent, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 3);
+
+ // change item
+ bs.setItemTitle(newId6, "Google Sites");
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+
+ // test getIdForItemAt
+ do_check_eq(bs.getIdForItemAt(testRoot, 0), workFolder);
+ // wrong parent, should return -1
+ do_check_eq(bs.getIdForItemAt(1337, 0), -1);
+ // wrong index, should return -1
+ do_check_eq(bs.getIdForItemAt(testRoot, 1337), -1);
+ // wrong parent and index, should return -1
+ do_check_eq(bs.getIdForItemAt(1337, 1337), -1);
+
+ // move folder, appending, to different folder
+ let oldParentCC = getChildCount(testRoot);
+ bs.moveItem(workFolder, homeFolder, bs.DEFAULT_INDEX);
+ do_check_eq(bookmarksObserver._itemMovedId, workFolder);
+ do_check_eq(bookmarksObserver._itemMovedOldParent, testRoot);
+ do_check_eq(bookmarksObserver._itemMovedOldIndex, 0);
+ do_check_eq(bookmarksObserver._itemMovedNewParent, homeFolder);
+ do_check_eq(bookmarksObserver._itemMovedNewIndex, 1);
+
+ // test that the new index is properly stored
+ do_check_eq(bs.getItemIndex(workFolder), 1);
+ do_check_eq(bs.getFolderIdForItem(workFolder), homeFolder);
+
+ // try to get index of the item from within the old parent folder
+ // check that it has been really removed from there
+ do_check_neq(bs.getIdForItemAt(testRoot, 0), workFolder);
+ // check the last item from within the old parent folder
+ do_check_neq(bs.getIdForItemAt(testRoot, -1), workFolder);
+ // check the index of the item within the new parent folder
+ do_check_eq(bs.getIdForItemAt(homeFolder, 1), workFolder);
+ // try to get index of the last item within the new parent folder
+ do_check_eq(bs.getIdForItemAt(homeFolder, -1), workFolder);
+ // XXX expose FolderCount, and check that the old parent has one less child?
+ do_check_eq(getChildCount(testRoot), oldParentCC-1);
+
+ // move item, appending, to different folder
+ bs.moveItem(newId5, testRoot, bs.DEFAULT_INDEX);
+ do_check_eq(bookmarksObserver._itemMovedId, newId5);
+ do_check_eq(bookmarksObserver._itemMovedOldParent, homeFolder);
+ do_check_eq(bookmarksObserver._itemMovedOldIndex, 0);
+ do_check_eq(bookmarksObserver._itemMovedNewParent, testRoot);
+ do_check_eq(bookmarksObserver._itemMovedNewIndex, 3);
+
+ // test get folder's index
+ let tmpFolder = bs.createFolder(testRoot, "tmp", 2);
+ do_check_eq(bs.getItemIndex(tmpFolder), 2);
+
+ // test setKeywordForBookmark
+ let kwTestItemId = bs.insertBookmark(testRoot, uri("http://keywordtest.com"),
+ bs.DEFAULT_INDEX, "");
+ bs.setKeywordForBookmark(kwTestItemId, "bar");
+
+ // test getKeywordForBookmark
+ let k = bs.getKeywordForBookmark(kwTestItemId);
+ do_check_eq("bar", k);
+
+ // test getURIForKeyword
+ let u = bs.getURIForKeyword("bar");
+ do_check_eq("http://keywordtest.com/", u.spec);
+
+ // test removeFolderChildren
+ // 1) add/remove each child type (bookmark, separator, folder)
+ tmpFolder = bs.createFolder(testRoot, "removeFolderChildren",
+ bs.DEFAULT_INDEX);
+ bs.insertBookmark(tmpFolder, uri("http://foo9.com/"), bs.DEFAULT_INDEX, "");
+ bs.createFolder(tmpFolder, "subfolder", bs.DEFAULT_INDEX);
+ bs.insertSeparator(tmpFolder, bs.DEFAULT_INDEX);
+ // 2) confirm that folder has 3 children
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setFolders([tmpFolder], 1);
+ try {
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 3);
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("test removeFolderChildren() - querying for children failed: " + ex);
+ }
+ // 3) remove all children
+ bs.removeFolderChildren(tmpFolder);
+ // 4) confirm that folder has 0 children
+ try {
+ result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 0);
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("removeFolderChildren(): " + ex);
+ }
+
+ // XXX - test folderReadOnly
+
+ // test bookmark id in query output
+ try {
+ options = hs.getNewQueryOptions();
+ query = hs.getNewQuery();
+ query.setFolders([testRoot], 1);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_print("bookmark itemId test: CC = " + cc);
+ do_check_true(cc > 0);
+ for (let i=0; i < cc; ++i) {
+ let node = rootNode.getChild(i);
+ if (node.type == node.RESULT_TYPE_FOLDER ||
+ node.type == node.RESULT_TYPE_URI ||
+ node.type == node.RESULT_TYPE_SEPARATOR ||
+ node.type == node.RESULT_TYPE_QUERY) {
+ do_check_true(node.itemId > 0);
+ }
+ else {
+ do_check_eq(node.itemId, -1);
+ }
+ }
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test that multiple bookmarks with same URI show up right in bookmark
+ // folder queries, todo: also to do for complex folder queries
+ try {
+ // test uri
+ let mURI = uri("http://multiple.uris.in.query");
+
+ let testFolder = bs.createFolder(testRoot, "test Folder", bs.DEFAULT_INDEX);
+ // add 2 bookmarks
+ bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 1");
+ bs.insertBookmark(testFolder, mURI, bs.DEFAULT_INDEX, "title 2");
+
+ // query
+ options = hs.getNewQueryOptions();
+ query = hs.getNewQuery();
+ query.setFolders([testFolder], 1);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_eq(cc, 2);
+ do_check_eq(rootNode.getChild(0).title, "title 1");
+ do_check_eq(rootNode.getChild(1).title, "title 2");
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test change bookmark uri
+ let newId10 = bs.insertBookmark(testRoot, uri("http://foo10.com/"),
+ bs.DEFAULT_INDEX, "");
+ dateAdded = bs.getItemDateAdded(newId10);
+ // after just inserting, modified should not be set
+ lastModified = bs.getItemLastModified(newId10);
+ do_check_eq(lastModified, dateAdded);
+
+ // Workaround possible VM timers issues moving lastModified and dateAdded
+ // to the past.
+ lastModified -= 1000;
+ bs.setItemLastModified(newId10, lastModified);
+ dateAdded -= 1000;
+ bs.setItemDateAdded(newId10, dateAdded);
+
+ bs.changeBookmarkURI(newId10, uri("http://foo11.com/"));
+
+ // check that lastModified is set after we change the bookmark uri
+ lastModified2 = bs.getItemLastModified(newId10);
+ do_print("test changeBookmarkURI");
+ do_print("dateAdded = " + dateAdded);
+ do_print("lastModified = " + lastModified);
+ do_print("lastModified2 = " + lastModified2);
+ do_check_true(is_time_ordered(lastModified, lastModified2));
+ do_check_true(is_time_ordered(dateAdded, lastModified2));
+
+ do_check_eq(bookmarksObserver._itemChangedId, newId10);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "uri");
+ do_check_eq(bookmarksObserver._itemChangedValue, "http://foo11.com/");
+ do_check_eq(bookmarksObserver._itemChangedOldValue, "http://foo10.com/");
+
+ // test getBookmarkURI
+ let newId11 = bs.insertBookmark(testRoot, uri("http://foo11.com/"),
+ bs.DEFAULT_INDEX, "");
+ let bmURI = bs.getBookmarkURI(newId11);
+ do_check_eq("http://foo11.com/", bmURI.spec);
+
+ // test getBookmarkURI with non-bookmark items
+ try {
+ bs.getBookmarkURI(testRoot);
+ do_throw("getBookmarkURI() should throw for non-bookmark items!");
+ } catch (ex) {}
+
+ // test getItemIndex
+ let newId12 = bs.insertBookmark(testRoot, uri("http://foo11.com/"), 1, "");
+ let bmIndex = bs.getItemIndex(newId12);
+ do_check_eq(1, bmIndex);
+
+ // insert a bookmark with title ZZZXXXYYY and then search for it.
+ // this test confirms that we can find bookmarks that we haven't visited
+ // (which are "hidden") and that we can find by title.
+ // see bug #369887 for more details
+ let newId13 = bs.insertBookmark(testRoot, uri("http://foobarcheese.com/"),
+ bs.DEFAULT_INDEX, "");
+ do_check_eq(bookmarksObserver._itemAddedId, newId13);
+ do_check_eq(bookmarksObserver._itemAddedParent, testRoot);
+ do_check_eq(bookmarksObserver._itemAddedIndex, 11);
+
+ // set bookmark title
+ bs.setItemTitle(newId13, "ZZZXXXYYY");
+ do_check_eq(bookmarksObserver._itemChangedId, newId13);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+ do_check_eq(bookmarksObserver._itemChangedValue, "ZZZXXXYYY");
+
+ // check if setting an item annotation triggers onItemChanged
+ bookmarksObserver._itemChangedId = -1;
+ anno.setItemAnnotation(newId3, "test-annotation", "foo", 0, 0);
+ do_check_eq(bookmarksObserver._itemChangedId, newId3);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "test-annotation");
+ do_check_true(bookmarksObserver._itemChanged_isAnnotationProperty);
+ do_check_eq(bookmarksObserver._itemChangedValue, "");
+
+ // test search on bookmark title ZZZXXXYYY
+ try {
+ options = hs.getNewQueryOptions();
+ options.excludeQueries = 1;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ query = hs.getNewQuery();
+ query.searchTerms = "ZZZXXXYYY";
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_eq(cc, 1);
+ let node = rootNode.getChild(0);
+ do_check_eq(node.title, "ZZZXXXYYY");
+ do_check_true(node.itemId > 0);
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test dateAdded and lastModified properties
+ // for a search query
+ try {
+ options = hs.getNewQueryOptions();
+ options.excludeQueries = 1;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ query = hs.getNewQuery();
+ query.searchTerms = "ZZZXXXYYY";
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_eq(cc, 1);
+ let node = rootNode.getChild(0);
+
+ do_check_eq(typeof node.dateAdded, "number");
+ do_check_true(node.dateAdded > 0);
+
+ do_check_eq(typeof node.lastModified, "number");
+ do_check_true(node.lastModified > 0);
+
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // test dateAdded and lastModified properties
+ // for a folder query
+ try {
+ options = hs.getNewQueryOptions();
+ query = hs.getNewQuery();
+ query.setFolders([testRoot], 1);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_true(cc > 0);
+ for (let i = 0; i < cc; i++) {
+ let node = rootNode.getChild(i);
+
+ if (node.type == node.RESULT_TYPE_URI) {
+ do_check_eq(typeof node.dateAdded, "number");
+ do_check_true(node.dateAdded > 0);
+
+ do_check_eq(typeof node.lastModified, "number");
+ do_check_true(node.lastModified > 0);
+ break;
+ }
+ }
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("bookmarks query: " + ex);
+ }
+
+ // check setItemLastModified() and setItemDateAdded()
+ let newId14 = bs.insertBookmark(testRoot, uri("http://bar.tld/"),
+ bs.DEFAULT_INDEX, "");
+ dateAdded = bs.getItemDateAdded(newId14);
+ lastModified = bs.getItemLastModified(newId14);
+ do_check_eq(lastModified, dateAdded);
+ bs.setItemLastModified(newId14, 1234000000000000);
+ let fakeLastModified = bs.getItemLastModified(newId14);
+ do_check_eq(fakeLastModified, 1234000000000000);
+ bs.setItemDateAdded(newId14, 4321000000000000);
+ let fakeDateAdded = bs.getItemDateAdded(newId14);
+ do_check_eq(fakeDateAdded, 4321000000000000);
+
+ // ensure that removing an item removes its annotations
+ do_check_true(anno.itemHasAnnotation(newId3, "test-annotation"));
+ bs.removeItem(newId3);
+ do_check_false(anno.itemHasAnnotation(newId3, "test-annotation"));
+
+ // bug 378820
+ let uri1 = uri("http://foo.tld/a");
+ bs.insertBookmark(testRoot, uri1, bs.DEFAULT_INDEX, "");
+ yield PlacesTestUtils.addVisits(uri1);
+
+ // bug 646993 - test bookmark titles longer than the maximum allowed length
+ let title15 = Array(TITLE_LENGTH_MAX + 5).join("X");
+ let title15expected = title15.substring(0, TITLE_LENGTH_MAX);
+ let newId15 = bs.insertBookmark(testRoot, uri("http://evil.com/"),
+ bs.DEFAULT_INDEX, title15);
+
+ do_check_eq(bs.getItemTitle(newId15).length,
+ title15expected.length);
+ do_check_eq(bookmarksObserver._itemAddedTitle, title15expected);
+ // test title length after updates
+ bs.setItemTitle(newId15, title15 + " updated");
+ do_check_eq(bs.getItemTitle(newId15).length,
+ title15expected.length);
+ do_check_eq(bookmarksObserver._itemChangedId, newId15);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+ do_check_eq(bookmarksObserver._itemChangedValue, title15expected);
+
+ testSimpleFolderResult();
+});
+
+function testSimpleFolderResult() {
+ // the time before we create a folder, in microseconds
+ // Workaround possible VM timers issues subtracting 1us.
+ let beforeCreate = Date.now() * 1000 - 1;
+ do_check_true(beforeCreate > 0);
+
+ // create a folder
+ let parent = bs.createFolder(root, "test", bs.DEFAULT_INDEX);
+
+ let dateCreated = bs.getItemDateAdded(parent);
+ do_print("check that the folder was created with a valid dateAdded");
+ do_print("beforeCreate = " + beforeCreate);
+ do_print("dateCreated = " + dateCreated);
+ do_check_true(is_time_ordered(beforeCreate, dateCreated));
+
+ // the time before we insert, in microseconds
+ // Workaround possible VM timers issues subtracting 1ms.
+ let beforeInsert = Date.now() * 1000 - 1;
+ do_check_true(beforeInsert > 0);
+
+ // insert a separator
+ let sep = bs.insertSeparator(parent, bs.DEFAULT_INDEX);
+
+ let dateAdded = bs.getItemDateAdded(sep);
+ do_print("check that the separator was created with a valid dateAdded");
+ do_print("beforeInsert = " + beforeInsert);
+ do_print("dateAdded = " + dateAdded);
+ do_check_true(is_time_ordered(beforeInsert, dateAdded));
+
+ // re-set item title separately so can test nodes' last modified
+ let item = bs.insertBookmark(parent, uri("about:blank"),
+ bs.DEFAULT_INDEX, "");
+ bs.setItemTitle(item, "test bookmark");
+
+ // see above
+ let folder = bs.createFolder(parent, "test folder", bs.DEFAULT_INDEX);
+ bs.setItemTitle(folder, "test folder");
+
+ let longName = Array(TITLE_LENGTH_MAX + 5).join("A");
+ let folderLongName = bs.createFolder(parent, longName, bs.DEFAULT_INDEX);
+ do_check_eq(bookmarksObserver._itemAddedTitle, longName.substring(0, TITLE_LENGTH_MAX));
+
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setFolders([parent], 1);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ do_check_eq(rootNode.childCount, 4);
+
+ let node = rootNode.getChild(0);
+ do_check_true(node.dateAdded > 0);
+ do_check_eq(node.lastModified, node.dateAdded);
+ do_check_eq(node.itemId, sep);
+ do_check_eq(node.title, "");
+ node = rootNode.getChild(1);
+ do_check_eq(node.itemId, item);
+ do_check_true(node.dateAdded > 0);
+ do_check_true(node.lastModified > 0);
+ do_check_eq(node.title, "test bookmark");
+ node = rootNode.getChild(2);
+ do_check_eq(node.itemId, folder);
+ do_check_eq(node.title, "test folder");
+ do_check_true(node.dateAdded > 0);
+ do_check_true(node.lastModified > 0);
+ node = rootNode.getChild(3);
+ do_check_eq(node.itemId, folderLongName);
+ do_check_eq(node.title, longName.substring(0, TITLE_LENGTH_MAX));
+ do_check_true(node.dateAdded > 0);
+ do_check_true(node.lastModified > 0);
+
+ // update with another long title
+ bs.setItemTitle(folderLongName, longName + " updated");
+ do_check_eq(bookmarksObserver._itemChangedId, folderLongName);
+ do_check_eq(bookmarksObserver._itemChangedProperty, "title");
+ do_check_eq(bookmarksObserver._itemChangedValue, longName.substring(0, TITLE_LENGTH_MAX));
+
+ node = rootNode.getChild(3);
+ do_check_eq(node.title, longName.substring(0, TITLE_LENGTH_MAX));
+
+ rootNode.containerOpen = false;
+}
+
+function getChildCount(aFolderId) {
+ let cc = -1;
+ try {
+ let options = hs.getNewQueryOptions();
+ let query = hs.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ let result = hs.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ cc = rootNode.childCount;
+ rootNode.containerOpen = false;
+ } catch (ex) {
+ do_throw("getChildCount failed: " + ex);
+ }
+ return cc;
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js
new file mode 100644
index 000000000..e8414359b
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_eraseEverything.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* test_eraseEverything() {
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("http://example.com/") });
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("http://mozilla.org/") });
+ let frecencyForExample = frecencyForUrl("http://example.com/");
+ let frecencyForMozilla = frecencyForUrl("http://example.com/");
+ Assert.ok(frecencyForExample > 0);
+ Assert.ok(frecencyForMozilla > 0);
+ let unfiledFolder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(unfiledFolder);
+ let unfiledBookmark = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/" });
+ checkBookmarkObject(unfiledBookmark);
+ let unfiledBookmarkInFolder =
+ yield PlacesUtils.bookmarks.insert({ parentGuid: unfiledFolder.guid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://mozilla.org/" });
+ checkBookmarkObject(unfiledBookmarkInFolder);
+ PlacesUtils.annotations.setItemAnnotation((yield PlacesUtils.promiseItemId(unfiledBookmarkInFolder.guid)),
+ "testanno1", "testvalue1", 0, 0);
+
+ let menuFolder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(menuFolder);
+ let menuBookmark = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/" });
+ checkBookmarkObject(menuBookmark);
+ let menuBookmarkInFolder =
+ yield PlacesUtils.bookmarks.insert({ parentGuid: menuFolder.guid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://mozilla.org/" });
+ checkBookmarkObject(menuBookmarkInFolder);
+ PlacesUtils.annotations.setItemAnnotation((yield PlacesUtils.promiseItemId(menuBookmarkInFolder.guid)),
+ "testanno1", "testvalue1", 0, 0);
+
+ let toolbarFolder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(toolbarFolder);
+ let toolbarBookmark = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/" });
+ checkBookmarkObject(toolbarBookmark);
+ let toolbarBookmarkInFolder =
+ yield PlacesUtils.bookmarks.insert({ parentGuid: toolbarFolder.guid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://mozilla.org/" });
+ checkBookmarkObject(toolbarBookmarkInFolder);
+ PlacesUtils.annotations.setItemAnnotation((yield PlacesUtils.promiseItemId(toolbarBookmarkInFolder.guid)),
+ "testanno1", "testvalue1", 0, 0);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ Assert.ok(frecencyForUrl("http://example.com/") > frecencyForExample);
+ Assert.ok(frecencyForUrl("http://example.com/") > frecencyForMozilla);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ Assert.equal(frecencyForUrl("http://example.com/"), frecencyForExample);
+ Assert.equal(frecencyForUrl("http://example.com/"), frecencyForMozilla);
+
+ // Check there are no orphan annotations.
+ let conn = yield PlacesUtils.promiseDBConnection();
+ let annoAttrs = yield conn.execute(`SELECT id, name FROM moz_anno_attributes`);
+ // Bug 1306445 will eventually remove the mobile root anno.
+ Assert.equal(annoAttrs.length, 1);
+ Assert.equal(annoAttrs[0].getResultByName("name"), PlacesUtils.MOBILE_ROOT_ANNO);
+ let annos = rows = yield conn.execute(`SELECT item_id, anno_attribute_id FROM moz_items_annos`);
+ Assert.equal(annos.length, 1);
+ Assert.equal(annos[0].getResultByName("item_id"), PlacesUtils.mobileFolderId);
+ Assert.equal(annos[0].getResultByName("anno_attribute_id"), annoAttrs[0].getResultByName("id"));
+});
+
+add_task(function* test_eraseEverything_roots() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // Ensure the roots have not been removed.
+ Assert.ok(yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.unfiledGuid));
+ Assert.ok(yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid));
+ Assert.ok(yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.menuGuid));
+ Assert.ok(yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.tagsGuid));
+ Assert.ok(yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.rootGuid));
+});
+
+add_task(function* test_eraseEverything_reparented() {
+ // Create a folder with 1 bookmark in it...
+ let folder1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER
+ });
+ let bookmark1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder1.guid,
+ url: "http://example.com/"
+ });
+ // ...and a second folder.
+ let folder2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER
+ });
+
+ // Reparent the bookmark to the 2nd folder.
+ bookmark1.parentGuid = folder2.guid;
+ yield PlacesUtils.bookmarks.update(bookmark1);
+
+ // Erase everything.
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // All the above items should no longer be in the GUIDHelper cache.
+ for (let guid of [folder1.guid, bookmark1.guid, folder2.guid]) {
+ yield Assert.rejects(PlacesUtils.promiseItemId(guid),
+ /no item found for the given GUID/);
+ }
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js
new file mode 100644
index 000000000..9527f02e6
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_fetch.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var gAccumulator = {
+ get callback() {
+ this.results = [];
+ return result => this.results.push(result);
+ }
+};
+
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.fetch(),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch(null),
+ /Input should be a valid object/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: "123456789012",
+ parentGuid: "012345678901" }),
+ /The following properties were expected: index/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: "123456789012",
+ index: 0 }),
+ /The following properties were expected: parentGuid/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({}),
+ /Unexpected number of conditions provided: 0/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: "123456789012",
+ parentGuid: "012345678901",
+ index: 0 }),
+ /Unexpected number of conditions provided: 2/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: "123456789012",
+ url: "http://example.com"}),
+ /Unexpected number of conditions provided: 2/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch("test"),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch(123),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: "test" }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: null }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ guid: 123 }),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: "test",
+ index: 0 }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: null,
+ index: 0 }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: 123,
+ index: 0 }),
+ /Invalid value for property 'parentGuid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012",
+ index: "0" }),
+ /Invalid value for property 'index'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012",
+ index: null }),
+ /Invalid value for property 'index'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012",
+ index: -10 }),
+ /Invalid value for property 'index'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ url: "http://te st/" }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ url: null }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch({ url: -10 }),
+ /Invalid value for property 'url'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.fetch("123456789012", "test"),
+ /onResult callback must be a valid function/);
+ Assert.throws(() => PlacesUtils.bookmarks.fetch("123456789012", {}),
+ /onResult callback must be a valid function/);
+});
+
+add_task(function* fetch_nonexistent_guid() {
+ let bm = yield PlacesUtils.bookmarks.fetch({ guid: "123456789012" },
+ gAccumulator.callback);
+ Assert.equal(bm, null);
+ Assert.equal(gAccumulator.results.length, 0);
+});
+
+add_task(function* fetch_bookmark() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch(bm1.guid,
+ gAccumulator.callback);
+ checkBookmarkObject(bm2);
+ Assert.equal(gAccumulator.results.length, 1);
+ checkBookmarkObject(gAccumulator.results[0]);
+ Assert.deepEqual(gAccumulator.results[0], bm1);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm2.url.href, "http://example.com/");
+ Assert.equal(bm2.title, "a bookmark");
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_bookmar_empty_title() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.index, 0);
+ Assert.ok(!("title" in bm2));
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_folder() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.equal(bm2.title, "a folder");
+ Assert.ok(!("url" in bm2));
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_folder_empty_title() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.index, 0);
+ Assert.ok(!("title" in bm2));
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_separator() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_SEPARATOR);
+ Assert.ok(!("url" in bm2));
+ Assert.ok(!("title" in bm2));
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_byposition_nonexisting_parentGuid() {
+ let bm = yield PlacesUtils.bookmarks.fetch({ parentGuid: "123456789012",
+ index: 0 },
+ gAccumulator.callback);
+ Assert.equal(bm, null);
+ Assert.equal(gAccumulator.results.length, 0);
+});
+
+add_task(function* fetch_byposition_nonexisting_index() {
+ let bm = yield PlacesUtils.bookmarks.fetch({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 100 },
+ gAccumulator.callback);
+ Assert.equal(bm, null);
+ Assert.equal(gAccumulator.results.length, 0);
+});
+
+add_task(function* fetch_byposition() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch({ parentGuid: bm1.parentGuid,
+ index: bm1.index },
+ gAccumulator.callback);
+ checkBookmarkObject(bm2);
+ Assert.equal(gAccumulator.results.length, 1);
+ checkBookmarkObject(gAccumulator.results[0]);
+ Assert.deepEqual(gAccumulator.results[0], bm1);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm2.url.href, "http://example.com/");
+ Assert.equal(bm2.title, "a bookmark");
+});
+
+add_task(function* fetch_byposition_default_index() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/last",
+ title: "last child" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch({ parentGuid: bm1.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX },
+ gAccumulator.callback);
+ checkBookmarkObject(bm2);
+ Assert.equal(gAccumulator.results.length, 1);
+ checkBookmarkObject(gAccumulator.results[0]);
+ Assert.deepEqual(gAccumulator.results[0], bm1);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 1);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm2.url.href, "http://example.com/last");
+ Assert.equal(bm2.title, "last child");
+
+ yield PlacesUtils.bookmarks.remove(bm1.guid);
+});
+
+add_task(function* fetch_byurl_nonexisting() {
+ let bm = yield PlacesUtils.bookmarks.fetch({ url: "http://nonexisting.com/" },
+ gAccumulator.callback);
+ Assert.equal(bm, null);
+ Assert.equal(gAccumulator.results.length, 0);
+});
+
+add_task(function* fetch_byurl() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://byurl.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm1);
+
+ // Also ensure that fecth-by-url excludes the tags folder.
+ PlacesUtils.tagging.tagURI(uri(bm1.url.href), ["Test Tag"]);
+
+ let bm2 = yield PlacesUtils.bookmarks.fetch({ url: bm1.url },
+ gAccumulator.callback);
+ checkBookmarkObject(bm2);
+ Assert.equal(gAccumulator.results.length, 1);
+ checkBookmarkObject(gAccumulator.results[0]);
+ Assert.deepEqual(gAccumulator.results[0], bm1);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm2.url.href, "http://byurl.com/");
+ Assert.equal(bm2.title, "a bookmark");
+
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://byurl.com/",
+ title: "a bookmark" });
+ let bm4 = yield PlacesUtils.bookmarks.fetch({ url: bm1.url },
+ gAccumulator.callback);
+ checkBookmarkObject(bm4);
+ Assert.deepEqual(bm3, bm4);
+ Assert.equal(gAccumulator.results.length, 2);
+ gAccumulator.results.forEach(checkBookmarkObject);
+ Assert.deepEqual(gAccumulator.results[0], bm4);
+
+ // After an update the returned bookmark should change.
+ yield PlacesUtils.bookmarks.update({ guid: bm1.guid, title: "new title" });
+ let bm5 = yield PlacesUtils.bookmarks.fetch({ url: bm1.url },
+ gAccumulator.callback);
+ checkBookmarkObject(bm5);
+ // Cannot use deepEqual cause lastModified changed.
+ Assert.equal(bm1.guid, bm5.guid);
+ Assert.ok(bm5.lastModified > bm1.lastModified);
+ Assert.equal(gAccumulator.results.length, 2);
+ gAccumulator.results.forEach(checkBookmarkObject);
+ Assert.deepEqual(gAccumulator.results[0], bm5);
+
+ // cleanup
+ PlacesUtils.tagging.untagURI(uri(bm1.url.href), ["Test Tag"]);
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js
new file mode 100644
index 000000000..35166bd95
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_getRecent.js
@@ -0,0 +1,44 @@
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.getRecent(),
+ /numberOfItems argument is required/);
+ Assert.throws(() => PlacesUtils.bookmarks.getRecent("abc"),
+ /numberOfItems argument must be an integer/);
+ Assert.throws(() => PlacesUtils.bookmarks.getRecent(1.2),
+ /numberOfItems argument must be an integer/);
+ Assert.throws(() => PlacesUtils.bookmarks.getRecent(0),
+ /numberOfItems argument must be greater than zero/);
+ Assert.throws(() => PlacesUtils.bookmarks.getRecent(-1),
+ /numberOfItems argument must be greater than zero/);
+});
+
+add_task(function* getRecent_returns_recent_bookmarks() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/path",
+ title: "another bookmark" });
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.net/",
+ title: "another bookmark" });
+ let bm4 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.net/path",
+ title: "yet another bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+ checkBookmarkObject(bm3);
+ checkBookmarkObject(bm4);
+
+ let results = yield PlacesUtils.bookmarks.getRecent(3);
+ Assert.equal(results.length, 3);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm4, results[0]);
+ checkBookmarkObject(results[1]);
+ Assert.deepEqual(bm3, results[1]);
+ checkBookmarkObject(results[2]);
+ Assert.deepEqual(bm2, results[2]);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js
new file mode 100644
index 000000000..0f772a92f
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_insert.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.insert(),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert(null),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({}),
+ /The following properties were expected/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ guid: "test" }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ guid: null }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ guid: 123 }),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ parentGuid: "test" }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ parentGuid: null }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ parentGuid: 123 }),
+ /Invalid value for property 'parentGuid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ index: "1" }),
+ /Invalid value for property 'index'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ index: -10 }),
+ /Invalid value for property 'index'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ dateAdded: -10 }),
+ /Invalid value for property 'dateAdded'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ dateAdded: "today" }),
+ /Invalid value for property 'dateAdded'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ dateAdded: Date.now() }),
+ /Invalid value for property 'dateAdded'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ lastModified: -10 }),
+ /Invalid value for property 'lastModified'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ lastModified: "today" }),
+ /Invalid value for property 'lastModified'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ lastModified: Date.now() }),
+ /Invalid value for property 'lastModified'/);
+ let time = new Date();
+ let future = new Date(time + 86400000);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ dateAdded: future,
+ lastModified: time }),
+ /Invalid value for property 'dateAdded'/);
+ let past = new Date(time - 86400000);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ lastModified: past }),
+ /Invalid value for property 'lastModified'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: -1 }),
+ /Invalid value for property 'type'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: 100 }),
+ /Invalid value for property 'type'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: "bookmark" }),
+ /Invalid value for property 'type'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ title: -1 }),
+ /Invalid value for property 'title'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: 10 }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://te st" }),
+ /Invalid value for property 'url'/);
+ let longurl = "http://www.example.com/";
+ for (let i = 0; i < 65536; i++) {
+ longurl += "a";
+ }
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: longurl }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: NetUtil.newURI(longurl) }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "te st" }),
+ /Invalid value for property 'url'/);
+});
+
+add_task(function* invalid_properties_for_bookmark_type() {
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ url: "http://www.moz.com/" }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ url: "http://www.moz.com/" }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ title: "test" }),
+ /Invalid value for property 'title'/);
+});
+
+add_task(function* long_title_trim() {
+ let longtitle = "a";
+ for (let i = 0; i < 4096; i++) {
+ longtitle += "a";
+ }
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: longtitle });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.index, 0);
+ Assert.equal(bm.dateAdded, bm.lastModified);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.equal(bm.title.length, 4096, "title should have been trimmed");
+ Assert.ok(!("url" in bm), "url should not be set");
+});
+
+add_task(function* create_separator() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.index, 1);
+ Assert.equal(bm.dateAdded, bm.lastModified);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR);
+ Assert.ok(!("title" in bm), "title should not be set");
+});
+
+add_task(function* create_separator_w_title_fail() {
+ try {
+ yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ title: "a separator" });
+ Assert.ok(false, "Trying to set title for a separator should reject");
+ } catch (ex) {}
+});
+
+add_task(function* create_separator_invalid_parent_fail() {
+ try {
+ yield PlacesUtils.bookmarks.insert({ parentGuid: "123456789012",
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ title: "a separator" });
+ Assert.ok(false, "Trying to create an item in a non existing parent reject");
+ } catch (ex) {}
+});
+
+add_task(function* create_separator_given_guid() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ guid: "123456789012" });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.guid, "123456789012");
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.index, 2);
+ Assert.equal(bm.dateAdded, bm.lastModified);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR);
+ Assert.ok(!("title" in bm), "title should not be set");
+});
+
+add_task(function* create_item_given_guid_no_type_fail() {
+ try {
+ yield PlacesUtils.bookmarks.insert({ parentGuid: "123456789012" });
+ Assert.ok(false, "Trying to create an item with a given guid but no type should reject");
+ } catch (ex) {}
+});
+
+add_task(function* create_separator_big_index() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: 9999 });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.index, 3);
+ Assert.equal(bm.dateAdded, bm.lastModified);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_SEPARATOR);
+ Assert.ok(!("title" in bm), "title should not be set");
+});
+
+add_task(function* create_separator_given_dateAdded() {
+ let time = new Date();
+ let past = new Date(time - 86400000);
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ dateAdded: past });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.dateAdded, past);
+ Assert.equal(bm.lastModified, past);
+});
+
+add_task(function* create_folder() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.dateAdded, bm.lastModified);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.ok(!("title" in bm), "title should not be set");
+
+ // And then create a nested folder.
+ let parentGuid = bm.guid;
+ bm = yield PlacesUtils.bookmarks.insert({ parentGuid: parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, parentGuid);
+ Assert.equal(bm.index, 0);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.strictEqual(bm.title, "a folder");
+});
+
+add_task(function* create_bookmark() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ let parentGuid = bm.guid;
+
+ bm = yield PlacesUtils.bookmarks.insert({ parentGuid: parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, parentGuid);
+ Assert.equal(bm.index, 0);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm.url.href, "http://example.com/");
+ Assert.equal(bm.title, "a bookmark");
+
+ // Check parent lastModified.
+ let parent = yield PlacesUtils.bookmarks.fetch({ guid: bm.parentGuid });
+ Assert.deepEqual(parent.lastModified, bm.dateAdded);
+
+ bm = yield PlacesUtils.bookmarks.insert({ parentGuid: parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: new URL("http://example.com/") });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, parentGuid);
+ Assert.equal(bm.index, 1);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm.url.href, "http://example.com/");
+ Assert.ok(!("title" in bm), "title should not be set");
+});
+
+add_task(function* create_bookmark_frecency() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ Assert.ok(frecencyForUrl(bm.url) > 0, "Check frecency has been updated")
+});
+
+add_task(function* create_bookmark_without_type() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm.url.href, "http://example.com/");
+ Assert.equal(bm.title, "a bookmark");
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js
new file mode 100644
index 000000000..02787425d
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_notifications.js
@@ -0,0 +1,527 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* insert_separator_notification() {
+ let observer = expectNotifications();
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid});
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ observer.check([ { name: "onItemAdded",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ null, null, bm.dateAdded,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* insert_folder_notification() {
+ let observer = expectNotifications();
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "a folder" });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ observer.check([ { name: "onItemAdded",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ null, bm.title, bm.dateAdded,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* insert_folder_notitle_notification() {
+ let observer = expectNotifications();
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ observer.check([ { name: "onItemAdded",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ null, null, bm.dateAdded,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* insert_bookmark_notification() {
+ let observer = expectNotifications();
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://example.com/"),
+ title: "a bookmark" });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ observer.check([ { name: "onItemAdded",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ bm.url, bm.title, bm.dateAdded,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* insert_bookmark_notitle_notification() {
+ let observer = expectNotifications();
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ observer.check([ { name: "onItemAdded",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ bm.url, null, bm.dateAdded,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* insert_bookmark_tag_notification() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://tag.example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let tagFolder = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: "tag" });
+ let observer = expectNotifications();
+ let tag = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: tagFolder.guid,
+ url: new URL("http://tag.example.com/") });
+ let tagId = yield PlacesUtils.promiseItemId(tag.guid);
+ let tagParentId = yield PlacesUtils.promiseItemId(tag.parentGuid);
+
+ observer.check([ { name: "onItemAdded",
+ arguments: [ tagId, tagParentId, tag.index, tag.type,
+ tag.url, null, tag.dateAdded,
+ tag.guid, tag.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ itemId, "tags", false, "",
+ bm.lastModified, bm.type, parentId,
+ bm.guid, bm.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* update_bookmark_lastModified() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://lastmod.example.com/") });
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ lastModified: new Date() });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ observer.check([ { name: "onItemChanged",
+ arguments: [ itemId, "lastModified", false,
+ `${bm.lastModified * 1000}`, bm.lastModified,
+ bm.type, parentId, bm.guid, bm.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* update_bookmark_title() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://title.example.com/") });
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ title: "new title" });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ observer.check([ { name: "onItemChanged",
+ arguments: [ itemId, "title", false, bm.title,
+ bm.lastModified, bm.type, parentId, bm.guid,
+ bm.parentGuid, "", Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* update_bookmark_uri() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://url.example.com/") });
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ url: "http://mozilla.org/" });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ observer.check([ { name: "onItemChanged",
+ arguments: [ itemId, "uri", false, bm.url.href,
+ bm.lastModified, bm.type, parentId, bm.guid,
+ bm.parentGuid, "http://url.example.com/",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* update_move_same_folder() {
+ // Ensure there are at least two items in place (others test do so for us,
+ // but we don't have to depend on that).
+ yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://move.example.com/") });
+ let bmItemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let bmParentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+ let bmOldIndex = bm.index;
+
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 0 });
+ Assert.equal(bm.index, 0);
+ observer.check([ { name: "onItemMoved",
+ arguments: [ bmItemId, bmParentId, bmOldIndex, bmParentId, bm.index,
+ bm.type, bm.guid, bm.parentGuid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+
+ // Test that we get the right index for DEFAULT_INDEX input.
+ bmOldIndex = 0;
+ observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX });
+ Assert.ok(bm.index > 0);
+ observer.check([ { name: "onItemMoved",
+ arguments: [ bmItemId, bmParentId, bmOldIndex, bmParentId, bm.index,
+ bm.type, bm.guid, bm.parentGuid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* update_move_different_folder() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://move.example.com/") });
+ let folder = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let bmItemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let bmOldParentId = PlacesUtils.unfiledBookmarksFolderId;
+ let bmOldIndex = bm.index;
+
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: folder.guid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX });
+ Assert.equal(bm.index, 0);
+ let bmNewParentId = yield PlacesUtils.promiseItemId(folder.guid);
+ observer.check([ { name: "onItemMoved",
+ arguments: [ bmItemId, bmOldParentId, bmOldIndex, bmNewParentId,
+ bm.index, bm.type, bm.guid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* remove_bookmark() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://remove.example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.remove(bm.guid);
+ // TODO (Bug 653910): onItemAnnotationRemoved notified even if there were no
+ // annotations.
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ itemId, parentId, bm.index, bm.type, bm.url,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* remove_folder() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let observer = expectNotifications();
+ bm = yield PlacesUtils.bookmarks.remove(bm.guid);
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ itemId, parentId, bm.index, bm.type, null,
+ bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* remove_bookmark_tag_notification() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: new URL("http://untag.example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let tagFolder = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: "tag" });
+ let tag = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: tagFolder.guid,
+ url: new URL("http://untag.example.com/") });
+ let tagId = yield PlacesUtils.promiseItemId(tag.guid);
+ let tagParentId = yield PlacesUtils.promiseItemId(tag.parentGuid);
+
+ let observer = expectNotifications();
+ yield PlacesUtils.bookmarks.remove(tag.guid);
+
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ tagId, tagParentId, tag.index, tag.type,
+ tag.url, tag.guid, tag.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ itemId, "tags", false, "",
+ bm.lastModified, bm.type, parentId,
+ bm.guid, bm.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* remove_folder_notification() {
+ let folder1 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let folder1Id = yield PlacesUtils.promiseItemId(folder1.guid);
+ let folder1ParentId = yield PlacesUtils.promiseItemId(folder1.parentGuid);
+
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: folder1.guid,
+ url: new URL("http://example.com/") });
+ let bmItemId = yield PlacesUtils.promiseItemId(bm.guid);
+
+ let folder2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: folder1.guid });
+ let folder2Id = yield PlacesUtils.promiseItemId(folder2.guid);
+
+ let bm2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: folder2.guid,
+ url: new URL("http://example.com/") });
+ let bm2ItemId = yield PlacesUtils.promiseItemId(bm2.guid);
+
+ let observer = expectNotifications();
+ yield PlacesUtils.bookmarks.remove(folder1.guid);
+
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ bm2ItemId, folder2Id, bm2.index, bm2.type,
+ bm2.url, bm2.guid, bm2.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder2Id, folder1Id, folder2.index,
+ folder2.type, null, folder2.guid,
+ folder2.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ bmItemId, folder1Id, bm.index, bm.type,
+ bm.url, bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder1Id, folder1ParentId, folder1.index,
+ folder1.type, null, folder1.guid,
+ folder1.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+});
+
+add_task(function* eraseEverything_notification() {
+ // Let's start from a clean situation.
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let folder1 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let folder1Id = yield PlacesUtils.promiseItemId(folder1.guid);
+ let folder1ParentId = yield PlacesUtils.promiseItemId(folder1.parentGuid);
+
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: folder1.guid,
+ url: new URL("http://example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let folder2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let folder2Id = yield PlacesUtils.promiseItemId(folder2.guid);
+ let folder2ParentId = yield PlacesUtils.promiseItemId(folder2.parentGuid);
+
+ let toolbarBm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: new URL("http://example.com/") });
+ let toolbarBmId = yield PlacesUtils.promiseItemId(toolbarBm.guid);
+ let toolbarBmParentId = yield PlacesUtils.promiseItemId(toolbarBm.parentGuid);
+
+ let menuBm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: new URL("http://example.com/") });
+ let menuBmId = yield PlacesUtils.promiseItemId(menuBm.guid);
+ let menuBmParentId = yield PlacesUtils.promiseItemId(menuBm.parentGuid);
+
+ let observer = expectNotifications();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // Bookmarks should always be notified before their parents.
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ bm.url, bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder2Id, folder2ParentId, folder2.index,
+ folder2.type, null, folder2.guid,
+ folder2.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder1Id, folder1ParentId, folder1.index,
+ folder1.type, null, folder1.guid,
+ folder1.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ menuBmId, menuBmParentId,
+ menuBm.index, menuBm.type,
+ menuBm.url, menuBm.guid,
+ menuBm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ toolbarBmId, toolbarBmParentId,
+ toolbarBm.index, toolbarBm.type,
+ toolbarBm.url, toolbarBm.guid,
+ toolbarBm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ ]);
+});
+
+add_task(function* eraseEverything_reparented_notification() {
+ // Let's start from a clean situation.
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let folder1 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let folder1Id = yield PlacesUtils.promiseItemId(folder1.guid);
+ let folder1ParentId = yield PlacesUtils.promiseItemId(folder1.parentGuid);
+
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: folder1.guid,
+ url: new URL("http://example.com/") });
+ let itemId = yield PlacesUtils.promiseItemId(bm.guid);
+
+ let folder2 = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let folder2Id = yield PlacesUtils.promiseItemId(folder2.guid);
+ let folder2ParentId = yield PlacesUtils.promiseItemId(folder2.parentGuid);
+
+ bm.parentGuid = folder2.guid;
+ bm = yield PlacesUtils.bookmarks.update(bm);
+ let parentId = yield PlacesUtils.promiseItemId(bm.parentGuid);
+
+ let observer = expectNotifications();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // Bookmarks should always be notified before their parents.
+ observer.check([ { name: "onItemRemoved",
+ arguments: [ itemId, parentId, bm.index, bm.type,
+ bm.url, bm.guid, bm.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder2Id, folder2ParentId, folder2.index,
+ folder2.type, null, folder2.guid,
+ folder2.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemRemoved",
+ arguments: [ folder1Id, folder1ParentId, folder1.index,
+ folder1.type, null, folder1.guid,
+ folder1.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ ]);
+});
+
+add_task(function* reorder_notification() {
+ let bookmarks = [
+ { type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example1.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example2.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example3.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ ];
+ let sorted = [];
+ for (let bm of bookmarks) {
+ sorted.push(yield PlacesUtils.bookmarks.insert(bm));
+ }
+
+ // Randomly reorder the array.
+ sorted.sort(() => 0.5 - Math.random());
+
+ let observer = expectNotifications();
+ yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.unfiledGuid,
+ sorted.map(bm => bm.guid));
+
+ let expectedNotifications = [];
+ for (let i = 0; i < sorted.length; ++i) {
+ let child = sorted[i];
+ let childId = yield PlacesUtils.promiseItemId(child.guid);
+ expectedNotifications.push({ name: "onItemMoved",
+ arguments: [ childId,
+ PlacesUtils.unfiledBookmarksFolderId,
+ child.index,
+ PlacesUtils.unfiledBookmarksFolderId,
+ i,
+ child.type,
+ child.guid,
+ child.parentGuid,
+ child.parentGuid,
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT
+ ] });
+ }
+ observer.check(expectedNotifications);
+});
+
+function expectNotifications() {
+ let notifications = [];
+ let observer = new Proxy(NavBookmarkObserver, {
+ get(target, name) {
+ if (name == "check") {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ return expectedNotifications =>
+ Assert.deepEqual(notifications, expectedNotifications);
+ }
+
+ if (name.startsWith("onItem")) {
+ return (...origArgs) => {
+ let args = Array.from(origArgs, arg => {
+ if (arg && arg instanceof Ci.nsIURI)
+ return new URL(arg.spec);
+ if (arg && typeof(arg) == "number" && arg >= Date.now() * 1000)
+ return new Date(parseInt(arg/1000));
+ return arg;
+ });
+ notifications.push({ name: name, arguments: args });
+ }
+ }
+
+ if (name in target)
+ return target[name];
+ return undefined;
+ }
+ });
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ return observer;
+}
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js
new file mode 100644
index 000000000..19085a282
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_remove.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.remove(),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove(null),
+ /Input should be a valid object/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.remove("test"),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove(123),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ guid: "test" }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ guid: null }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ guid: 123 }),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ parentGuid: "test" }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ parentGuid: null }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ parentGuid: 123 }),
+ /Invalid value for property 'parentGuid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ url: "http://te st/" }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ url: null }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.remove({ url: -10 }),
+ /Invalid value for property 'url'/);
+});
+
+add_task(function* remove_nonexistent_guid() {
+ try {
+ yield PlacesUtils.bookmarks.remove({ guid: "123456789012"});
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/No bookmarks found for the provided GUID/.test(ex));
+ }
+});
+
+add_task(function* remove_roots_fail() {
+ let guids = [PlacesUtils.bookmarks.rootGuid,
+ PlacesUtils.bookmarks.unfiledGuid,
+ PlacesUtils.bookmarks.menuGuid,
+ PlacesUtils.bookmarks.toolbarGuid,
+ PlacesUtils.bookmarks.tagsGuid,
+ PlacesUtils.bookmarks.mobileGuid];
+ for (let guid of guids) {
+ Assert.throws(() => PlacesUtils.bookmarks.remove(guid),
+ /It's not possible to remove Places root folders/);
+ }
+});
+
+add_task(function* remove_normal_folder_under_root_succeeds() {
+ let folder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.rootGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(folder);
+ let removed_folder = yield PlacesUtils.bookmarks.remove(folder);
+ Assert.deepEqual(folder, removed_folder);
+ Assert.strictEqual((yield PlacesUtils.bookmarks.fetch(folder.guid)), null);
+});
+
+add_task(function* remove_bookmark() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_BOOKMARK);
+ Assert.equal(bm2.url.href, "http://example.com/");
+ Assert.equal(bm2.title, "a bookmark");
+});
+
+
+add_task(function* remove_bookmark_orphans() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(bm1);
+ PlacesUtils.annotations.setItemAnnotation((yield PlacesUtils.promiseItemId(bm1.guid)),
+ "testanno", "testvalue", 0, 0);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ // Check there are no orphan annotations.
+ let conn = yield PlacesUtils.promiseDBConnection();
+ let annoAttrs = yield conn.execute(`SELECT id, name FROM moz_anno_attributes`);
+ // Bug 1306445 will eventually remove the mobile root anno.
+ Assert.equal(annoAttrs.length, 1);
+ Assert.equal(annoAttrs[0].getResultByName("name"), PlacesUtils.MOBILE_ROOT_ANNO);
+ let annos = rows = yield conn.execute(`SELECT item_id, anno_attribute_id FROM moz_items_annos`);
+ Assert.equal(annos.length, 1);
+ Assert.equal(annos[0].getResultByName("item_id"), PlacesUtils.mobileFolderId);
+ Assert.equal(annos[0].getResultByName("anno_attribute_id"), annoAttrs[0].getResultByName("id"));
+});
+
+add_task(function* remove_bookmark_empty_title() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.index, 0);
+ Assert.ok(!("title" in bm2));
+});
+
+add_task(function* remove_folder() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_FOLDER);
+ Assert.equal(bm2.title, "a folder");
+ Assert.ok(!("url" in bm2));
+});
+
+add_task(function* test_nested_contents_removed() {
+ let folder1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ let folder2 = yield PlacesUtils.bookmarks.insert({ parentGuid: folder1.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ let sep = yield PlacesUtils.bookmarks.insert({ parentGuid: folder2.guid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ yield PlacesUtils.bookmarks.remove(folder1);
+ Assert.strictEqual((yield PlacesUtils.bookmarks.fetch(folder1.guid)), null);
+ Assert.strictEqual((yield PlacesUtils.bookmarks.fetch(folder2.guid)), null);
+ Assert.strictEqual((yield PlacesUtils.bookmarks.fetch(sep.guid)), null);
+});
+
+add_task(function* remove_folder_empty_title() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "" });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.index, 0);
+ Assert.ok(!("title" in bm2));
+});
+
+add_task(function* remove_separator() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ checkBookmarkObject(bm1);
+
+ let bm2 = yield PlacesUtils.bookmarks.remove(bm1.guid);
+ checkBookmarkObject(bm2);
+
+ Assert.deepEqual(bm1, bm2);
+ Assert.equal(bm2.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ Assert.equal(bm2.index, 0);
+ Assert.deepEqual(bm2.dateAdded, bm2.lastModified);
+ Assert.equal(bm2.type, PlacesUtils.bookmarks.TYPE_SEPARATOR);
+ Assert.ok(!("url" in bm2));
+ Assert.ok(!("title" in bm2));
+});
+
+add_task(function* test_nested_content_fails_when_not_allowed() {
+ let folder1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ yield PlacesUtils.bookmarks.insert({ parentGuid: folder1.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a folder" });
+ yield Assert.rejects(PlacesUtils.bookmarks.remove(folder1, {preventRemovalOfNonEmptyFolders: true}),
+ /Cannot remove a non-empty folder./);
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js
new file mode 100644
index 000000000..4f6617280
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_reorder.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.reorder(),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder(null),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("test"),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder(123),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.reorder({ guid: "test" }),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012"),
+ /Must provide a sorted array of children GUIDs./);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", {}),
+ /Must provide a sorted array of children GUIDs./);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", null),
+ /Must provide a sorted array of children GUIDs./);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", []),
+ /Must provide a sorted array of children GUIDs./);
+
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", [ null ]),
+ /Invalid GUID found in the sorted children array/);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", [ "" ]),
+ /Invalid GUID found in the sorted children array/);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", [ {} ]),
+ /Invalid GUID found in the sorted children array/);
+ Assert.throws(() => PlacesUtils.bookmarks.reorder("123456789012", [ "012345678901", null ]),
+ /Invalid GUID found in the sorted children array/);
+});
+
+add_task(function* reorder_nonexistent_guid() {
+ yield Assert.rejects(PlacesUtils.bookmarks.reorder("123456789012", [ "012345678901" ]),
+ /No folder found for the provided GUID/,
+ "Should throw for nonexisting guid");
+});
+
+add_task(function* reorder() {
+ let bookmarks = [
+ { url: "http://example1.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { url: "http://example2.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ },
+ { url: "http://example3.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ }
+ ];
+
+ let sorted = [];
+ for (let bm of bookmarks) {
+ sorted.push(yield PlacesUtils.bookmarks.insert(bm));
+ }
+
+ // Check the initial append sorting.
+ Assert.ok(sorted.every((bm, i) => bm.index == i),
+ "Initial bookmarks sorting is correct");
+
+ // Apply random sorting and run multiple tests.
+ for (let t = 0; t < 4; t++) {
+ sorted.sort(() => 0.5 - Math.random());
+ let sortedGuids = sorted.map(child => child.guid);
+ dump("Expected order: " + sortedGuids.join() + "\n");
+ // Add a nonexisting guid to the array, to ensure nothing will break.
+ sortedGuids.push("123456789012");
+ yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.unfiledGuid,
+ sortedGuids);
+ for (let i = 0; i < sorted.length; ++i) {
+ let item = yield PlacesUtils.bookmarks.fetch(sorted[i].guid);
+ Assert.equal(item.index, i);
+ }
+ }
+
+ do_print("Test partial sorting");
+ // Try a partial sorting by passing only 2 entries.
+ // The unspecified entries should retain the original order.
+ sorted = [ sorted[1], sorted[0] ].concat(sorted.slice(2));
+ let sortedGuids = [ sorted[0].guid, sorted[1].guid ];
+ dump("Expected order: " + sorted.map(b => b.guid).join() + "\n");
+ yield PlacesUtils.bookmarks.reorder(PlacesUtils.bookmarks.unfiledGuid,
+ sortedGuids);
+ for (let i = 0; i < sorted.length; ++i) {
+ let item = yield PlacesUtils.bookmarks.fetch(sorted[i].guid);
+ Assert.equal(item.index, i);
+ }
+
+ // Use triangular numbers to detect skipped position.
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(
+ `SELECT parent
+ FROM moz_bookmarks
+ GROUP BY parent
+ HAVING (SUM(DISTINCT position + 1) - (count(*) * (count(*) + 1) / 2)) <> 0`);
+ Assert.equal(rows.length, 0, "All the bookmarks should have consistent positions");
+});
+
+add_task(function* move_and_reorder() {
+ // Start clean.
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let bm1 = yield PlacesUtils.bookmarks.insert({
+ url: "http://example1.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ });
+ let f1 = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ });
+ let bm2 = yield PlacesUtils.bookmarks.insert({
+ url: "http://example2.com/",
+ parentGuid: f1.guid
+ });
+ let f2 = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ });
+ let bm3 = yield PlacesUtils.bookmarks.insert({
+ url: "http://example3.com/",
+ parentGuid: f2.guid
+ });
+ let bm4 = yield PlacesUtils.bookmarks.insert({
+ url: "http://example4.com/",
+ parentGuid: f2.guid
+ });
+ let bm5 = yield PlacesUtils.bookmarks.insert({
+ url: "http://example5.com/",
+ parentGuid: f2.guid
+ });
+
+ // Invert f2 children.
+ // This is critical to reproduce the bug, cause it inverts the position
+ // compared to the natural insertion order.
+ yield PlacesUtils.bookmarks.reorder(f2.guid, [bm5.guid, bm4.guid, bm3.guid]);
+
+ bm1.parentGuid = f1.guid;
+ bm1.index = 0;
+ yield PlacesUtils.bookmarks.update(bm1);
+
+ bm1 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ Assert.equal(bm1.index, 0);
+ bm2 = yield PlacesUtils.bookmarks.fetch(bm2.guid);
+ Assert.equal(bm2.index, 1);
+ bm3 = yield PlacesUtils.bookmarks.fetch(bm3.guid);
+ Assert.equal(bm3.index, 2);
+ bm4 = yield PlacesUtils.bookmarks.fetch(bm4.guid);
+ Assert.equal(bm4.index, 1);
+ bm5 = yield PlacesUtils.bookmarks.fetch(bm5.guid);
+ Assert.equal(bm5.index, 0);
+
+ // No-op reorder on f1 children.
+ // Nothing should change. Though, due to bug 1293365 this was causing children
+ // of other folders to get messed up.
+ yield PlacesUtils.bookmarks.reorder(f1.guid, [bm1.guid, bm2.guid]);
+
+ bm1 = yield PlacesUtils.bookmarks.fetch(bm1.guid);
+ Assert.equal(bm1.index, 0);
+ bm2 = yield PlacesUtils.bookmarks.fetch(bm2.guid);
+ Assert.equal(bm2.index, 1);
+ bm3 = yield PlacesUtils.bookmarks.fetch(bm3.guid);
+ Assert.equal(bm3.index, 2);
+ bm4 = yield PlacesUtils.bookmarks.fetch(bm4.guid);
+ Assert.equal(bm4.index, 1);
+ bm5 = yield PlacesUtils.bookmarks.fetch(bm5.guid);
+ Assert.equal(bm5.index, 0);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js
new file mode 100644
index 000000000..02f7c5460
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_search.js
@@ -0,0 +1,223 @@
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.search(),
+ /Query object is required/);
+ Assert.throws(() => PlacesUtils.bookmarks.search(null),
+ /Query object is required/);
+ Assert.throws(() => PlacesUtils.bookmarks.search({title: 50}),
+ /Title option must be a string/);
+ Assert.throws(() => PlacesUtils.bookmarks.search({url: {url: "wombat"}}),
+ /Url option must be a string or a URL object/);
+ Assert.throws(() => PlacesUtils.bookmarks.search(50),
+ /Query must be an object or a string/);
+ Assert.throws(() => PlacesUtils.bookmarks.search(true),
+ /Query must be an object or a string/);
+});
+
+add_task(function* search_bookmark() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/",
+ title: "another bookmark" });
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ url: "http://menu.org/",
+ title: "an on-menu bookmark" });
+ let bm4 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: "http://toolbar.org/",
+ title: "an on-toolbar bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+ checkBookmarkObject(bm3);
+ checkBookmarkObject(bm4);
+
+ // finds a result by query
+ let results = yield PlacesUtils.bookmarks.search("example.com");
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm1, results[0]);
+
+ // finds multiple results
+ results = yield PlacesUtils.bookmarks.search("example");
+ Assert.equal(results.length, 2);
+ checkBookmarkObject(results[0]);
+ checkBookmarkObject(results[1]);
+
+ // finds menu bookmarks
+ results = yield PlacesUtils.bookmarks.search("an on-menu bookmark");
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm3, results[0]);
+
+ // finds toolbar bookmarks
+ results = yield PlacesUtils.bookmarks.search("an on-toolbar bookmark");
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm4, results[0]);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* search_bookmark_by_query_object() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/",
+ title: "another bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+
+ let results = yield PlacesUtils.bookmarks.search({query: "example.com"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+
+ Assert.deepEqual(bm1, results[0]);
+
+ results = yield PlacesUtils.bookmarks.search({query: "example"});
+ Assert.equal(results.length, 2);
+ checkBookmarkObject(results[0]);
+ checkBookmarkObject(results[1]);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* search_bookmark_by_url() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/path",
+ title: "another bookmark" });
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/path",
+ title: "third bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+ checkBookmarkObject(bm3);
+
+ // finds the correct result by url
+ let results = yield PlacesUtils.bookmarks.search({url: "http://example.com/"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm1, results[0]);
+
+ // normalizes the url
+ results = yield PlacesUtils.bookmarks.search({url: "http:/example.com"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm1, results[0]);
+
+ // returns multiple matches
+ results = yield PlacesUtils.bookmarks.search({url: "http://example.org/path"});
+ Assert.equal(results.length, 2);
+
+ // requires exact match
+ results = yield PlacesUtils.bookmarks.search({url: "http://example.org/"});
+ Assert.equal(results.length, 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* search_bookmark_by_title() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/path",
+ title: "another bookmark" });
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.net/",
+ title: "another bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+ checkBookmarkObject(bm3);
+
+ // finds the correct result by title
+ let results = yield PlacesUtils.bookmarks.search({title: "a bookmark"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm1, results[0]);
+
+ // returns multiple matches
+ results = yield PlacesUtils.bookmarks.search({title: "another bookmark"});
+ Assert.equal(results.length, 2);
+
+ // requires exact match
+ results = yield PlacesUtils.bookmarks.search({title: "bookmark"});
+ Assert.equal(results.length, 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* search_bookmark_combinations() {
+ let bm1 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ let bm2 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.org/path",
+ title: "another bookmark" });
+ let bm3 = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.net/",
+ title: "third bookmark" });
+ checkBookmarkObject(bm1);
+ checkBookmarkObject(bm2);
+ checkBookmarkObject(bm3);
+
+ // finds the correct result if title and url match
+ let results = yield PlacesUtils.bookmarks.search({url: "http://example.com/", title: "a bookmark"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm1, results[0]);
+
+ // does not match if query is not matching but url and title match
+ results = yield PlacesUtils.bookmarks.search({url: "http://example.com/", title: "a bookmark", query: "nonexistent"});
+ Assert.equal(results.length, 0);
+
+ // does not match if one parameter is not matching
+ results = yield PlacesUtils.bookmarks.search({url: "http://what.ever", title: "a bookmark"});
+ Assert.equal(results.length, 0);
+
+ // query only matches if other fields match as well
+ results = yield PlacesUtils.bookmarks.search({query: "bookmark", url: "http://example.net/"});
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm3, results[0]);
+
+ // non-matching query will also return no results
+ results = yield PlacesUtils.bookmarks.search({query: "nonexistent", url: "http://example.net/"});
+ Assert.equal(results.length, 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* search_folder() {
+ let folder = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "a test folder" });
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: folder.guid,
+ url: "http://example.com/",
+ title: "a bookmark" });
+ checkBookmarkObject(folder);
+ checkBookmarkObject(bm);
+
+ // also finds folders
+ let results = yield PlacesUtils.bookmarks.search("a test folder");
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.equal(folder.title, results[0].title);
+ Assert.equal(folder.type, results[0].type);
+ Assert.equal(folder.parentGuid, results[0].parentGuid);
+
+ // finds elements in folders
+ results = yield PlacesUtils.bookmarks.search("example.com");
+ Assert.equal(results.length, 1);
+ checkBookmarkObject(results[0]);
+ Assert.deepEqual(bm, results[0]);
+ Assert.equal(folder.guid, results[0].parentGuid);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js b/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js
new file mode 100644
index 000000000..d077fd6f3
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarks_update.js
@@ -0,0 +1,414 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* invalid_input_throws() {
+ Assert.throws(() => PlacesUtils.bookmarks.update(),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.update(null),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({}),
+ /The following properties were expected/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ guid: "test" }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ guid: null }),
+ /Invalid value for property 'guid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ guid: 123 }),
+ /Invalid value for property 'guid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ parentGuid: "test" }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ parentGuid: null }),
+ /Invalid value for property 'parentGuid'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ parentGuid: 123 }),
+ /Invalid value for property 'parentGuid'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ index: "1" }),
+ /Invalid value for property 'index'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ index: -10 }),
+ /Invalid value for property 'index'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ dateAdded: -10 }),
+ /Invalid value for property 'dateAdded'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ dateAdded: "today" }),
+ /Invalid value for property 'dateAdded'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ dateAdded: Date.now() }),
+ /Invalid value for property 'dateAdded'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ lastModified: -10 }),
+ /Invalid value for property 'lastModified'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ lastModified: "today" }),
+ /Invalid value for property 'lastModified'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ lastModified: Date.now() }),
+ /Invalid value for property 'lastModified'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ type: -1 }),
+ /Invalid value for property 'type'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ type: 100 }),
+ /Invalid value for property 'type'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ type: "bookmark" }),
+ /Invalid value for property 'type'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ url: 10 }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ url: "http://te st" }),
+ /Invalid value for property 'url'/);
+ let longurl = "http://www.example.com/";
+ for (let i = 0; i < 65536; i++) {
+ longurl += "a";
+ }
+ Assert.throws(() => PlacesUtils.bookmarks.update({ url: longurl }),
+ /Invalid value for property 'url'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ url: NetUtil.newURI(longurl) }),
+ /Invalid value for property 'url'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ url: "te st" }),
+ /Invalid value for property 'url'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ title: -1 }),
+ /Invalid value for property 'title'/);
+ Assert.throws(() => PlacesUtils.bookmarks.update({ title: {} }),
+ /Invalid value for property 'title'/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ guid: "123456789012" }),
+ /Not enough properties to update/);
+
+ Assert.throws(() => PlacesUtils.bookmarks.update({ guid: "123456789012",
+ parentGuid: "012345678901" }),
+ /The following properties were expected: index/);
+});
+
+add_task(function* nonexisting_bookmark_throws() {
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: "123456789012",
+ title: "test" });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/No bookmarks found for the provided GUID/.test(ex));
+ }
+});
+
+add_task(function* invalid_properties_for_existing_bookmark() {
+ let bm = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://example.com/" });
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/The bookmark type cannot be changed/.test(ex));
+ }
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ dateAdded: new Date() });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/The bookmark dateAdded cannot be changed/.test(ex));
+ }
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ dateAdded: new Date() });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/The bookmark dateAdded cannot be changed/.test(ex));
+ }
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: "123456789012",
+ index: 1 });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/No bookmarks found for the provided parentGuid/.test(ex));
+ }
+
+ let past = new Date(Date.now() - 86400000);
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ lastModified: past });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Invalid value for property 'lastModified'/.test(ex));
+ }
+
+ let folder = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: folder.guid,
+ url: "http://example.com/" });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Invalid value for property 'url'/.test(ex));
+ }
+
+ let separator = yield PlacesUtils.bookmarks.insert({ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: separator.guid,
+ url: "http://example.com/" });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Invalid value for property 'url'/.test(ex));
+ }
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: separator.guid,
+ title: "test" });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Invalid value for property 'title'/.test(ex));
+ }
+});
+
+add_task(function* long_title_trim() {
+ let longtitle = "a";
+ for (let i = 0; i < 4096; i++) {
+ longtitle += "a";
+ }
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "title" });
+ checkBookmarkObject(bm);
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ title: longtitle });
+ let newTitle = bm.title;
+ Assert.equal(newTitle.length, 4096, "title should have been trimmed");
+
+ bm = yield PlacesUtils.bookmarks.fetch(bm.guid);
+ Assert.equal(bm.title, newTitle);
+});
+
+add_task(function* update_lastModified() {
+ let yesterday = new Date(Date.now() - 86400000);
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "title",
+ dateAdded: yesterday });
+ checkBookmarkObject(bm);
+ Assert.deepEqual(bm.lastModified, yesterday);
+
+ let time = new Date();
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ lastModified: time });
+ checkBookmarkObject(bm);
+ Assert.deepEqual(bm.lastModified, time);
+
+ bm = yield PlacesUtils.bookmarks.fetch(bm.guid);
+ Assert.deepEqual(bm.lastModified, time);
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ lastModified: yesterday });
+ Assert.deepEqual(bm.lastModified, yesterday);
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ title: "title2" });
+ Assert.ok(bm.lastModified >= time);
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ title: "" });
+ Assert.ok(!("title" in bm));
+
+ bm = yield PlacesUtils.bookmarks.fetch(bm.guid);
+ Assert.ok(!("title" in bm));
+});
+
+add_task(function* update_url() {
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/",
+ title: "title" });
+ checkBookmarkObject(bm);
+ let lastModified = bm.lastModified;
+ let frecency = frecencyForUrl(bm.url);
+ Assert.ok(frecency > 0, "Check frecency has been updated");
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ url: "http://mozilla.org/" });
+ checkBookmarkObject(bm);
+ Assert.ok(bm.lastModified >= lastModified);
+ Assert.equal(bm.url.href, "http://mozilla.org/");
+
+ bm = yield PlacesUtils.bookmarks.fetch(bm.guid);
+ Assert.equal(bm.url.href, "http://mozilla.org/");
+ Assert.ok(bm.lastModified >= lastModified);
+
+ Assert.equal(frecencyForUrl("http://example.com/"), frecency, "Check frecency for example.com");
+ Assert.equal(frecencyForUrl("http://mozilla.org/"), frecency, "Check frecency for mozilla.org");
+});
+
+add_task(function* update_index() {
+ let parent = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER }) ;
+ let f1 = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ Assert.equal(f1.index, 0);
+ let f2 = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ Assert.equal(f2.index, 1);
+ let f3 = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ Assert.equal(f3.index, 2);
+ let lastModified = f1.lastModified;
+
+ f1 = yield PlacesUtils.bookmarks.update({ guid: f1.guid,
+ parentGuid: f1.parentGuid,
+ index: 1});
+ checkBookmarkObject(f1);
+ Assert.equal(f1.index, 1);
+ Assert.ok(f1.lastModified >= lastModified);
+
+ parent = yield PlacesUtils.bookmarks.fetch(f1.parentGuid);
+ Assert.deepEqual(parent.lastModified, f1.lastModified);
+
+ f2 = yield PlacesUtils.bookmarks.fetch(f2.guid);
+ Assert.equal(f2.index, 0);
+
+ f3 = yield PlacesUtils.bookmarks.fetch(f3.guid);
+ Assert.equal(f3.index, 2);
+
+ f3 = yield PlacesUtils.bookmarks.update({ guid: f3.guid,
+ index: 0 });
+ f1 = yield PlacesUtils.bookmarks.fetch(f1.guid);
+ Assert.equal(f1.index, 2);
+
+ f2 = yield PlacesUtils.bookmarks.fetch(f2.guid);
+ Assert.equal(f2.index, 1);
+});
+
+add_task(function* update_move_folder_into_descendant_throws() {
+ let parent = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER }) ;
+ let descendant = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: parent.guid,
+ parentGuid: parent.guid,
+ index: 0 });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Cannot insert a folder into itself or one of its descendants/.test(ex));
+ }
+
+ try {
+ yield PlacesUtils.bookmarks.update({ guid: parent.guid,
+ parentGuid: descendant.guid,
+ index: 0 });
+ Assert.ok(false, "Should have thrown");
+ } catch (ex) {
+ Assert.ok(/Cannot insert a folder into itself or one of its descendants/.test(ex));
+ }
+});
+
+add_task(function* update_move() {
+ let parent = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER }) ;
+ let bm = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK }) ;
+ let descendant = yield PlacesUtils.bookmarks.insert({ parentGuid: parent.guid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ Assert.equal(descendant.index, 1);
+ let lastModified = bm.lastModified;
+
+ // This is moving to a nonexisting index by purpose, it will be appended.
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: descendant.guid,
+ index: 1 });
+ checkBookmarkObject(bm);
+ Assert.equal(bm.parentGuid, descendant.guid);
+ Assert.equal(bm.index, 0);
+ Assert.ok(bm.lastModified >= lastModified);
+
+ parent = yield PlacesUtils.bookmarks.fetch(parent.guid);
+ descendant = yield PlacesUtils.bookmarks.fetch(descendant.guid);
+ Assert.deepEqual(parent.lastModified, bm.lastModified);
+ Assert.deepEqual(descendant.lastModified, bm.lastModified);
+ Assert.equal(descendant.index, 0);
+
+ bm = yield PlacesUtils.bookmarks.fetch(bm.guid);
+ Assert.equal(bm.parentGuid, descendant.guid);
+ Assert.equal(bm.index, 0);
+
+ bm = yield PlacesUtils.bookmarks.update({ guid: bm.guid,
+ parentGuid: parent.guid,
+ index: 0 });
+ Assert.equal(bm.parentGuid, parent.guid);
+ Assert.equal(bm.index, 0);
+
+ descendant = yield PlacesUtils.bookmarks.fetch(descendant.guid);
+ Assert.equal(descendant.index, 1);
+});
+
+add_task(function* update_move_append() {
+ let folder_a =
+ yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(folder_a);
+ let folder_b =
+ yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ checkBookmarkObject(folder_b);
+
+ /* folder_a: [sep_1, sep_2, sep_3], folder_b: [] */
+ let sep_1 = yield PlacesUtils.bookmarks.insert({ parentGuid: folder_a.guid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ checkBookmarkObject(sep_1);
+ let sep_2 = yield PlacesUtils.bookmarks.insert({ parentGuid: folder_a.guid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ checkBookmarkObject(sep_2);
+ let sep_3 = yield PlacesUtils.bookmarks.insert({ parentGuid: folder_a.guid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+ checkBookmarkObject(sep_3);
+
+ function ensurePosition(info, parentGuid, index) {
+ checkBookmarkObject(info);
+ Assert.equal(info.parentGuid, parentGuid);
+ Assert.equal(info.index, index);
+ }
+
+ // folder_a: [sep_2, sep_3, sep_1], folder_b: []
+ sep_1.index = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ // Note sep_1 includes parentGuid even though we're not moving the item to
+ // another folder
+ sep_1 = yield PlacesUtils.bookmarks.update(sep_1);
+ ensurePosition(sep_1, folder_a.guid, 2);
+ sep_2 = yield PlacesUtils.bookmarks.fetch(sep_2.guid);
+ ensurePosition(sep_2, folder_a.guid, 0);
+ sep_3 = yield PlacesUtils.bookmarks.fetch(sep_3.guid);
+ ensurePosition(sep_3, folder_a.guid, 1);
+ sep_1 = yield PlacesUtils.bookmarks.fetch(sep_1.guid);
+ ensurePosition(sep_1, folder_a.guid, 2);
+
+ // folder_a: [sep_2, sep_1], folder_b: [sep_3]
+ sep_3.index = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ sep_3.parentGuid = folder_b.guid;
+ sep_3 = yield PlacesUtils.bookmarks.update(sep_3);
+ ensurePosition(sep_3, folder_b.guid, 0);
+ sep_2 = yield PlacesUtils.bookmarks.fetch(sep_2.guid);
+ ensurePosition(sep_2, folder_a.guid, 0);
+ sep_1 = yield PlacesUtils.bookmarks.fetch(sep_1.guid);
+ ensurePosition(sep_1, folder_a.guid, 1);
+ sep_3 = yield PlacesUtils.bookmarks.fetch(sep_3.guid);
+ ensurePosition(sep_3, folder_b.guid, 0);
+
+ // folder_a: [sep_1], folder_b: [sep_3, sep_2]
+ sep_2.index = Number.MAX_SAFE_INTEGER;
+ sep_2.parentGuid = folder_b.guid;
+ sep_2 = yield PlacesUtils.bookmarks.update(sep_2);
+ ensurePosition(sep_2, folder_b.guid, 1);
+ sep_1 = yield PlacesUtils.bookmarks.fetch(sep_1.guid);
+ ensurePosition(sep_1, folder_a.guid, 0);
+ sep_3 = yield PlacesUtils.bookmarks.fetch(sep_3.guid);
+ ensurePosition(sep_3, folder_b.guid, 0);
+ sep_2 = yield PlacesUtils.bookmarks.fetch(sep_2.guid);
+ ensurePosition(sep_2, folder_b.guid, 1);
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js b/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js
new file mode 100644
index 000000000..f5cf34641
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_bookmarkstree_cache.js
@@ -0,0 +1,18 @@
+
+// Bug 1192692 - promiseBookmarksTree caches items without adding observers to
+// invalidate the cache.
+add_task(function* boookmarks_tree_cache() {
+ // Note that for this test to be effective, it needs to use the "old" sync
+ // bookmarks methods - using, eg, PlacesUtils.bookmarks.insert() doesn't
+ // demonstrate the problem as it indirectly arranges for the observers to
+ // be added.
+ let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("http://example.com"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "A title");
+ yield PlacesUtils.promiseBookmarksTree();
+
+ PlacesUtils.bookmarks.removeItem(id);
+
+ yield Assert.rejects(PlacesUtils.promiseItemGuid(id));
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_changeBookmarkURI.js b/toolkit/components/places/tests/bookmarks/test_changeBookmarkURI.js
new file mode 100644
index 000000000..55ffecf2f
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_changeBookmarkURI.js
@@ -0,0 +1,68 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get bookmark service
+var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+/**
+ * Ensures that the Places APIs recognize that aBookmarkedUri is bookmarked
+ * via aBookmarkId and that aUnbookmarkedUri is not bookmarked at all.
+ *
+ * @param aBookmarkId
+ * an item ID whose corresponding URI is aBookmarkedUri
+ * @param aBookmarkedUri
+ * a bookmarked URI that has a corresponding item ID aBookmarkId
+ * @param aUnbookmarkedUri
+ * a URI that is not currently bookmarked at all
+ */
+function checkUris(aBookmarkId, aBookmarkedUri, aUnbookmarkedUri)
+{
+ // Ensure that aBookmarkedUri equals some URI that is bookmarked
+ var uri = bmsvc.getBookmarkedURIFor(aBookmarkedUri);
+ do_check_neq(uri, null);
+ do_check_true(uri.equals(aBookmarkedUri));
+
+ // Ensure that aBookmarkedUri is considered bookmarked
+ do_check_true(bmsvc.isBookmarked(aBookmarkedUri));
+
+ // Ensure that the URI corresponding to aBookmarkId equals aBookmarkedUri
+ do_check_true(bmsvc.getBookmarkURI(aBookmarkId).equals(aBookmarkedUri));
+
+ // Ensure that aUnbookmarkedUri does not equal any URI that is bookmarked
+ uri = bmsvc.getBookmarkedURIFor(aUnbookmarkedUri);
+ do_check_eq(uri, null);
+
+ // Ensure that aUnbookmarkedUri is not considered bookmarked
+ do_check_false(bmsvc.isBookmarked(aUnbookmarkedUri));
+}
+
+// main
+function run_test() {
+ // Create a folder
+ var folderId = bmsvc.createFolder(bmsvc.toolbarFolder,
+ "test",
+ bmsvc.DEFAULT_INDEX);
+
+ // Create 2 URIs
+ var uri1 = uri("http://www.dogs.com");
+ var uri2 = uri("http://www.cats.com");
+
+ // Bookmark the first one
+ var bookmarkId = bmsvc.insertBookmark(folderId,
+ uri1,
+ bmsvc.DEFAULT_INDEX,
+ "Dogs");
+
+ // uri1 is bookmarked via bookmarkId, uri2 is not
+ checkUris(bookmarkId, uri1, uri2);
+
+ // Change the URI of the bookmark to uri2
+ bmsvc.changeBookmarkURI(bookmarkId, uri2);
+
+ // uri2 is now bookmarked via bookmarkId, uri1 is not
+ checkUris(bookmarkId, uri2, uri1);
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_getBookmarkedURIFor.js b/toolkit/components/places/tests/bookmarks/test_getBookmarkedURIFor.js
new file mode 100644
index 000000000..c43e8e283
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_getBookmarkedURIFor.js
@@ -0,0 +1,84 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Test bookmarksService.getBookmarkedURIFor(aURI);
+ */
+
+var hs = PlacesUtils.history;
+var bs = PlacesUtils.bookmarks;
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_getBookmarkedURIFor() {
+ let now = Date.now() * 1000;
+ const sourceURI = uri("http://test.mozilla.org/");
+ // Add a visit and a bookmark.
+ yield PlacesTestUtils.addVisits({ uri: sourceURI, visitDate: now });
+ do_check_eq(bs.getBookmarkedURIFor(sourceURI), null);
+
+ let sourceItemId = bs.insertBookmark(bs.unfiledBookmarksFolder,
+ sourceURI,
+ bs.DEFAULT_INDEX,
+ "bookmark");
+ do_check_true(bs.getBookmarkedURIFor(sourceURI).equals(sourceURI));
+
+ // Add a redirected visit.
+ const permaURI = uri("http://perma.mozilla.org/");
+ yield PlacesTestUtils.addVisits({
+ uri: permaURI,
+ transition: TRANSITION_REDIRECT_PERMANENT,
+ visitDate: now++,
+ referrer: sourceURI
+ });
+ do_check_true(bs.getBookmarkedURIFor(sourceURI).equals(sourceURI));
+ do_check_true(bs.getBookmarkedURIFor(permaURI).equals(sourceURI));
+ // Add a bookmark to the destination.
+ let permaItemId = bs.insertBookmark(bs.unfiledBookmarksFolder,
+ permaURI,
+ bs.DEFAULT_INDEX,
+ "bookmark");
+ do_check_true(bs.getBookmarkedURIFor(sourceURI).equals(sourceURI));
+ do_check_true(bs.getBookmarkedURIFor(permaURI).equals(permaURI));
+ // Now remove the bookmark on the destination.
+ bs.removeItem(permaItemId);
+ // We should see the source as bookmark.
+ do_check_true(bs.getBookmarkedURIFor(permaURI).equals(sourceURI));
+
+ // Add another redirected visit.
+ const tempURI = uri("http://perma.mozilla.org/");
+ yield PlacesTestUtils.addVisits({
+ uri: tempURI,
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ visitDate: now++,
+ referrer: permaURI
+ });
+
+ do_check_true(bs.getBookmarkedURIFor(sourceURI).equals(sourceURI));
+ do_check_true(bs.getBookmarkedURIFor(tempURI).equals(sourceURI));
+ // Add a bookmark to the destination.
+ let tempItemId = bs.insertBookmark(bs.unfiledBookmarksFolder,
+ tempURI,
+ bs.DEFAULT_INDEX,
+ "bookmark");
+ do_check_true(bs.getBookmarkedURIFor(sourceURI).equals(sourceURI));
+ do_check_true(bs.getBookmarkedURIFor(tempURI).equals(tempURI));
+
+ // Now remove the bookmark on the destination.
+ bs.removeItem(tempItemId);
+ // We should see the source as bookmark.
+ do_check_true(bs.getBookmarkedURIFor(tempURI).equals(sourceURI));
+ // Remove the source bookmark as well.
+ bs.removeItem(sourceItemId);
+ do_check_eq(bs.getBookmarkedURIFor(tempURI), null);
+
+ // Try to pass in a never seen URI, should return null and a new entry should
+ // not be added to the database.
+ do_check_eq(bs.getBookmarkedURIFor(uri("http://does.not.exist/")), null);
+ do_check_false(page_in_database("http://does.not.exist/"));
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_keywords.js b/toolkit/components/places/tests/bookmarks/test_keywords.js
new file mode 100644
index 000000000..149d6d0b0
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_keywords.js
@@ -0,0 +1,310 @@
+const URI1 = NetUtil.newURI("http://test1.mozilla.org/");
+const URI2 = NetUtil.newURI("http://test2.mozilla.org/");
+const URI3 = NetUtil.newURI("http://test3.mozilla.org/");
+
+function check_keyword(aURI, aKeyword) {
+ if (aKeyword)
+ aKeyword = aKeyword.toLowerCase();
+
+ for (let bm of PlacesUtils.getBookmarksForURI(aURI)) {
+ let keyword = PlacesUtils.bookmarks.getKeywordForBookmark(bm);
+ if (keyword && !aKeyword) {
+ throw (`${aURI.spec} should not have a keyword`);
+ } else if (aKeyword && keyword == aKeyword) {
+ Assert.equal(keyword, aKeyword);
+ }
+ }
+
+ if (aKeyword) {
+ let uri = PlacesUtils.bookmarks.getURIForKeyword(aKeyword);
+ Assert.equal(uri.spec, aURI.spec);
+ // Check case insensitivity.
+ uri = PlacesUtils.bookmarks.getURIForKeyword(aKeyword.toUpperCase());
+ Assert.equal(uri.spec, aURI.spec);
+ }
+}
+
+function* check_orphans() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(
+ `SELECT id FROM moz_keywords k
+ WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id)
+ `);
+ Assert.equal(rows.length, 0);
+}
+
+function expectNotifications() {
+ let notifications = [];
+ let observer = new Proxy(NavBookmarkObserver, {
+ get(target, name) {
+ if (name == "check") {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ return expectedNotifications =>
+ Assert.deepEqual(notifications, expectedNotifications);
+ }
+
+ if (name.startsWith("onItemChanged")) {
+ return function(id, prop, isAnno, val, lastMod, itemType, parentId, guid, parentGuid, oldVal) {
+ if (prop != "keyword")
+ return;
+ let args = Array.from(arguments, arg => {
+ if (arg && arg instanceof Ci.nsIURI)
+ return new URL(arg.spec);
+ if (arg && typeof(arg) == "number" && arg >= Date.now() * 1000)
+ return new Date(parseInt(arg/1000));
+ return arg;
+ });
+ notifications.push({ name: name, arguments: args });
+ }
+ }
+
+ return target[name];
+ }
+ });
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ return observer;
+}
+
+add_task(function test_invalid_input() {
+ Assert.throws(() => PlacesUtils.bookmarks.getURIForKeyword(null),
+ /NS_ERROR_ILLEGAL_VALUE/);
+ Assert.throws(() => PlacesUtils.bookmarks.getURIForKeyword(""),
+ /NS_ERROR_ILLEGAL_VALUE/);
+ Assert.throws(() => PlacesUtils.bookmarks.getKeywordForBookmark(null),
+ /NS_ERROR_ILLEGAL_VALUE/);
+ Assert.throws(() => PlacesUtils.bookmarks.getKeywordForBookmark(0),
+ /NS_ERROR_ILLEGAL_VALUE/);
+ Assert.throws(() => PlacesUtils.bookmarks.setKeywordForBookmark(null, "k"),
+ /NS_ERROR_ILLEGAL_VALUE/);
+ Assert.throws(() => PlacesUtils.bookmarks.setKeywordForBookmark(0, "k"),
+ /NS_ERROR_ILLEGAL_VALUE/);
+});
+
+add_task(function* test_addBookmarkAndKeyword() {
+ check_keyword(URI1, null);
+ let fc = yield foreign_count(URI1);
+ let observer = expectNotifications();
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI1,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test");
+
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
+ let bookmark = yield PlacesUtils.bookmarks.fetch({ url: URI1 });
+ observer.check([ { name: "onItemChanged",
+ arguments: [ itemId, "keyword", false, "keyword",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ check_keyword(URI1, "keyword");
+ Assert.equal((yield foreign_count(URI1)), fc + 2); // + 1 bookmark + 1 keyword
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield check_orphans();
+});
+
+add_task(function* test_addBookmarkToURIHavingKeyword() {
+ // The uri has already a keyword.
+ check_keyword(URI1, "keyword");
+ let fc = yield foreign_count(URI1);
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI1,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test");
+ check_keyword(URI1, "keyword");
+ Assert.equal((yield foreign_count(URI1)), fc + 1); // + 1 bookmark
+
+ PlacesUtils.bookmarks.removeItem(itemId);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+add_task(function* test_sameKeywordDifferentURI() {
+ let fc1 = yield foreign_count(URI1);
+ let fc2 = yield foreign_count(URI2);
+ let observer = expectNotifications();
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI2,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test2");
+ check_keyword(URI1, "keyword");
+ check_keyword(URI2, null);
+
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "kEyWoRd");
+
+ let bookmark1 = yield PlacesUtils.bookmarks.fetch({ url: URI1 });
+ let bookmark2 = yield PlacesUtils.bookmarks.fetch({ url: URI2 });
+ observer.check([ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+ "keyword", false, "",
+ bookmark1.lastModified, bookmark1.type,
+ (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+ bookmark1.guid, bookmark1.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ itemId, "keyword", false, "keyword",
+ bookmark2.lastModified, bookmark2.type,
+ (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+ bookmark2.guid, bookmark2.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // The keyword should have been "moved" to the new URI.
+ check_keyword(URI1, null);
+ Assert.equal((yield foreign_count(URI1)), fc1 - 1); // - 1 keyword
+ check_keyword(URI2, "keyword");
+ Assert.equal((yield foreign_count(URI2)), fc2 + 2); // + 1 bookmark + 1 keyword
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+add_task(function* test_sameURIDifferentKeyword() {
+ let fc = yield foreign_count(URI2);
+ let observer = expectNotifications();
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI2,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test2");
+ check_keyword(URI2, "keyword");
+
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword2");
+
+ let bookmarks = [];
+ yield PlacesUtils.bookmarks.fetch({ url: URI2 }, bookmark => bookmarks.push(bookmark));
+ observer.check([ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmarks[0].guid)),
+ "keyword", false, "keyword2",
+ bookmarks[0].lastModified, bookmarks[0].type,
+ (yield PlacesUtils.promiseItemId(bookmarks[0].parentGuid)),
+ bookmarks[0].guid, bookmarks[0].parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmarks[1].guid)),
+ "keyword", false, "keyword2",
+ bookmarks[1].lastModified, bookmarks[1].type,
+ (yield PlacesUtils.promiseItemId(bookmarks[1].parentGuid)),
+ bookmarks[1].guid, bookmarks[1].parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ check_keyword(URI2, "keyword2");
+ Assert.equal((yield foreign_count(URI2)), fc + 2); // + 1 bookmark + 1 keyword
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+add_task(function* test_removeBookmarkWithKeyword() {
+ let fc = yield foreign_count(URI2);
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI2,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test");
+
+ // The keyword should not be removed, since there are other bookmarks yet.
+ PlacesUtils.bookmarks.removeItem(itemId);
+
+ check_keyword(URI2, "keyword2");
+ Assert.equal((yield foreign_count(URI2)), fc); // + 1 bookmark - 1 bookmark
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+add_task(function* test_unsetKeyword() {
+ let fc = yield foreign_count(URI2);
+ let observer = expectNotifications();
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI2,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test");
+
+ // The keyword should be removed from any bookmark.
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, null);
+
+ let bookmarks = [];
+ yield PlacesUtils.bookmarks.fetch({ url: URI2 }, bookmark => bookmarks.push(bookmark));
+ do_print(bookmarks.length);
+ observer.check([ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmarks[0].guid)),
+ "keyword", false, "",
+ bookmarks[0].lastModified, bookmarks[0].type,
+ (yield PlacesUtils.promiseItemId(bookmarks[0].parentGuid)),
+ bookmarks[0].guid, bookmarks[0].parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmarks[1].guid)),
+ "keyword", false, "",
+ bookmarks[1].lastModified, bookmarks[1].type,
+ (yield PlacesUtils.promiseItemId(bookmarks[1].parentGuid)),
+ bookmarks[1].guid, bookmarks[1].parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmarks[2].guid)),
+ "keyword", false, "",
+ bookmarks[2].lastModified, bookmarks[2].type,
+ (yield PlacesUtils.promiseItemId(bookmarks[2].parentGuid)),
+ bookmarks[2].guid, bookmarks[2].parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+
+ check_keyword(URI1, null);
+ check_keyword(URI2, null);
+ Assert.equal((yield foreign_count(URI2)), fc - 1); // + 1 bookmark - 2 keyword
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+add_task(function* test_addRemoveBookmark() {
+ let observer = expectNotifications();
+
+ let itemId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ URI3,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test3");
+
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
+ let bookmark = yield PlacesUtils.bookmarks.fetch({ url: URI3 });
+ let parentId = yield PlacesUtils.promiseItemId(bookmark.parentGuid);
+ PlacesUtils.bookmarks.removeItem(itemId);
+
+ observer.check([ { name: "onItemChanged",
+ arguments: [ itemId,
+ "keyword", false, "keyword",
+ bookmark.lastModified, bookmark.type,
+ parentId,
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] }
+ ]);
+
+ check_keyword(URI3, null);
+ // Don't check the foreign count since the process is async.
+ // The new test_keywords.js in unit is checking this though.
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ check_orphans();
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_nsINavBookmarkObserver.js b/toolkit/components/places/tests/bookmarks/test_nsINavBookmarkObserver.js
new file mode 100644
index 000000000..06f45b18e
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_nsINavBookmarkObserver.js
@@ -0,0 +1,640 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that each nsINavBookmarksObserver method gets the correct input.
+Cu.import("resource://gre/modules/PromiseUtils.jsm");
+
+const GUID_RE = /^[a-zA-Z0-9\-_]{12}$/;
+
+var gBookmarksObserver = {
+ expected: [],
+ setup(expected) {
+ this.expected = expected;
+ this.deferred = PromiseUtils.defer();
+ return this.deferred.promise;
+ },
+ validate: function (aMethodName, aArguments) {
+ do_check_eq(this.expected[0].name, aMethodName);
+
+ let args = this.expected.shift().args;
+ do_check_eq(aArguments.length, args.length);
+ for (let i = 0; i < aArguments.length; i++) {
+ do_check_true(args[i].check(aArguments[i]), aMethodName + "(args[" + i + "]: " + args[i].name + ")");
+ }
+
+ if (this.expected.length === 0) {
+ this.deferred.resolve();
+ }
+ },
+
+ // nsINavBookmarkObserver
+ onBeginUpdateBatch() {
+ return this.validate("onBeginUpdateBatch", arguments);
+ },
+ onEndUpdateBatch() {
+ return this.validate("onEndUpdateBatch", arguments);
+ },
+ onItemAdded() {
+ return this.validate("onItemAdded", arguments);
+ },
+ onItemRemoved() {
+ return this.validate("onItemRemoved", arguments);
+ },
+ onItemChanged() {
+ return this.validate("onItemChanged", arguments);
+ },
+ onItemVisited() {
+ return this.validate("onItemVisited", arguments);
+ },
+ onItemMoved() {
+ return this.validate("onItemMoved", arguments);
+ },
+
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]),
+};
+
+var gBookmarkSkipObserver = {
+ skipTags: true,
+ skipDescendantsOnItemRemoval: true,
+
+ expected: null,
+ setup(expected) {
+ this.expected = expected;
+ this.deferred = PromiseUtils.defer();
+ return this.deferred.promise;
+ },
+ validate: function (aMethodName) {
+ do_check_eq(this.expected.shift(), aMethodName);
+ if (this.expected.length === 0) {
+ this.deferred.resolve();
+ }
+ },
+
+ // nsINavBookmarkObserver
+ onBeginUpdateBatch() {
+ return this.validate("onBeginUpdateBatch", arguments);
+ },
+ onEndUpdateBatch() {
+ return this.validate("onEndUpdateBatch", arguments);
+ },
+ onItemAdded() {
+ return this.validate("onItemAdded", arguments);
+ },
+ onItemRemoved() {
+ return this.validate("onItemRemoved", arguments);
+ },
+ onItemChanged() {
+ return this.validate("onItemChanged", arguments);
+ },
+ onItemVisited() {
+ return this.validate("onItemVisited", arguments);
+ },
+ onItemMoved() {
+ return this.validate("onItemMoved", arguments);
+ },
+
+ // nsISupports
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]),
+};
+
+
+add_task(function setup() {
+ PlacesUtils.bookmarks.addObserver(gBookmarksObserver, false);
+ PlacesUtils.bookmarks.addObserver(gBookmarkSkipObserver, false);
+});
+
+add_task(function* batch() {
+ let promise = Promise.all([
+ gBookmarksObserver.setup([
+ { name: "onBeginUpdateBatch",
+ args: [] },
+ { name: "onEndUpdateBatch",
+ args: [] },
+ ]),
+ gBookmarkSkipObserver.setup([
+ "onBeginUpdateBatch", "onEndUpdateBatch"
+ ])]);
+ PlacesUtils.bookmarks.runInBatchMode({
+ runBatched: function () {
+ // Nothing.
+ }
+ }, null);
+ yield promise;
+});
+
+add_task(function* onItemAdded_bookmark() {
+ const TITLE = "Bookmark 1";
+ let uri = NetUtil.newURI("http://1.mozilla.org/");
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemAdded"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "title", check: v => v === TITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ TITLE);
+ yield promise;
+});
+
+add_task(function* onItemAdded_separator() {
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemAdded"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "index", check: v => v === 1 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_SEPARATOR },
+ { name: "uri", check: v => v === null },
+ { name: "title", check: v => v === null },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.insertSeparator(PlacesUtils.unfiledBookmarksFolderId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ yield promise;
+});
+
+add_task(function* onItemAdded_folder() {
+ const TITLE = "Folder 1";
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemAdded"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "index", check: v => v === 2 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "title", check: v => v === TITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId,
+ TITLE,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ yield promise;
+});
+
+add_task(function* onItemChanged_title_bookmark() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ const TITLE = "New title";
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemChanged"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemChanged", // This is an unfortunate effect of bug 653910.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "title" },
+ { name: "isAnno", check: v => v === false },
+ { name: "newValue", check: v => v === TITLE },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.setItemTitle(id, TITLE);
+ yield promise;
+});
+
+add_task(function* onItemChanged_tags_bookmark() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let uri = PlacesUtils.bookmarks.getBookmarkURI(id);
+ const TAG = "tag";
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemChanged", "onItemChanged"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemAdded", // This is the tag folder.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.tagsFolderId },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "title", check: v => v === TAG },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemAdded", // This is the tag.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "title", check: v => v === null },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemChanged",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "tags" },
+ { name: "isAnno", check: v => v === false },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved", // This is the tag.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved", // This is the tag folder.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.tagsFolderId },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemChanged",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "tags" },
+ { name: "isAnno", check: v => v === false },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.tagging.tagURI(uri, [TAG]);
+ PlacesUtils.tagging.untagURI(uri, [TAG]);
+ yield promise;
+});
+
+add_task(function* onItemMoved_bookmark() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemMoved", "onItemMoved"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemMoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "oldParentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "oldIndex", check: v => v === 0 },
+ { name: "newParentId", check: v => v === PlacesUtils.toolbarFolderId },
+ { name: "newIndex", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldParentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "newParentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemMoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "oldParentId", check: v => v === PlacesUtils.toolbarFolderId },
+ { name: "oldIndex", check: v => v === 0 },
+ { name: "newParentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "newIndex", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldParentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "newParentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.moveItem(id, PlacesUtils.toolbarFolderId, 0);
+ PlacesUtils.bookmarks.moveItem(id, PlacesUtils.unfiledBookmarksFolderId, 0);
+ yield promise;
+});
+
+add_task(function* onItemMoved_bookmark() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let uri = PlacesUtils.bookmarks.getBookmarkURI(id);
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemVisited"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemVisited",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "visitId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "time", check: v => typeof(v) == "number" && v > 0 },
+ { name: "transitionType", check: v => v === PlacesUtils.history.TRANSITION_TYPED },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ ] },
+ ])]);
+ PlacesTestUtils.addVisits({ uri: uri, transition: TRANSITION_TYPED });
+ yield promise;
+});
+
+add_task(function* onItemRemoved_bookmark() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let uri = PlacesUtils.bookmarks.getBookmarkURI(id);
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemChanged", "onItemRemoved"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemChanged", // This is an unfortunate effect of bug 653910.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "" },
+ { name: "isAnno", check: v => v === true },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.removeItem(id);
+ yield promise;
+});
+
+add_task(function* onItemRemoved_separator() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemChanged", "onItemRemoved"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemChanged", // This is an unfortunate effect of bug 653910.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "" },
+ { name: "isAnno", check: v => v === true },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_SEPARATOR },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_SEPARATOR },
+ { name: "uri", check: v => v === null },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.removeItem(id);
+ yield promise;
+});
+
+add_task(function* onItemRemoved_folder() {
+ let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemChanged", "onItemRemoved"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemChanged", // This is an unfortunate effect of bug 653910.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "" },
+ { name: "isAnno", check: v => v === true },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ PlacesUtils.bookmarks.removeItem(id);
+ yield promise;
+});
+
+add_task(function* onItemRemoved_folder_recursive() {
+ const TITLE = "Folder 3";
+ const BMTITLE = "Bookmark 1";
+ let uri = NetUtil.newURI("http://1.mozilla.org/");
+ let promise = Promise.all([
+ gBookmarkSkipObserver.setup([
+ "onItemAdded", "onItemAdded", "onItemAdded", "onItemAdded",
+ "onItemChanged", "onItemRemoved"
+ ]),
+ gBookmarksObserver.setup([
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.unfiledBookmarksFolderId },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "title", check: v => v === TITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0) },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "title", check: v => v === BMTITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0) },
+ { name: "index", check: v => v === 1 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "title", check: v => v === TITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemAdded",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => v === PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0), 1) },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "title", check: v => v === BMTITLE },
+ { name: "dateAdded", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemChanged", // This is an unfortunate effect of bug 653910.
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "property", check: v => v === "" },
+ { name: "isAnno", check: v => v === true },
+ { name: "newValue", check: v => v === "" },
+ { name: "lastModified", check: v => typeof(v) == "number" && v > 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "oldValue", check: v => typeof(v) == "string" },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 1 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_BOOKMARK },
+ { name: "uri", check: v => v instanceof Ci.nsIURI && v.equals(uri) },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ { name: "onItemRemoved",
+ args: [
+ { name: "itemId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "parentId", check: v => typeof(v) == "number" && v > 0 },
+ { name: "index", check: v => v === 0 },
+ { name: "itemType", check: v => v === PlacesUtils.bookmarks.TYPE_FOLDER },
+ { name: "uri", check: v => v === null },
+ { name: "guid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "parentGuid", check: v => typeof(v) == "string" && GUID_RE.test(v) },
+ { name: "source", check: v => Object.values(PlacesUtils.bookmarks.SOURCES).includes(v) },
+ ] },
+ ])]);
+ let folder = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId,
+ TITLE,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.insertBookmark(folder,
+ uri, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ BMTITLE);
+ let folder2 = PlacesUtils.bookmarks.createFolder(folder, TITLE,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.insertBookmark(folder2,
+ uri, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ BMTITLE);
+
+ PlacesUtils.bookmarks.removeItem(folder);
+ yield promise;
+});
+
+add_task(function cleanup()
+{
+ PlacesUtils.bookmarks.removeObserver(gBookmarksObserver);
+ PlacesUtils.bookmarks.removeObserver(gBookmarkSkipObserver);
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_protectRoots.js b/toolkit/components/places/tests/bookmarks/test_protectRoots.js
new file mode 100644
index 000000000..0a59f1653
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_protectRoots.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test()
+{
+ const ROOTS = [
+ PlacesUtils.bookmarksMenuFolderId,
+ PlacesUtils.toolbarFolderId,
+ PlacesUtils.unfiledBookmarksFolderId,
+ PlacesUtils.tagsFolderId,
+ PlacesUtils.placesRootId,
+ PlacesUtils.mobileFolderId,
+ ];
+
+ for (let root of ROOTS) {
+ do_check_true(PlacesUtils.isRootItem(root));
+
+ try {
+ PlacesUtils.bookmarks.removeItem(root);
+ do_throw("Trying to remove a root should throw");
+ } catch (ex) {}
+
+ try {
+ PlacesUtils.bookmarks.moveItem(root, PlacesUtils.placesRootId, 0);
+ do_throw("Trying to move a root should throw");
+ } catch (ex) {}
+
+ try {
+ PlacesUtils.bookmarks.removeFolderChildren(root);
+ if (root == PlacesUtils.placesRootId)
+ do_throw("Trying to remove children of the main root should throw");
+ } catch (ex) {
+ if (root != PlacesUtils.placesRootId)
+ do_throw("Trying to remove children of other roots should not throw");
+ }
+ }
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js b/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js
new file mode 100644
index 000000000..537974b38
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_removeFolderTransaction_reinsert.js
@@ -0,0 +1,70 @@
+/**
+ * This test ensures that reinserting a folder within a transaction gives it
+ * a different GUID, and passes the GUID to the observers.
+ */
+
+add_task(function* test_removeFolderTransaction_reinsert() {
+ let folder = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "Test folder",
+ });
+ let folderId = yield PlacesUtils.promiseItemId(folder.guid);
+ let fx = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ title: "Get Firefox!",
+ url: "http://getfirefox.com",
+ });
+ let fxId = yield PlacesUtils.promiseItemId(fx.guid);
+ let tb = yield PlacesUtils.bookmarks.insert({
+ parentGuid: folder.guid,
+ title: "Get Thunderbird!",
+ url: "http://getthunderbird.com",
+ });
+ let tbId = yield PlacesUtils.promiseItemId(tb.guid);
+
+ let notifications = [];
+ function checkNotifications(expected, message) {
+ deepEqual(notifications, expected, message);
+ notifications.length = 0;
+ }
+
+ let observer = {
+ onItemAdded(itemId, parentId, index, type, uri, title, dateAdded, guid,
+ parentGuid) {
+ notifications.push(["onItemAdded", itemId, parentId, guid, parentGuid]);
+ },
+ onItemRemoved(itemId, parentId, index, type, uri, guid, parentGuid) {
+ notifications.push(["onItemRemoved", itemId, parentId, guid, parentGuid]);
+ },
+ };
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ PlacesUtils.registerShutdownFunction(function() {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ });
+
+ let transaction = PlacesUtils.bookmarks.getRemoveFolderTransaction(folderId);
+ deepEqual(notifications, [], "We haven't executed the transaction yet");
+
+ transaction.doTransaction();
+ checkNotifications([
+ ["onItemRemoved", tbId, folderId, tb.guid, folder.guid],
+ ["onItemRemoved", fxId, folderId, fx.guid, folder.guid],
+ ["onItemRemoved", folderId, PlacesUtils.bookmarksMenuFolderId, folder.guid,
+ PlacesUtils.bookmarks.menuGuid],
+ ], "Executing transaction should remove folder and its descendants");
+
+ transaction.undoTransaction();
+ // At this point, the restored folder has the same ID, but a different GUID.
+ let newFolderGuid = yield PlacesUtils.promiseItemGuid(folderId);
+ checkNotifications([
+ ["onItemAdded", folderId, PlacesUtils.bookmarksMenuFolderId, newFolderGuid,
+ PlacesUtils.bookmarks.menuGuid],
+ ], "Undo should reinsert folder with same ID and different GUID");
+
+ transaction.redoTransaction();
+ checkNotifications([
+ ["onItemRemoved", folderId, PlacesUtils.bookmarksMenuFolderId,
+ newFolderGuid, PlacesUtils.bookmarks.menuGuid],
+ ], "Redo should forward new GUID to observer");
+});
diff --git a/toolkit/components/places/tests/bookmarks/test_removeItem.js b/toolkit/components/places/tests/bookmarks/test_removeItem.js
new file mode 100644
index 000000000..ec846b28e
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_removeItem.js
@@ -0,0 +1,30 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+
+const DEFAULT_INDEX = PlacesUtils.bookmarks.DEFAULT_INDEX;
+
+function run_test() {
+ // folder to hold this test
+ var folderId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.toolbarFolderId,
+ "", DEFAULT_INDEX);
+
+ // add a bookmark to the new folder
+ var bookmarkURI = uri("http://iasdjkf");
+ do_check_false(PlacesUtils.bookmarks.isBookmarked(bookmarkURI));
+ var bookmarkId = PlacesUtils.bookmarks.insertBookmark(folderId, bookmarkURI,
+ DEFAULT_INDEX, "");
+ do_check_eq(PlacesUtils.bookmarks.getItemTitle(bookmarkId), "");
+
+ // remove the folder using removeItem
+ PlacesUtils.bookmarks.removeItem(folderId);
+ do_check_eq(PlacesUtils.bookmarks.getBookmarkIdsForURI(bookmarkURI).length, 0);
+ do_check_false(PlacesUtils.bookmarks.isBookmarked(bookmarkURI));
+ do_check_eq(PlacesUtils.bookmarks.getItemIndex(bookmarkId), -1);
+}
diff --git a/toolkit/components/places/tests/bookmarks/test_savedsearches.js b/toolkit/components/places/tests/bookmarks/test_savedsearches.js
new file mode 100644
index 000000000..eee2c4489
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/test_savedsearches.js
@@ -0,0 +1,209 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// get bookmarks root id
+var root = PlacesUtils.bookmarksMenuFolderId;
+
+// a search term that matches a default bookmark
+const searchTerm = "about";
+
+var testRoot;
+
+// main
+function run_test() {
+ // create a folder to hold all the tests
+ // this makes the tests more tolerant of changes to the default bookmarks set
+ // also, name it using the search term, for testing that containers that match don't show up in query results
+ testRoot = PlacesUtils.bookmarks.createFolder(
+ root, searchTerm, PlacesUtils.bookmarks.DEFAULT_INDEX);
+
+ run_next_test();
+}
+
+add_test(function test_savedsearches_bookmarks() {
+ // add a bookmark that matches the search term
+ var bookmarkId = PlacesUtils.bookmarks.insertBookmark(
+ root, uri("http://foo.com"), PlacesUtils.bookmarks.DEFAULT_INDEX,
+ searchTerm);
+
+ // create a saved-search that matches a default bookmark
+ var searchId = PlacesUtils.bookmarks.insertBookmark(
+ testRoot, uri("place:terms=" + searchTerm + "&excludeQueries=1&expandQueries=1&queryType=1"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, searchTerm);
+
+ // query for the test root, expandQueries=0
+ // the query should show up as a regular bookmark
+ try {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.expandQueries = 0;
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([testRoot], 1);
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_eq(cc, 1);
+ for (let i = 0; i < cc; i++) {
+ let node = rootNode.getChild(i);
+ // test that queries have valid itemId
+ do_check_true(node.itemId > 0);
+ // test that the container is closed
+ node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(node.containerOpen, false);
+ }
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("expandQueries=0 query error: " + ex);
+ }
+
+ // bookmark saved search
+ // query for the test root, expandQueries=1
+ // the query should show up as a query container, with 1 child
+ try {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.expandQueries = 1;
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([testRoot], 1);
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let rootNode = result.root;
+ rootNode.containerOpen = true;
+ let cc = rootNode.childCount;
+ do_check_eq(cc, 1);
+ for (let i = 0; i < cc; i++) {
+ let node = rootNode.getChild(i);
+ // test that query node type is container when expandQueries=1
+ do_check_eq(node.type, node.RESULT_TYPE_QUERY);
+ // test that queries (as containers) have valid itemId
+ do_check_true(node.itemId > 0);
+ node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ node.containerOpen = true;
+
+ // test that queries have children when excludeItems=1
+ // test that query nodes don't show containers (shouldn't have our folder that matches)
+ // test that queries don't show themselves in query results (shouldn't have our saved search)
+ do_check_eq(node.childCount, 1);
+
+ // test that bookmark shows in query results
+ var item = node.getChild(0);
+ do_check_eq(item.itemId, bookmarkId);
+
+ // XXX - FAILING - test live-update of query results - add a bookmark that matches the query
+ // var tmpBmId = PlacesUtils.bookmarks.insertBookmark(
+ // root, uri("http://" + searchTerm + ".com"),
+ // PlacesUtils.bookmarks.DEFAULT_INDEX, searchTerm + "blah");
+ // do_check_eq(query.childCount, 2);
+
+ // XXX - test live-update of query results - delete a bookmark that matches the query
+ // PlacesUtils.bookmarks.removeItem(tmpBMId);
+ // do_check_eq(query.childCount, 1);
+
+ // test live-update of query results - add a folder that matches the query
+ PlacesUtils.bookmarks.createFolder(
+ root, searchTerm + "zaa", PlacesUtils.bookmarks.DEFAULT_INDEX);
+ do_check_eq(node.childCount, 1);
+ // test live-update of query results - add a query that matches the query
+ PlacesUtils.bookmarks.insertBookmark(
+ root, uri("place:terms=foo&excludeQueries=1&expandQueries=1&queryType=1"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, searchTerm + "blah");
+ do_check_eq(node.childCount, 1);
+ }
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("expandQueries=1 bookmarks query: " + ex);
+ }
+
+ // delete the bookmark search
+ PlacesUtils.bookmarks.removeItem(searchId);
+
+ run_next_test();
+});
+
+add_task(function* test_savedsearches_history() {
+ // add a visit that matches the search term
+ var testURI = uri("http://" + searchTerm + ".com");
+ yield PlacesTestUtils.addVisits({ uri: testURI, title: searchTerm });
+
+ // create a saved-search that matches the visit we added
+ var searchId = PlacesUtils.bookmarks.insertBookmark(testRoot,
+ uri("place:terms=" + searchTerm + "&excludeQueries=1&expandQueries=1&queryType=0"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, searchTerm);
+
+ // query for the test root, expandQueries=1
+ // the query should show up as a query container, with 1 child
+ try {
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.expandQueries = 1;
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([testRoot], 1);
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var rootNode = result.root;
+ rootNode.containerOpen = true;
+ var cc = rootNode.childCount;
+ do_check_eq(cc, 1);
+ for (var i = 0; i < cc; i++) {
+ var node = rootNode.getChild(i);
+ // test that query node type is container when expandQueries=1
+ do_check_eq(node.type, node.RESULT_TYPE_QUERY);
+ // test that queries (as containers) have valid itemId
+ do_check_eq(node.itemId, searchId);
+ node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ node.containerOpen = true;
+
+ // test that queries have children when excludeItems=1
+ // test that query nodes don't show containers (shouldn't have our folder that matches)
+ // test that queries don't show themselves in query results (shouldn't have our saved search)
+ do_check_eq(node.childCount, 1);
+
+ // test that history visit shows in query results
+ var item = node.getChild(0);
+ do_check_eq(item.type, item.RESULT_TYPE_URI);
+ do_check_eq(item.itemId, -1); // history visit
+ do_check_eq(item.uri, testURI.spec); // history visit
+
+ // test live-update of query results - add a history visit that matches the query
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://foo.com"),
+ title: searchTerm + "blah"
+ });
+ do_check_eq(node.childCount, 2);
+
+ // test live-update of query results - delete a history visit that matches the query
+ PlacesUtils.history.removePage(uri("http://foo.com"));
+ do_check_eq(node.childCount, 1);
+ node.containerOpen = false;
+ }
+
+ // test live-update of moved queries
+ var tmpFolderId = PlacesUtils.bookmarks.createFolder(
+ testRoot, "foo", PlacesUtils.bookmarks.DEFAULT_INDEX);
+ PlacesUtils.bookmarks.moveItem(
+ searchId, tmpFolderId, PlacesUtils.bookmarks.DEFAULT_INDEX);
+ var tmpFolderNode = rootNode.getChild(0);
+ do_check_eq(tmpFolderNode.itemId, tmpFolderId);
+ tmpFolderNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ tmpFolderNode.containerOpen = true;
+ do_check_eq(tmpFolderNode.childCount, 1);
+
+ // test live-update of renamed queries
+ PlacesUtils.bookmarks.setItemTitle(searchId, "foo");
+ do_check_eq(tmpFolderNode.title, "foo");
+
+ // test live-update of deleted queries
+ PlacesUtils.bookmarks.removeItem(searchId);
+ try {
+ tmpFolderNode = root.getChild(1);
+ do_throw("query was not removed");
+ } catch (ex) {}
+
+ tmpFolderNode.containerOpen = false;
+ rootNode.containerOpen = false;
+ }
+ catch (ex) {
+ do_throw("expandQueries=1 bookmarks query: " + ex);
+ }
+});
diff --git a/toolkit/components/places/tests/bookmarks/xpcshell.ini b/toolkit/components/places/tests/bookmarks/xpcshell.ini
new file mode 100644
index 000000000..c290fd693
--- /dev/null
+++ b/toolkit/components/places/tests/bookmarks/xpcshell.ini
@@ -0,0 +1,50 @@
+[DEFAULT]
+head = head_bookmarks.js
+tail =
+skip-if = toolkit == 'android'
+
+[test_1016953-renaming-uncompressed.js]
+[test_1017502-bookmarks_foreign_count.js]
+[test_384228.js]
+[test_385829.js]
+[test_388695.js]
+[test_393498.js]
+[test_395101.js]
+[test_395593.js]
+[test_405938_restore_queries.js]
+[test_417228-exclude-from-backup.js]
+[test_417228-other-roots.js]
+[test_424958-json-quoted-folders.js]
+[test_448584.js]
+[test_458683.js]
+[test_466303-json-remove-backups.js]
+[test_477583_json-backup-in-future.js]
+[test_675416.js]
+[test_711914.js]
+[test_818584-discard-duplicate-backups.js]
+[test_818587_compress-bookmarks-backups.js]
+[test_818593-store-backup-metadata.js]
+[test_992901-backup-unsorted-hierarchy.js]
+[test_997030-bookmarks-html-encode.js]
+[test_1129529.js]
+[test_async_observers.js]
+[test_bmindex.js]
+[test_bookmarkstree_cache.js]
+[test_bookmarks.js]
+[test_bookmarks_eraseEverything.js]
+[test_bookmarks_fetch.js]
+[test_bookmarks_getRecent.js]
+[test_bookmarks_insert.js]
+[test_bookmarks_notifications.js]
+[test_bookmarks_remove.js]
+[test_bookmarks_reorder.js]
+[test_bookmarks_search.js]
+[test_bookmarks_update.js]
+[test_changeBookmarkURI.js]
+[test_getBookmarkedURIFor.js]
+[test_keywords.js]
+[test_nsINavBookmarkObserver.js]
+[test_protectRoots.js]
+[test_removeFolderTransaction_reinsert.js]
+[test_removeItem.js]
+[test_savedsearches.js]
diff --git a/toolkit/components/places/tests/browser/.eslintrc.js b/toolkit/components/places/tests/browser/.eslintrc.js
new file mode 100644
index 000000000..7a41a9cde
--- /dev/null
+++ b/toolkit/components/places/tests/browser/.eslintrc.js
@@ -0,0 +1,8 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/mochitest/browser.eslintrc.js",
+ "../../../../../testing/mochitest/mochitest.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/browser/399606-history.go-0.html b/toolkit/components/places/tests/browser/399606-history.go-0.html
new file mode 100644
index 000000000..039708ed7
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-history.go-0.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>history.go(0)</title>
+<script>
+setTimeout('history.go(0)', 1000);
+</script>
+</head>
+<body>
+Testing history.go(0)
+</body>
+</html>
diff --git a/toolkit/components/places/tests/browser/399606-httprefresh.html b/toolkit/components/places/tests/browser/399606-httprefresh.html
new file mode 100644
index 000000000..e43455ee0
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-httprefresh.html
@@ -0,0 +1,8 @@
+<html><head>
+<meta http-equiv="content-type" content="text/html; charset=UTF-8">
+
+<meta http-equiv="refresh" content="1">
+<title>httprefresh</title>
+</head><body>
+Testing httprefresh
+</body></html>
diff --git a/toolkit/components/places/tests/browser/399606-location.reload.html b/toolkit/components/places/tests/browser/399606-location.reload.html
new file mode 100644
index 000000000..0f46538cd
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-location.reload.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>location.reload()</title>
+<script>
+setTimeout('location.reload();', 100);
+</script>
+</head>
+<body>
+Testing location.reload();
+</body>
+</html>
diff --git a/toolkit/components/places/tests/browser/399606-location.replace.html b/toolkit/components/places/tests/browser/399606-location.replace.html
new file mode 100644
index 000000000..36705402c
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-location.replace.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>location.replace</title>
+<script>
+setTimeout('location.replace(window.location.href)', 1000);
+</script>
+</head>
+<body>
+Testing location.replace
+</body>
+</html>
diff --git a/toolkit/components/places/tests/browser/399606-window.location.href.html b/toolkit/components/places/tests/browser/399606-window.location.href.html
new file mode 100644
index 000000000..61a2c8ba0
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-window.location.href.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>window.location.href</title>
+<script>
+setTimeout('window.location.href = window.location.href', 1000);
+</script>
+</head>
+<body>
+Testing window.location.href
+</body>
+</html>
diff --git a/toolkit/components/places/tests/browser/399606-window.location.html b/toolkit/components/places/tests/browser/399606-window.location.html
new file mode 100644
index 000000000..e77f73071
--- /dev/null
+++ b/toolkit/components/places/tests/browser/399606-window.location.html
@@ -0,0 +1,11 @@
+<html>
+<head>
+<title>window.location</title>
+<script>
+setTimeout('window.location = window.location', 1000);
+</script>
+</head>
+<body>
+Testing window.location
+</body>
+</html>
diff --git a/toolkit/components/places/tests/browser/461710_iframe.html b/toolkit/components/places/tests/browser/461710_iframe.html
new file mode 100644
index 000000000..7480fe58f
--- /dev/null
+++ b/toolkit/components/places/tests/browser/461710_iframe.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ </head>
+ <body>
+ <iframe id="iframe"></iframe>
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/461710_link_page-2.html b/toolkit/components/places/tests/browser/461710_link_page-2.html
new file mode 100644
index 000000000..1fc3e0959
--- /dev/null
+++ b/toolkit/components/places/tests/browser/461710_link_page-2.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Link page 2</title>
+ <style type="text/css">
+ a:link { color: #0000ff; }
+ a:visited { color: #ff0000; }
+ </style>
+ </head>
+ <body>
+ <p><a href="461710_visited_page.html" id="link">Link to the second visited page</a></p>
+ </body>
+</html> \ No newline at end of file
diff --git a/toolkit/components/places/tests/browser/461710_link_page-3.html b/toolkit/components/places/tests/browser/461710_link_page-3.html
new file mode 100644
index 000000000..596661803
--- /dev/null
+++ b/toolkit/components/places/tests/browser/461710_link_page-3.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Link page 3</title>
+ <style type="text/css">
+ a:link { color: #0000ff; }
+ a:visited { color: #ff0000; }
+ </style>
+ </head>
+ <body>
+ <p><a href="461710_visited_page.html" id="link">Link to the third visited page</a></p>
+ </body>
+</html> \ No newline at end of file
diff --git a/toolkit/components/places/tests/browser/461710_link_page.html b/toolkit/components/places/tests/browser/461710_link_page.html
new file mode 100644
index 000000000..6bea50628
--- /dev/null
+++ b/toolkit/components/places/tests/browser/461710_link_page.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Link page</title>
+ <style type="text/css">
+ a:link { color: #0000ff; }
+ a:visited { color: #ff0000; }
+ </style>
+ </head>
+ <body>
+ <p><a href="461710_visited_page.html" id="link">Link to the visited page</a></p>
+ </body>
+</html> \ No newline at end of file
diff --git a/toolkit/components/places/tests/browser/461710_visited_page.html b/toolkit/components/places/tests/browser/461710_visited_page.html
new file mode 100644
index 000000000..90e65116b
--- /dev/null
+++ b/toolkit/components/places/tests/browser/461710_visited_page.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Visited page</title>
+ </head>
+ <body>
+ <p>This page is marked as visited</p>
+ </body>
+</html> \ No newline at end of file
diff --git a/toolkit/components/places/tests/browser/begin.html b/toolkit/components/places/tests/browser/begin.html
new file mode 100644
index 000000000..da4c16dd2
--- /dev/null
+++ b/toolkit/components/places/tests/browser/begin.html
@@ -0,0 +1,10 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+
+<html>
+ <body>
+ <a id="clickme" href="redirect_twice.sjs">Redirect twice</a>
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/browser.ini b/toolkit/components/places/tests/browser/browser.ini
new file mode 100644
index 000000000..e6abe987f
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+support-files =
+ colorAnalyzer/category-discover.png
+ colorAnalyzer/dictionaryGeneric-16.png
+ colorAnalyzer/extensionGeneric-16.png
+ colorAnalyzer/localeGeneric.png
+ head.js
+
+[browser_bug248970.js]
+[browser_bug399606.js]
+[browser_bug461710.js]
+[browser_bug646422.js]
+[browser_bug680727.js]
+[browser_colorAnalyzer.js]
+[browser_double_redirect.js]
+[browser_favicon_privatebrowsing_perwindowpb.js]
+[browser_favicon_setAndFetchFaviconForPage.js]
+[browser_favicon_setAndFetchFaviconForPage_failures.js]
+[browser_history_post.js]
+[browser_notfound.js]
+[browser_redirect.js]
+[browser_settitle.js]
+[browser_visited_notfound.js]
+[browser_visituri.js]
+[browser_visituri_nohistory.js]
+[browser_visituri_privatebrowsing_perwindowpb.js] \ No newline at end of file
diff --git a/toolkit/components/places/tests/browser/browser_bug248970.js b/toolkit/components/places/tests/browser/browser_bug248970.js
new file mode 100644
index 000000000..5850a3038
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug248970.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This test performs checks on the history testing area as outlined
+// https://wiki.mozilla.org/Firefox3.1/PrivateBrowsing/TestPlan#History
+// http://developer.mozilla.org/en/Using_the_Places_history_service
+
+var visitedURIs = [
+ "http://www.test-link.com/",
+ "http://www.test-typed.com/",
+ "http://www.test-bookmark.com/",
+ "http://www.test-redirect-permanent.com/",
+ "http://www.test-redirect-temporary.com/",
+ "http://www.test-embed.com/",
+ "http://www.test-framed.com/",
+ "http://www.test-download.com/"
+].map(NetUtil.newURI.bind(NetUtil));
+
+add_task(function* () {
+ let windowsToClose = [];
+ let placeItemsCount = 0;
+
+ registerCleanupFunction(function() {
+ windowsToClose.forEach(function(win) {
+ win.close();
+ });
+ });
+
+ yield PlacesTestUtils.clearHistory();
+
+ // Ensure we wait for the default bookmarks import.
+ yield new Promise(resolve => {
+ waitForCondition(() => {
+ placeItemsCount = getPlacesItemsCount();
+ return placeItemsCount > 0
+ }, resolve, "Should have default bookmarks")
+ });
+
+ // Create a handful of history items with various visit types
+ yield PlacesTestUtils.addVisits([
+ { uri: visitedURIs[0], transition: TRANSITION_LINK },
+ { uri: visitedURIs[1], transition: TRANSITION_TYPED },
+ { uri: visitedURIs[2], transition: TRANSITION_BOOKMARK },
+ { uri: visitedURIs[3], transition: TRANSITION_REDIRECT_PERMANENT },
+ { uri: visitedURIs[4], transition: TRANSITION_REDIRECT_TEMPORARY },
+ { uri: visitedURIs[5], transition: TRANSITION_EMBED },
+ { uri: visitedURIs[6], transition: TRANSITION_FRAMED_LINK },
+ { uri: visitedURIs[7], transition: TRANSITION_DOWNLOAD }
+ ]);
+
+ placeItemsCount += 7;
+ // We added 7 new items to history.
+ is(getPlacesItemsCount(), placeItemsCount,
+ "Check the total items count");
+
+ function* testOnWindow(aIsPrivate, aCount) {
+ let win = yield new Promise(resolve => {
+ whenNewWindowLoaded({ private: aIsPrivate }, resolve);
+ });
+ windowsToClose.push(win);
+
+ // History items should be retrievable by query
+ yield checkHistoryItems();
+
+ // Updates the place items count
+ let count = getPlacesItemsCount();
+
+ // Create Bookmark
+ let title = "title " + windowsToClose.length;
+ let keyword = "keyword " + windowsToClose.length;
+ let url = "http://test-a-" + windowsToClose.length + ".com/";
+
+ yield PlacesUtils.bookmarks.insert({ url, title,
+ parentGuid: PlacesUtils.bookmarks.menuGuid });
+ yield PlacesUtils.keywords.insert({ url, keyword });
+ count++;
+
+ ok((yield PlacesUtils.bookmarks.fetch({ url })),
+ "Bookmark should be bookmarked, data should be retrievable");
+ is(getPlacesItemsCount(), count,
+ "Check the new bookmark items count");
+ is(isBookmarkAltered(), false, "Check if bookmark has been visited");
+ }
+
+ // Test on windows.
+ yield testOnWindow(false);
+ yield testOnWindow(true);
+ yield testOnWindow(false);
+});
+
+/**
+ * Function performs a really simple query on our places entries,
+ * and makes sure that the number of entries equal num_places_entries.
+ */
+function getPlacesItemsCount() {
+ // Get bookmarks count
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = true;
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(
+ PlacesUtils.history.getNewQuery(), options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ root.containerOpen = false;
+
+ // Get history item count
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;
+ root = PlacesUtils.history.executeQuery(
+ PlacesUtils.history.getNewQuery(), options).root;
+ root.containerOpen = true;
+ cc += root.childCount;
+ root.containerOpen = false;
+
+ return cc;
+}
+
+function* checkHistoryItems() {
+ for (let i = 0; i < visitedURIs.length; i++) {
+ let visitedUri = visitedURIs[i];
+ ok((yield promiseIsURIVisited(visitedUri)), "");
+ if (/embed/.test(visitedUri.spec)) {
+ is((yield PlacesTestUtils.isPageInDB(visitedUri)), false, "Check if URI is in database");
+ } else {
+ ok((yield PlacesTestUtils.isPageInDB(visitedUri)), "Check if URI is in database");
+ }
+ }
+}
+
+/**
+ * Function attempts to check if Bookmark-A has been visited
+ * during private browsing mode, function should return false
+ *
+ * @returns false if the accessCount has not changed
+ * true if the accessCount has changed
+ */
+function isBookmarkAltered() {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.maxResults = 1; // should only expect a new bookmark
+
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.bookmarksMenuFolder], 1);
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ is(root.childCount, options.maxResults, "Check new bookmarks results");
+ let node = root.getChild(0);
+ root.containerOpen = false;
+
+ return (node.accessCount != 0);
+}
diff --git a/toolkit/components/places/tests/browser/browser_bug399606.js b/toolkit/components/places/tests/browser/browser_bug399606.js
new file mode 100644
index 000000000..b5eee0f92
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug399606.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+gBrowser.selectedTab = gBrowser.addTab();
+
+function test() {
+ waitForExplicitFinish();
+
+ var URIs = [
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.href.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-history.go-0.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.replace.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-location.reload.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-httprefresh.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/399606-window.location.html",
+ ];
+ var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+ // Create and add history observer.
+ var historyObserver = {
+ visitCount: Array(),
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType) {
+ info("Received onVisit: " + aURI.spec);
+ if (aURI.spec in this.visitCount)
+ this.visitCount[aURI.spec]++;
+ else
+ this.visitCount[aURI.spec] = 1;
+ },
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+ };
+ hs.addObserver(historyObserver, false);
+
+ function confirm_results() {
+ gBrowser.removeCurrentTab();
+ hs.removeObserver(historyObserver, false);
+ for (let aURI in historyObserver.visitCount) {
+ is(historyObserver.visitCount[aURI], 1,
+ "onVisit has been received right number of times for " + aURI);
+ }
+ PlacesTestUtils.clearHistory().then(finish);
+ }
+
+ var loadCount = 0;
+ function handleLoad(aEvent) {
+ loadCount++;
+ info("new load count is " + loadCount);
+
+ if (loadCount == 3) {
+ gBrowser.removeEventListener("DOMContentLoaded", handleLoad, true);
+ gBrowser.loadURI("about:blank");
+ executeSoon(check_next_uri);
+ }
+ }
+
+ function check_next_uri() {
+ if (URIs.length) {
+ let uri = URIs.shift();
+ loadCount = 0;
+ gBrowser.addEventListener("DOMContentLoaded", handleLoad, true);
+ gBrowser.loadURI(uri);
+ }
+ else {
+ confirm_results();
+ }
+ }
+ executeSoon(check_next_uri);
+}
diff --git a/toolkit/components/places/tests/browser/browser_bug461710.js b/toolkit/components/places/tests/browser/browser_bug461710.js
new file mode 100644
index 000000000..12af87a06
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug461710.js
@@ -0,0 +1,82 @@
+const kRed = "rgb(255, 0, 0)";
+const kBlue = "rgb(0, 0, 255)";
+
+const prefix = "http://example.com/tests/toolkit/components/places/tests/browser/461710_";
+
+add_task(function* () {
+ let contentPage = prefix + "iframe.html";
+ let normalWindow = yield BrowserTestUtils.openNewBrowserWindow();
+
+ let browser = normalWindow.gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURI(browser, contentPage);
+ yield BrowserTestUtils.browserLoaded(browser, contentPage);
+
+ let privateWindow = yield BrowserTestUtils.openNewBrowserWindow({private: true});
+
+ browser = privateWindow.gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURI(browser, contentPage);
+ yield BrowserTestUtils.browserLoaded(browser, contentPage);
+
+ let tests = [{
+ win: normalWindow,
+ topic: "uri-visit-saved",
+ subtest: "visited_page.html"
+ }, {
+ win: normalWindow,
+ topic: "visited-status-resolution",
+ subtest: "link_page.html",
+ color: kRed,
+ message: "Visited link coloring should work outside of private mode"
+ }, {
+ win: privateWindow,
+ topic: "visited-status-resolution",
+ subtest: "link_page-2.html",
+ color: kBlue,
+ message: "Visited link coloring should not work inside of private mode"
+ }, {
+ win: normalWindow,
+ topic: "visited-status-resolution",
+ subtest: "link_page-3.html",
+ color: kRed,
+ message: "Visited link coloring should work outside of private mode"
+ }];
+
+ let visited_page_url = prefix + tests[0].subtest;
+ for (let test of tests) {
+ let promise = new Promise(resolve => {
+ let uri = NetUtil.newURI(visited_page_url);
+ Services.obs.addObserver(function observe(aSubject) {
+ if (uri.equals(aSubject.QueryInterface(Ci.nsIURI))) {
+ Services.obs.removeObserver(observe, test.topic);
+ resolve();
+ }
+ }, test.topic, false);
+ });
+ ContentTask.spawn(test.win.gBrowser.selectedBrowser, prefix + test.subtest, function* (aSrc) {
+ content.document.getElementById("iframe").src = aSrc;
+ });
+ yield promise;
+
+ if (test.color) {
+ // In e10s waiting for visited-status-resolution is not enough to ensure links
+ // have been updated, because it only tells us that messages to update links
+ // have been dispatched. We must still wait for the actual links to update.
+ yield BrowserTestUtils.waitForCondition(function* () {
+ let color = yield ContentTask.spawn(test.win.gBrowser.selectedBrowser, null, function* () {
+ let iframe = content.document.getElementById("iframe");
+ let elem = iframe.contentDocument.getElementById("link");
+ return content.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils)
+ .getVisitedDependentComputedStyle(elem, "", "color");
+ });
+ return (color == test.color);
+ }, test.message);
+ // The harness will consider the test as failed overall if there were no
+ // passes or failures, so record it as a pass.
+ ok(true, test.message);
+ }
+ }
+
+ yield BrowserTestUtils.closeWindow(normalWindow);
+ yield BrowserTestUtils.closeWindow(privateWindow);
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug646422.js b/toolkit/components/places/tests/browser/browser_bug646422.js
new file mode 100644
index 000000000..1a81de4e1
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug646422.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for Bug 646224. Make sure that after changing the URI via
+ * history.pushState, the history service has a title stored for the new URI.
+ **/
+
+add_task(function* () {
+ let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, 'http://example.com');
+
+ let newTitlePromise = new Promise(resolve => {
+ let observer = {
+ onTitleChanged: function(uri, title) {
+ // If the uri of the page whose title is changing ends with 'new_page',
+ // then it's the result of our pushState.
+ if (/new_page$/.test(uri.spec)) {
+ resolve(title);
+ PlacesUtils.history.removeObserver(observer);
+ }
+ },
+
+ onBeginUpdateBatch: function() { },
+ onEndUpdateBatch: function() { },
+ onVisit: function() { },
+ onDeleteURI: function() { },
+ onClearHistory: function() { },
+ onPageChanged: function() { },
+ onDeleteVisits: function() { },
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+ };
+
+ PlacesUtils.history.addObserver(observer, false);
+ });
+
+ yield ContentTask.spawn(tab.linkedBrowser, null, function* () {
+ let title = content.document.title;
+ content.history.pushState('', '', 'new_page');
+ Assert.ok(title, "Content window should initially have a title.");
+ });
+
+ let newtitle = yield newTitlePromise;
+
+ yield ContentTask.spawn(tab.linkedBrowser, { newtitle }, function* (args) {
+ Assert.equal(args.newtitle, content.document.title, "Title after pushstate.");
+ });
+
+ yield PlacesTestUtils.clearHistory();
+ gBrowser.removeTab(tab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_bug680727.js b/toolkit/components/places/tests/browser/browser_bug680727.js
new file mode 100644
index 000000000..560cbfe6c
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_bug680727.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Ensure that clicking the button in the Offline mode neterror page updates
+ global history. See bug 680727. */
+/* TEST_PATH=toolkit/components/places/tests/browser/browser_bug680727.js make -C $(OBJDIR) mochitest-browser-chrome */
+
+
+const kUniqueURI = Services.io.newURI("http://mochi.test:8888/#bug_680727",
+ null, null);
+var gAsyncHistory =
+ Cc["@mozilla.org/browser/history;1"].getService(Ci.mozIAsyncHistory);
+
+var proxyPrefValue;
+var ourTab;
+
+function test() {
+ waitForExplicitFinish();
+
+ // Tests always connect to localhost, and per bug 87717, localhost is now
+ // reachable in offline mode. To avoid this, disable any proxy.
+ proxyPrefValue = Services.prefs.getIntPref("network.proxy.type");
+ Services.prefs.setIntPref("network.proxy.type", 0);
+
+ // Clear network cache.
+ Components.classes["@mozilla.org/netwerk/cache-storage-service;1"]
+ .getService(Components.interfaces.nsICacheStorageService)
+ .clear();
+
+ // Go offline, expecting the error page.
+ Services.io.offline = true;
+
+ BrowserTestUtils.openNewForegroundTab(gBrowser).then(tab => {
+ ourTab = tab;
+ BrowserTestUtils.waitForContentEvent(ourTab.linkedBrowser, "DOMContentLoaded")
+ .then(errorListener);
+ BrowserTestUtils.loadURI(ourTab.linkedBrowser, kUniqueURI.spec);
+ });
+}
+
+// ------------------------------------------------------------------------------
+// listen to loading the neterror page. (offline mode)
+function errorListener() {
+ ok(Services.io.offline, "Services.io.offline is true.");
+
+ // This is an error page.
+ ContentTask.spawn(ourTab.linkedBrowser, kUniqueURI.spec, function(uri) {
+ Assert.equal(content.document.documentURI.substring(0, 27),
+ "about:neterror?e=netOffline", "Document URI is the error page.");
+
+ // But location bar should show the original request.
+ Assert.equal(content.location.href, uri, "Docshell URI is the original URI.");
+ }).then(() => {
+ // Global history does not record URI of a failed request.
+ return PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ gAsyncHistory.isURIVisited(kUniqueURI, errorAsyncListener);
+ });
+ });
+}
+
+function errorAsyncListener(aURI, aIsVisited) {
+ ok(kUniqueURI.equals(aURI) && !aIsVisited,
+ "The neterror page is not listed in global history.");
+
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+
+ // Now press the "Try Again" button, with offline mode off.
+ Services.io.offline = false;
+
+ BrowserTestUtils.waitForContentEvent(ourTab.linkedBrowser, "DOMContentLoaded")
+ .then(reloadListener);
+
+ ContentTask.spawn(ourTab.linkedBrowser, null, function() {
+ Assert.ok(content.document.getElementById("errorTryAgain"),
+ "The error page has got a #errorTryAgain element");
+ content.document.getElementById("errorTryAgain").click();
+ });
+}
+
+// ------------------------------------------------------------------------------
+// listen to reload of neterror.
+function reloadListener() {
+ // This listener catches "DOMContentLoaded" on being called
+ // nsIWPL::onLocationChange(...). That is right *AFTER*
+ // IHistory::VisitURI(...) is called.
+ ok(!Services.io.offline, "Services.io.offline is false.");
+
+ ContentTask.spawn(ourTab.linkedBrowser, kUniqueURI.spec, function(uri) {
+ // This is not an error page.
+ Assert.equal(content.document.documentURI, uri,
+ "Document URI is not the offline-error page, but the original URI.");
+ }).then(() => {
+ // Check if global history remembers the successfully-requested URI.
+ PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ gAsyncHistory.isURIVisited(kUniqueURI, reloadAsyncListener);
+ });
+ });
+}
+
+function reloadAsyncListener(aURI, aIsVisited) {
+ ok(kUniqueURI.equals(aURI) && aIsVisited, "We have visited the URI.");
+ PlacesTestUtils.clearHistory().then(finish);
+}
+
+registerCleanupFunction(function* () {
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+ Services.io.offline = false;
+ yield BrowserTestUtils.removeTab(ourTab);
+});
diff --git a/toolkit/components/places/tests/browser/browser_colorAnalyzer.js b/toolkit/components/places/tests/browser/browser_colorAnalyzer.js
new file mode 100644
index 000000000..7b7fe6ec5
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_colorAnalyzer.js
@@ -0,0 +1,259 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const CA = Cc["@mozilla.org/places/colorAnalyzer;1"].
+ getService(Ci.mozIColorAnalyzer);
+
+const hiddenWindowDoc = Cc["@mozilla.org/appshell/appShellService;1"].
+ getService(Ci.nsIAppShellService).
+ hiddenDOMWindow.document;
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Passes the given uri to findRepresentativeColor.
+ * If expected is null, you expect it to fail.
+ * If expected is a function, it will call that function.
+ * If expected is a color, you expect that color to be returned.
+ * Message is used in the calls to is().
+ */
+function frcTest(uri, expected, message) {
+ return new Promise(resolve => {
+ CA.findRepresentativeColor(Services.io.newURI(uri, "", null),
+ function(success, color) {
+ if (expected == null) {
+ ok(!success, message);
+ } else if (typeof expected == "function") {
+ expected(color, message);
+ } else {
+ ok(success, "success: " + message);
+ is(color, expected, message);
+ }
+ resolve();
+ });
+ });
+}
+
+/**
+ * Handy function for getting an image into findRepresentativeColor and testing it.
+ * Makes a canvas with the given dimensions, calls paintCanvasFunc with the 2d
+ * context of the canvas, sticks the generated canvas into findRepresentativeColor.
+ * See frcTest.
+ */
+function canvasTest(width, height, paintCanvasFunc, expected, message) {
+ let canvas = hiddenWindowDoc.createElementNS(XHTML_NS, "canvas");
+ canvas.width = width;
+ canvas.height = height;
+ paintCanvasFunc(canvas.getContext("2d"));
+ let uri = canvas.toDataURL();
+ return frcTest(uri, expected, message);
+}
+
+// simple test - draw a red box in the center, make sure we get red back
+add_task(function* test_redSquare() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "red";
+ ctx.fillRect(2, 2, 12, 12);
+ }, 0xFF0000, "redSquare analysis returns red");
+});
+
+
+// draw a blue square in one corner, red in the other, such that blue overlaps
+// red by one pixel, making it the dominant color
+add_task(function* test_blueOverlappingRed() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "red";
+ ctx.fillRect(0, 0, 8, 8);
+ ctx.fillStyle = "blue";
+ ctx.fillRect(7, 7, 8, 8);
+ }, 0x0000FF, "blueOverlappingRed analysis returns blue");
+});
+
+// draw a red gradient next to a solid blue rectangle to ensure that a large
+// block of similar colors beats out a smaller block of one color
+add_task(function* test_redGradientBlueSolid() {
+ yield canvasTest(16, 16, function(ctx) {
+ let gradient = ctx.createLinearGradient(0, 0, 1, 15);
+ gradient.addColorStop(0, "#FF0000");
+ gradient.addColorStop(1, "#FF0808");
+
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "blue";
+ ctx.fillRect(9, 0, 7, 16);
+ }, function(actual, message) {
+ ok(actual >= 0xFF0000 && actual <= 0xFF0808, message);
+ }, "redGradientBlueSolid analysis returns redish");
+});
+
+// try a transparent image, should fail
+add_task(function* test_transparent() {
+ yield canvasTest(16, 16, function(ctx) {
+ // do nothing!
+ }, null, "transparent analysis fails");
+});
+
+add_task(function* test_invalidURI() {
+ yield frcTest("data:blah,Imnotavaliddatauri", null, "invalid URI analysis fails");
+});
+
+add_task(function* test_malformedPNGURI() {
+ yield frcTest("data:image/png;base64,iVBORblahblahblah", null,
+ "malformed PNG URI analysis fails");
+});
+
+add_task(function* test_unresolvableURI() {
+ yield frcTest("http://www.example.com/blah/idontexist.png", null,
+ "unresolvable URI analysis fails");
+});
+
+// draw a small blue box on a red background to make sure the algorithm avoids
+// using the background color
+add_task(function* test_blueOnRedBackground() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "red";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "blue";
+ ctx.fillRect(4, 4, 8, 8);
+ }, 0x0000FF, "blueOnRedBackground analysis returns blue");
+});
+
+// draw a slightly different color in the corners to make sure the corner colors
+// don't have to be exactly equal to be considered the background color
+add_task(function* test_variableBackground() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "#FEFEFE";
+ ctx.fillRect(15, 0, 1, 1);
+ ctx.fillStyle = "#FDFDFD";
+ ctx.fillRect(15, 15, 1, 1);
+ ctx.fillStyle = "#FCFCFC";
+ ctx.fillRect(0, 15, 1, 1);
+ ctx.fillStyle = "black";
+ ctx.fillRect(4, 4, 8, 8);
+ }, 0x000000, "variableBackground analysis returns black");
+});
+
+// like the above test, but make the colors different enough that they aren't
+// considered the background color
+add_task(function* test_tooVariableBackground() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "#EEDDCC";
+ ctx.fillRect(15, 0, 1, 1);
+ ctx.fillStyle = "#DDDDDD";
+ ctx.fillRect(15, 15, 1, 1);
+ ctx.fillStyle = "#CCCCCC";
+ ctx.fillRect(0, 15, 1, 1);
+ ctx.fillStyle = "black";
+ ctx.fillRect(4, 4, 8, 8);
+ }, function(actual, message) {
+ isnot(actual, 0x000000, message);
+ }, "tooVariableBackground analysis doesn't return black");
+});
+
+// draw a small black/white box over transparent background to make sure the
+// algorithm doesn't think rgb(0,0,0) == rgba(0,0,0,0)
+add_task(function* test_transparentBackgroundConflation() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "black";
+ ctx.fillRect(2, 2, 12, 12);
+ ctx.fillStyle = "white";
+ ctx.fillRect(5, 5, 6, 6);
+ }, 0x000000, "transparentBackgroundConflation analysis returns black");
+});
+
+
+// make sure we fall back to the background color if we have no other choice
+// (instead of failing as if there were no colors)
+add_task(function* test_backgroundFallback() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "black";
+ ctx.fillRect(0, 0, 16, 16);
+ }, 0x000000, "backgroundFallback analysis returns black");
+});
+
+// draw red rectangle next to a pink one to make sure the algorithm picks the
+// more interesting color
+add_task(function* test_interestingColorPreference() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "#FFDDDD";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "red";
+ ctx.fillRect(0, 0, 3, 16);
+ }, 0xFF0000, "interestingColorPreference analysis returns red");
+});
+
+// draw high saturation but dark red next to slightly less saturated color but
+// much lighter, to make sure the algorithm doesn't pick colors that are
+// nearly black just because of high saturation (in HSL terms)
+add_task(function* test_saturationDependence() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "hsl(0, 100%, 5%)";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "hsl(0, 90%, 35%)";
+ ctx.fillRect(0, 0, 8, 16);
+ }, 0xA90808, "saturationDependence analysis returns lighter red");
+});
+
+// make sure the preference for interesting colors won't stupidly pick 1 pixel
+// of red over 169 black pixels
+add_task(function* test_interestingColorPreferenceLenient() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "black";
+ ctx.fillRect(1, 1, 13, 13);
+ ctx.fillStyle = "red";
+ ctx.fillRect(3, 3, 1, 1);
+ }, 0x000000, "interestingColorPreferenceLenient analysis returns black");
+});
+
+// ...but 6 pixels of red is more reasonable
+add_task(function* test_interestingColorPreferenceNotTooLenient() {
+ yield canvasTest(16, 16, function(ctx) {
+ ctx.fillStyle = "black";
+ ctx.fillRect(1, 1, 13, 13);
+ ctx.fillStyle = "red";
+ ctx.fillRect(3, 3, 3, 2);
+ }, 0xFF0000, "interestingColorPreferenceNotTooLenient analysis returns red");
+});
+
+var maxPixels = 144; // see ColorAnalyzer MAXIMUM_PIXELS const
+
+// make sure that images larger than maxPixels*maxPixels fail
+add_task(function* test_imageTooLarge() {
+ yield canvasTest(1+maxPixels, 1+maxPixels, function(ctx) {
+ ctx.fillStyle = "red";
+ ctx.fillRect(0, 0, 1+maxPixels, 1+maxPixels);
+ }, null, "imageTooLarge analysis fails");
+});
+
+// the rest of the tests are for coverage of "real" favicons
+// exact color isn't terribly important, just make sure it's reasonable
+const filePrefix = getRootDirectory(gTestPath) + "colorAnalyzer/";
+
+add_task(function* test_categoryDiscover() {
+ yield frcTest(filePrefix + "category-discover.png", 0xB28D3A,
+ "category-discover analysis returns red");
+});
+
+add_task(function* test_localeGeneric() {
+ yield frcTest(filePrefix + "localeGeneric.png", 0x3EC23E,
+ "localeGeneric analysis returns green");
+});
+
+add_task(function* test_dictionaryGeneric() {
+ yield frcTest(filePrefix + "dictionaryGeneric-16.png", 0x854C30,
+ "dictionaryGeneric-16 analysis returns brown");
+});
+
+add_task(function* test_extensionGeneric() {
+ yield frcTest(filePrefix + "extensionGeneric-16.png", 0x53BA3F,
+ "extensionGeneric-16 analysis returns green");
+});
diff --git a/toolkit/components/places/tests/browser/browser_double_redirect.js b/toolkit/components/places/tests/browser/browser_double_redirect.js
new file mode 100644
index 000000000..1e5dc9c16
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_double_redirect.js
@@ -0,0 +1,63 @@
+// Test for bug 411966.
+// When a page redirects multiple times, from_visit should point to the
+// previous visit in the chain, not to the first visit in the chain.
+
+add_task(function* () {
+ yield PlacesTestUtils.clearHistory();
+
+ const BASE_URL = "http://example.com/tests/toolkit/components/places/tests/browser/";
+ const TEST_URI = NetUtil.newURI(BASE_URL + "begin.html");
+ const FIRST_REDIRECTING_URI = NetUtil.newURI(BASE_URL + "redirect_twice.sjs");
+ const FINAL_URI = NetUtil.newURI(BASE_URL + "final.html");
+
+ let promiseVisits = new Promise(resolve => {
+ PlacesUtils.history.addObserver({
+ __proto__: NavHistoryObserver.prototype,
+ _notified: [],
+ onVisit: function (uri, id, time, sessionId, referrerId, transition) {
+ info("Received onVisit: " + uri.spec);
+ this._notified.push(uri);
+
+ if (!uri.equals(FINAL_URI)) {
+ return;
+ }
+
+ is(this._notified.length, 4);
+ PlacesUtils.history.removeObserver(this);
+
+ Task.spawn(function* () {
+ // Get all pages visited from the original typed one
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(
+ `SELECT url FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE from_visit IN
+ (SELECT v.id FROM moz_historyvisits v
+ JOIN moz_places p ON p.id = v.place_id
+ WHERE p.url_hash = hash(:url) AND p.url = :url)
+ `, { url: TEST_URI.spec });
+
+ is(rows.length, 1, "Found right number of visits");
+ let visitedUrl = rows[0].getResultByName("url");
+ // Check that redirect from_visit is not from the original typed one
+ is(visitedUrl, FIRST_REDIRECTING_URI.spec, "Check referrer for " + visitedUrl);
+
+ resolve();
+ });
+ }
+ }, false);
+ });
+
+ PlacesUtils.history.markPageAsTyped(TEST_URI);
+ yield BrowserTestUtils.withNewTab({
+ gBrowser,
+ url: TEST_URI.spec,
+ }, function* (browser) {
+ // Load begin page, click link on page to record visits.
+ yield BrowserTestUtils.synthesizeMouseAtCenter("#clickme", {}, browser);
+
+ yield promiseVisits;
+ });
+
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js
new file mode 100644
index 000000000..51d82adc6
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_favicon_privatebrowsing_perwindowpb.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ const pageURI =
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon.html";
+ let windowsToClose = [];
+
+ registerCleanupFunction(function() {
+ windowsToClose.forEach(function(aWin) {
+ aWin.close();
+ });
+ });
+
+ function testOnWindow(aIsPrivate, aCallback) {
+ whenNewWindowLoaded({private: aIsPrivate}, function(aWin) {
+ windowsToClose.push(aWin);
+ executeSoon(() => aCallback(aWin));
+ });
+ }
+
+ function waitForTabLoad(aWin, aCallback) {
+ aWin.gBrowser.selectedBrowser.addEventListener("load", function onLoad() {
+ aWin.gBrowser.selectedBrowser.removeEventListener("load", onLoad, true);
+ aCallback();
+ }, true);
+ aWin.gBrowser.selectedBrowser.loadURI(pageURI);
+ }
+
+ testOnWindow(true, function(win) {
+ waitForTabLoad(win, function() {
+ PlacesUtils.favicons.getFaviconURLForPage(NetUtil.newURI(pageURI),
+ function(uri, dataLen, data, mimeType) {
+ is(uri, null, "No result should be found");
+ finish();
+ }
+ );
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage.js b/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage.js
new file mode 100644
index 000000000..60df8ebd7
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage.js
@@ -0,0 +1,152 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file tests the normal operation of setAndFetchFaviconForPage.
+function test() {
+ // Initialization
+ waitForExplicitFinish();
+ let windowsToClose = [];
+ let favIconLocation =
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal32.png";
+ let favIconURI = NetUtil.newURI(favIconLocation);
+ let favIconMimeType= "image/png";
+ let pageURI;
+ let favIconData;
+
+ function testOnWindow(aOptions, aCallback) {
+ whenNewWindowLoaded(aOptions, function(aWin) {
+ windowsToClose.push(aWin);
+ executeSoon(() => aCallback(aWin));
+ });
+ }
+
+ // This function is called after calling finish() on the test.
+ registerCleanupFunction(function() {
+ windowsToClose.forEach(function(aWin) {
+ aWin.close();
+ });
+ });
+
+ function getIconFile(aCallback) {
+ NetUtil.asyncFetch({
+ uri: favIconLocation,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ }, function(inputStream, status) {
+ if (!Components.isSuccessCode(status)) {
+ ok(false, "Could not get the icon file");
+ // Handle error.
+ return;
+ }
+
+ // Check the returned size versus the expected size.
+ let size = inputStream.available();
+ favIconData = NetUtil.readInputStreamToString(inputStream, size);
+ is(size, favIconData.length, "Check correct icon size");
+ // Check that the favicon loaded correctly before starting the actual tests.
+ is(favIconData.length, 344, "Check correct icon length (344)");
+
+ if (aCallback) {
+ aCallback();
+ } else {
+ finish();
+ }
+ });
+ }
+
+ function testNormal(aWindow, aCallback) {
+ pageURI = NetUtil.newURI("http://example.com/normal");
+ waitForFaviconChanged(pageURI, favIconURI, aWindow,
+ function testNormalCallback() {
+ checkFaviconDataForPage(pageURI, favIconMimeType, favIconData, aWindow,
+ aCallback);
+ }
+ );
+
+ addVisits({uri: pageURI, transition: TRANSITION_TYPED}, aWindow,
+ function () {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, favIconURI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ }
+ );
+ }
+
+ function testAboutURIBookmarked(aWindow, aCallback) {
+ pageURI = NetUtil.newURI("about:testAboutURI_bookmarked");
+ waitForFaviconChanged(pageURI, favIconURI, aWindow,
+ function testAboutURIBookmarkedCallback() {
+ checkFaviconDataForPage(pageURI, favIconMimeType, favIconData, aWindow,
+ aCallback);
+ }
+ );
+
+ aWindow.PlacesUtils.bookmarks.insertBookmark(
+ aWindow.PlacesUtils.unfiledBookmarksFolderId, pageURI,
+ aWindow.PlacesUtils.bookmarks.DEFAULT_INDEX, pageURI.spec);
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, favIconURI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ }
+
+ function testPrivateBrowsingBookmarked(aWindow, aCallback) {
+ pageURI = NetUtil.newURI("http://example.com/privateBrowsing_bookmarked");
+ waitForFaviconChanged(pageURI, favIconURI, aWindow,
+ function testPrivateBrowsingBookmarkedCallback() {
+ checkFaviconDataForPage(pageURI, favIconMimeType, favIconData, aWindow,
+ aCallback);
+ }
+ );
+
+ aWindow.PlacesUtils.bookmarks.insertBookmark(
+ aWindow.PlacesUtils.unfiledBookmarksFolderId, pageURI,
+ aWindow.PlacesUtils.bookmarks.DEFAULT_INDEX, pageURI.spec);
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, favIconURI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ }
+
+ function testDisabledHistoryBookmarked(aWindow, aCallback) {
+ pageURI = NetUtil.newURI("http://example.com/disabledHistory_bookmarked");
+ waitForFaviconChanged(pageURI, favIconURI, aWindow,
+ function testDisabledHistoryBookmarkedCallback() {
+ checkFaviconDataForPage(pageURI, favIconMimeType, favIconData, aWindow,
+ aCallback);
+ }
+ );
+
+ // Disable history while changing the favicon.
+ aWindow.Services.prefs.setBoolPref("places.history.enabled", false);
+
+ aWindow.PlacesUtils.bookmarks.insertBookmark(
+ aWindow.PlacesUtils.unfiledBookmarksFolderId, pageURI,
+ aWindow.PlacesUtils.bookmarks.DEFAULT_INDEX, pageURI.spec);
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, favIconURI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ // The setAndFetchFaviconForPage function calls CanAddURI synchronously, thus
+ // we can set the preference back to true immediately. We don't clear the
+ // preference because not all products enable Places by default.
+ aWindow.Services.prefs.setBoolPref("places.history.enabled", true);
+ }
+
+ getIconFile(function () {
+ testOnWindow({}, function(aWin) {
+ testNormal(aWin, function () {
+ testOnWindow({}, function(aWin2) {
+ testAboutURIBookmarked(aWin2, function () {
+ testOnWindow({private: true}, function(aWin3) {
+ testPrivateBrowsingBookmarked(aWin3, function () {
+ testOnWindow({}, function(aWin4) {
+ testDisabledHistoryBookmarked(aWin4, finish);
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage_failures.js b/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage_failures.js
new file mode 100644
index 000000000..bd73af441
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_favicon_setAndFetchFaviconForPage_failures.js
@@ -0,0 +1,261 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file tests setAndFetchFaviconForPage when it is called with invalid
+ * arguments, and when no favicon is stored for the given arguments.
+ */
+function test() {
+ // Initialization
+ waitForExplicitFinish();
+ let windowsToClose = [];
+ let favIcon16Location =
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal16.png";
+ let favIcon32Location =
+ "http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal32.png";
+ let favIcon16URI = NetUtil.newURI(favIcon16Location);
+ let favIcon32URI = NetUtil.newURI(favIcon32Location);
+ let lastPageURI = NetUtil.newURI("http://example.com/verification");
+ // This error icon must stay in sync with FAVICON_ERRORPAGE_URL in
+ // nsIFaviconService.idl, aboutCertError.xhtml and netError.xhtml.
+ let favIconErrorPageURI =
+ NetUtil.newURI("chrome://global/skin/icons/warning-16.png");
+ let favIconsResultCount = 0;
+
+ function testOnWindow(aOptions, aCallback) {
+ whenNewWindowLoaded(aOptions, function(aWin) {
+ windowsToClose.push(aWin);
+ executeSoon(() => aCallback(aWin));
+ });
+ }
+
+ // This function is called after calling finish() on the test.
+ registerCleanupFunction(function() {
+ windowsToClose.forEach(function(aWin) {
+ aWin.close();
+ });
+ });
+
+ function checkFavIconsDBCount(aCallback) {
+ let stmt = DBConn().createAsyncStatement("SELECT url FROM moz_favicons");
+ stmt.executeAsync({
+ handleResult: function final_handleResult(aResultSet) {
+ while (aResultSet.getNextRow()) {
+ favIconsResultCount++;
+ }
+ },
+ handleError: function final_handleError(aError) {
+ throw ("Unexpected error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function final_handleCompletion(aReason) {
+ // begin testing
+ info("Previous records in moz_favicons: " + favIconsResultCount);
+ if (aCallback) {
+ aCallback();
+ }
+ }
+ });
+ stmt.finalize();
+ }
+
+ function testNullPageURI(aWindow, aCallback) {
+ try {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(null, favIcon16URI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ throw ("Exception expected because aPageURI is null.");
+ } catch (ex) {
+ // We expected an exception.
+ ok(true, "Exception expected because aPageURI is null");
+ }
+
+ if (aCallback) {
+ aCallback();
+ }
+ }
+
+ function testNullFavIconURI(aWindow, aCallback) {
+ try {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI("http://example.com/null_faviconURI"), null,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null, Services.scriptSecurityManager.getSystemPrincipal());
+ throw ("Exception expected because aFaviconURI is null.");
+ } catch (ex) {
+ // We expected an exception.
+ ok(true, "Exception expected because aFaviconURI is null.");
+ }
+
+ if (aCallback) {
+ aCallback();
+ }
+ }
+
+ function testAboutURI(aWindow, aCallback) {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI("about:testAboutURI"), favIcon16URI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null, Services.scriptSecurityManager.getSystemPrincipal());
+
+ if (aCallback) {
+ aCallback();
+ }
+ }
+
+ function testPrivateBrowsingNonBookmarkedURI(aWindow, aCallback) {
+ let pageURI = NetUtil.newURI("http://example.com/privateBrowsing");
+ addVisits({ uri: pageURI, transitionType: TRANSITION_TYPED }, aWindow,
+ function () {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI,
+ favIcon16URI, true,
+ aWindow.PlacesUtils.favicons.FAVICON_LOAD_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ if (aCallback) {
+ aCallback();
+ }
+ });
+ }
+
+ function testDisabledHistory(aWindow, aCallback) {
+ let pageURI = NetUtil.newURI("http://example.com/disabledHistory");
+ addVisits({ uri: pageURI, transition: TRANSITION_TYPED }, aWindow,
+ function () {
+ aWindow.Services.prefs.setBoolPref("places.history.enabled", false);
+
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI,
+ favIcon16URI, true,
+ aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ // The setAndFetchFaviconForPage function calls CanAddURI synchronously, thus
+ // we can set the preference back to true immediately . We don't clear the
+ // preference because not all products enable Places by default.
+ aWindow.Services.prefs.setBoolPref("places.history.enabled", true);
+
+ if (aCallback) {
+ aCallback();
+ }
+ });
+ }
+
+ function testErrorIcon(aWindow, aCallback) {
+ let pageURI = NetUtil.newURI("http://example.com/errorIcon");
+ addVisits({ uri: pageURI, transition: TRANSITION_TYPED }, aWindow,
+ function () {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI,
+ favIconErrorPageURI, true,
+ aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ if (aCallback) {
+ aCallback();
+ }
+ });
+ }
+
+ function testNonExistingPage(aWindow, aCallback) {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI("http://example.com/nonexistingPage"), favIcon16URI,
+ true, aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ if (aCallback) {
+ aCallback();
+ }
+ }
+
+ function testFinalVerification(aWindow, aCallback) {
+ // Only the last test should raise the onPageChanged notification,
+ // executing the waitForFaviconChanged callback.
+ waitForFaviconChanged(lastPageURI, favIcon32URI, aWindow,
+ function final_callback() {
+ // Check that only one record corresponding to the last favicon is present.
+ let resultCount = 0;
+ let stmt = DBConn().createAsyncStatement("SELECT url FROM moz_favicons");
+ stmt.executeAsync({
+ handleResult: function final_handleResult(aResultSet) {
+
+ // If the moz_favicons DB had been previously loaded (before our
+ // test began), we should focus only in the URI we are testing and
+ // skip the URIs not related to our test.
+ if (favIconsResultCount > 0) {
+ for (let row; (row = aResultSet.getNextRow()); ) {
+ if (favIcon32URI.spec === row.getResultByIndex(0)) {
+ is(favIcon32URI.spec, row.getResultByIndex(0),
+ "Check equal favicons");
+ resultCount++;
+ }
+ }
+ } else {
+ for (let row; (row = aResultSet.getNextRow()); ) {
+ is(favIcon32URI.spec, row.getResultByIndex(0),
+ "Check equal favicons");
+ resultCount++;
+ }
+ }
+ },
+ handleError: function final_handleError(aError) {
+ throw ("Unexpected error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function final_handleCompletion(aReason) {
+ is(Ci.mozIStorageStatementCallback.REASON_FINISHED, aReason,
+ "Check reasons are equal");
+ is(1, resultCount, "Check result count");
+ if (aCallback) {
+ aCallback();
+ }
+ }
+ });
+ stmt.finalize();
+ });
+
+ // This is the only test that should cause the waitForFaviconChanged
+ // callback to be invoked. In turn, the callback will invoke
+ // finish() causing the tests to finish.
+ addVisits({ uri: lastPageURI, transition: TRANSITION_TYPED }, aWindow,
+ function () {
+ aWindow.PlacesUtils.favicons.setAndFetchFaviconForPage(lastPageURI,
+ favIcon32URI, true,
+ aWindow.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ });
+ }
+
+ checkFavIconsDBCount(function () {
+ testOnWindow({}, function(aWin) {
+ testNullPageURI(aWin, function () {
+ testOnWindow({}, function(aWin2) {
+ testNullFavIconURI(aWin2, function() {
+ testOnWindow({}, function(aWin3) {
+ testAboutURI(aWin3, function() {
+ testOnWindow({private: true}, function(aWin4) {
+ testPrivateBrowsingNonBookmarkedURI(aWin4, function () {
+ testOnWindow({}, function(aWin5) {
+ testDisabledHistory(aWin5, function () {
+ testOnWindow({}, function(aWin6) {
+ testErrorIcon(aWin6, function() {
+ testOnWindow({}, function(aWin7) {
+ testNonExistingPage(aWin7, function() {
+ testOnWindow({}, function(aWin8) {
+ testFinalVerification(aWin8, function() {
+ finish();
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/browser/browser_history_post.js b/toolkit/components/places/tests/browser/browser_history_post.js
new file mode 100644
index 000000000..c85e720f8
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_history_post.js
@@ -0,0 +1,23 @@
+const PAGE_URI = "http://example.com/tests/toolkit/components/places/tests/browser/history_post.html";
+const SJS_URI = NetUtil.newURI("http://example.com/tests/toolkit/components/places/tests/browser/history_post.sjs");
+
+add_task(function* () {
+ yield BrowserTestUtils.withNewTab({gBrowser, url: PAGE_URI}, Task.async(function* (aBrowser) {
+ yield ContentTask.spawn(aBrowser, null, function* () {
+ let doc = content.document;
+ let submit = doc.getElementById("submit");
+ let iframe = doc.getElementById("post_iframe");
+ let p = new Promise((resolve, reject) => {
+ iframe.addEventListener("load", function onLoad() {
+ iframe.removeEventListener("load", onLoad);
+ resolve();
+ });
+ });
+ submit.click();
+ yield p;
+ });
+ let visited = yield promiseIsURIVisited(SJS_URI);
+ ok(!visited, "The POST page should not be added to history");
+ ok(!(yield PlacesTestUtils.isPageInDB(SJS_URI.spec)), "The page should not be in the database");
+ }));
+});
diff --git a/toolkit/components/places/tests/browser/browser_notfound.js b/toolkit/components/places/tests/browser/browser_notfound.js
new file mode 100644
index 000000000..20467eef4
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_notfound.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* () {
+ const TEST_URL = "http://mochi.test:8888/notFoundPage.html";
+
+ // Used to verify errors are not marked as typed.
+ PlacesUtils.history.markPageAsTyped(NetUtil.newURI(TEST_URL));
+
+ // Create and add history observer.
+ let visitedPromise = new Promise(resolve => {
+ let historyObserver = {
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType) {
+ PlacesUtils.history.removeObserver(historyObserver);
+ info("Received onVisit: " + aURI.spec);
+ fieldForUrl(aURI, "frecency", function (aFrecency) {
+ is(aFrecency, 0, "Frecency should be 0");
+ fieldForUrl(aURI, "hidden", function (aHidden) {
+ is(aHidden, 0, "Page should not be hidden");
+ fieldForUrl(aURI, "typed", function (aTyped) {
+ is(aTyped, 0, "page should not be marked as typed");
+ resolve();
+ });
+ });
+ });
+ },
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+ });
+
+ let newTabPromise = BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ yield Promise.all([visitedPromise, newTabPromise]);
+
+ yield PlacesTestUtils.clearHistory();
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_redirect.js b/toolkit/components/places/tests/browser/browser_redirect.js
new file mode 100644
index 000000000..d8a19731a
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_redirect.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* () {
+ const REDIRECT_URI = NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/browser/redirect.sjs");
+ const TARGET_URI = NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/browser/redirect-target.html");
+
+ // Create and add history observer.
+ let visitedPromise = new Promise(resolve => {
+ let historyObserver = {
+ _redirectNotified: false,
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType) {
+ info("Received onVisit: " + aURI.spec);
+
+ if (aURI.equals(REDIRECT_URI)) {
+ this._redirectNotified = true;
+ // Wait for the target page notification.
+ return;
+ }
+
+ PlacesUtils.history.removeObserver(historyObserver);
+
+ ok(this._redirectNotified, "The redirect should have been notified");
+
+ fieldForUrl(REDIRECT_URI, "frecency", function (aFrecency) {
+ ok(aFrecency != 0, "Frecency or the redirecting page should not be 0");
+
+ fieldForUrl(REDIRECT_URI, "hidden", function (aHidden) {
+ is(aHidden, 1, "The redirecting page should be hidden");
+
+ fieldForUrl(TARGET_URI, "frecency", function (aFrecency2) {
+ ok(aFrecency2 != 0, "Frecency of the target page should not be 0");
+
+ fieldForUrl(TARGET_URI, "hidden", function (aHidden2) {
+ is(aHidden2, 0, "The target page should not be hidden");
+ resolve();
+ });
+ });
+ });
+ });
+ },
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+ });
+
+ let newTabPromise = BrowserTestUtils.openNewForegroundTab(gBrowser, REDIRECT_URI.spec);
+ yield Promise.all([visitedPromise, newTabPromise]);
+
+ yield PlacesTestUtils.clearHistory();
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_settitle.js b/toolkit/components/places/tests/browser/browser_settitle.js
new file mode 100644
index 000000000..68c8deda7
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_settitle.js
@@ -0,0 +1,76 @@
+var conn = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+
+/**
+ * Gets a single column value from either the places or historyvisits table.
+ */
+function getColumn(table, column, url)
+{
+ var stmt = conn.createStatement(
+ `SELECT ${column} FROM ${table} WHERE url_hash = hash(:val) AND url = :val`);
+ try {
+ stmt.params.val = url;
+ stmt.executeStep();
+ return stmt.row[column];
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+add_task(function* ()
+{
+ // Make sure titles are correctly saved for a URI with the proper
+ // notifications.
+
+ // Create and add history observer.
+ let titleChangedPromise = new Promise(resolve => {
+ var historyObserver = {
+ data: [],
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function(aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType) {
+ },
+ onTitleChanged: function(aURI, aPageTitle, aGUID) {
+ this.data.push({ uri: aURI, title: aPageTitle, guid: aGUID });
+
+ // We only expect one title change.
+ //
+ // Although we are loading two different pages, the first page does not
+ // have a title. Since the title starts out as empty and then is set
+ // to empty, there is no title change notification.
+
+ PlacesUtils.history.removeObserver(this);
+ resolve(this.data);
+ },
+ onDeleteURI: function() {},
+ onClearHistory: function() {},
+ onPageChanged: function() {},
+ onDeleteVisits: function() {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+ });
+
+ const url1 = "http://example.com/tests/toolkit/components/places/tests/browser/title1.html";
+ yield BrowserTestUtils.openNewForegroundTab(gBrowser, url1);
+
+ const url2 = "http://example.com/tests/toolkit/components/places/tests/browser/title2.html";
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, url2);
+ yield loadPromise;
+
+ let data = yield titleChangedPromise;
+ is(data[0].uri.spec, "http://example.com/tests/toolkit/components/places/tests/browser/title2.html");
+ is(data[0].title, "Some title");
+ is(data[0].guid, getColumn("moz_places", "guid", data[0].uri.spec));
+
+ data.forEach(function(item) {
+ var title = getColumn("moz_places", "title", data[0].uri.spec);
+ is(title, item.title);
+ });
+
+ gBrowser.removeCurrentTab();
+ yield PlacesTestUtils.clearHistory();
+});
+
diff --git a/toolkit/components/places/tests/browser/browser_visited_notfound.js b/toolkit/components/places/tests/browser/browser_visited_notfound.js
new file mode 100644
index 000000000..b2b4f25b8
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visited_notfound.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_URI = NetUtil.newURI("http://mochi.test:8888/notFoundPage.html");
+
+function test() {
+ waitForExplicitFinish();
+
+ gBrowser.selectedTab = gBrowser.addTab();
+ registerCleanupFunction(function() {
+ gBrowser.removeCurrentTab();
+ });
+
+ // First add a visit to the page, this will ensure that later we skip
+ // updating the frecency for a newly not-found page.
+ addVisits({ uri: TEST_URI }, window, () => {
+ info("Added visit");
+ fieldForUrl(TEST_URI, "frecency", aFrecency => {
+ ok(aFrecency > 0, "Frecency should be > 0");
+ continueTest(aFrecency);
+ });
+ });
+}
+
+function continueTest(aOldFrecency) {
+ // Used to verify errors are not marked as typed.
+ PlacesUtils.history.markPageAsTyped(TEST_URI);
+ gBrowser.selectedBrowser.loadURI(TEST_URI.spec);
+
+ // Create and add history observer.
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType) {
+ PlacesUtils.history.removeObserver(historyObserver);
+ info("Received onVisit: " + aURI.spec);
+ fieldForUrl(aURI, "frecency", function (aFrecency) {
+ is(aFrecency, aOldFrecency, "Frecency should be unchanged");
+ fieldForUrl(aURI, "hidden", function (aHidden) {
+ is(aHidden, 0, "Page should not be hidden");
+ fieldForUrl(aURI, "typed", function (aTyped) {
+ is(aTyped, 0, "page should not be marked as typed");
+ PlacesTestUtils.clearHistory().then(finish);
+ });
+ });
+ });
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+}
diff --git a/toolkit/components/places/tests/browser/browser_visituri.js b/toolkit/components/places/tests/browser/browser_visituri.js
new file mode 100644
index 000000000..8ba2b7272
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri.js
@@ -0,0 +1,84 @@
+/**
+ * One-time observer callback.
+ */
+function promiseObserve(name, checkFn) {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject) {
+ if (checkFn(subject)) {
+ Services.obs.removeObserver(observer, name);
+ resolve();
+ }
+ }, name, false);
+ });
+}
+
+var conn = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+
+/**
+ * Gets a single column value from either the places or historyvisits table.
+ */
+function getColumn(table, column, fromColumnName, fromColumnValue) {
+ let sql = `SELECT ${column}
+ FROM ${table}
+ WHERE ${fromColumnName} = :val
+ ${fromColumnName == "url" ? "AND url_hash = hash(:val)" : ""}
+ LIMIT 1`;
+ let stmt = conn.createStatement(sql);
+ try {
+ stmt.params.val = fromColumnValue;
+ ok(stmt.executeStep(), "Expect to get a row");
+ return stmt.row[column];
+ }
+ finally {
+ stmt.reset();
+ }
+}
+
+add_task(function* () {
+ // Make sure places visit chains are saved correctly with a redirect
+ // transitions.
+
+ // Part 1: observe history events that fire when a visit occurs.
+ // Make sure visits appear in order, and that the visit chain is correct.
+ var expectedUrls = [
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html",
+ "http://example.com/tests/toolkit/components/places/tests/browser/redirect_twice.sjs",
+ "http://example.com/tests/toolkit/components/places/tests/browser/redirect_once.sjs",
+ "http://example.com/tests/toolkit/components/places/tests/browser/final.html"
+ ];
+ var currentIndex = 0;
+
+ function checkObserver(subject) {
+ var uri = subject.QueryInterface(Ci.nsIURI);
+ var expected = expectedUrls[currentIndex];
+ is(uri.spec, expected, "Saved URL visit " + uri.spec);
+
+ var placeId = getColumn("moz_places", "id", "url", uri.spec);
+ var fromVisitId = getColumn("moz_historyvisits", "from_visit", "place_id", placeId);
+
+ if (currentIndex == 0) {
+ is(fromVisitId, 0, "First visit has no from visit");
+ }
+ else {
+ var lastVisitId = getColumn("moz_historyvisits", "place_id", "id", fromVisitId);
+ var fromVisitUrl = getColumn("moz_places", "url", "id", lastVisitId);
+ is(fromVisitUrl, expectedUrls[currentIndex - 1],
+ "From visit was " + expectedUrls[currentIndex - 1]);
+ }
+
+ currentIndex++;
+ return (currentIndex >= expectedUrls.length);
+ }
+ let visitUriPromise = promiseObserve("uri-visit-saved", checkObserver);
+
+ const testUrl = "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+ yield BrowserTestUtils.openNewForegroundTab(gBrowser, testUrl);
+
+ // Load begin page, click link on page to record visits.
+ yield BrowserTestUtils.synthesizeMouseAtCenter("#clickme", { }, gBrowser.selectedBrowser);
+ yield visitUriPromise;
+
+ yield PlacesTestUtils.clearHistory();
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_visituri_nohistory.js b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js
new file mode 100644
index 000000000..a3a8e7626
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri_nohistory.js
@@ -0,0 +1,42 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const INITIAL_URL = "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+const FINAL_URL = "http://example.com/tests/toolkit/components/places/tests/browser/final.html";
+
+/**
+ * One-time observer callback.
+ */
+function promiseObserve(name)
+{
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject) {
+ Services.obs.removeObserver(observer, name);
+ resolve(subject);
+ }, name, false);
+ });
+}
+
+add_task(function* ()
+{
+ yield new Promise(resolve => SpecialPowers.pushPrefEnv({"set": [["places.history.enabled", false]]}, resolve));
+
+ let visitUriPromise = promiseObserve("uri-visit-saved");
+
+ yield BrowserTestUtils.openNewForegroundTab(gBrowser, INITIAL_URL);
+
+ yield new Promise(resolve => SpecialPowers.popPrefEnv(resolve));
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gBrowser.loadURI(FINAL_URL);
+ yield browserLoadedPromise;
+
+ let subject = yield visitUriPromise;
+ let uri = subject.QueryInterface(Ci.nsIURI);
+ is(uri.spec, FINAL_URL, "received expected visit");
+
+ yield PlacesTestUtils.clearHistory();
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js
new file mode 100644
index 000000000..abde69a7d
--- /dev/null
+++ b/toolkit/components/places/tests/browser/browser_visituri_privatebrowsing_perwindowpb.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function test() {
+ // initialization
+ waitForExplicitFinish();
+ let windowsToClose = [];
+ let initialURL =
+ "http://example.com/tests/toolkit/components/places/tests/browser/begin.html";
+ let finalURL =
+ "http://example.com/tests/toolkit/components/places/tests/browser/final.html";
+ let observer = null;
+ let enumerator = null;
+ let currentObserver = null;
+ let uri = null;
+
+ function doTest(aIsPrivateMode, aWindow, aTestURI, aCallback) {
+ observer = {
+ observe: function(aSubject, aTopic, aData) {
+ // The uri-visit-saved topic should only work when on normal mode.
+ if (aTopic == "uri-visit-saved") {
+ // Remove the observers set on per window private mode and normal
+ // mode.
+ enumerator = aWindow.Services.obs.enumerateObservers("uri-visit-saved");
+ while (enumerator.hasMoreElements()) {
+ currentObserver = enumerator.getNext();
+ aWindow.Services.obs.removeObserver(currentObserver, "uri-visit-saved");
+ }
+
+ // The expected visit should be the finalURL because private mode
+ // should not register a visit with the initialURL.
+ uri = aSubject.QueryInterface(Ci.nsIURI);
+ is(uri.spec, finalURL, "Check received expected visit");
+ }
+ }
+ };
+
+ aWindow.Services.obs.addObserver(observer, "uri-visit-saved", false);
+
+ BrowserTestUtils.browserLoaded(aWindow.gBrowser.selectedBrowser).then(aCallback);
+ aWindow.gBrowser.selectedBrowser.loadURI(aTestURI);
+ }
+
+ function testOnWindow(aOptions, aCallback) {
+ whenNewWindowLoaded(aOptions, function(aWin) {
+ windowsToClose.push(aWin);
+ // execute should only be called when need, like when you are opening
+ // web pages on the test. If calling executeSoon() is not necesary, then
+ // call whenNewWindowLoaded() instead of testOnWindow() on your test.
+ executeSoon(() => aCallback(aWin));
+ });
+ }
+
+ // This function is called after calling finish() on the test.
+ registerCleanupFunction(function() {
+ windowsToClose.forEach(function(aWin) {
+ aWin.close();
+ });
+ });
+
+ // test first when on private mode
+ testOnWindow({private: true}, function(aWin) {
+ doTest(true, aWin, initialURL, function() {
+ // then test when not on private mode
+ testOnWindow({}, function(aWin2) {
+ doTest(false, aWin2, finalURL, function () {
+ PlacesTestUtils.clearHistory().then(finish);
+ });
+ });
+ });
+ });
+}
diff --git a/toolkit/components/places/tests/browser/colorAnalyzer/category-discover.png b/toolkit/components/places/tests/browser/colorAnalyzer/category-discover.png
new file mode 100644
index 000000000..a6f5b49b3
--- /dev/null
+++ b/toolkit/components/places/tests/browser/colorAnalyzer/category-discover.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/colorAnalyzer/dictionaryGeneric-16.png b/toolkit/components/places/tests/browser/colorAnalyzer/dictionaryGeneric-16.png
new file mode 100644
index 000000000..4ad1a1a82
--- /dev/null
+++ b/toolkit/components/places/tests/browser/colorAnalyzer/dictionaryGeneric-16.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/colorAnalyzer/extensionGeneric-16.png b/toolkit/components/places/tests/browser/colorAnalyzer/extensionGeneric-16.png
new file mode 100644
index 000000000..fc6c8a258
--- /dev/null
+++ b/toolkit/components/places/tests/browser/colorAnalyzer/extensionGeneric-16.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/colorAnalyzer/localeGeneric.png b/toolkit/components/places/tests/browser/colorAnalyzer/localeGeneric.png
new file mode 100644
index 000000000..4d9ac5ad8
--- /dev/null
+++ b/toolkit/components/places/tests/browser/colorAnalyzer/localeGeneric.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/favicon-normal16.png b/toolkit/components/places/tests/browser/favicon-normal16.png
new file mode 100644
index 000000000..62b69a3d0
--- /dev/null
+++ b/toolkit/components/places/tests/browser/favicon-normal16.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/favicon-normal32.png b/toolkit/components/places/tests/browser/favicon-normal32.png
new file mode 100644
index 000000000..5535363c9
--- /dev/null
+++ b/toolkit/components/places/tests/browser/favicon-normal32.png
Binary files differ
diff --git a/toolkit/components/places/tests/browser/favicon.html b/toolkit/components/places/tests/browser/favicon.html
new file mode 100644
index 000000000..a0f5ea959
--- /dev/null
+++ b/toolkit/components/places/tests/browser/favicon.html
@@ -0,0 +1,13 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+
+<html>
+ <head>
+ <link rel="shortcut icon" href="http://example.org/tests/toolkit/components/places/tests/browser/favicon-normal32.png">
+ </head>
+ <body>
+ OK we're done!
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/final.html b/toolkit/components/places/tests/browser/final.html
new file mode 100644
index 000000000..ccd581918
--- /dev/null
+++ b/toolkit/components/places/tests/browser/final.html
@@ -0,0 +1,10 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+
+<html>
+ <body>
+ OK we're done!
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/head.js b/toolkit/components/places/tests/browser/head.js
new file mode 100644
index 000000000..897585a81
--- /dev/null
+++ b/toolkit/components/places/tests/browser/head.js
@@ -0,0 +1,319 @@
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
+ "resource://testing-common/PlacesTestUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserTestUtils",
+ "resource://testing-common/BrowserTestUtils.jsm");
+
+const TRANSITION_LINK = PlacesUtils.history.TRANSITION_LINK;
+const TRANSITION_TYPED = PlacesUtils.history.TRANSITION_TYPED;
+const TRANSITION_BOOKMARK = PlacesUtils.history.TRANSITION_BOOKMARK;
+const TRANSITION_REDIRECT_PERMANENT = PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT;
+const TRANSITION_REDIRECT_TEMPORARY = PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY;
+const TRANSITION_EMBED = PlacesUtils.history.TRANSITION_EMBED;
+const TRANSITION_FRAMED_LINK = PlacesUtils.history.TRANSITION_FRAMED_LINK;
+const TRANSITION_DOWNLOAD = PlacesUtils.history.TRANSITION_DOWNLOAD;
+
+/**
+ * Returns a moz_places field value for a url.
+ *
+ * @param aURI
+ * The URI or spec to get field for.
+ * param aCallback
+ * Callback function that will get the property value.
+ */
+function fieldForUrl(aURI, aFieldName, aCallback)
+{
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection.createAsyncStatement(
+ `SELECT ${aFieldName} FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url`
+ );
+ stmt.params.page_url = url;
+ stmt.executeAsync({
+ _value: -1,
+ handleResult: function(aResultSet) {
+ let row = aResultSet.getNextRow();
+ if (!row)
+ ok(false, "The page should exist in the database");
+ this._value = row.getResultByName(aFieldName);
+ },
+ handleError: function() {},
+ handleCompletion: function(aReason) {
+ if (aReason != Ci.mozIStorageStatementCallback.REASON_FINISHED)
+ ok(false, "The statement should properly succeed");
+ aCallback(this._value);
+ }
+ });
+ stmt.finalize();
+}
+
+/**
+ * Generic nsINavHistoryObserver that doesn't implement anything, but provides
+ * dummy methods to prevent errors about an object not having a certain method.
+ */
+function NavHistoryObserver() {}
+
+NavHistoryObserver.prototype = {
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onVisit: function () {},
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavHistoryObserver,
+ ])
+};
+
+/**
+ * Waits for the first OnPageChanged notification for ATTRIBUTE_FAVICON, and
+ * verifies that it matches the expected page URI and associated favicon URI.
+ *
+ * This function also double-checks the GUID parameter of the notification.
+ *
+ * @param aExpectedPageURI
+ * nsIURI object of the page whose favicon should change.
+ * @param aExpectedFaviconURI
+ * nsIURI object of the newly associated favicon.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function waitForFaviconChanged(aExpectedPageURI, aExpectedFaviconURI, aWindow,
+ aCallback) {
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onPageChanged: function WFFC_onPageChanged(aURI, aWhat, aValue, aGUID) {
+ if (aWhat != Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON) {
+ return;
+ }
+ aWindow.PlacesUtils.history.removeObserver(this);
+
+ ok(aURI.equals(aExpectedPageURI),
+ "Check URIs are equal for the page which favicon changed");
+ is(aValue, aExpectedFaviconURI.spec,
+ "Check changed favicon URI is the expected");
+ checkGuidForURI(aURI, aGUID);
+
+ if (aCallback) {
+ aCallback();
+ }
+ }
+ };
+ aWindow.PlacesUtils.history.addObserver(historyObserver, false);
+}
+
+/**
+ * Asynchronously adds visits to a page, invoking a callback function when done.
+ *
+ * @param aPlaceInfo
+ * Either an nsIURI, in such a case a single LINK visit will be added.
+ * Or can be an object describing the visit to add, or an array
+ * of these objects:
+ * { uri: nsIURI of the page,
+ * transition: one of the TRANSITION_* from nsINavHistoryService,
+ * [optional] title: title of the page,
+ * [optional] visitDate: visit date in microseconds from the epoch
+ * [optional] referrer: nsIURI of the referrer for this visit
+ * }
+ * @param [optional] aCallback
+ * Function to be invoked on completion.
+ * @param [optional] aStack
+ * The stack frame used to report errors.
+ */
+function addVisits(aPlaceInfo, aWindow, aCallback, aStack) {
+ let places = [];
+ if (aPlaceInfo instanceof Ci.nsIURI) {
+ places.push({ uri: aPlaceInfo });
+ }
+ else if (Array.isArray(aPlaceInfo)) {
+ places = places.concat(aPlaceInfo);
+ } else {
+ places.push(aPlaceInfo)
+ }
+
+ // Create mozIVisitInfo for each entry.
+ let now = Date.now();
+ for (let place of places) {
+ if (!place.title) {
+ place.title = "test visit for " + place.uri.spec;
+ }
+ place.visits = [{
+ transitionType: place.transition === undefined ? TRANSITION_LINK
+ : place.transition,
+ visitDate: place.visitDate || (now++) * 1000,
+ referrerURI: place.referrer
+ }];
+ }
+
+ aWindow.PlacesUtils.asyncHistory.updatePlaces(
+ places,
+ {
+ handleError: function AAV_handleError() {
+ throw ("Unexpected error in adding visit.");
+ },
+ handleResult: function () {},
+ handleCompletion: function UP_handleCompletion() {
+ if (aCallback)
+ aCallback();
+ }
+ }
+ );
+}
+
+/**
+ * Checks that the favicon for the given page matches the provided data.
+ *
+ * @param aPageURI
+ * nsIURI object for the page to check.
+ * @param aExpectedMimeType
+ * Expected MIME type of the icon, for example "image/png".
+ * @param aExpectedData
+ * Expected icon data, expressed as an array of byte values.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconDataForPage(aPageURI, aExpectedMimeType, aExpectedData,
+ aWindow, aCallback) {
+ aWindow.PlacesUtils.favicons.getFaviconDataForPage(aPageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ is(aExpectedMimeType, aMimeType, "Check expected MimeType");
+ is(aExpectedData.length, aData.length,
+ "Check favicon data for the given page matches the provided data");
+ checkGuidForURI(aPageURI);
+ aCallback();
+ });
+}
+
+/**
+ * Tests that a guid was set in moz_places for a given uri.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param [optional] aGUID
+ * The expected guid in the database.
+ */
+function checkGuidForURI(aURI, aGUID) {
+ let guid = doGetGuidForURI(aURI);
+ if (aGUID) {
+ doCheckValidPlacesGuid(aGUID);
+ is(guid, aGUID, "Check equal guid for URIs");
+ }
+}
+
+/**
+ * Retrieves the guid for a given uri.
+ *
+ * @param aURI
+ * The uri to check.
+ * @return the associated the guid.
+ */
+function doGetGuidForURI(aURI) {
+ let stmt = DBConn().createStatement(
+ `SELECT guid
+ FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = aURI.spec;
+ ok(stmt.executeStep(), "Check get guid for uri from moz_places");
+ let guid = stmt.row.guid;
+ stmt.finalize();
+ doCheckValidPlacesGuid(guid);
+ return guid;
+}
+
+/**
+ * Tests if a given guid is valid for use in Places or not.
+ *
+ * @param aGuid
+ * The guid to test.
+ */
+function doCheckValidPlacesGuid(aGuid) {
+ ok(/^[a-zA-Z0-9\-_]{12}$/.test(aGuid), "Check guid for valid places");
+}
+
+/**
+ * Gets the database connection. If the Places connection is invalid it will
+ * try to create a new connection.
+ *
+ * @param [optional] aForceNewConnection
+ * Forces creation of a new connection to the database. When a
+ * connection is asyncClosed it cannot anymore schedule async statements,
+ * though connectionReady will keep returning true (Bug 726990).
+ *
+ * @return The database connection or null if unable to get one.
+ */
+function DBConn(aForceNewConnection) {
+ let gDBConn;
+ if (!aForceNewConnection) {
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ if (db.connectionReady)
+ return db;
+ }
+
+ // If the Places database connection has been closed, create a new connection.
+ if (!gDBConn || aForceNewConnection) {
+ let file = Services.dirsvc.get('ProfD', Ci.nsIFile);
+ file.append("places.sqlite");
+ let dbConn = gDBConn = Services.storage.openDatabase(file);
+
+ // Be sure to cleanly close this connection.
+ Services.obs.addObserver(function DBCloseCallback(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(DBCloseCallback, aTopic);
+ dbConn.asyncClose();
+ }, "profile-before-change", false);
+ }
+
+ return gDBConn.connectionReady ? gDBConn : null;
+}
+
+function whenNewWindowLoaded(aOptions, aCallback) {
+ BrowserTestUtils.waitForNewWindow().then(aCallback);
+ OpenBrowserWindow(aOptions);
+}
+
+/**
+ * Asynchronously check a url is visited.
+ *
+ * @param aURI The URI.
+ * @param aExpectedValue The expected value.
+ * @return {Promise}
+ * @resolves When the check has been added successfully.
+ * @rejects JavaScript exception.
+ */
+function promiseIsURIVisited(aURI, aExpectedValue) {
+ return new Promise(resolve => {
+ PlacesUtils.asyncHistory.isURIVisited(aURI, function(unused, aIsVisited) {
+ resolve(aIsVisited);
+ });
+ });
+}
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ let tries = 0;
+ let interval = setInterval(function() {
+ if (tries >= 30) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ let conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, 200);
+ function moveOn() {
+ clearInterval(interval);
+ nextTest();
+ }
+}
diff --git a/toolkit/components/places/tests/browser/history_post.html b/toolkit/components/places/tests/browser/history_post.html
new file mode 100644
index 000000000..a579a9b8a
--- /dev/null
+++ b/toolkit/components/places/tests/browser/history_post.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Test post pages are not added to history</title>
+ </head>
+ <body>
+ <iframe name="post_iframe" id="post_iframe"></iframe>
+ <form method="post" action="http://example.com/tests/toolkit/components/places/tests/browser/history_post.sjs" target="post_iframe">
+ <input type="submit" id="submit"/>
+ </form>
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/history_post.sjs b/toolkit/components/places/tests/browser/history_post.sjs
new file mode 100644
index 000000000..3c86aad7b
--- /dev/null
+++ b/toolkit/components/places/tests/browser/history_post.sjs
@@ -0,0 +1,6 @@
+function handleRequest(request, response)
+{
+ response.setStatusLine("1.0", 200, "OK");
+ response.setHeader("Content-Type", "text/plain; charset=utf-8", false);
+ response.write("Ciao");
+}
diff --git a/toolkit/components/places/tests/browser/redirect-target.html b/toolkit/components/places/tests/browser/redirect-target.html
new file mode 100644
index 000000000..370026338
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect-target.html
@@ -0,0 +1 @@
+<!DOCTYPE html><html><body><p>Ciao!</p></body></html>
diff --git a/toolkit/components/places/tests/browser/redirect.sjs b/toolkit/components/places/tests/browser/redirect.sjs
new file mode 100644
index 000000000..f55e78eb1
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect.sjs
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function handleRequest(request, response)
+{
+ let page = "<!DOCTYPE html><html><body><p>Redirecting...</p></body></html>";
+
+ response.setStatusLine(request.httpVersion, "301", "Moved Permanently");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Content-Length", page.length + "", false);
+ response.setHeader("Location", "redirect-target.html", false);
+ response.write(page);
+}
diff --git a/toolkit/components/places/tests/browser/redirect_once.sjs b/toolkit/components/places/tests/browser/redirect_once.sjs
new file mode 100644
index 000000000..8b2a8aa55
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_once.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 301, "Found");
+ response.setHeader("Location", "final.html", false);
+}
diff --git a/toolkit/components/places/tests/browser/redirect_twice.sjs b/toolkit/components/places/tests/browser/redirect_twice.sjs
new file mode 100644
index 000000000..099d20022
--- /dev/null
+++ b/toolkit/components/places/tests/browser/redirect_twice.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_once.sjs", false);
+}
diff --git a/toolkit/components/places/tests/browser/title1.html b/toolkit/components/places/tests/browser/title1.html
new file mode 100644
index 000000000..3c98d693e
--- /dev/null
+++ b/toolkit/components/places/tests/browser/title1.html
@@ -0,0 +1,12 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+
+<html>
+ <head>
+ </head>
+ <body>
+ title1.html
+ </body>
+</html>
diff --git a/toolkit/components/places/tests/browser/title2.html b/toolkit/components/places/tests/browser/title2.html
new file mode 100644
index 000000000..28a6b69b5
--- /dev/null
+++ b/toolkit/components/places/tests/browser/title2.html
@@ -0,0 +1,14 @@
+<!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+-->
+
+<html>
+ <head>
+ <title>Some title</title>
+ </head>
+ <body>
+ title2.html
+ </body>
+</html>
+
diff --git a/toolkit/components/places/tests/chrome/.eslintrc.js b/toolkit/components/places/tests/chrome/.eslintrc.js
new file mode 100644
index 000000000..bf379df8d
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/.eslintrc.js
@@ -0,0 +1,8 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/mochitest/chrome.eslintrc.js",
+ "../../../../../testing/mochitest/mochitest.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/chrome/bad_links.atom b/toolkit/components/places/tests/chrome/bad_links.atom
new file mode 100644
index 000000000..446927252
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/bad_links.atom
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+
+ <title>Example Feed</title>
+ <link href="http://example.org/"/>
+ <updated>2003-12-13T18:30:02Z</updated>
+
+ <author>
+ <name>John Doe</name>
+ </author>
+ <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+ <entry>
+
+ <title>First good item</title>
+ <link href="http://example.org/first"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+ <updated>2003-12-13T18:30:02Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+ <entry>
+
+ <title>data: link</title>
+ <link href="data:text/plain,Hi"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b</id>
+ <updated>2003-12-13T18:30:03Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+ <entry>
+
+ <title>javascript: link</title>
+ <link href="javascript:alert('Hi')"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6c</id>
+ <updated>2003-12-13T18:30:04Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+ <entry>
+
+ <title>file: link</title>
+ <link href="file:///var/"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6d</id>
+ <updated>2003-12-13T18:30:05Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+ <entry>
+
+ <title>chrome: link</title>
+ <link href="chrome://browser/content/browser.js"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6e</id>
+ <updated>2003-12-13T18:30:06Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+ <entry>
+
+ <title>Last good item</title>
+ <link href="http://example.org/last"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6b</id>
+ <updated>2003-12-13T18:30:07Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+
+</feed>
diff --git a/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xul b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xul
new file mode 100644
index 000000000..d7bbfda67
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/browser_disableglobalhistory.xul
@@ -0,0 +1,44 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+
+<window title="Test disableglobalhistory attribute on remote browsers"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="run_test();">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+
+ <browser id="inprocess_disabled" src="about:blank" type="content" disableglobalhistory="true" />
+ <browser id="inprocess_enabled" src="about:blank" type="content" />
+
+ <browser id="remote_disabled" src="about:blank" type="content" disableglobalhistory="true" />
+ <browser id="remote_enabled" src="about:blank" type="content" />
+
+ <script type="text/javascript;version=1.7">
+ const {interfaces: Ci, classes: Cc, results: Cr, utils: Cu} = Components;
+
+ Cu.import("resource://testing-common/ContentTask.jsm");
+ ContentTask.setTestScope(window.opener.wrappedJSObject);
+
+ function expectUseGlobalHistory(id, expected) {
+ let browser = document.getElementById(id);
+ return ContentTask.spawn(browser, {id, expected}, function*({id, expected}) {
+ Assert.equal(docShell.useGlobalHistory, expected,
+ "Got the right useGlobalHistory state in the docShell of " + id);
+ });
+ }
+
+ function run_test() {
+ spawn_task(function*() {
+ yield expectUseGlobalHistory("inprocess_disabled", false);
+ yield expectUseGlobalHistory("inprocess_enabled", true);
+
+ yield expectUseGlobalHistory("remote_disabled", false);
+ yield expectUseGlobalHistory("remote_enabled", true);
+ window.opener.done();
+ });
+ };
+
+ </script>
+</window> \ No newline at end of file
diff --git a/toolkit/components/places/tests/chrome/chrome.ini b/toolkit/components/places/tests/chrome/chrome.ini
new file mode 100644
index 000000000..5ac753e73
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/chrome.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+
+[test_303567.xul]
+[test_341972a.xul]
+[test_341972b.xul]
+[test_342484.xul]
+[test_371798.xul]
+[test_381357.xul]
+[test_favicon_annotations.xul]
+[test_reloadLivemarks.xul]
+[test_browser_disableglobalhistory.xul]
+support-files = browser_disableglobalhistory.xul \ No newline at end of file
diff --git a/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss
new file mode 100644
index 000000000..612b0a5c2
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<rss version="2.0">
+ <channel>
+ <title>feed title</title>
+ <ttl>180</ttl>
+ <item>
+ <title>linked feed item</title>
+ <link>http://feed-item-link.com</link>
+ </item>
+ <item>
+ <title>link-less feed item</title>
+ </item>
+ <item>
+ <title>linked feed item</title>
+ <link>http://feed-item-link.com</link>
+ </item>
+ </channel>
+</rss>
diff --git a/toolkit/components/places/tests/chrome/link-less-items.rss b/toolkit/components/places/tests/chrome/link-less-items.rss
new file mode 100644
index 000000000..a30d4a353
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/link-less-items.rss
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<rss version="2.0">
+ <channel>
+ <title>feed title</title>
+ <link>http://feed-link.com</link>
+ <ttl>180</ttl>
+ <item>
+ <title>linked feed item</title>
+ <link>http://feed-item-link.com</link>
+ </item>
+ <item>
+ <title>link-less feed item</title>
+ </item>
+ <item>
+ <title>linked feed item</title>
+ <link>http://feed-item-link.com</link>
+ </item>
+ </channel>
+</rss>
diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss b/toolkit/components/places/tests/chrome/rss_as_html.rss
new file mode 100644
index 000000000..e82305035
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/rss_as_html.rss
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="ISO-8859-1" ?>
+<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+<channel>
+<title>sadfasdfasdfasfasdf</title>
+<link>http://www.example.com</link>
+<description>asdfasdfasdf.example.com</description>
+<language>de</language>
+<copyright>asdfasdfasdfasdf</copyright>
+<lastBuildDate>Tue, 11 Mar 2008 18:52:52 +0100</lastBuildDate>
+<docs>http://blogs.law.harvard.edu/tech/rss</docs>
+<ttl>10</ttl>
+<item>
+<title>The First Title</title>
+<link>http://www.example.com/index.html</link>
+<pubDate>Tue, 11 Mar 2008 18:24:43 +0100</pubDate>
+<content:encoded>
+<![CDATA[
+<p>
+askdlfjas;dfkjas;fkdj
+</p>
+]]>
+</content:encoded>
+<description>aklsjdhfasdjfahasdfhj</description>
+<guid>http://foo.example.com/asdfasdf</guid>
+</item>
+</channel>
+</rss>
diff --git a/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^
new file mode 100644
index 000000000..04fbaa08f
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/rss_as_html.rss^headers^
@@ -0,0 +1,2 @@
+HTTP 200 OK
+Content-Type: text/html
diff --git a/toolkit/components/places/tests/chrome/sample_feed.atom b/toolkit/components/places/tests/chrome/sample_feed.atom
new file mode 100644
index 000000000..add75efb4
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/sample_feed.atom
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+
+ <title>Example Feed</title>
+ <link href="http://example.org/"/>
+ <updated>2003-12-13T18:30:02Z</updated>
+
+ <author>
+ <name>John Doe</name>
+ </author>
+ <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+ <entry>
+
+ <title>Atom-Powered Robots Run Amok</title>
+ <link href="http://example.org/2003/12/13/atom03"/>
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+ <updated>2003-12-13T18:30:02Z</updated>
+
+ <summary>Some text.</summary>
+ </entry>
+
+</feed>
diff --git a/toolkit/components/places/tests/chrome/test_303567.xul b/toolkit/components/places/tests/chrome/test_303567.xul
new file mode 100644
index 000000000..37ae77cbb
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_303567.xul
@@ -0,0 +1,122 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Add Bad Livemarks"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+// Test that for feeds with items that have no link:
+// * the link-less items are present in the database.
+// * the feed's site URI is substituted for each item's link.
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+const LIVEMARKS = [
+ { feedURI: NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/link-less-items.rss"),
+ siteURI: NetUtil.newURI("http://mochi.test:8888/"),
+ urls: [
+ "http://feed-item-link.com/",
+ "http://feed-link.com/",
+ "http://feed-item-link.com/",
+ ],
+ message: "Ensure link-less livemark item picked up site uri.",
+ },
+ { feedURI: NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss"),
+ siteURI: null,
+ urls: [
+ "http://feed-item-link.com/",
+ "http://feed-item-link.com/",
+ ],
+ message: "Ensure livemark item links did not inherit site uri."
+ },
+];
+
+function runTest()
+{
+ let loadCount = 0;
+
+ function testLivemark(aLivemarkData) {
+ PlacesUtils.livemarks.addLivemark(
+ { title: "foo"
+ , parentGuid: PlacesUtils.bookmarks.toolbarGuid
+ , feedURI: aLivemarkData.feedURI
+ , siteURI: aLivemarkData.siteURI
+ })
+ .then(function (aLivemark) {
+ is (aLivemark.feedURI.spec, aLivemarkData.feedURI.spec,
+ "Get correct feedURI");
+ if (aLivemarkData.siteURI) {
+ is (aLivemark.siteURI.spec, aLivemarkData.siteURI.spec,
+ "Get correct siteURI");
+ }
+ else {
+ is (aLivemark.siteURI, null, "Get correct siteURI");
+ }
+
+ waitForLivemarkLoad(aLivemark, function (aLivemark) {
+ let nodes = aLivemark.getNodesForContainer({});
+ is(nodes.length, aLivemarkData.urls.length,
+ "Ensure all the livemark items were created.");
+ aLivemarkData.urls.forEach(function (aUrl, aIndex) {
+ let node = nodes[aIndex];
+ is(node.uri, aUrl, aLivemarkData.message);
+ });
+
+ PlacesUtils.livemarks.removeLivemark(aLivemark).then(() => {
+ if (++loadCount == LIVEMARKS.length)
+ SimpleTest.finish();
+ });
+ });
+ }, function () {
+ is(true, false, "Should not fail adding a livemark");
+ }
+ );
+ }
+
+ LIVEMARKS.forEach(testLivemark);
+}
+
+function waitForLivemarkLoad(aLivemark, aCallback) {
+ // Don't need a real node here.
+ let node = {};
+ let resultObserver = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(node) {
+ isnot(aLivemark.status, Ci.mozILivemark.STATUS_FAILED,
+ "Loading livemark should success");
+ if (aLivemark.status == Ci.mozILivemark.STATUS_READY) {
+ aLivemark.unregisterForUpdates(node, resultObserver);
+ aCallback(aLivemark);
+ }
+ }
+ };
+ aLivemark.registerForUpdates(node, resultObserver);
+ aLivemark.reload();
+}
+
+]]>
+</script>
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_341972a.xul b/toolkit/components/places/tests/chrome/test_341972a.xul
new file mode 100644
index 000000000..7c78136a9
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_341972a.xul
@@ -0,0 +1,87 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Update Livemark SiteURI"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+/*
+ Test updating livemark siteURI to the value from the feed
+ */
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+function runTest() {
+ const FEEDSPEC = "http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/sample_feed.atom";
+ const INITIALSITESPEC = "http://mochi.test:8888/";
+ const FEEDSITESPEC = "http://example.org/";
+
+ PlacesUtils.livemarks.addLivemark(
+ { title: "foo"
+ , parentGuid: PlacesUtils.bookmarks.toolbarGuid
+ , feedURI: NetUtil.newURI(FEEDSPEC)
+ , siteURI: NetUtil.newURI(INITIALSITESPEC)
+ })
+ .then(function (aLivemark) {
+ is(aLivemark.siteURI.spec, INITIALSITESPEC,
+ "Has correct initial livemark site URI");
+
+ waitForLivemarkLoad(aLivemark, function (aLivemark) {
+ is(aLivemark.siteURI.spec, FEEDSITESPEC,
+ "livemark site URI set to value in feed");
+
+ PlacesUtils.livemarks.removeLivemark(aLivemark).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ }, function () {
+ is(true, false, "Should not fail adding a livemark");
+ }
+ );
+}
+
+function waitForLivemarkLoad(aLivemark, aCallback) {
+ // Don't need a real node here.
+ let node = {};
+ let resultObserver = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(node) {
+ isnot(aLivemark.status, Ci.mozILivemark.STATUS_FAILED,
+ "Loading livemark should success");
+ if (aLivemark.status == Ci.mozILivemark.STATUS_READY) {
+ aLivemark.unregisterForUpdates(node, resultObserver);
+ aCallback(aLivemark);
+ }
+ }
+ };
+ aLivemark.registerForUpdates(node, resultObserver);
+ aLivemark.reload();
+}
+
+]]>
+</script>
+
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_341972b.xul b/toolkit/components/places/tests/chrome/test_341972b.xul
new file mode 100644
index 000000000..86cdc75f3
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_341972b.xul
@@ -0,0 +1,84 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Update Livemark SiteURI, null to start"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+/*
+ Test updating livemark siteURI to the value from the feed, when it's null
+ */
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+function runTest() {
+ const FEEDSPEC = "http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/sample_feed.atom";
+ const FEEDSITESPEC = "http://example.org/";
+
+ PlacesUtils.livemarks.addLivemark(
+ { title: "foo"
+ , parentGuid: PlacesUtils.bookmarks.toolbarGuid
+ , feedURI: NetUtil.newURI(FEEDSPEC)
+ })
+ .then(function (aLivemark) {
+ is(aLivemark.siteURI, null, "Has null livemark site URI");
+
+ waitForLivemarkLoad(aLivemark, function (aLivemark) {
+ is(aLivemark.siteURI.spec, FEEDSITESPEC,
+ "livemark site URI set to value in feed");
+
+ PlacesUtils.livemarks.removeLivemark(aLivemark).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ }, function () {
+ is(true, false, "Should not fail adding a livemark");
+ }
+ );
+}
+
+function waitForLivemarkLoad(aLivemark, aCallback) {
+ // Don't need a real node here.
+ let node = {};
+ let resultObserver = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(node) {
+ isnot(aLivemark.status, Ci.mozILivemark.STATUS_FAILED,
+ "Loading livemark should success");
+ if (aLivemark.status == Ci.mozILivemark.STATUS_READY) {
+ aLivemark.unregisterForUpdates(node, resultObserver);
+ aCallback(aLivemark);
+ }
+ }
+ };
+ aLivemark.registerForUpdates(node, resultObserver);
+ aLivemark.reload();
+}
+
+]]>
+</script>
+
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_342484.xul b/toolkit/components/places/tests/chrome/test_342484.xul
new file mode 100644
index 000000000..353313abb
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_342484.xul
@@ -0,0 +1,88 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Add Bad Livemarks"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+/*
+ Test loading feeds with items that aren't allowed
+ */
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+function runTest() {
+ const FEEDSPEC = "http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/bad_links.atom";
+ const GOOD_URLS = ["http://example.org/first", "http://example.org/last"];
+
+ PlacesUtils.livemarks.addLivemark(
+ { title: "foo"
+ , parentGuid: PlacesUtils.bookmarks.toolbarGuid
+ , feedURI: NetUtil.newURI(FEEDSPEC)
+ , siteURI: NetUtil.newURI("http:/mochi.test/")
+ })
+ .then(function (aLivemark) {
+ waitForLivemarkLoad(aLivemark, function (aLivemark) {
+ let nodes = aLivemark.getNodesForContainer({});
+
+ is(nodes.length, 2, "Created the two good livemark items");
+ for (let i = 0; i < nodes.length; ++i) {
+ let node = nodes[i];
+ ok(GOOD_URLS.includes(node.uri), "livemark item created with bad uri " + node.uri);
+ }
+
+ PlacesUtils.livemarks.removeLivemark(aLivemark).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ }, function () {
+ is(true, false, "Should not fail adding a livemark");
+ }
+ );
+}
+
+function waitForLivemarkLoad(aLivemark, aCallback) {
+ // Don't need a real node here.
+ let node = {};
+ let resultObserver = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(node) {
+ isnot(aLivemark.status, Ci.mozILivemark.STATUS_FAILED,
+ "Loading livemark should success");
+ if (aLivemark.status == Ci.mozILivemark.STATUS_READY) {
+ aLivemark.unregisterForUpdates(node, resultObserver);
+ aCallback(aLivemark);
+ }
+ }
+ };
+ aLivemark.registerForUpdates(node, resultObserver);
+ aLivemark.reload();
+}
+
+]]>
+</script>
+
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_371798.xul b/toolkit/components/places/tests/chrome/test_371798.xul
new file mode 100644
index 000000000..241db75c3
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_371798.xul
@@ -0,0 +1,101 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Bug 371798"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+// Test the asynchronous live-updating of bookmarks query results
+SimpleTest.waitForExplicitFinish();
+
+var {utils: Cu, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+const TEST_URI = NetUtil.newURI("http://foo.com");
+
+function promiseOnItemChanged() {
+ return new Promise(resolve => {
+ PlacesUtils.bookmarks.addObserver({
+ onBeginUpdateBatch() {},
+ onEndUpdateBatch() {},
+ onItemAdded() {},
+ onItemRemoved() {},
+ onItemVisited() {},
+ onItemMoved() {},
+
+ onItemChanged() {
+ PlacesUtils.bookmarks.removeObserver(this);
+ resolve();
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver])
+ }, false);
+ });
+}
+
+Task.spawn(function* () {
+ // add 2 bookmarks to the toolbar, same URI, different titles (set later)
+ let bm1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: TEST_URI
+ });
+
+ let bm2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: TEST_URI
+ });
+
+ // query for bookmarks
+ let rootNode = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+
+ // set up observer
+ let promiseObserved = promiseOnItemChanged();
+
+ // modify the bookmark's title
+ yield PlacesUtils.bookmarks.update({
+ guid: bm2.guid, title: "foo"
+ });
+
+ // wait for notification
+ yield promiseObserved;
+
+ // Continue after our observer gets notified of onItemChanged
+ // which is triggered by updating the item's title.
+ // After receiving the notification, our original query should also
+ // have been live-updated, so we can iterate through its children,
+ // to check that only the modified bookmark has changed.
+
+ // result node should be updated
+ let cc = rootNode.childCount;
+ for (let i = 0; i < cc; ++i) {
+ let node = rootNode.getChild(i);
+ // test that bm1 does not have new title
+ if (node.bookmarkGuid == bm1.guid)
+ ok(node.title != "foo",
+ "Changing a bookmark's title did not affect the title of other bookmarks with the same URI");
+ }
+ rootNode.containerOpen = false;
+
+ // clean up
+ yield PlacesUtils.bookmarks.remove(bm1);
+ yield PlacesUtils.bookmarks.remove(bm2);
+}).catch(err => {
+ ok(false, `uncaught error: ${err}`);
+}).then(() => {
+ SimpleTest.finish();
+});
+]]>
+</script>
+
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_381357.xul b/toolkit/components/places/tests/chrome/test_381357.xul
new file mode 100644
index 000000000..6bd6cb024
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_381357.xul
@@ -0,0 +1,85 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Add Livemarks from RSS feed served as text/html"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+/*
+ Test loading feeds with text/html
+ */
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+function runTest() {
+ const FEEDSPEC = "http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/rss_as_html.rss";
+
+ PlacesUtils.livemarks.addLivemark(
+ { title: "foo"
+ , parentGuid: PlacesUtils.bookmarks.toolbarGuid
+ , feedURI: NetUtil.newURI(FEEDSPEC)
+ , siteURI: NetUtil.newURI("http:/mochi.test/")
+ })
+ .then(function (aLivemark) {
+ waitForLivemarkLoad(aLivemark, function (aLivemark) {
+ let nodes = aLivemark.getNodesForContainer({});
+
+ is(nodes[0].title, "The First Title",
+ "livemark site URI set to value in feed");
+
+ PlacesUtils.livemarks.removeLivemark(aLivemark).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ }, function () {
+ is(true, false, "Should not fail adding a livemark");
+ SimpleTest.finish();
+ }
+ );
+}
+
+function waitForLivemarkLoad(aLivemark, aCallback) {
+ // Don't need a real node here.
+ let node = {};
+ let resultObserver = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(node) {
+ isnot(aLivemark.status, Ci.mozILivemark.STATUS_FAILED,
+ "Loading livemark should success");
+ if (aLivemark.status == Ci.mozILivemark.STATUS_READY) {
+ aLivemark.unregisterForUpdates(node, resultObserver);
+ aCallback(aLivemark);
+ }
+ }
+ };
+ aLivemark.registerForUpdates(node, resultObserver);
+ aLivemark.reload();
+}
+
+]]>
+</script>
+
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xul b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xul
new file mode 100644
index 000000000..3a84f3030
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_browser_disableglobalhistory.xul
@@ -0,0 +1,26 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"
+ type="text/css"?>
+
+<window title="Test disableglobalhistory attribute on remote browsers"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <!-- test results are displayed in the html:body -->
+ <body xmlns="http://www.w3.org/1999/xhtml">
+ </body>
+
+ <script type="text/javascript;version=1.7">
+ SimpleTest.waitForExplicitFinish();
+
+ let w = window.open('browser_disableglobalhistory.xul', '_blank', 'chrome,resizable=yes,width=400,height=600');
+
+ function done() {
+ w.close();
+ SimpleTest.finish();
+ }
+ </script>
+
+</window> \ No newline at end of file
diff --git a/toolkit/components/places/tests/chrome/test_favicon_annotations.xul b/toolkit/components/places/tests/chrome/test_favicon_annotations.xul
new file mode 100644
index 000000000..b7647cbc6
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_favicon_annotations.xul
@@ -0,0 +1,168 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!--
+ * This file tests the moz-anno protocol, which was added in Bug 316077 and how
+ * it loads favicons.
+-->
+
+<window title="Favicon Annotation Protocol Test"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="test();">
+
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/>
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/WindowSnapshot.js"/>
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/>
+
+ <script type="application/javascript">
+ <![CDATA[
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+let fs = Cc["@mozilla.org/browser/favicon-service;1"].
+ getService(Ci.nsIFaviconService);
+
+// Test descriptions that will be printed in the case of failure.
+let testDescriptions = [
+ "moz-anno URI with no data in the database loads default icon",
+ "URI added to the database is properly loaded",
+];
+
+// URIs to load (will be compared with expectedURIs of the same index).
+let testURIs = [
+ "http://mozilla.org/2009/made-up-favicon/places-rocks/",
+ "http://mozilla.org/should-be-barney/",
+];
+
+// URIs to load for expected results.
+let expectedURIs = [
+ fs.defaultFavicon.spec,
+ "data:image/png,%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%10%00%00%00%10%08%06%00%00%00%1F%F3%FFa%00%00%00%04gAMA%00%00%AF%C87%05%8A%E9%00%00%00%19tEXtSoftware%00Adobe%20ImageReadyq%C9e%3C%00%00%01%D6IDATx%DAb%FC%FF%FF%3F%03%25%00%20%80%98%909%EF%DF%BFg%EF%EC%EC%FC%AD%AC%AC%FC%DF%95%91%F1%BF%89%89%C9%7F%20%FF%D7%EA%D5%AB%B7%DF%BBwO%16%9B%01%00%01%C4%00r%01%08%9F9s%C6%CD%D8%D8%F8%BF%0B%03%C3%FF3%40%BC%0A%88%EF%02q%1A%10%BB%40%F1%AAU%ABv%C1%D4%C30%40%00%81%89%993g%3E%06%1A%F6%3F%14%AA%11D%97%03%F1%7Fc%08%0D%E2%2B))%FD%17%04%89%A1%19%00%10%40%0C%D00%F8%0F3%00%C8%F8%BF%1B%E4%0Ac%88a%E5%60%17%19%FF%0F%0D%0D%05%1B%02v%D9%DD%BB%0A0%03%00%02%08%AC%B9%A3%A3%E3%17%03%D4v%90%01%EF%18%106%C3%0Cz%07%C5%BB%A1%DE%82y%07%20%80%A0%A6%08B%FCn%0C1%60%26%D4%20d%C3VA%C3%06%26%BE%0A%EA-%80%00%82%B9%E0%F7L4%0D%EF%90%F8%C6%60%2F%0A%82%BD%01%13%07%0700%D0%01%02%88%11%E4%02P%B41%DC%BB%C7%D0%014%0D%E8l%06W%20%06%BA%88%A1%1C%1AS%15%40%7C%16%CA6.%2Fgx%BFg%0F%83%CB%D9%B3%0C%7B%80%7C%80%00%02%BB%00%E8%9F%ED%20%1B%3A%A0%A6%9F%81%DA%DC%01%C5%B0%80%ED%80%FA%BF%BC%BC%FC%3F%83%12%90%9D%96%F6%1F%20%80%18%DE%BD%7B%C7%0E%8E%05AD%20%FEGr%A6%A0%A0%E0%7F%25P%80%02%9D%0F%D28%13%18%23%C6%C0%B0%02E%3D%C8%F5%00%01%04%8F%05P%A8%BA%40my%87%E4%12c%A8%8D%20%8B%D0%D3%00%08%03%04%10%9C%01R%E4%82d%3B%C8%A0%99%C6%90%90%C6%A5%19%84%01%02%08%9E%17%80%C9x%F7%7B%A0%DBVC%F9%A0%C0%5C%7D%16%2C%CE%00%F4%C6O%5C%99%09%20%800L%04y%A5%03%1A%95%A0%80%05%05%14.%DBA%18%20%80%18)%CD%CE%00%01%06%00%0C'%94%C7%C0k%C9%2C%00%00%00%00IEND%AEB%60%82",
+];
+
+
+/**
+ * The event listener placed on our test windows used to determine when it is
+ * safe to compare the two windows.
+ */
+let _results = [];
+function loadEventHandler()
+{
+ _results.push(snapshotWindow(window));
+
+ loadNextTest();
+}
+
+/**
+ * This runs the comparison.
+ */
+function compareResults(aIndex, aImage1, aImage2)
+{
+ let [correct, data1, data2] = compareSnapshots(aImage1, aImage2, true);
+ SimpleTest.ok(correct,
+ "Test '" + testDescriptions[aIndex] + "' matches expectations. " +
+ "Data from window 1 is '" + data1 + "'. " +
+ "Data from window 2 is '" + data2 + "'");
+}
+
+/**
+ * Loads the next set of URIs to compare against.
+ */
+let _counter = -1;
+function loadNextTest()
+{
+ _counter++;
+ // If we have no more tests, finish.
+ if (_counter / 2 == testDescriptions.length) {
+ for (let i = 0; i < _results.length; i = i + 2)
+ compareResults(i / 2, _results[i], _results[i + 1]);
+
+ SimpleTest.finish();
+ return;
+ }
+
+ let nextURI = function() {
+ let index = Math.floor(_counter / 2);
+ if ((_counter % 2) == 0)
+ return "moz-anno:favicon:" + testURIs[index];
+ return expectedURIs[index];
+ }
+
+ let img = document.getElementById("favicon");
+ img.setAttribute("src", nextURI());
+}
+
+function test()
+{
+ SimpleTest.waitForExplicitFinish();
+ let db = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsPIPlacesDatabase).
+ DBConnection;
+
+ // Empty any old favicons
+ db.executeSimpleSQL("DELETE FROM moz_favicons");
+
+ let ios = Cc["@mozilla.org/network/io-service;1"].
+ getService(Ci.nsIIOService);
+ let uri = function(aSpec) {
+ return ios.newURI(aSpec, null, null);
+ };
+
+ let pageURI = uri("http://example.com/favicon_annotations");
+ let history = Cc["@mozilla.org/browser/history;1"]
+ .getService(Ci.mozIAsyncHistory);
+ history.updatePlaces(
+ {
+ uri: pageURI,
+ visits: [{ transitionType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ visitDate: Date.now() * 1000
+ }],
+ },
+ {
+ handleError: function UP_handleError() {
+ ok(false, "Unexpected error in adding visit.");
+ },
+ handleResult: function () {},
+ handleCompletion: function UP_handleCompletion() {
+ // Set the favicon data. Note that the "moz-anno:" protocol requires
+ // the favicon to be stored in the database, but the
+ // replaceFaviconDataFromDataURL function will not save the favicon
+ // unless it is associated with a page. Thus, we must associate the
+ // icon with a page explicitly in order for it to be visible through
+ // the protocol.
+ var systemPrincipal = Cc["@mozilla.org/systemprincipal;1"]
+ .createInstance(Ci.nsIPrincipal);
+
+ fs.replaceFaviconDataFromDataURL(uri(testURIs[1]), expectedURIs[1],
+ (Date.now() + 60 * 60 * 24 * 1000) * 1000,
+ systemPrincipal);
+
+ fs.setAndFetchFaviconForPage(pageURI, uri(testURIs[1]), true,
+ fs.FAVICON_LOAD_NON_PRIVATE,
+ null, systemPrincipal);
+
+ // And start our test process.
+ loadNextTest();
+ }
+ }
+ );
+
+
+}
+
+ ]]>
+ </script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml">
+ <img id="favicon" onload="loadEventHandler();"/>
+ <p id="display"></p>
+ <div id="content" style="display:none;"></div>
+ <pre id="test"></pre>
+ </body>
+</window>
diff --git a/toolkit/components/places/tests/chrome/test_reloadLivemarks.xul b/toolkit/components/places/tests/chrome/test_reloadLivemarks.xul
new file mode 100644
index 000000000..43772d09f
--- /dev/null
+++ b/toolkit/components/places/tests/chrome/test_reloadLivemarks.xul
@@ -0,0 +1,155 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet
+ href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<window title="Reload Livemarks"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ onload="runTest()" onunload="cleanup()">
+ <script type="application/javascript"
+ src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" />
+
+<script type="application/javascript">
+<![CDATA[
+// Test that for concurrent reload of livemarks.
+
+SimpleTest.waitForExplicitFinish();
+
+var Cc = Components.classes;
+var Ci = Components.interfaces;
+var Cr = Components.results;
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/NetUtil.jsm");
+Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
+
+let gLivemarks = [
+ { id: -1,
+ title: "foo",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ feedURI: NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/link-less-items.rss")
+ },
+ { id: -1,
+ title: "bar",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ feedURI: NetUtil.newURI("http://mochi.test:8888/tests/toolkit/components/places/tests/chrome/link-less-items-no-site-uri.rss")
+ },
+];
+
+function runTest()
+{
+ addLivemarks(function () {
+ reloadLivemarks(false, function () {
+ reloadLivemarks(true, function () {
+ removeLivemarks(SimpleTest.finish);
+ });
+ });
+ // Ensure this normal reload doesn't overwrite the forced one.
+ PlacesUtils.livemarks.reloadLivemarks();
+ });
+}
+
+function addLivemarks(aCallback) {
+ info("Adding livemarks");
+ let count = gLivemarks.length;
+ gLivemarks.forEach(function(aLivemarkData) {
+ PlacesUtils.livemarks.addLivemark(aLivemarkData)
+ .then(function (aLivemark) {
+ ok(aLivemark.feedURI.equals(aLivemarkData.feedURI), "Livemark added");
+ aLivemarkData.id = aLivemark.id;
+ if (--count == 0) {
+ aCallback();
+ }
+ },
+ function () {
+ is(true, false, "Should not fail adding a livemark.");
+ aCallback();
+ });
+ });
+}
+
+function reloadLivemarks(aForceUpdate, aCallback) {
+ info("Reloading livemarks with forceUpdate: " + aForceUpdate);
+ let count = gLivemarks.length;
+ gLivemarks.forEach(function(aLivemarkData) {
+ PlacesUtils.livemarks.getLivemark(aLivemarkData)
+ .then(aLivemark => {
+ ok(aLivemark.feedURI.equals(aLivemarkData.feedURI), "Livemark found");
+ aLivemarkData._observer = new resultObserver(aLivemark, function() {
+ if (++count == gLivemarks.length) {
+ aCallback();
+ }
+ });
+ if (--count == 0) {
+ PlacesUtils.livemarks.reloadLivemarks(aForceUpdate);
+ }
+ },
+ function() {
+ is(true, false, "Should not fail getting a livemark.");
+ aCallback();
+ }
+ );
+ });
+}
+
+function removeLivemarks(aCallback) {
+ info("Removing livemarks");
+ let count = gLivemarks.length;
+ gLivemarks.forEach(function(aLivemarkData) {
+ PlacesUtils.livemarks.removeLivemark(aLivemarkData).then(
+ function (aLivemark) {
+ if (--count == 0) {
+ aCallback();
+ }
+ },
+ function() {
+ is(true, false, "Should not fail adding a livemark.");
+ aCallback();
+ }
+ );
+ });
+}
+
+function resultObserver(aLivemark, aCallback) {
+ this._node = {};
+ this._livemark = aLivemark;
+ this._callback = aCallback;
+ this._livemark.registerForUpdates(this._node, this);
+}
+resultObserver.prototype = {
+ nodeInserted: function() {},
+ nodeRemoved: function() {},
+ nodeAnnotationChanged: function() {},
+ nodeTitleChanged: function() {},
+ nodeHistoryDetailsChanged: function() {},
+ nodeMoved: function() {},
+ ontainerStateChanged: function () {},
+ sortingChanged: function() {},
+ batching: function() {},
+ invalidateContainer: function(aContainer) {
+ // Wait for load finish.
+ if (this._livemark.status == Ci.mozILivemark.STATUS_LOADING)
+ return;
+
+ this._terminate();
+ this._callback();
+ },
+ _terminate: function () {
+ if (!this._terminated) {
+ this._livemark.unregisterForUpdates(this._node);
+ this._terminated = true;
+ }
+ }
+};
+
+function cleanup() {
+ gLivemarks.forEach(function(aLivemarkData) {
+ if (aLivemarkData._observer)
+ aLivemarkData._observer._terminate();
+ });
+}
+]]>
+</script>
+</window>
diff --git a/toolkit/components/places/tests/cpp/mock_Link.h b/toolkit/components/places/tests/cpp/mock_Link.h
new file mode 100644
index 000000000..92ef25d6a
--- /dev/null
+++ b/toolkit/components/places/tests/cpp/mock_Link.h
@@ -0,0 +1,229 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This is a mock Link object which can be used in tests.
+ */
+
+#ifndef mock_Link_h__
+#define mock_Link_h__
+
+#include "mozilla/MemoryReporting.h"
+#include "mozilla/dom/Link.h"
+#include "mozilla/dom/URLSearchParams.h"
+
+class mock_Link : public mozilla::dom::Link
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit mock_Link(void (*aHandlerFunction)(nsLinkState),
+ bool aRunNextTest = true)
+ : mozilla::dom::Link(nullptr)
+ , mHandler(aHandlerFunction)
+ , mRunNextTest(aRunNextTest)
+ {
+ // Create a cyclic ownership, so that the link will be released only
+ // after its status has been updated. This will ensure that, when it should
+ // run the next test, it will happen at the end of the test function, if
+ // the link status has already been set before. Indeed the link status is
+ // updated on a separate connection, thus may happen at any time.
+ mDeathGrip = this;
+ }
+
+ virtual void SetLinkState(nsLinkState aState) override
+ {
+ // Notify our callback function.
+ mHandler(aState);
+
+ // Break the cycle so the object can be destroyed.
+ mDeathGrip = nullptr;
+ }
+
+ virtual size_t SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const override
+ {
+ return 0; // the value shouldn't matter
+ }
+
+protected:
+ ~mock_Link() {
+ // Run the next test if we are supposed to.
+ if (mRunNextTest) {
+ run_next_test();
+ }
+ }
+
+private:
+ void (*mHandler)(nsLinkState);
+ bool mRunNextTest;
+ RefPtr<Link> mDeathGrip;
+};
+
+NS_IMPL_ISUPPORTS(
+ mock_Link,
+ mozilla::dom::Link
+)
+
+////////////////////////////////////////////////////////////////////////////////
+//// Needed Link Methods
+
+namespace mozilla {
+namespace dom {
+
+Link::Link(Element* aElement)
+: mElement(aElement)
+, mLinkState(eLinkState_NotLink)
+, mRegistered(false)
+{
+}
+
+Link::~Link()
+{
+}
+
+bool
+Link::ElementHasHref() const
+{
+ NS_NOTREACHED("Unexpected call to Link::ElementHasHref");
+ return false; // suppress compiler warning
+}
+
+void
+Link::SetLinkState(nsLinkState aState)
+{
+ NS_NOTREACHED("Unexpected call to Link::SetLinkState");
+}
+
+void
+Link::ResetLinkState(bool aNotify, bool aHasHref)
+{
+ NS_NOTREACHED("Unexpected call to Link::ResetLinkState");
+}
+
+nsIURI*
+Link::GetURI() const
+{
+ NS_NOTREACHED("Unexpected call to Link::GetURI");
+ return nullptr; // suppress compiler warning
+}
+
+size_t
+Link::SizeOfExcludingThis(mozilla::MallocSizeOf aMallocSizeOf) const
+{
+ NS_NOTREACHED("Unexpected call to Link::SizeOfExcludingThis");
+ return 0;
+}
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(URLSearchParams)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(URLSearchParams)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(URLSearchParams)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+NS_IMPL_CYCLE_COLLECTION_TRACE_WRAPPERCACHE(URLSearchParams)
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(URLSearchParams)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(URLSearchParams)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(URLSearchParams)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+
+URLSearchParams::URLSearchParams(nsISupports* aParent,
+ URLSearchParamsObserver* aObserver)
+{
+}
+
+URLSearchParams::~URLSearchParams()
+{
+}
+
+JSObject*
+URLSearchParams::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
+{
+ return nullptr;
+}
+
+void
+URLSearchParams::ParseInput(const nsACString& aInput)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::ParseInput");
+}
+
+void
+URLSearchParams::Serialize(nsAString& aValue) const
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Serialize");
+}
+
+void
+URLSearchParams::Get(const nsAString& aName, nsString& aRetval)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Get");
+}
+
+void
+URLSearchParams::GetAll(const nsAString& aName, nsTArray<nsString >& aRetval)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::GetAll");
+}
+
+void
+URLSearchParams::Set(const nsAString& aName, const nsAString& aValue)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Set");
+}
+
+void
+URLSearchParams::Append(const nsAString& aName, const nsAString& aValue)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Append");
+}
+
+void
+URLSearchParams::AppendInternal(const nsAString& aName, const nsAString& aValue)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::AppendInternal");
+}
+
+bool
+URLSearchParams::Has(const nsAString& aName)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Has");
+ return false;
+}
+
+void
+URLSearchParams::Delete(const nsAString& aName)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::Delete");
+}
+
+void
+URLSearchParams::DeleteAll()
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::DeleteAll");
+}
+
+void
+URLSearchParams::NotifyObserver()
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::NotifyObserver");
+}
+
+NS_IMETHODIMP
+URLSearchParams::GetSendInfo(nsIInputStream** aBody, uint64_t* aContentLength,
+ nsACString& aContentType, nsACString& aCharset)
+{
+ NS_NOTREACHED("Unexpected call to URLSearchParams::GetSendInfo");
+ return NS_OK;
+}
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mock_Link_h__
diff --git a/toolkit/components/places/tests/cpp/moz.build b/toolkit/components/places/tests/cpp/moz.build
new file mode 100644
index 000000000..f6bd91bd7
--- /dev/null
+++ b/toolkit/components/places/tests/cpp/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+GeckoCppUnitTests([
+ 'test_IHistory',
+])
+
+if CONFIG['JS_SHARED_LIBRARY']:
+ USE_LIBS += [
+ 'js',
+ ]
diff --git a/toolkit/components/places/tests/cpp/places_test_harness.h b/toolkit/components/places/tests/cpp/places_test_harness.h
new file mode 100644
index 000000000..557a25f90
--- /dev/null
+++ b/toolkit/components/places/tests/cpp/places_test_harness.h
@@ -0,0 +1,413 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "TestHarness.h"
+#include "nsMemory.h"
+#include "nsThreadUtils.h"
+#include "nsDocShellCID.h"
+
+#include "nsToolkitCompsCID.h"
+#include "nsINavHistoryService.h"
+#include "nsIObserverService.h"
+#include "nsIURI.h"
+#include "mozilla/IHistory.h"
+#include "mozIStorageConnection.h"
+#include "mozIStorageStatement.h"
+#include "mozIStorageAsyncStatement.h"
+#include "mozIStorageStatementCallback.h"
+#include "mozIStoragePendingStatement.h"
+#include "nsPIPlacesDatabase.h"
+#include "nsIObserver.h"
+#include "prinrval.h"
+#include "prtime.h"
+#include "mozilla/Attributes.h"
+
+#define WAITFORTOPIC_TIMEOUT_SECONDS 5
+
+
+static size_t gTotalTests = 0;
+static size_t gPassedTests = 0;
+
+#define do_check_true(aCondition) \
+ PR_BEGIN_MACRO \
+ gTotalTests++; \
+ if (aCondition) { \
+ gPassedTests++; \
+ } else { \
+ fail("%s | Expected true, got false at line %d", __FILE__, __LINE__); \
+ } \
+ PR_END_MACRO
+
+#define do_check_false(aCondition) \
+ PR_BEGIN_MACRO \
+ gTotalTests++; \
+ if (!aCondition) { \
+ gPassedTests++; \
+ } else { \
+ fail("%s | Expected false, got true at line %d", __FILE__, __LINE__); \
+ } \
+ PR_END_MACRO
+
+#define do_check_success(aResult) \
+ do_check_true(NS_SUCCEEDED(aResult))
+
+#ifdef LINUX
+// XXX Linux opt builds on tinderbox are orange due to linking with stdlib.
+// This is sad and annoying, but it's a workaround that works.
+#define do_check_eq(aExpected, aActual) \
+ do_check_true(aExpected == aActual)
+#else
+#include <sstream>
+
+#define do_check_eq(aActual, aExpected) \
+ PR_BEGIN_MACRO \
+ gTotalTests++; \
+ if (aExpected == aActual) { \
+ gPassedTests++; \
+ } else { \
+ std::ostringstream temp; \
+ temp << __FILE__ << " | Expected '" << aExpected << "', got '"; \
+ temp << aActual <<"' at line " << __LINE__; \
+ fail(temp.str().c_str()); \
+ } \
+ PR_END_MACRO
+#endif
+
+struct Test
+{
+ void (*func)(void);
+ const char* const name;
+};
+#define TEST(aName) \
+ {aName, #aName}
+
+/**
+ * Runs the next text.
+ */
+void run_next_test();
+
+/**
+ * To be used around asynchronous work.
+ */
+void do_test_pending();
+void do_test_finished();
+
+/**
+ * Spins current thread until a topic is received.
+ */
+class WaitForTopicSpinner final : public nsIObserver
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit WaitForTopicSpinner(const char* const aTopic)
+ : mTopicReceived(false)
+ , mStartTime(PR_IntervalNow())
+ {
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->AddObserver(this, aTopic, false);
+ }
+
+ void Spin() {
+ while (!mTopicReceived) {
+ if ((PR_IntervalNow() - mStartTime) > (WAITFORTOPIC_TIMEOUT_SECONDS * PR_USEC_PER_SEC)) {
+ // Timed out waiting for the topic.
+ do_check_true(false);
+ break;
+ }
+ (void)NS_ProcessNextEvent();
+ }
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) override
+ {
+ mTopicReceived = true;
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->RemoveObserver(this, aTopic);
+ return NS_OK;
+ }
+
+private:
+ ~WaitForTopicSpinner() {}
+
+ bool mTopicReceived;
+ PRIntervalTime mStartTime;
+};
+NS_IMPL_ISUPPORTS(
+ WaitForTopicSpinner,
+ nsIObserver
+)
+
+/**
+ * Spins current thread until an async statement is executed.
+ */
+class AsyncStatementSpinner final : public mozIStorageStatementCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_MOZISTORAGESTATEMENTCALLBACK
+
+ AsyncStatementSpinner();
+ void SpinUntilCompleted();
+ uint16_t completionReason;
+
+protected:
+ ~AsyncStatementSpinner() {}
+
+ volatile bool mCompleted;
+};
+
+NS_IMPL_ISUPPORTS(AsyncStatementSpinner,
+ mozIStorageStatementCallback)
+
+AsyncStatementSpinner::AsyncStatementSpinner()
+: completionReason(0)
+, mCompleted(false)
+{
+}
+
+NS_IMETHODIMP
+AsyncStatementSpinner::HandleResult(mozIStorageResultSet *aResultSet)
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AsyncStatementSpinner::HandleError(mozIStorageError *aError)
+{
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AsyncStatementSpinner::HandleCompletion(uint16_t aReason)
+{
+ completionReason = aReason;
+ mCompleted = true;
+ return NS_OK;
+}
+
+void AsyncStatementSpinner::SpinUntilCompleted()
+{
+ nsCOMPtr<nsIThread> thread(::do_GetCurrentThread());
+ nsresult rv = NS_OK;
+ bool processed = true;
+ while (!mCompleted && NS_SUCCEEDED(rv)) {
+ rv = thread->ProcessNextEvent(true, &processed);
+ }
+}
+
+struct PlaceRecord
+{
+ int64_t id;
+ int32_t hidden;
+ int32_t typed;
+ int32_t visitCount;
+ nsCString guid;
+};
+
+struct VisitRecord
+{
+ int64_t id;
+ int64_t lastVisitId;
+ int32_t transitionType;
+};
+
+already_AddRefed<mozilla::IHistory>
+do_get_IHistory()
+{
+ nsCOMPtr<mozilla::IHistory> history = do_GetService(NS_IHISTORY_CONTRACTID);
+ do_check_true(history);
+ return history.forget();
+}
+
+already_AddRefed<nsINavHistoryService>
+do_get_NavHistory()
+{
+ nsCOMPtr<nsINavHistoryService> serv =
+ do_GetService(NS_NAVHISTORYSERVICE_CONTRACTID);
+ do_check_true(serv);
+ return serv.forget();
+}
+
+already_AddRefed<mozIStorageConnection>
+do_get_db()
+{
+ nsCOMPtr<nsINavHistoryService> history = do_get_NavHistory();
+ nsCOMPtr<nsPIPlacesDatabase> database = do_QueryInterface(history);
+ do_check_true(database);
+
+ nsCOMPtr<mozIStorageConnection> dbConn;
+ nsresult rv = database->GetDBConnection(getter_AddRefs(dbConn));
+ do_check_success(rv);
+ return dbConn.forget();
+}
+
+/**
+ * Get the place record from the database.
+ *
+ * @param aURI The unique URI of the place we are looking up
+ * @param result Out parameter where the result is stored
+ */
+void
+do_get_place(nsIURI* aURI, PlaceRecord& result)
+{
+ nsCOMPtr<mozIStorageConnection> dbConn = do_get_db();
+ nsCOMPtr<mozIStorageStatement> stmt;
+
+ nsCString spec;
+ nsresult rv = aURI->GetSpec(spec);
+ do_check_success(rv);
+
+ rv = dbConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT id, hidden, typed, visit_count, guid FROM moz_places "
+ "WHERE url_hash = hash(?1) AND url = ?1"
+ ), getter_AddRefs(stmt));
+ do_check_success(rv);
+
+ rv = stmt->BindUTF8StringByIndex(0, spec);
+ do_check_success(rv);
+
+ bool hasResults;
+ rv = stmt->ExecuteStep(&hasResults);
+ do_check_success(rv);
+ if (!hasResults) {
+ result.id = 0;
+ return;
+ }
+
+ rv = stmt->GetInt64(0, &result.id);
+ do_check_success(rv);
+ rv = stmt->GetInt32(1, &result.hidden);
+ do_check_success(rv);
+ rv = stmt->GetInt32(2, &result.typed);
+ do_check_success(rv);
+ rv = stmt->GetInt32(3, &result.visitCount);
+ do_check_success(rv);
+ rv = stmt->GetUTF8String(4, result.guid);
+ do_check_success(rv);
+}
+
+/**
+ * Gets the most recent visit to a place.
+ *
+ * @param placeID ID from the moz_places table
+ * @param result Out parameter where visit is stored
+ */
+void
+do_get_lastVisit(int64_t placeId, VisitRecord& result)
+{
+ nsCOMPtr<mozIStorageConnection> dbConn = do_get_db();
+ nsCOMPtr<mozIStorageStatement> stmt;
+
+ nsresult rv = dbConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT id, from_visit, visit_type FROM moz_historyvisits "
+ "WHERE place_id=?1 "
+ "LIMIT 1"
+ ), getter_AddRefs(stmt));
+ do_check_success(rv);
+
+ rv = stmt->BindInt64ByIndex(0, placeId);
+ do_check_success(rv);
+
+ bool hasResults;
+ rv = stmt->ExecuteStep(&hasResults);
+ do_check_success(rv);
+
+ if (!hasResults) {
+ result.id = 0;
+ return;
+ }
+
+ rv = stmt->GetInt64(0, &result.id);
+ do_check_success(rv);
+ rv = stmt->GetInt64(1, &result.lastVisitId);
+ do_check_success(rv);
+ rv = stmt->GetInt32(2, &result.transitionType);
+ do_check_success(rv);
+}
+
+void
+do_wait_async_updates() {
+ nsCOMPtr<mozIStorageConnection> db = do_get_db();
+ nsCOMPtr<mozIStorageAsyncStatement> stmt;
+
+ db->CreateAsyncStatement(NS_LITERAL_CSTRING("BEGIN EXCLUSIVE"),
+ getter_AddRefs(stmt));
+ nsCOMPtr<mozIStoragePendingStatement> pending;
+ (void)stmt->ExecuteAsync(nullptr, getter_AddRefs(pending));
+
+ db->CreateAsyncStatement(NS_LITERAL_CSTRING("COMMIT"),
+ getter_AddRefs(stmt));
+ RefPtr<AsyncStatementSpinner> spinner = new AsyncStatementSpinner();
+ (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending));
+
+ spinner->SpinUntilCompleted();
+}
+
+/**
+ * Adds a URI to the database.
+ *
+ * @param aURI
+ * The URI to add to the database.
+ */
+void
+addURI(nsIURI* aURI)
+{
+ nsCOMPtr<mozilla::IHistory> history = do_GetService(NS_IHISTORY_CONTRACTID);
+ do_check_true(history);
+ nsresult rv = history->VisitURI(aURI, nullptr, mozilla::IHistory::TOP_LEVEL);
+ do_check_success(rv);
+
+ do_wait_async_updates();
+}
+
+static const char TOPIC_PROFILE_CHANGE[] = "profile-before-change";
+static const char TOPIC_PLACES_CONNECTION_CLOSED[] = "places-connection-closed";
+
+class WaitForConnectionClosed final : public nsIObserver
+{
+ RefPtr<WaitForTopicSpinner> mSpinner;
+
+ ~WaitForConnectionClosed() {}
+
+public:
+ NS_DECL_ISUPPORTS
+
+ WaitForConnectionClosed()
+ {
+ nsCOMPtr<nsIObserverService> os =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ MOZ_ASSERT(os);
+ if (os) {
+ MOZ_ALWAYS_SUCCEEDS(os->AddObserver(this, TOPIC_PROFILE_CHANGE, false));
+ }
+ mSpinner = new WaitForTopicSpinner(TOPIC_PLACES_CONNECTION_CLOSED);
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) override
+ {
+ nsCOMPtr<nsIObserverService> os =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ MOZ_ASSERT(os);
+ if (os) {
+ MOZ_ALWAYS_SUCCEEDS(os->RemoveObserver(this, aTopic));
+ }
+
+ mSpinner->Spin();
+
+ return NS_OK;
+ }
+};
+
+NS_IMPL_ISUPPORTS(WaitForConnectionClosed, nsIObserver)
diff --git a/toolkit/components/places/tests/cpp/places_test_harness_tail.h b/toolkit/components/places/tests/cpp/places_test_harness_tail.h
new file mode 100644
index 000000000..4bbd45ccb
--- /dev/null
+++ b/toolkit/components/places/tests/cpp/places_test_harness_tail.h
@@ -0,0 +1,149 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "nsWidgetsCID.h"
+#include "nsIComponentRegistrar.h"
+#ifdef MOZ_CRASHREPORTER
+#include "nsICrashReporter.h"
+#endif
+
+#ifndef TEST_NAME
+#error "Must #define TEST_NAME before including places_test_harness_tail.h"
+#endif
+
+#ifndef TEST_FILE
+#error "Must #define TEST_FILE before include places_test_harness_tail.h"
+#endif
+
+int gTestsIndex = 0;
+
+#define TEST_INFO_STR "TEST-INFO | (%s) | "
+
+class RunNextTest : public mozilla::Runnable
+{
+public:
+ NS_IMETHOD Run() override
+ {
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ if (gTestsIndex < int(mozilla::ArrayLength(gTests))) {
+ do_test_pending();
+ Test &test = gTests[gTestsIndex++];
+ (void)fprintf(stderr, TEST_INFO_STR "Running %s.\n", TEST_FILE,
+ test.name);
+ test.func();
+ }
+
+ do_test_finished();
+ return NS_OK;
+ }
+};
+
+void
+run_next_test()
+{
+ nsCOMPtr<nsIRunnable> event = new RunNextTest();
+ do_check_success(NS_DispatchToCurrentThread(event));
+}
+
+int gPendingTests = 0;
+
+void
+do_test_pending()
+{
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ gPendingTests++;
+}
+
+void
+do_test_finished()
+{
+ NS_ASSERTION(NS_IsMainThread(), "Not running on the main thread?");
+ NS_ASSERTION(gPendingTests > 0, "Invalid pending test count!");
+ gPendingTests--;
+}
+
+void
+disable_idle_service()
+{
+ (void)fprintf(stderr, TEST_INFO_STR "Disabling Idle Service.\n", TEST_FILE);
+ static NS_DEFINE_IID(kIdleCID, NS_IDLE_SERVICE_CID);
+ nsresult rv;
+ nsCOMPtr<nsIFactory> idleFactory = do_GetClassObject(kIdleCID, &rv);
+ do_check_success(rv);
+ nsCOMPtr<nsIComponentRegistrar> registrar;
+ rv = NS_GetComponentRegistrar(getter_AddRefs(registrar));
+ do_check_success(rv);
+ rv = registrar->UnregisterFactory(kIdleCID, idleFactory);
+ do_check_success(rv);
+}
+
+int
+main(int aArgc,
+ char** aArgv)
+{
+ ScopedXPCOM xpcom(TEST_NAME);
+ if (xpcom.failed())
+ return -1;
+ // Initialize a profile folder to ensure a clean shutdown.
+ nsCOMPtr<nsIFile> profile = xpcom.GetProfileDirectory();
+ if (!profile) {
+ fail("Couldn't get the profile directory.");
+ return -1;
+ }
+
+#ifdef MOZ_CRASHREPORTER
+ char* enabled = PR_GetEnv("MOZ_CRASHREPORTER");
+ if (enabled && !strcmp(enabled, "1")) {
+ // bug 787458: move this to an even-more-common location to use in all
+ // C++ unittests
+ nsCOMPtr<nsICrashReporter> crashreporter =
+ do_GetService("@mozilla.org/toolkit/crash-reporter;1");
+ if (crashreporter) {
+ fprintf(stderr, "Setting up crash reporting\n");
+
+ nsCOMPtr<nsIProperties> dirsvc =
+ do_GetService(NS_DIRECTORY_SERVICE_CONTRACTID);
+ if (!dirsvc)
+ NS_RUNTIMEABORT("Couldn't get directory service");
+ nsCOMPtr<nsIFile> cwd;
+ nsresult rv = dirsvc->Get(NS_OS_CURRENT_WORKING_DIR,
+ NS_GET_IID(nsIFile),
+ getter_AddRefs(cwd));
+ if (NS_FAILED(rv))
+ NS_RUNTIMEABORT("Couldn't get CWD");
+ crashreporter->SetEnabled(true);
+ crashreporter->SetMinidumpPath(cwd);
+ }
+ }
+#endif
+
+ RefPtr<WaitForConnectionClosed> spinClose = new WaitForConnectionClosed();
+
+ // Tinderboxes are constantly on idle. Since idle tasks can interact with
+ // tests, causing random failures, disable the idle service.
+ disable_idle_service();
+
+ do_test_pending();
+ run_next_test();
+
+ // Spin the event loop until we've run out of tests to run.
+ while (gPendingTests) {
+ (void)NS_ProcessNextEvent();
+ }
+
+ // And let any other events finish before we quit.
+ (void)NS_ProcessPendingEvents(nullptr);
+
+ // Check that we have passed all of our tests, and output accordingly.
+ if (gPassedTests == gTotalTests) {
+ passed(TEST_FILE);
+ }
+
+ (void)fprintf(stderr, TEST_INFO_STR "%u of %u tests passed\n",
+ TEST_FILE, unsigned(gPassedTests), unsigned(gTotalTests));
+
+ return gPassedTests == gTotalTests ? 0 : -1;
+}
diff --git a/toolkit/components/places/tests/cpp/test_IHistory.cpp b/toolkit/components/places/tests/cpp/test_IHistory.cpp
new file mode 100644
index 000000000..90998ce8c
--- /dev/null
+++ b/toolkit/components/places/tests/cpp/test_IHistory.cpp
@@ -0,0 +1,639 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "places_test_harness.h"
+#include "nsIPrefService.h"
+#include "nsIPrefBranch.h"
+#include "mozilla/Attributes.h"
+#include "nsNetUtil.h"
+
+#include "mock_Link.h"
+using namespace mozilla;
+using namespace mozilla::dom;
+
+/**
+ * This file tests the IHistory interface.
+ */
+
+////////////////////////////////////////////////////////////////////////////////
+//// Helper Methods
+
+void
+expect_visit(nsLinkState aState)
+{
+ do_check_true(aState == eLinkState_Visited);
+}
+
+void
+expect_no_visit(nsLinkState aState)
+{
+ do_check_true(aState == eLinkState_Unvisited);
+}
+
+already_AddRefed<nsIURI>
+new_test_uri()
+{
+ // Create a unique spec.
+ static int32_t specNumber = 0;
+ nsAutoCString spec = NS_LITERAL_CSTRING("http://mozilla.org/");
+ spec.AppendInt(specNumber++);
+
+ // Create the URI for the spec.
+ nsCOMPtr<nsIURI> testURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(testURI), spec);
+ do_check_success(rv);
+ return testURI.forget();
+}
+
+class VisitURIObserver final : public nsIObserver
+{
+ ~VisitURIObserver() {}
+
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit VisitURIObserver(int aExpectedVisits = 1) :
+ mVisits(0),
+ mExpectedVisits(aExpectedVisits)
+ {
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->AddObserver(this,
+ "uri-visit-saved",
+ false);
+ }
+
+ void WaitForNotification()
+ {
+ while (mVisits < mExpectedVisits) {
+ (void)NS_ProcessNextEvent();
+ }
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) override
+ {
+ mVisits++;
+
+ if (mVisits == mExpectedVisits) {
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ (void)observerService->RemoveObserver(this, "uri-visit-saved");
+ }
+
+ return NS_OK;
+ }
+private:
+ int mVisits;
+ int mExpectedVisits;
+};
+NS_IMPL_ISUPPORTS(
+ VisitURIObserver,
+ nsIObserver
+)
+
+////////////////////////////////////////////////////////////////////////////////
+//// Test Functions
+
+void
+test_set_places_enabled()
+{
+ // Ensure places is enabled for everyone.
+ nsresult rv;
+ nsCOMPtr<nsIPrefBranch> prefBranch =
+ do_GetService(NS_PREFSERVICE_CONTRACTID, &rv);
+ do_check_success(rv);
+
+ rv = prefBranch->SetBoolPref("places.history.enabled", true);
+ do_check_success(rv);
+
+ // Run the next test.
+ run_next_test();
+}
+
+
+void
+test_wait_checkpoint()
+{
+ // This "fake" test is here to wait for the initial WAL checkpoint we force
+ // after creating the database schema, since that may happen at any time,
+ // and cause concurrent readers to access an older checkpoint.
+ nsCOMPtr<mozIStorageConnection> db = do_get_db();
+ nsCOMPtr<mozIStorageAsyncStatement> stmt;
+ db->CreateAsyncStatement(NS_LITERAL_CSTRING("SELECT 1"),
+ getter_AddRefs(stmt));
+ RefPtr<AsyncStatementSpinner> spinner = new AsyncStatementSpinner();
+ nsCOMPtr<mozIStoragePendingStatement> pending;
+ (void)stmt->ExecuteAsync(spinner, getter_AddRefs(pending));
+ spinner->SpinUntilCompleted();
+
+ // Run the next test.
+ run_next_test();
+}
+
+// These variables are shared between part 1 and part 2 of the test. Part 2
+// sets the nsCOMPtr's to nullptr, freeing the reference.
+namespace test_unvisited_does_not_notify {
+ nsCOMPtr<nsIURI> testURI;
+ RefPtr<Link> testLink;
+} // namespace test_unvisited_does_not_notify
+void
+test_unvisited_does_not_notify_part1()
+{
+ using namespace test_unvisited_does_not_notify;
+
+ // This test is done in two parts. The first part registers for a URI that
+ // should not be visited. We then run another test that will also do a
+ // lookup and will be notified. Since requests are answered in the order they
+ // are requested (at least as long as the same URI isn't asked for later), we
+ // will know that the Link was not notified.
+
+ // First, we need a test URI.
+ testURI = new_test_uri();
+
+ // Create our test Link.
+ testLink = new mock_Link(expect_no_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, testLink);
+ do_check_success(rv);
+
+ // Run the next test.
+ run_next_test();
+}
+
+void
+test_visited_notifies()
+{
+ // First, we add our test URI to history.
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our test Link. The callback function will release the reference we
+ // have on the Link.
+ RefPtr<Link> link = new mock_Link(expect_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ // Note: test will continue upon notification.
+}
+
+void
+test_unvisited_does_not_notify_part2()
+{
+ using namespace test_unvisited_does_not_notify;
+
+ // We would have had a failure at this point had the content node been told it
+ // was visited. Therefore, it is safe to unregister our content node.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->UnregisterVisitedCallback(testURI, testLink);
+ do_check_success(rv);
+
+ // Clear the stored variables now.
+ testURI = nullptr;
+ testLink = nullptr;
+
+ // Run the next test.
+ run_next_test();
+}
+
+void
+test_same_uri_notifies_both()
+{
+ // First, we add our test URI to history.
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our two test Links. The callback function will release the
+ // reference we have on the Links. Only the second Link should run the next
+ // test!
+ RefPtr<Link> link1 = new mock_Link(expect_visit, false);
+ RefPtr<Link> link2 = new mock_Link(expect_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, link1);
+ do_check_success(rv);
+ rv = history->RegisterVisitedCallback(testURI, link2);
+ do_check_success(rv);
+
+ // Note: test will continue upon notification.
+}
+
+void
+test_unregistered_visited_does_not_notify()
+{
+ // This test must have a test that has a successful notification after it.
+ // The Link would have been notified by now if we were buggy and notified
+ // unregistered Links (due to request serialization).
+
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+ RefPtr<Link> link = new mock_Link(expect_no_visit);
+
+ // Now, register our Link to be notified.
+ nsCOMPtr<IHistory> history(do_get_IHistory());
+ nsresult rv = history->RegisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ // Unregister the Link.
+ rv = history->UnregisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ // And finally add a visit for the URI.
+ addURI(testURI);
+
+ // If history tries to notify us, we'll either crash because the Link will
+ // have been deleted (we are the only thing holding a reference to it), or our
+ // expect_no_visit call back will produce a failure. Either way, the test
+ // will be reported as a failure.
+
+ // Run the next test.
+ run_next_test();
+}
+
+void
+test_new_visit_notifies_waiting_Link()
+{
+ // Create our test Link. The callback function will release the reference we
+ // have on the link.
+ RefPtr<Link> link = new mock_Link(expect_visit);
+
+ // Now, register our content node to be notified.
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ // Add ourselves to history.
+ addURI(testURI);
+
+ // Note: test will continue upon notification.
+}
+
+void
+test_RegisterVisitedCallback_returns_before_notifying()
+{
+ // Add a URI so that it's already in history.
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+ addURI(testURI);
+
+ // Create our test Link.
+ RefPtr<Link> link = new mock_Link(expect_no_visit);
+
+ // Now, register our content node to be notified. It should not be notified.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ // Remove ourselves as an observer. We would have failed if we had been
+ // notified.
+ rv = history->UnregisterVisitedCallback(testURI, link);
+ do_check_success(rv);
+
+ run_next_test();
+}
+
+namespace test_observer_topic_dispatched_helpers {
+ #define URI_VISITED "visited"
+ #define URI_NOT_VISITED "not visited"
+ #define URI_VISITED_RESOLUTION_TOPIC "visited-status-resolution"
+ class statusObserver final : public nsIObserver
+ {
+ ~statusObserver() {}
+
+ public:
+ NS_DECL_ISUPPORTS
+
+ statusObserver(nsIURI* aURI,
+ const bool aExpectVisit,
+ bool& _notified)
+ : mURI(aURI)
+ , mExpectVisit(aExpectVisit)
+ , mNotified(_notified)
+ {
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ do_check_true(observerService);
+ (void)observerService->AddObserver(this,
+ URI_VISITED_RESOLUTION_TOPIC,
+ false);
+ }
+
+ NS_IMETHOD Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) override
+ {
+ // Make sure we got notified of the right topic.
+ do_check_false(strcmp(aTopic, URI_VISITED_RESOLUTION_TOPIC));
+
+ // If this isn't for our URI, do not do anything.
+ nsCOMPtr<nsIURI> notifiedURI = do_QueryInterface(aSubject);
+ do_check_true(notifiedURI);
+
+ bool isOurURI;
+ nsresult rv = notifiedURI->Equals(mURI, &isOurURI);
+ do_check_success(rv);
+ if (!isOurURI) {
+ return NS_OK;
+ }
+
+ // Check that we have either the visited or not visited string.
+ bool visited = !!NS_LITERAL_STRING(URI_VISITED).Equals(aData);
+ bool notVisited = !!NS_LITERAL_STRING(URI_NOT_VISITED).Equals(aData);
+ do_check_true(visited || notVisited);
+
+ // Check to make sure we got the state we expected.
+ do_check_eq(visited, mExpectVisit);
+
+ // Indicate that we've been notified.
+ mNotified = true;
+
+ // Remove ourselves as an observer.
+ nsCOMPtr<nsIObserverService> observerService =
+ do_GetService(NS_OBSERVERSERVICE_CONTRACTID);
+ (void)observerService->RemoveObserver(this,
+ URI_VISITED_RESOLUTION_TOPIC);
+ return NS_OK;
+ }
+ private:
+ nsCOMPtr<nsIURI> mURI;
+ const bool mExpectVisit;
+ bool& mNotified;
+ };
+ NS_IMPL_ISUPPORTS(
+ statusObserver,
+ nsIObserver
+ )
+} // namespace test_observer_topic_dispatched_helpers
+void
+test_observer_topic_dispatched()
+{
+ using namespace test_observer_topic_dispatched_helpers;
+
+ // Create two URIs, making sure only one is in history.
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+ nsCOMPtr<nsIURI> notVisitedURI = new_test_uri();
+ bool urisEqual;
+ nsresult rv = visitedURI->Equals(notVisitedURI, &urisEqual);
+ do_check_success(rv);
+ do_check_false(urisEqual);
+ addURI(visitedURI);
+
+ // Need two Link objects as well - one for each URI.
+ RefPtr<Link> visitedLink = new mock_Link(expect_visit, false);
+ RefPtr<Link> visitedLinkCopy = visitedLink;
+ RefPtr<Link> notVisitedLink = new mock_Link(expect_no_visit);
+
+ // Add the right observers for the URIs to check results.
+ bool visitedNotified = false;
+ nsCOMPtr<nsIObserver> visitedObs =
+ new statusObserver(visitedURI, true, visitedNotified);
+ bool notVisitedNotified = false;
+ nsCOMPtr<nsIObserver> unvisitedObs =
+ new statusObserver(notVisitedURI, false, notVisitedNotified);
+
+ // Register our Links to be notified.
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ rv = history->RegisterVisitedCallback(visitedURI, visitedLink);
+ do_check_success(rv);
+ rv = history->RegisterVisitedCallback(notVisitedURI, notVisitedLink);
+ do_check_success(rv);
+
+ // Spin the event loop as long as we have not been properly notified.
+ while (!visitedNotified || !notVisitedNotified) {
+ (void)NS_ProcessNextEvent();
+ }
+
+ // Unregister our observer that would not have been released.
+ rv = history->UnregisterVisitedCallback(notVisitedURI, notVisitedLink);
+ do_check_success(rv);
+
+ run_next_test();
+}
+
+void
+test_visituri_inserts()
+{
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+
+ do_check_true(place.id > 0);
+ do_check_false(place.hidden);
+ do_check_false(place.typed);
+ do_check_eq(place.visitCount, 1);
+
+ run_next_test();
+}
+
+void
+test_visituri_updates()
+{
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+ RefPtr<VisitURIObserver> finisher;
+
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+ finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+ finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+
+ do_check_eq(place.visitCount, 2);
+
+ run_next_test();
+}
+
+void
+test_visituri_preserves_shown_and_typed()
+{
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+ // this simulates the uri visit happening in a frame. Normally frame
+ // transitions would be hidden unless it was previously loaded top-level
+ history->VisitURI(visitedURI, lastURI, 0);
+
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver(2);
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_false(place.hidden);
+
+ run_next_test();
+}
+
+void
+test_visituri_creates_visit()
+{
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_true(visit.id > 0);
+ do_check_eq(visit.lastVisitId, 0);
+ do_check_eq(visit.transitionType, nsINavHistoryService::TRANSITION_LINK);
+
+ run_next_test();
+}
+
+void
+test_visituri_transition_typed()
+{
+ nsCOMPtr<nsINavHistoryService> navHistory = do_get_NavHistory();
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+
+ navHistory->MarkPageAsTyped(visitedURI);
+ history->VisitURI(visitedURI, lastURI, mozilla::IHistory::TOP_LEVEL);
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_true(visit.transitionType == nsINavHistoryService::TRANSITION_TYPED);
+
+ run_next_test();
+}
+
+void
+test_visituri_transition_embed()
+{
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsCOMPtr<nsIURI> lastURI = new_test_uri();
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+
+ history->VisitURI(visitedURI, lastURI, 0);
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ PlaceRecord place;
+ VisitRecord visit;
+ do_get_place(visitedURI, place);
+ do_get_lastVisit(place.id, visit);
+
+ do_check_eq(place.id, 0);
+ do_check_eq(visit.id, 0);
+
+ run_next_test();
+}
+
+void
+test_new_visit_adds_place_guid()
+{
+ // First, add a visit and wait. This will also add a place.
+ nsCOMPtr<nsIURI> visitedURI = new_test_uri();
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->VisitURI(visitedURI, nullptr,
+ mozilla::IHistory::TOP_LEVEL);
+ do_check_success(rv);
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ // Check that we have a guid for our visit.
+ PlaceRecord place;
+ do_get_place(visitedURI, place);
+ do_check_eq(place.visitCount, 1);
+ do_check_eq(place.guid.Length(), 12);
+
+ run_next_test();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// IPC-only Tests
+
+void
+test_two_null_links_same_uri()
+{
+ // Tests that we do not crash when we have had two nullptr Links passed to
+ // RegisterVisitedCallback and then the visit occurs (bug 607469). This only
+ // happens in IPC builds.
+ nsCOMPtr<nsIURI> testURI = new_test_uri();
+
+ nsCOMPtr<IHistory> history = do_get_IHistory();
+ nsresult rv = history->RegisterVisitedCallback(testURI, nullptr);
+ do_check_success(rv);
+ rv = history->RegisterVisitedCallback(testURI, nullptr);
+ do_check_success(rv);
+
+ rv = history->VisitURI(testURI, nullptr, mozilla::IHistory::TOP_LEVEL);
+ do_check_success(rv);
+
+ RefPtr<VisitURIObserver> finisher = new VisitURIObserver();
+ finisher->WaitForNotification();
+
+ run_next_test();
+}
+
+////////////////////////////////////////////////////////////////////////////////
+//// Test Harness
+
+/**
+ * Note: for tests marked "Order Important!", please see the test for details.
+ */
+Test gTests[] = {
+ TEST(test_set_places_enabled), // Must come first!
+ TEST(test_wait_checkpoint), // Must come second!
+ TEST(test_unvisited_does_not_notify_part1), // Order Important!
+ TEST(test_visited_notifies),
+ TEST(test_unvisited_does_not_notify_part2), // Order Important!
+ TEST(test_same_uri_notifies_both),
+ TEST(test_unregistered_visited_does_not_notify), // Order Important!
+ TEST(test_new_visit_notifies_waiting_Link),
+ TEST(test_RegisterVisitedCallback_returns_before_notifying),
+ TEST(test_observer_topic_dispatched),
+ TEST(test_visituri_inserts),
+ TEST(test_visituri_updates),
+ TEST(test_visituri_preserves_shown_and_typed),
+ TEST(test_visituri_creates_visit),
+ TEST(test_visituri_transition_typed),
+ TEST(test_visituri_transition_embed),
+ TEST(test_new_visit_adds_place_guid),
+
+ // The rest of these tests are tests that are only run in IPC builds.
+ TEST(test_two_null_links_same_uri),
+};
+
+const char* file = __FILE__;
+#define TEST_NAME "IHistory"
+#define TEST_FILE file
+#include "places_test_harness_tail.h"
diff --git a/toolkit/components/places/tests/expiration/.eslintrc.js b/toolkit/components/places/tests/expiration/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/expiration/head_expiration.js b/toolkit/components/places/tests/expiration/head_expiration.js
new file mode 100644
index 000000000..2be4af307
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/head_expiration.js
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+
+// Simulates an expiration at shutdown.
+function shutdownExpiration()
+{
+ let expire = Cc["@mozilla.org/places/expiration;1"].getService(Ci.nsIObserver);
+ expire.observe(null, "places-will-close-connection", null);
+}
+
+
+/**
+ * Causes expiration component to start, otherwise it would wait for the first
+ * history notification.
+ */
+function force_expiration_start() {
+ Cc["@mozilla.org/places/expiration;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "testing-mode", null);
+}
+
+
+/**
+ * Forces an expiration run.
+ *
+ * @param [optional] aLimit
+ * Limit for the expiration. Pass -1 for unlimited.
+ * Any other non-positive value will just expire orphans.
+ *
+ * @return {Promise}
+ * @resolves When expiration finishes.
+ * @rejects Never.
+ */
+function promiseForceExpirationStep(aLimit) {
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ let expire = Cc["@mozilla.org/places/expiration;1"].getService(Ci.nsIObserver);
+ expire.observe(null, "places-debug-start-expiration", aLimit);
+ return promise;
+}
+
+
+/**
+ * Expiration preferences helpers.
+ */
+
+function setInterval(aNewInterval) {
+ Services.prefs.setIntPref("places.history.expiration.interval_seconds", aNewInterval);
+}
+function getInterval() {
+ return Services.prefs.getIntPref("places.history.expiration.interval_seconds");
+}
+function clearInterval() {
+ try {
+ Services.prefs.clearUserPref("places.history.expiration.interval_seconds");
+ }
+ catch (ex) {}
+}
+
+
+function setMaxPages(aNewMaxPages) {
+ Services.prefs.setIntPref("places.history.expiration.max_pages", aNewMaxPages);
+}
+function getMaxPages() {
+ return Services.prefs.getIntPref("places.history.expiration.max_pages");
+}
+function clearMaxPages() {
+ try {
+ Services.prefs.clearUserPref("places.history.expiration.max_pages");
+ }
+ catch (ex) {}
+}
+
+
+function setHistoryEnabled(aHistoryEnabled) {
+ Services.prefs.setBoolPref("places.history.enabled", aHistoryEnabled);
+}
+function getHistoryEnabled() {
+ return Services.prefs.getBoolPref("places.history.enabled");
+}
+function clearHistoryEnabled() {
+ try {
+ Services.prefs.clearUserPref("places.history.enabled");
+ }
+ catch (ex) {}
+}
+
+/**
+ * Returns a PRTime in the past usable to add expirable visits.
+ *
+ * param [optional] daysAgo
+ * Expiration ignores any visit added in the last 7 days, so by default
+ * this will be set to 7.
+ * @note to be safe against DST issues we go back one day more.
+ */
+function getExpirablePRTime(daysAgo = 7) {
+ let dateObj = new Date();
+ // Normalize to midnight
+ dateObj.setHours(0);
+ dateObj.setMinutes(0);
+ dateObj.setSeconds(0);
+ dateObj.setMilliseconds(0);
+ dateObj = new Date(dateObj.getTime() - (daysAgo + 1) * 86400000);
+ return dateObj.getTime() * 1000;
+}
diff --git a/toolkit/components/places/tests/expiration/test_analyze_runs.js b/toolkit/components/places/tests/expiration/test_analyze_runs.js
new file mode 100644
index 000000000..1a84e1b38
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_analyze_runs.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Constants
+
+const TOPIC_AUTOCOMPLETE_FEEDBACK_INCOMING = "autocomplete-will-enter-text";
+
+// Helpers
+
+/**
+ * Ensures that we have no data in the tables created by ANALYZE.
+ */
+function clearAnalyzeData() {
+ let db = DBConn();
+ if (!db.tableExists("sqlite_stat1")) {
+ return;
+ }
+ db.executeSimpleSQL("DELETE FROM sqlite_stat1");
+}
+
+/**
+ * Checks that we ran ANALYZE on the specified table.
+ *
+ * @param aTableName
+ * The table to check if ANALYZE was ran.
+ * @param aRan
+ * True if it was expected to run, false otherwise
+ */
+function do_check_analyze_ran(aTableName, aRan) {
+ let db = DBConn();
+ do_check_true(db.tableExists("sqlite_stat1"));
+ let stmt = db.createStatement("SELECT idx FROM sqlite_stat1 WHERE tbl = :table");
+ stmt.params.table = aTableName;
+ try {
+ if (aRan) {
+ do_check_true(stmt.executeStep());
+ do_check_neq(stmt.row.idx, null);
+ }
+ else {
+ do_check_false(stmt.executeStep());
+ }
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+// Tests
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* init_tests() {
+ const TEST_URI = NetUtil.newURI("http://mozilla.org/");
+ const TEST_TITLE = "This is a test";
+
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: TEST_TITLE,
+ url: TEST_URI
+ });
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ let thing = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteInput,
+ Ci.nsIAutoCompletePopup,
+ Ci.nsIAutoCompleteController]),
+ get popup() { return thing; },
+ get controller() { return thing; },
+ popupOpen: true,
+ selectedIndex: 0,
+ getValueAt: function() { return TEST_URI.spec; },
+ searchString: TEST_TITLE,
+ };
+ Services.obs.notifyObservers(thing, TOPIC_AUTOCOMPLETE_FEEDBACK_INCOMING,
+ null);
+});
+
+add_task(function* test_timed() {
+ clearAnalyzeData();
+
+ // Set a low interval and wait for the timed expiration to start.
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ setInterval(3);
+ yield promise;
+ setInterval(3600);
+
+ do_check_analyze_ran("moz_places", false);
+ do_check_analyze_ran("moz_bookmarks", false);
+ do_check_analyze_ran("moz_historyvisits", false);
+ do_check_analyze_ran("moz_inputhistory", true);
+});
+
+add_task(function* test_debug() {
+ clearAnalyzeData();
+
+ yield promiseForceExpirationStep(1);
+
+ do_check_analyze_ran("moz_places", true);
+ do_check_analyze_ran("moz_bookmarks", true);
+ do_check_analyze_ran("moz_historyvisits", true);
+ do_check_analyze_ran("moz_inputhistory", true);
+});
+
+add_task(function* test_clear_history() {
+ clearAnalyzeData();
+
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ let listener = Cc["@mozilla.org/places/expiration;1"]
+ .getService(Ci.nsINavHistoryObserver);
+ listener.onClearHistory();
+ yield promise;
+
+ do_check_analyze_ran("moz_places", true);
+ do_check_analyze_ran("moz_bookmarks", false);
+ do_check_analyze_ran("moz_historyvisits", true);
+ do_check_analyze_ran("moz_inputhistory", true);
+});
diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_history.js b/toolkit/components/places/tests/expiration/test_annos_expire_history.js
new file mode 100644
index 000000000..f9568a769
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_annos_expire_history.js
@@ -0,0 +1,93 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * EXPIRE_WITH_HISTORY annotations should be expired when a page has no more
+ * visits, even if the page still exists in the database.
+ * This expiration policy is only valid for page annotations.
+ */
+
+var as = Cc["@mozilla.org/browser/annotation-service;1"].
+ getService(Ci.nsIAnnotationService);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_annos_expire_history() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ // Add some visited page and a couple expire with history annotations for each.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ as.setPageAnnotation(pageURI, "page_expire1", "test", 0, as.EXPIRE_WITH_HISTORY);
+ as.setPageAnnotation(pageURI, "page_expire2", "test", 0, as.EXPIRE_WITH_HISTORY);
+ }
+
+ let pages = as.getPagesWithAnnotation("page_expire1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_expire2");
+ do_check_eq(pages.length, 5);
+
+ // Add some bookmarked page and a couple session annotations for each.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://item_anno." + i + ".mozilla.org/");
+ // We also add a visit before bookmarking.
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null
+ });
+ // Notice we use page annotations here, items annotations can't use this
+ // kind of expiration policy.
+ as.setPageAnnotation(pageURI, "item_persist1", "test", 0, as.EXPIRE_WITH_HISTORY);
+ as.setPageAnnotation(pageURI, "item_persist2", "test", 0, as.EXPIRE_WITH_HISTORY);
+ }
+
+ let items = as.getPagesWithAnnotation("item_persist1");
+ do_check_eq(items.length, 5);
+ items = as.getPagesWithAnnotation("item_persist2");
+ do_check_eq(items.length, 5);
+
+ // Add other visited page and a couple expire with history annotations for each.
+ // We won't expire these visits, so the annotations should survive.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://persist_page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ as.setPageAnnotation(pageURI, "page_persist1", "test", 0, as.EXPIRE_WITH_HISTORY);
+ as.setPageAnnotation(pageURI, "page_persist2", "test", 0, as.EXPIRE_WITH_HISTORY);
+ }
+
+ pages = as.getPagesWithAnnotation("page_persist1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_persist2");
+ do_check_eq(pages.length, 5);
+
+ // Expire all visits for the first 5 pages and the bookmarks.
+ yield promiseForceExpirationStep(10);
+
+ pages = as.getPagesWithAnnotation("page_expire1");
+ do_check_eq(pages.length, 0);
+ pages = as.getPagesWithAnnotation("page_expire2");
+ do_check_eq(pages.length, 0);
+ items = as.getItemsWithAnnotation("item_persist1");
+ do_check_eq(items.length, 0);
+ items = as.getItemsWithAnnotation("item_persist2");
+ do_check_eq(items.length, 0);
+ pages = as.getPagesWithAnnotation("page_persist1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_persist2");
+ do_check_eq(pages.length, 5);
+});
diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_never.js b/toolkit/components/places/tests/expiration/test_annos_expire_never.js
new file mode 100644
index 000000000..f146f25b5
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_annos_expire_never.js
@@ -0,0 +1,95 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * EXPIRE_NEVER annotations should be expired when a page is removed from the
+ * database.
+ * If the annotation is a page annotation this will happen when the page is
+ * expired, namely when the page has no visits and is not bookmarked.
+ * Otherwise if it's an item annotation the annotation will be expired when
+ * the item is removed, thus expiration won't handle this case at all.
+ */
+
+var as = Cc["@mozilla.org/browser/annotation-service;1"].
+ getService(Ci.nsIAnnotationService);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_annos_expire_never() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ // Add some visited page and a couple expire never annotations for each.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ as.setPageAnnotation(pageURI, "page_expire1", "test", 0, as.EXPIRE_NEVER);
+ as.setPageAnnotation(pageURI, "page_expire2", "test", 0, as.EXPIRE_NEVER);
+ }
+
+ let pages = as.getPagesWithAnnotation("page_expire1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_expire2");
+ do_check_eq(pages.length, 5);
+
+ // Add some bookmarked page and a couple expire never annotations for each.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://item_anno." + i + ".mozilla.org/");
+ // We also add a visit before bookmarking.
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null
+ });
+ let id = yield PlacesUtils.promiseItemId(bm.guid);
+ as.setItemAnnotation(id, "item_persist1", "test", 0, as.EXPIRE_NEVER);
+ as.setItemAnnotation(id, "item_persist2", "test", 0, as.EXPIRE_NEVER);
+ }
+
+ let items = as.getItemsWithAnnotation("item_persist1");
+ do_check_eq(items.length, 5);
+ items = as.getItemsWithAnnotation("item_persist2");
+ do_check_eq(items.length, 5);
+
+ // Add other visited page and a couple expire never annotations for each.
+ // We won't expire these visits, so the annotations should survive.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://persist_page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ as.setPageAnnotation(pageURI, "page_persist1", "test", 0, as.EXPIRE_NEVER);
+ as.setPageAnnotation(pageURI, "page_persist2", "test", 0, as.EXPIRE_NEVER);
+ }
+
+ pages = as.getPagesWithAnnotation("page_persist1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_persist2");
+ do_check_eq(pages.length, 5);
+
+ // Expire all visits for the first 5 pages and the bookmarks.
+ yield promiseForceExpirationStep(10);
+
+ pages = as.getPagesWithAnnotation("page_expire1");
+ do_check_eq(pages.length, 0);
+ pages = as.getPagesWithAnnotation("page_expire2");
+ do_check_eq(pages.length, 0);
+ items = as.getItemsWithAnnotation("item_persist1");
+ do_check_eq(items.length, 5);
+ items = as.getItemsWithAnnotation("item_persist2");
+ do_check_eq(items.length, 5);
+ pages = as.getPagesWithAnnotation("page_persist1");
+ do_check_eq(pages.length, 5);
+ pages = as.getPagesWithAnnotation("page_persist2");
+ do_check_eq(pages.length, 5);
+});
diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_policy.js b/toolkit/components/places/tests/expiration/test_annos_expire_policy.js
new file mode 100644
index 000000000..2fe50e13e
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_annos_expire_policy.js
@@ -0,0 +1,189 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Annotations can be set with a timed expiration policy.
+ * Supported policies are:
+ * - EXPIRE_DAYS: annotation would be expired after 7 days
+ * - EXPIRE_WEEKS: annotation would be expired after 30 days
+ * - EXPIRE_MONTHS: annotation would be expired after 180 days
+ */
+
+var as = Cc["@mozilla.org/browser/annotation-service;1"].
+ getService(Ci.nsIAnnotationService);
+
+/**
+ * Creates an aged annotation.
+ *
+ * @param aIdentifier Either a page url or an item id.
+ * @param aIdentifier Name of the annotation.
+ * @param aValue Value for the annotation.
+ * @param aExpirePolicy Expiration policy of the annotation.
+ * @param aAgeInDays Age in days of the annotation.
+ * @param [optional] aLastModifiedAgeInDays Age in days of the annotation, for lastModified.
+ */
+var now = Date.now();
+function add_old_anno(aIdentifier, aName, aValue, aExpirePolicy,
+ aAgeInDays, aLastModifiedAgeInDays) {
+ let expireDate = (now - (aAgeInDays * 86400 * 1000)) * 1000;
+ let lastModifiedDate = 0;
+ if (aLastModifiedAgeInDays)
+ lastModifiedDate = (now - (aLastModifiedAgeInDays * 86400 * 1000)) * 1000;
+
+ let sql;
+ if (typeof(aIdentifier) == "number") {
+ // Item annotation.
+ as.setItemAnnotation(aIdentifier, aName, aValue, 0, aExpirePolicy);
+ // Update dateAdded for the last added annotation.
+ sql = "UPDATE moz_items_annos SET dateAdded = :expire_date, lastModified = :last_modified " +
+ "WHERE id = (SELECT id FROM moz_items_annos " +
+ "WHERE item_id = :id " +
+ "ORDER BY dateAdded DESC LIMIT 1)";
+ }
+ else if (aIdentifier instanceof Ci.nsIURI) {
+ // Page annotation.
+ as.setPageAnnotation(aIdentifier, aName, aValue, 0, aExpirePolicy);
+ // Update dateAdded for the last added annotation.
+ sql = "UPDATE moz_annos SET dateAdded = :expire_date, lastModified = :last_modified " +
+ "WHERE id = (SELECT a.id FROM moz_annos a " +
+ "LEFT JOIN moz_places h on h.id = a.place_id " +
+ "WHERE h.url_hash = hash(:id) AND h.url = :id " +
+ "ORDER BY a.dateAdded DESC LIMIT 1)";
+ }
+ else
+ do_throw("Wrong identifier type");
+
+ let stmt = DBConn().createStatement(sql);
+ stmt.params.id = (typeof(aIdentifier) == "number") ? aIdentifier
+ : aIdentifier.spec;
+ stmt.params.expire_date = expireDate;
+ stmt.params.last_modified = lastModifiedDate;
+ try {
+ stmt.executeStep();
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_annos_expire_policy() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ let now_specific_to_test = getExpirablePRTime();
+ // Add some bookmarked page and timed annotations for each.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://item_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now_specific_to_test++ });
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null
+ });
+ let id = yield PlacesUtils.promiseItemId(bm.guid);
+ // Add a 6 days old anno.
+ add_old_anno(id, "persist_days", "test", as.EXPIRE_DAYS, 6);
+ // Add a 8 days old anno, modified 5 days ago.
+ add_old_anno(id, "persist_lm_days", "test", as.EXPIRE_DAYS, 8, 6);
+ // Add a 8 days old anno.
+ add_old_anno(id, "expire_days", "test", as.EXPIRE_DAYS, 8);
+
+ // Add a 29 days old anno.
+ add_old_anno(id, "persist_weeks", "test", as.EXPIRE_WEEKS, 29);
+ // Add a 31 days old anno, modified 29 days ago.
+ add_old_anno(id, "persist_lm_weeks", "test", as.EXPIRE_WEEKS, 31, 29);
+ // Add a 31 days old anno.
+ add_old_anno(id, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+
+ // Add a 179 days old anno.
+ add_old_anno(id, "persist_months", "test", as.EXPIRE_MONTHS, 179);
+ // Add a 181 days old anno, modified 179 days ago.
+ add_old_anno(id, "persist_lm_months", "test", as.EXPIRE_MONTHS, 181, 179);
+ // Add a 181 days old anno.
+ add_old_anno(id, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+
+ // Add a 6 days old anno.
+ add_old_anno(pageURI, "persist_days", "test", as.EXPIRE_DAYS, 6);
+ // Add a 8 days old anno, modified 5 days ago.
+ add_old_anno(pageURI, "persist_lm_days", "test", as.EXPIRE_DAYS, 8, 6);
+ // Add a 8 days old anno.
+ add_old_anno(pageURI, "expire_days", "test", as.EXPIRE_DAYS, 8);
+
+ // Add a 29 days old anno.
+ add_old_anno(pageURI, "persist_weeks", "test", as.EXPIRE_WEEKS, 29);
+ // Add a 31 days old anno, modified 29 days ago.
+ add_old_anno(pageURI, "persist_lm_weeks", "test", as.EXPIRE_WEEKS, 31, 29);
+ // Add a 31 days old anno.
+ add_old_anno(pageURI, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+
+ // Add a 179 days old anno.
+ add_old_anno(pageURI, "persist_months", "test", as.EXPIRE_MONTHS, 179);
+ // Add a 181 days old anno, modified 179 days ago.
+ add_old_anno(pageURI, "persist_lm_months", "test", as.EXPIRE_MONTHS, 181, 179);
+ // Add a 181 days old anno.
+ add_old_anno(pageURI, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+ }
+
+ // Add some visited page and timed annotations for each.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now_specific_to_test++ });
+ // Add a 6 days old anno.
+ add_old_anno(pageURI, "persist_days", "test", as.EXPIRE_DAYS, 6);
+ // Add a 8 days old anno, modified 5 days ago.
+ add_old_anno(pageURI, "persist_lm_days", "test", as.EXPIRE_DAYS, 8, 6);
+ // Add a 8 days old anno.
+ add_old_anno(pageURI, "expire_days", "test", as.EXPIRE_DAYS, 8);
+
+ // Add a 29 days old anno.
+ add_old_anno(pageURI, "persist_weeks", "test", as.EXPIRE_WEEKS, 29);
+ // Add a 31 days old anno, modified 29 days ago.
+ add_old_anno(pageURI, "persist_lm_weeks", "test", as.EXPIRE_WEEKS, 31, 29);
+ // Add a 31 days old anno.
+ add_old_anno(pageURI, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+
+ // Add a 179 days old anno.
+ add_old_anno(pageURI, "persist_months", "test", as.EXPIRE_MONTHS, 179);
+ // Add a 181 days old anno, modified 179 days ago.
+ add_old_anno(pageURI, "persist_lm_months", "test", as.EXPIRE_MONTHS, 181, 179);
+ // Add a 181 days old anno.
+ add_old_anno(pageURI, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+ }
+
+ // Expire all visits for the bookmarks.
+ yield promiseForceExpirationStep(5);
+
+ ["expire_days", "expire_weeks", "expire_months"].forEach(function(aAnno) {
+ let pages = as.getPagesWithAnnotation(aAnno);
+ do_check_eq(pages.length, 0);
+ });
+
+ ["expire_days", "expire_weeks", "expire_months"].forEach(function(aAnno) {
+ let items = as.getItemsWithAnnotation(aAnno);
+ do_check_eq(items.length, 0);
+ });
+
+ ["persist_days", "persist_lm_days", "persist_weeks", "persist_lm_weeks",
+ "persist_months", "persist_lm_months"].forEach(function(aAnno) {
+ let pages = as.getPagesWithAnnotation(aAnno);
+ do_check_eq(pages.length, 10);
+ });
+
+ ["persist_days", "persist_lm_days", "persist_weeks", "persist_lm_weeks",
+ "persist_months", "persist_lm_months"].forEach(function(aAnno) {
+ let items = as.getItemsWithAnnotation(aAnno);
+ do_check_eq(items.length, 5);
+ });
+});
diff --git a/toolkit/components/places/tests/expiration/test_annos_expire_session.js b/toolkit/components/places/tests/expiration/test_annos_expire_session.js
new file mode 100644
index 000000000..68c995f80
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_annos_expire_session.js
@@ -0,0 +1,83 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Session annotations should be expired when browsing session ends.
+ */
+
+var as = Cc["@mozilla.org/browser/annotation-service;1"].
+ getService(Ci.nsIAnnotationService);
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_annos_expire_session() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Add some visited page and a couple session annotations for each.
+ let now = Date.now() * 1000;
+ for (let i = 0; i < 10; i++) {
+ let pageURI = uri("http://session_page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI, visitDate: now++ });
+ as.setPageAnnotation(pageURI, "test1", "test", 0, as.EXPIRE_SESSION);
+ as.setPageAnnotation(pageURI, "test2", "test", 0, as.EXPIRE_SESSION);
+ }
+
+ // Add some bookmarked page and a couple session annotations for each.
+ for (let i = 0; i < 10; i++) {
+ let pageURI = uri("http://session_item_anno." + i + ".mozilla.org/");
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null
+ });
+ let id = yield PlacesUtils.promiseItemId(bm.guid);
+ as.setItemAnnotation(id, "test1", "test", 0, as.EXPIRE_SESSION);
+ as.setItemAnnotation(id, "test2", "test", 0, as.EXPIRE_SESSION);
+ }
+
+
+ let pages = as.getPagesWithAnnotation("test1");
+ do_check_eq(pages.length, 10);
+ pages = as.getPagesWithAnnotation("test2");
+ do_check_eq(pages.length, 10);
+ let items = as.getItemsWithAnnotation("test1");
+ do_check_eq(items.length, 10);
+ items = as.getItemsWithAnnotation("test2");
+ do_check_eq(items.length, 10);
+
+ let deferred = Promise.defer();
+ waitForConnectionClosed(function() {
+ let stmt = DBConn(true).createAsyncStatement(
+ `SELECT id FROM moz_annos
+ UNION ALL
+ SELECT id FROM moz_items_annos
+ WHERE expiration = :expiration`
+ );
+ stmt.params.expiration = as.EXPIRE_SESSION;
+ stmt.executeAsync({
+ handleResult: function(aResultSet) {
+ dump_table("moz_annos");
+ dump_table("moz_items_annos");
+ do_throw("Should not find any leftover session annotations");
+ },
+ handleError: function(aError) {
+ do_throw("Error code " + aError.result + " with message '" +
+ aError.message + "' returned.");
+ },
+ handleCompletion: function(aReason) {
+ do_check_eq(aReason, Ci.mozIStorageStatementCallback.REASON_FINISHED);
+ deferred.resolve();
+ }
+ });
+ stmt.finalize();
+ });
+ yield deferred.promise;
+});
diff --git a/toolkit/components/places/tests/expiration/test_clearHistory.js b/toolkit/components/places/tests/expiration/test_clearHistory.js
new file mode 100644
index 000000000..d3879d7ad
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_clearHistory.js
@@ -0,0 +1,157 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * History.clear() should expire everything but bookmarked pages and valid
+ * annos.
+ */
+
+var hs = PlacesUtils.history;
+var as = PlacesUtils.annotations;
+
+/**
+ * Creates an aged annotation.
+ *
+ * @param aIdentifier Either a page url or an item id.
+ * @param aIdentifier Name of the annotation.
+ * @param aValue Value for the annotation.
+ * @param aExpirePolicy Expiration policy of the annotation.
+ * @param aAgeInDays Age in days of the annotation.
+ * @param [optional] aLastModifiedAgeInDays Age in days of the annotation, for lastModified.
+ */
+var now = Date.now();
+function add_old_anno(aIdentifier, aName, aValue, aExpirePolicy,
+ aAgeInDays, aLastModifiedAgeInDays) {
+ let expireDate = (now - (aAgeInDays * 86400 * 1000)) * 1000;
+ let lastModifiedDate = 0;
+ if (aLastModifiedAgeInDays)
+ lastModifiedDate = (now - (aLastModifiedAgeInDays * 86400 * 1000)) * 1000;
+
+ let sql;
+ if (typeof(aIdentifier) == "number") {
+ // Item annotation.
+ as.setItemAnnotation(aIdentifier, aName, aValue, 0, aExpirePolicy);
+ // Update dateAdded for the last added annotation.
+ sql = "UPDATE moz_items_annos SET dateAdded = :expire_date, lastModified = :last_modified " +
+ "WHERE id = ( " +
+ "SELECT a.id FROM moz_items_annos a " +
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id " +
+ "WHERE a.item_id = :id " +
+ "AND n.name = :anno_name " +
+ "ORDER BY a.dateAdded DESC LIMIT 1 " +
+ ")";
+ }
+ else if (aIdentifier instanceof Ci.nsIURI) {
+ // Page annotation.
+ as.setPageAnnotation(aIdentifier, aName, aValue, 0, aExpirePolicy);
+ // Update dateAdded for the last added annotation.
+ sql = "UPDATE moz_annos SET dateAdded = :expire_date, lastModified = :last_modified " +
+ "WHERE id = ( " +
+ "SELECT a.id FROM moz_annos a " +
+ "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id " +
+ "JOIN moz_places h on h.id = a.place_id " +
+ "WHERE h.url_hash = hash(:id) AND h.url = :id " +
+ "AND n.name = :anno_name " +
+ "ORDER BY a.dateAdded DESC LIMIT 1 " +
+ ")";
+ }
+ else
+ do_throw("Wrong identifier type");
+
+ let stmt = DBConn().createStatement(sql);
+ stmt.params.id = (typeof(aIdentifier) == "number") ? aIdentifier
+ : aIdentifier.spec;
+ stmt.params.expire_date = expireDate;
+ stmt.params.last_modified = lastModifiedDate;
+ stmt.params.anno_name = aName;
+ try {
+ stmt.executeStep();
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_historyClear() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire all expirable pages.
+ setMaxPages(0);
+
+ // Add some bookmarked page with visit and annotations.
+ for (let i = 0; i < 5; i++) {
+ let pageURI = uri("http://item_anno." + i + ".mozilla.org/");
+ // This visit will be expired.
+ yield PlacesTestUtils.addVisits({ uri: pageURI });
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: pageURI,
+ title: null
+ });
+ let id = yield PlacesUtils.promiseItemId(bm.guid);
+ // Will persist because it's an EXPIRE_NEVER item anno.
+ as.setItemAnnotation(id, "persist", "test", 0, as.EXPIRE_NEVER);
+ // Will persist because the page is bookmarked.
+ as.setPageAnnotation(pageURI, "persist", "test", 0, as.EXPIRE_NEVER);
+ // All EXPIRE_SESSION annotations are expected to expire on clear history.
+ as.setItemAnnotation(id, "expire_session", "test", 0, as.EXPIRE_SESSION);
+ as.setPageAnnotation(pageURI, "expire_session", "test", 0, as.EXPIRE_SESSION);
+ // Annotations with timed policy will expire regardless bookmarked status.
+ add_old_anno(id, "expire_days", "test", as.EXPIRE_DAYS, 8);
+ add_old_anno(id, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+ add_old_anno(id, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+ add_old_anno(pageURI, "expire_days", "test", as.EXPIRE_DAYS, 8);
+ add_old_anno(pageURI, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+ add_old_anno(pageURI, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+ }
+
+ // Add some visited page and annotations for each.
+ for (let i = 0; i < 5; i++) {
+ // All page annotations related to these expired pages are expected to
+ // expire as well.
+ let pageURI = uri("http://page_anno." + i + ".mozilla.org/");
+ yield PlacesTestUtils.addVisits({ uri: pageURI });
+ as.setPageAnnotation(pageURI, "expire", "test", 0, as.EXPIRE_NEVER);
+ as.setPageAnnotation(pageURI, "expire_session", "test", 0, as.EXPIRE_SESSION);
+ add_old_anno(pageURI, "expire_days", "test", as.EXPIRE_DAYS, 8);
+ add_old_anno(pageURI, "expire_weeks", "test", as.EXPIRE_WEEKS, 31);
+ add_old_anno(pageURI, "expire_months", "test", as.EXPIRE_MONTHS, 181);
+ }
+
+ // Expire all visits for the bookmarks
+ yield PlacesUtils.history.clear();
+
+ ["expire_days", "expire_weeks", "expire_months", "expire_session",
+ "expire"].forEach(function(aAnno) {
+ let pages = as.getPagesWithAnnotation(aAnno);
+ do_check_eq(pages.length, 0);
+ });
+
+ ["expire_days", "expire_weeks", "expire_months", "expire_session",
+ "expire"].forEach(function(aAnno) {
+ let items = as.getItemsWithAnnotation(aAnno);
+ do_check_eq(items.length, 0);
+ });
+
+ let pages = as.getPagesWithAnnotation("persist");
+ do_check_eq(pages.length, 5);
+
+ let items = as.getItemsWithAnnotation("persist");
+ do_check_eq(items.length, 5);
+
+ for (let itemId of items) {
+ // Check item exists.
+ let guid = yield PlacesUtils.promiseItemGuid(itemId);
+ do_check_true((yield PlacesUtils.bookmarks.fetch({guid})), "item exists");
+ }
+});
diff --git a/toolkit/components/places/tests/expiration/test_debug_expiration.js b/toolkit/components/places/tests/expiration/test_debug_expiration.js
new file mode 100644
index 000000000..456c03363
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_debug_expiration.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiration can be manually triggered through a debug topic, but that should
+ * only expire orphan entries, unless -1 is passed as limit.
+ */
+
+var gNow = getExpirablePRTime(60);
+
+add_task(function* test_expire_orphans()
+{
+ // Add visits to 2 pages and force a orphan expiration. Visits should survive.
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://page1.mozilla.org/"),
+ visitDate: gNow++
+ });
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://page2.mozilla.org/"),
+ visitDate: gNow++
+ });
+ // Create a orphan place.
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://page3.mozilla.org/",
+ title: ""
+ });
+ yield PlacesUtils.bookmarks.remove(bm);
+
+ // Expire now.
+ yield promiseForceExpirationStep(0);
+
+ // Check that visits survived.
+ do_check_eq(visits_in_database("http://page1.mozilla.org/"), 1);
+ do_check_eq(visits_in_database("http://page2.mozilla.org/"), 1);
+ do_check_false(page_in_database("http://page3.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_expire_orphans_optionalarg()
+{
+ // Add visits to 2 pages and force a orphan expiration. Visits should survive.
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://page1.mozilla.org/"),
+ visitDate: gNow++
+ });
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://page2.mozilla.org/"),
+ visitDate: gNow++
+ });
+ // Create a orphan place.
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "http://page3.mozilla.org/",
+ title: ""
+ });
+ yield PlacesUtils.bookmarks.remove(bm);
+
+ // Expire now.
+ yield promiseForceExpirationStep();
+
+ // Check that visits survived.
+ do_check_eq(visits_in_database("http://page1.mozilla.org/"), 1);
+ do_check_eq(visits_in_database("http://page2.mozilla.org/"), 1);
+ do_check_false(page_in_database("http://page3.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_expire_limited()
+{
+ yield PlacesTestUtils.addVisits([
+ { // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gNow++
+ },
+ { // Should not be expired cause we limit 1
+ uri: "http://new.mozilla.org/",
+ visitDate: gNow++
+ },
+ ]);
+
+ // Expire now.
+ yield promiseForceExpirationStep(1);
+
+ // Check that newer visit survived.
+ do_check_eq(visits_in_database("http://new.mozilla.org/"), 1);
+ // Other visits should have been expired.
+ do_check_false(page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_expire_limited_longurl()
+{
+ let longurl = "http://long.mozilla.org/" + "a".repeat(232);
+ yield PlacesTestUtils.addVisits([
+ { // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gNow++
+ },
+ { // Should be expired cause it's a long url older than 60 days.
+ uri: longurl,
+ visitDate: gNow++
+ },
+ { // Should not be expired cause younger than 60 days.
+ uri: longurl,
+ visitDate: getExpirablePRTime(58)
+ }
+ ]);
+
+ yield promiseForceExpirationStep(1);
+
+ // Check that some visits survived.
+ do_check_eq(visits_in_database(longurl), 1);
+ // Other visits should have been expired.
+ do_check_false(page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_expire_limited_exoticurl()
+{
+ yield PlacesTestUtils.addVisits([
+ { // Should be expired cause it's the oldest visit
+ uri: "http://old.mozilla.org/",
+ visitDate: gNow++
+ },
+ { // Should be expired cause it's a long url older than 60 days.
+ uri: "http://download.mozilla.org",
+ visitDate: gNow++,
+ transition: 7
+ },
+ { // Should not be expired cause younger than 60 days.
+ uri: "http://nonexpirable-download.mozilla.org",
+ visitDate: getExpirablePRTime(58),
+ transition: 7
+ }
+ ]);
+
+ yield promiseForceExpirationStep(1);
+
+ // Check that some visits survived.
+ do_check_eq(visits_in_database("http://nonexpirable-download.mozilla.org/"), 1);
+ // The visits are gone, the url is not yet, cause we limited the expiration
+ // to one entry, and we already removed http://old.mozilla.org/.
+ // The page normally would be expired by the next expiration run.
+ do_check_eq(visits_in_database("http://download.mozilla.org/"), 0);
+ // Other visits should have been expired.
+ do_check_false(page_in_database("http://old.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_expire_unlimited()
+{
+ let longurl = "http://long.mozilla.org/" + "a".repeat(232);
+ yield PlacesTestUtils.addVisits([
+ {
+ uri: "http://old.mozilla.org/",
+ visitDate: gNow++
+ },
+ {
+ uri: "http://new.mozilla.org/",
+ visitDate: gNow++
+ },
+ // Add expirable visits.
+ {
+ uri: "http://download.mozilla.org/",
+ visitDate: gNow++,
+ transition: PlacesUtils.history.TRANSITION_DOWNLOAD
+ },
+ {
+ uri: longurl,
+ visitDate: gNow++
+ },
+
+ // Add non-expirable visits
+ {
+ uri: "http://nonexpirable.mozilla.org/",
+ visitDate: getExpirablePRTime(5)
+ },
+ {
+ uri: "http://nonexpirable-download.mozilla.org/",
+ visitDate: getExpirablePRTime(5),
+ transition: PlacesUtils.history.TRANSITION_DOWNLOAD
+ },
+ {
+ uri: longurl,
+ visitDate: getExpirablePRTime(5)
+ }
+ ]);
+
+ yield promiseForceExpirationStep(-1);
+
+ // Check that some visits survived.
+ do_check_eq(visits_in_database("http://nonexpirable.mozilla.org/"), 1);
+ do_check_eq(visits_in_database("http://nonexpirable-download.mozilla.org/"), 1);
+ do_check_eq(visits_in_database(longurl), 1);
+ // Other visits should have been expired.
+ do_check_false(page_in_database("http://old.mozilla.org/"));
+ do_check_false(page_in_database("http://download.mozilla.org/"));
+ do_check_false(page_in_database("http://new.mozilla.org/"));
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+});
+
+function run_test()
+{
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+ // Set maxPages to a low value, so it's easy to go over it.
+ setMaxPages(1);
+
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/expiration/test_idle_daily.js b/toolkit/components/places/tests/expiration/test_idle_daily.js
new file mode 100644
index 000000000..05e5a8125
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_idle_daily.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that expiration runs on idle-daily.
+
+function run_test() {
+ do_test_pending();
+
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ Services.obs.addObserver(function observeExpiration(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observeExpiration,
+ PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ do_test_finished();
+ }, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false);
+
+ let expire = Cc["@mozilla.org/places/expiration;1"].
+ getService(Ci.nsIObserver);
+ expire.observe(null, "idle-daily", null);
+}
diff --git a/toolkit/components/places/tests/expiration/test_notifications.js b/toolkit/components/places/tests/expiration/test_notifications.js
new file mode 100644
index 000000000..06e585c6c
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications.js
@@ -0,0 +1,38 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Ensure that History (through category cache) notifies us just once.
+ */
+
+var os = Cc["@mozilla.org/observer-service;1"].
+ getService(Ci.nsIObserverService);
+
+var gObserver = {
+ notifications: 0,
+ observe: function(aSubject, aTopic, aData) {
+ this.notifications++;
+ }
+};
+os.addObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false);
+
+function run_test() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ PlacesTestUtils.clearHistory();
+
+ do_timeout(2000, check_result);
+ do_test_pending();
+}
+
+function check_result() {
+ os.removeObserver(gObserver, PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ do_check_eq(gObserver.notifications, 1);
+ do_test_finished();
+}
diff --git a/toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js b/toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js
new file mode 100644
index 000000000..f70cd2b58
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications_onDeleteURI.js
@@ -0,0 +1,114 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiring a full page should fire an onDeleteURI notification.
+ */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+var tests = [
+
+ { desc: "Add 1 bookmarked page.",
+ addPages: 1,
+ addBookmarks: 1,
+ expectedNotifications: 0, // No expirable pages.
+ },
+
+ { desc: "Add 2 pages, 1 bookmarked.",
+ addPages: 2,
+ addBookmarks: 1,
+ expectedNotifications: 1, // Only one expirable page.
+ },
+
+ { desc: "Add 10 pages, none bookmarked.",
+ addPages: 10,
+ addBookmarks: 0,
+ expectedNotifications: 10, // Will expire everything.
+ },
+
+ { desc: "Add 10 pages, all bookmarked.",
+ addPages: 10,
+ addBookmarks: 10,
+ expectedNotifications: 0, // No expirable pages.
+ },
+
+];
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_notifications_onDeleteURI() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire anything that is expirable.
+ setMaxPages(0);
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex -1];
+ print("\nTEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now++ });
+ }
+
+ // Setup bookmarks.
+ currentTest.bookmarks = [];
+ for (let i = 0; i < currentTest.addBookmarks; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: null,
+ url: page
+ });
+ currentTest.bookmarks.push(page);
+ }
+
+ // Observe history.
+ historyObserver = {
+ onBeginUpdateBatch: function PEX_onBeginUpdateBatch() {},
+ onEndUpdateBatch: function PEX_onEndUpdateBatch() {},
+ onClearHistory: function() {},
+ onVisit: function() {},
+ onTitleChanged: function() {},
+ onDeleteURI: function(aURI, aGUID, aReason) {
+ currentTest.receivedNotifications++;
+ // Check this uri was not bookmarked.
+ do_check_eq(currentTest.bookmarks.indexOf(aURI.spec), -1);
+ do_check_valid_places_guid(aGUID);
+ do_check_eq(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
+ },
+ onPageChanged: function() {},
+ onDeleteVisits: function(aURI, aTime) { },
+ };
+ hs.addObserver(historyObserver, false);
+
+ // Expire now.
+ yield promiseForceExpirationStep(-1);
+
+ hs.removeObserver(historyObserver, false);
+
+ do_check_eq(currentTest.receivedNotifications,
+ currentTest.expectedNotifications);
+
+ // Clean up.
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+ }
+
+ clearMaxPages();
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js b/toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js
new file mode 100644
index 000000000..e6b99ff8b
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_notifications_onDeleteVisits.js
@@ -0,0 +1,142 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiring only visits for a page, but not the full page, should fire an
+ * onDeleteVisits notification.
+ */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+var tests = [
+
+ { desc: "Add 1 bookmarked page.",
+ addPages: 1,
+ visitsPerPage: 1,
+ addBookmarks: 1,
+ limitExpiration: -1,
+ expectedNotifications: 1, // Will expire visits for 1 page.
+ },
+
+ { desc: "Add 2 pages, 1 bookmarked.",
+ addPages: 2,
+ visitsPerPage: 1,
+ addBookmarks: 1,
+ limitExpiration: -1,
+ expectedNotifications: 1, // Will expire visits for 1 page.
+ },
+
+ { desc: "Add 10 pages, none bookmarked.",
+ addPages: 10,
+ visitsPerPage: 1,
+ addBookmarks: 0,
+ limitExpiration: -1,
+ expectedNotifications: 0, // Will expire only full pages.
+ },
+
+ { desc: "Add 10 pages, all bookmarked.",
+ addPages: 10,
+ visitsPerPage: 1,
+ addBookmarks: 10,
+ limitExpiration: -1,
+ expectedNotifications: 10, // Will expire visist for all pages.
+ },
+
+ { desc: "Add 10 pages with lot of visits, none bookmarked.",
+ addPages: 10,
+ visitsPerPage: 10,
+ addBookmarks: 0,
+ limitExpiration: 10,
+ expectedNotifications: 10, // Will expire 1 visist for each page, but won't
+ }, // expire pages since they still have visits.
+
+];
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_notifications_onDeleteVisits() {
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ // Expire anything that is expirable.
+ setMaxPages(0);
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex -1];
+ print("\nTEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let timeInMicroseconds = getExpirablePRTime(8);
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+ }
+
+ for (let j = 0; j < currentTest.visitsPerPage; j++) {
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(page), visitDate: newTimeInMicroseconds() });
+ }
+ }
+
+ // Setup bookmarks.
+ currentTest.bookmarks = [];
+ for (let i = 0; i < currentTest.addBookmarks; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: null,
+ url: page
+ });
+ currentTest.bookmarks.push(page);
+ }
+
+ // Observe history.
+ historyObserver = {
+ onBeginUpdateBatch: function PEX_onBeginUpdateBatch() {},
+ onEndUpdateBatch: function PEX_onEndUpdateBatch() {},
+ onClearHistory: function() {},
+ onVisit: function() {},
+ onTitleChanged: function() {},
+ onDeleteURI: function(aURI, aGUID, aReason) {
+ // Check this uri was not bookmarked.
+ do_check_eq(currentTest.bookmarks.indexOf(aURI.spec), -1);
+ do_check_valid_places_guid(aGUID);
+ do_check_eq(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
+ },
+ onPageChanged: function() {},
+ onDeleteVisits: function(aURI, aTime, aGUID, aReason) {
+ currentTest.receivedNotifications++;
+ do_check_guid_for_uri(aURI, aGUID);
+ do_check_eq(aReason, Ci.nsINavHistoryObserver.REASON_EXPIRED);
+ },
+ };
+ hs.addObserver(historyObserver, false);
+
+ // Expire now.
+ yield promiseForceExpirationStep(currentTest.limitExpiration);
+
+ hs.removeObserver(historyObserver, false);
+
+ do_check_eq(currentTest.receivedNotifications,
+ currentTest.expectedNotifications);
+
+ // Clean up.
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+ }
+
+ clearMaxPages();
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/expiration/test_outdated_analyze.js b/toolkit/components/places/tests/expiration/test_outdated_analyze.js
new file mode 100644
index 000000000..9cf61f06b
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_outdated_analyze.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that expiration executes ANALYZE when statistics are outdated.
+
+const TEST_URL = "http://www.mozilla.org/";
+
+XPCOMUtils.defineLazyServiceGetter(this, "gHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory");
+
+/**
+ * Object that represents a mozIVisitInfo object.
+ *
+ * @param [optional] aTransitionType
+ * The transition type of the visit. Defaults to TRANSITION_LINK if not
+ * provided.
+ * @param [optional] aVisitTime
+ * The time of the visit. Defaults to now if not provided.
+ */
+function VisitInfo(aTransitionType, aVisitTime) {
+ this.transitionType =
+ aTransitionType === undefined ? TRANSITION_LINK : aTransitionType;
+ this.visitDate = aVisitTime || Date.now() * 1000;
+}
+
+function run_test() {
+ do_test_pending();
+
+ // Init expiration before "importing".
+ force_expiration_start();
+
+ // Add a bunch of pages (at laast IMPORT_PAGES_THRESHOLD pages).
+ let places = [];
+ for (let i = 0; i < 100; i++) {
+ places.push({
+ uri: NetUtil.newURI(TEST_URL + i),
+ title: "Title" + i,
+ visits: [new VisitInfo]
+ });
+ }
+ gHistory.updatePlaces(places);
+
+ // Set interval to a small value to expire on it.
+ setInterval(1); // 1s
+
+ Services.obs.addObserver(function observeExpiration(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observeExpiration,
+ PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+
+ // Check that statistica are up-to-date.
+ let stmt = DBConn().createAsyncStatement(
+ "SELECT (SELECT COUNT(*) FROM moz_places) - "
+ + "(SELECT SUBSTR(stat,1,LENGTH(stat)-2) FROM sqlite_stat1 "
+ + "WHERE idx = 'moz_places_url_hashindex')"
+ );
+ stmt.executeAsync({
+ handleResult: function(aResultSet) {
+ let row = aResultSet.getNextRow();
+ this._difference = row.getResultByIndex(0);
+ },
+ handleError: function(aError) {
+ do_throw("Unexpected error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function(aReason) {
+ do_check_true(this._difference === 0);
+ do_test_finished();
+ }
+ });
+ stmt.finalize();
+ }, PlacesUtils.TOPIC_EXPIRATION_FINISHED, false);
+}
diff --git a/toolkit/components/places/tests/expiration/test_pref_interval.js b/toolkit/components/places/tests/expiration/test_pref_interval.js
new file mode 100644
index 000000000..44c749d7a
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_pref_interval.js
@@ -0,0 +1,61 @@
+/**
+ * What this is aimed to test:
+ *
+ * Expiration relies on an interval, that is user-preffable setting
+ * "places.history.expiration.interval_seconds".
+ * On pref change it will stop current interval timer and fire a new one,
+ * that will obey the new value.
+ * If the pref is set to a number <= 0 we will use the default value.
+ */
+
+// Default timer value for expiration in seconds. Must have same value as
+// PREF_INTERVAL_SECONDS_NOTSET in nsPlacesExpiration.
+const DEFAULT_TIMER_DELAY_SECONDS = 3 * 60;
+
+// Sync this with the const value in the component.
+const EXPIRE_AGGRESSIVITY_MULTIPLIER = 3;
+
+var tests = [
+
+ // This test should be the first, so the interval won't be influenced by
+ // status of history.
+ { desc: "Set interval to 1s.",
+ interval: 1,
+ expectedTimerDelay: 1
+ },
+
+ { desc: "Set interval to a negative value.",
+ interval: -1,
+ expectedTimerDelay: DEFAULT_TIMER_DELAY_SECONDS
+ },
+
+ { desc: "Set interval to 0.",
+ interval: 0,
+ expectedTimerDelay: DEFAULT_TIMER_DELAY_SECONDS
+ },
+
+ { desc: "Set interval to a large value.",
+ interval: 100,
+ expectedTimerDelay: 100
+ },
+
+];
+
+add_task(function* test() {
+ // The pref should not exist by default.
+ Assert.throws(() => getInterval());
+
+ // Force the component, so it will start observing preferences.
+ force_expiration_start();
+
+ for (let currentTest of tests) {
+ print(currentTest.desc);
+ let promise = promiseTopicObserved("test-interval-changed");
+ setInterval(currentTest.interval);
+ let [, data] = yield promise;
+ Assert.equal(data, currentTest.expectedTimerDelay * EXPIRE_AGGRESSIVITY_MULTIPLIER);
+ }
+
+ clearInterval();
+});
+
diff --git a/toolkit/components/places/tests/expiration/test_pref_maxpages.js b/toolkit/components/places/tests/expiration/test_pref_maxpages.js
new file mode 100644
index 000000000..6a237afbb
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/test_pref_maxpages.js
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * What this is aimed to test:
+ *
+ * Expiration will obey to hardware spec, but user can set a custom maximum
+ * number of pages to retain, to restrict history, through
+ * "places.history.expiration.max_pages".
+ * This limit is used at next expiration run.
+ * If the pref is set to a number < 0 we will use the default value.
+ */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+var tests = [
+
+ { desc: "Set max_pages to a negative value, with 1 page.",
+ maxPages: -1,
+ addPages: 1,
+ expectedNotifications: 0, // Will ignore and won't expire anything.
+ },
+
+ { desc: "Set max_pages to 0.",
+ maxPages: 0,
+ addPages: 1,
+ expectedNotifications: 1,
+ },
+
+ { desc: "Set max_pages to 0, with 2 pages.",
+ maxPages: 0,
+ addPages: 2,
+ expectedNotifications: 2, // Will expire everything.
+ },
+
+ // Notice if we are over limit we do a full step of expiration. So we ensure
+ // that we will expire if we are over the limit, but we don't ensure that we
+ // will expire exactly up to the limit. Thus in this case we expire
+ // everything.
+ { desc: "Set max_pages to 1 with 2 pages.",
+ maxPages: 1,
+ addPages: 2,
+ expectedNotifications: 2, // Will expire everything (in this case).
+ },
+
+ { desc: "Set max_pages to 10, with 9 pages.",
+ maxPages: 10,
+ addPages: 9,
+ expectedNotifications: 0, // We are at the limit, won't expire anything.
+ },
+
+ { desc: "Set max_pages to 10 with 10 pages.",
+ maxPages: 10,
+ addPages: 10,
+ expectedNotifications: 0, // We are below the limit, won't expire anything.
+ },
+];
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_pref_maxpages() {
+ // The pref should not exist by default.
+ try {
+ getMaxPages();
+ do_throw("interval pref should not exist by default");
+ }
+ catch (ex) {}
+
+ // Set interval to a large value so we don't expire on it.
+ setInterval(3600); // 1h
+
+ for (let testIndex = 1; testIndex <= tests.length; testIndex++) {
+ let currentTest = tests[testIndex -1];
+ print("\nTEST " + testIndex + ": " + currentTest.desc);
+ currentTest.receivedNotifications = 0;
+
+ // Setup visits.
+ let now = getExpirablePRTime();
+ for (let i = 0; i < currentTest.addPages; i++) {
+ let page = "http://" + testIndex + "." + i + ".mozilla.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(page), visitDate: now++ });
+ }
+
+ // Observe history.
+ let historyObserver = {
+ onBeginUpdateBatch: function PEX_onBeginUpdateBatch() {},
+ onEndUpdateBatch: function PEX_onEndUpdateBatch() {},
+ onClearHistory: function() {},
+ onVisit: function() {},
+ onTitleChanged: function() {},
+ onDeleteURI: function(aURI) {
+ print("onDeleteURI " + aURI.spec);
+ currentTest.receivedNotifications++;
+ },
+ onPageChanged: function() {},
+ onDeleteVisits: function(aURI, aTime) {
+ print("onDeleteVisits " + aURI.spec + " " + aTime);
+ },
+ };
+ hs.addObserver(historyObserver, false);
+
+ setMaxPages(currentTest.maxPages);
+
+ // Expire now.
+ yield promiseForceExpirationStep(-1);
+
+ hs.removeObserver(historyObserver, false);
+
+ do_check_eq(currentTest.receivedNotifications,
+ currentTest.expectedNotifications);
+
+ // Clean up.
+ yield PlacesTestUtils.clearHistory();
+ }
+
+ clearMaxPages();
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/expiration/xpcshell.ini b/toolkit/components/places/tests/expiration/xpcshell.ini
new file mode 100644
index 000000000..cda7ac052
--- /dev/null
+++ b/toolkit/components/places/tests/expiration/xpcshell.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+head = head_expiration.js
+tail =
+skip-if = toolkit == 'android'
+
+[test_analyze_runs.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_annos_expire_history.js]
+[test_annos_expire_never.js]
+[test_annos_expire_policy.js]
+[test_annos_expire_session.js]
+[test_clearHistory.js]
+[test_debug_expiration.js]
+[test_idle_daily.js]
+[test_notifications.js]
+[test_notifications_onDeleteURI.js]
+[test_notifications_onDeleteVisits.js]
+[test_outdated_analyze.js]
+[test_pref_interval.js]
+[test_pref_maxpages.js]
+skip-if = os == "linux" # bug 1284083
diff --git a/toolkit/components/places/tests/favicons/.eslintrc.js b/toolkit/components/places/tests/favicons/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png
new file mode 100644
index 000000000..723008771
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-big32.jpg.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png
new file mode 100644
index 000000000..9932c18fb
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-big4.jpg.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png
new file mode 100644
index 000000000..9f16bef43
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-big48.ico.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png
new file mode 100644
index 000000000..ed158d161
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-big64.png.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png
new file mode 100644
index 000000000..585c9e897
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-scale160x3.jpg.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png
new file mode 100644
index 000000000..e07dabc79
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/expected-favicon-scale3x160.jpg.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big16.ico b/toolkit/components/places/tests/favicons/favicon-big16.ico
new file mode 100644
index 000000000..d44438903
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-big16.ico
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big32.jpg b/toolkit/components/places/tests/favicons/favicon-big32.jpg
new file mode 100644
index 000000000..b2131bf0c
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-big32.jpg
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big4.jpg b/toolkit/components/places/tests/favicons/favicon-big4.jpg
new file mode 100644
index 000000000..b84fcd35a
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-big4.jpg
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big48.ico b/toolkit/components/places/tests/favicons/favicon-big48.ico
new file mode 100644
index 000000000..f22522411
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-big48.ico
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-big64.png b/toolkit/components/places/tests/favicons/favicon-big64.png
new file mode 100644
index 000000000..2756cf0cb
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-big64.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-normal16.png b/toolkit/components/places/tests/favicons/favicon-normal16.png
new file mode 100644
index 000000000..62b69a3d0
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-normal16.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-normal32.png b/toolkit/components/places/tests/favicons/favicon-normal32.png
new file mode 100644
index 000000000..5535363c9
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-normal32.png
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg
new file mode 100644
index 000000000..422ee7ea0
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-scale160x3.jpg
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg
new file mode 100644
index 000000000..e8514966a
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/favicon-scale3x160.jpg
Binary files differ
diff --git a/toolkit/components/places/tests/favicons/head_favicons.js b/toolkit/components/places/tests/favicons/head_favicons.js
new file mode 100644
index 000000000..cc81791e8
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/head_favicons.js
@@ -0,0 +1,105 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+
+// This error icon must stay in sync with FAVICON_ERRORPAGE_URL in
+// nsIFaviconService.idl, aboutCertError.xhtml and netError.xhtml.
+const FAVICON_ERRORPAGE_URI =
+ NetUtil.newURI("chrome://global/skin/icons/warning-16.png");
+
+/**
+ * Waits for the first OnPageChanged notification for ATTRIBUTE_FAVICON, and
+ * verifies that it matches the expected page URI and associated favicon URI.
+ *
+ * This function also double-checks the GUID parameter of the notification.
+ *
+ * @param aExpectedPageURI
+ * nsIURI object of the page whose favicon should change.
+ * @param aExpectedFaviconURI
+ * nsIURI object of the newly associated favicon.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function waitForFaviconChanged(aExpectedPageURI, aExpectedFaviconURI,
+ aCallback) {
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onPageChanged: function WFFC_onPageChanged(aURI, aWhat, aValue, aGUID) {
+ if (aWhat != Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON) {
+ return;
+ }
+ PlacesUtils.history.removeObserver(this);
+
+ do_check_true(aURI.equals(aExpectedPageURI));
+ do_check_eq(aValue, aExpectedFaviconURI.spec);
+ do_check_guid_for_uri(aURI, aGUID);
+ aCallback();
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+}
+
+/**
+ * Checks that the favicon for the given page matches the provided data.
+ *
+ * @param aPageURI
+ * nsIURI object for the page to check.
+ * @param aExpectedMimeType
+ * Expected MIME type of the icon, for example "image/png".
+ * @param aExpectedData
+ * Expected icon data, expressed as an array of byte values.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconDataForPage(aPageURI, aExpectedMimeType, aExpectedData,
+ aCallback) {
+ PlacesUtils.favicons.getFaviconDataForPage(aPageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ do_check_eq(aExpectedMimeType, aMimeType);
+ do_check_true(compareArrays(aExpectedData, aData));
+ do_check_guid_for_uri(aPageURI);
+ aCallback();
+ });
+}
+
+/**
+ * Checks that the given page has no associated favicon.
+ *
+ * @param aPageURI
+ * nsIURI object for the page to check.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconMissingForPage(aPageURI, aCallback) {
+ PlacesUtils.favicons.getFaviconURLForPage(aPageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ do_check_true(aURI === null);
+ aCallback();
+ });
+}
+
+function promiseFaviconMissingForPage(aPageURI) {
+ return new Promise(resolve => checkFaviconMissingForPage(aPageURI, resolve));
+}
+
+function promiseFaviconChanged(aExpectedPageURI, aExpectedFaviconURI) {
+ return new Promise(resolve => waitForFaviconChanged(aExpectedPageURI, aExpectedFaviconURI, resolve));
+}
diff --git a/toolkit/components/places/tests/favicons/test_expireAllFavicons.js b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js
new file mode 100644
index 000000000..c5d8edfdd
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_expireAllFavicons.js
@@ -0,0 +1,39 @@
+/**
+ * This file tests that favicons are correctly expired by expireAllFavicons.
+ */
+
+"use strict";
+
+const TEST_PAGE_URI = NetUtil.newURI("http://example.com/");
+const BOOKMARKED_PAGE_URI = NetUtil.newURI("http://example.com/bookmarked");
+
+add_task(function* test_expireAllFavicons() {
+ // Add a visited page.
+ yield PlacesTestUtils.addVisits({ uri: TEST_PAGE_URI, transition: TRANSITION_TYPED });
+
+ // Set a favicon for our test page.
+ yield promiseSetIconForPage(TEST_PAGE_URI, SMALLPNG_DATA_URI);
+
+ // Add a page with a bookmark.
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: BOOKMARKED_PAGE_URI,
+ title: "Test bookmark"
+ });
+
+ // Set a favicon for our bookmark.
+ yield promiseSetIconForPage(BOOKMARKED_PAGE_URI, SMALLPNG_DATA_URI);
+
+ // Start expiration only after data has been saved in the database.
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_FAVICONS_EXPIRED);
+ PlacesUtils.favicons.expireAllFavicons();
+ yield promise;
+
+ // Check that the favicons for the pages we added were removed.
+ yield promiseFaviconMissingForPage(TEST_PAGE_URI);
+ yield promiseFaviconMissingForPage(BOOKMARKED_PAGE_URI);
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/favicons/test_favicons_conversions.js b/toolkit/components/places/tests/favicons/test_favicons_conversions.js
new file mode 100644
index 000000000..fa0d332ec
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_favicons_conversions.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests the image conversions done by the favicon service.
+ */
+
+// Globals
+
+// The pixel values we get on Windows are sometimes +/- 1 value compared to
+// other platforms, so we need to skip some image content tests.
+var isWindows = ("@mozilla.org/windows-registry-key;1" in Cc);
+
+/**
+ * Checks the conversion of the given test image file.
+ *
+ * @param aFileName
+ * File that contains the favicon image, located in the test folder.
+ * @param aFileMimeType
+ * MIME type of the image contained in the file.
+ * @param aFileLength
+ * Expected length of the file.
+ * @param aExpectConversion
+ * If false, the icon should be stored as is. If true, the expected data
+ * is loaded from a file named "expected-" + aFileName + ".png".
+ * @param aVaryOnWindows
+ * Indicates that the content of the converted image can be different on
+ * Windows and should not be checked on that platform.
+ * @param aCallback
+ * This function is called after the check finished.
+ */
+function checkFaviconDataConversion(aFileName, aFileMimeType, aFileLength,
+ aExpectConversion, aVaryOnWindows,
+ aCallback) {
+ let pageURI = NetUtil.newURI("http://places.test/page/" + aFileName);
+ PlacesTestUtils.addVisits({ uri: pageURI, transition: TRANSITION_TYPED }).then(
+ function () {
+ let faviconURI = NetUtil.newURI("http://places.test/icon/" + aFileName);
+ let fileData = readFileOfLength(aFileName, aFileLength);
+
+ PlacesUtils.favicons.replaceFaviconData(faviconURI, fileData, fileData.length,
+ aFileMimeType);
+ PlacesUtils.favicons.setAndFetchFaviconForPage(pageURI, faviconURI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function CFDC_verify(aURI, aDataLen, aData, aMimeType) {
+ if (!aExpectConversion) {
+ do_check_true(compareArrays(aData, fileData));
+ do_check_eq(aMimeType, aFileMimeType);
+ } else {
+ if (!aVaryOnWindows || !isWindows) {
+ let expectedFile = do_get_file("expected-" + aFileName + ".png");
+ do_check_true(compareArrays(aData, readFileData(expectedFile)));
+ }
+ do_check_eq(aMimeType, "image/png");
+ }
+
+ aCallback();
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+}
+
+// Tests
+
+function run_test() {
+ run_next_test();
+}
+
+add_test(function test_storing_a_normal_16x16_icon() {
+ // 16x16 png, 286 bytes.
+ // optimized: no
+ checkFaviconDataConversion("favicon-normal16.png", "image/png", 286,
+ false, false, run_next_test);
+});
+
+add_test(function test_storing_a_normal_32x32_icon() {
+ // 32x32 png, 344 bytes.
+ // optimized: no
+ checkFaviconDataConversion("favicon-normal32.png", "image/png", 344,
+ false, false, run_next_test);
+});
+
+add_test(function test_storing_a_big_16x16_icon() {
+ // in: 16x16 ico, 1406 bytes.
+ // optimized: no
+ checkFaviconDataConversion("favicon-big16.ico", "image/x-icon", 1406,
+ false, false, run_next_test);
+});
+
+add_test(function test_storing_an_oversize_4x4_icon() {
+ // in: 4x4 jpg, 4751 bytes.
+ // optimized: yes
+ checkFaviconDataConversion("favicon-big4.jpg", "image/jpeg", 4751,
+ true, false, run_next_test);
+});
+
+add_test(function test_storing_an_oversize_32x32_icon() {
+ // in: 32x32 jpg, 3494 bytes.
+ // optimized: yes
+ checkFaviconDataConversion("favicon-big32.jpg", "image/jpeg", 3494,
+ true, true, run_next_test);
+});
+
+add_test(function test_storing_an_oversize_48x48_icon() {
+ // in: 48x48 ico, 56646 bytes.
+ // (howstuffworks.com icon, contains 13 icons with sizes from 16x16 to
+ // 48x48 in varying depths)
+ // optimized: yes
+ checkFaviconDataConversion("favicon-big48.ico", "image/x-icon", 56646,
+ true, false, run_next_test);
+});
+
+add_test(function test_storing_an_oversize_64x64_icon() {
+ // in: 64x64 png, 10698 bytes.
+ // optimized: yes
+ checkFaviconDataConversion("favicon-big64.png", "image/png", 10698,
+ true, false, run_next_test);
+});
+
+add_test(function test_scaling_an_oversize_160x3_icon() {
+ // in: 160x3 jpg, 5095 bytes.
+ // optimized: yes
+ checkFaviconDataConversion("favicon-scale160x3.jpg", "image/jpeg", 5095,
+ true, false, run_next_test);
+});
+
+add_test(function test_scaling_an_oversize_3x160_icon() {
+ // in: 3x160 jpg, 5059 bytes.
+ // optimized: yes
+ checkFaviconDataConversion("favicon-scale3x160.jpg", "image/jpeg", 5059,
+ true, false, run_next_test);
+});
diff --git a/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js
new file mode 100644
index 000000000..73eea7436
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_getFaviconDataForPage.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests getFaviconDataForPage.
+ */
+
+// Globals
+
+const FAVICON_URI = NetUtil.newURI(do_get_file("favicon-normal32.png"));
+const FAVICON_DATA = readFileData(do_get_file("favicon-normal32.png"));
+const FAVICON_MIMETYPE = "image/png";
+
+// Tests
+
+function run_test()
+{
+ // Check that the favicon loaded correctly before starting the actual tests.
+ do_check_eq(FAVICON_DATA.length, 344);
+ run_next_test();
+}
+
+add_test(function test_normal()
+{
+ let pageURI = NetUtil.newURI("http://example.com/normal");
+
+ PlacesTestUtils.addVisits(pageURI).then(function () {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI, FAVICON_URI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function () {
+ PlacesUtils.favicons.getFaviconDataForPage(pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ do_check_true(aURI.equals(FAVICON_URI));
+ do_check_eq(FAVICON_DATA.length, aDataLen);
+ do_check_true(compareArrays(FAVICON_DATA, aData));
+ do_check_eq(FAVICON_MIMETYPE, aMimeType);
+ run_next_test();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+});
+
+add_test(function test_missing()
+{
+ let pageURI = NetUtil.newURI("http://example.com/missing");
+
+ PlacesUtils.favicons.getFaviconDataForPage(pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ // Check also the expected data types.
+ do_check_true(aURI === null);
+ do_check_true(aDataLen === 0);
+ do_check_true(aData.length === 0);
+ do_check_true(aMimeType === "");
+ run_next_test();
+ });
+});
diff --git a/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js
new file mode 100644
index 000000000..fb2e23ff9
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_getFaviconURLForPage.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests getFaviconURLForPage.
+ */
+
+// Tests
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_test(function test_normal()
+{
+ let pageURI = NetUtil.newURI("http://example.com/normal");
+
+ PlacesTestUtils.addVisits(pageURI).then(function () {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ pageURI, SMALLPNG_DATA_URI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function () {
+ PlacesUtils.favicons.getFaviconURLForPage(pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ do_check_true(aURI.equals(SMALLPNG_DATA_URI));
+
+ // Check also the expected data types.
+ do_check_true(aDataLen === 0);
+ do_check_true(aData.length === 0);
+ do_check_true(aMimeType === "");
+ run_next_test();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+});
+
+add_test(function test_missing()
+{
+ let pageURI = NetUtil.newURI("http://example.com/missing");
+
+ PlacesUtils.favicons.getFaviconURLForPage(pageURI,
+ function (aURI, aDataLen, aData, aMimeType) {
+ // Check also the expected data types.
+ do_check_true(aURI === null);
+ do_check_true(aDataLen === 0);
+ do_check_true(aData.length === 0);
+ do_check_true(aMimeType === "");
+ run_next_test();
+ });
+});
diff --git a/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js b/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js
new file mode 100644
index 000000000..d055d8d61
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_moz-anno_favicon_mime_type.js
@@ -0,0 +1,90 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This test ensures that the mime type is set for moz-anno channels of favicons
+ * properly. Added with work in bug 481227.
+ */
+
+// Constants
+Cu.import("resource://gre/modules/NetUtil.jsm");
+
+const testFaviconData = "data:image/png,%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%10%00%00%00%10%08%06%00%00%00%1F%F3%FFa%00%00%00%04gAMA%00%00%AF%C87%05%8A%E9%00%00%00%19tEXtSoftware%00Adobe%20ImageReadyq%C9e%3C%00%00%01%D6IDATx%DAb%FC%FF%FF%3F%03%25%00%20%80%98%909%EF%DF%BFg%EF%EC%EC%FC%AD%AC%AC%FC%DF%95%91%F1%BF%89%89%C9%7F%20%FF%D7%EA%D5%AB%B7%DF%BBwO%16%9B%01%00%01%C4%00r%01%08%9F9s%C6%CD%D8%D8%F8%BF%0B%03%C3%FF3%40%BC%0A%88%EF%02q%1A%10%BB%40%F1%AAU%ABv%C1%D4%C30%40%00%81%89%993g%3E%06%1A%F6%3F%14%AA%11D%97%03%F1%7Fc%08%0D%E2%2B))%FD%17%04%89%A1%19%00%10%40%0C%D00%F8%0F3%00%C8%F8%BF%1B%E4%0Ac%88a%E5%60%17%19%FF%0F%0D%0D%05%1B%02v%D9%DD%BB%0A0%03%00%02%08%AC%B9%A3%A3%E3%17%03%D4v%90%01%EF%18%106%C3%0Cz%07%C5%BB%A1%DE%82y%07%20%80%A0%A6%08B%FCn%0C1%60%26%D4%20d%C3VA%C3%06%26%BE%0A%EA-%80%00%82%B9%E0%F7L4%0D%EF%90%F8%C6%60%2F%0A%82%BD%01%13%07%0700%D0%01%02%88%11%E4%02P%B41%DC%BB%C7%D0%014%0D%E8l%06W%20%06%BA%88%A1%1C%1AS%15%40%7C%16%CA6.%2Fgx%BFg%0F%83%CB%D9%B3%0C%7B%80%7C%80%00%02%BB%00%E8%9F%ED%20%1B%3A%A0%A6%9F%81%DA%DC%01%C5%B0%80%ED%80%FA%BF%BC%BC%FC%3F%83%12%90%9D%96%F6%1F%20%80%18%DE%BD%7B%C7%0E%8E%05AD%20%FEGr%A6%A0%A0%E0%7F%25P%80%02%9D%0F%D28%13%18%23%C6%C0%B0%02E%3D%C8%F5%00%01%04%8F%05P%A8%BA%40my%87%E4%12c%A8%8D%20%8B%D0%D3%00%08%03%04%10%9C%01R%E4%82d%3B%C8%A0%99%C6%90%90%C6%A5%19%84%01%02%08%9E%17%80%C9x%F7%7B%A0%DBVC%F9%A0%C0%5C%7D%16%2C%CE%00%F4%C6O%5C%99%09%20%800L%04y%A5%03%1A%95%A0%80%05%05%14.%DBA%18%20%80%18)%CD%CE%00%01%06%00%0C'%94%C7%C0k%C9%2C%00%00%00%00IEND%AEB%60%82";
+const moz_anno_favicon_prefix = "moz-anno:favicon:";
+
+// streamListener
+
+function streamListener(aExpectedContentType)
+{
+ this._expectedContentType = aExpectedContentType;
+}
+streamListener.prototype =
+{
+ onStartRequest: function(aRequest, aContext)
+ {
+ // We have other tests that make sure the data is what we expect. We just
+ // need to check the content type here.
+ let channel = aRequest.QueryInterface(Ci.nsIChannel);
+ dump("*** Checking " + channel.URI.spec + "\n");
+ do_check_eq(channel.contentType, this._expectedContentType);
+
+ // If we somehow throw before doing the above check, the test will pass, so
+ // we do this for extra sanity.
+ this._checked = true;
+ },
+ onStopRequest: function()
+ {
+ do_check_true(this._checked);
+ do_test_finished();
+ },
+ onDataAvailable: function(aRequest, aContext, aInputStream, aOffset, aCount)
+ {
+ aRequest.cancel(Cr.NS_ERROR_ABORT);
+ }
+};
+
+// Test Runner
+
+function run_test()
+{
+ let fs = Cc["@mozilla.org/browser/favicon-service;1"].
+ getService(Ci.nsIFaviconService);
+
+ // Test that the default icon has the content type of image/png.
+ let channel = NetUtil.newChannel({
+ uri: fs.defaultFavicon,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ });
+ channel.asyncOpen2(new streamListener("image/png"));
+ do_test_pending();
+
+ // Test URI that we don't know anything about. Will end up being the default
+ // icon, so expect image/png.
+ channel = NetUtil.newChannel({
+ uri: moz_anno_favicon_prefix + "http://mozilla.org",
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ });
+ channel.asyncOpen2(new streamListener("image/png"));
+ do_test_pending();
+
+ // Test that the content type of a favicon we add ends up being image/png.
+ let testURI = uri("http://mozilla.org/");
+ // Add the data before opening
+ fs.replaceFaviconDataFromDataURL(testURI, testFaviconData,
+ (Date.now() + 60 * 60 * 24 * 1000) * 1000,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ // Open the channel
+ channel = NetUtil.newChannel({
+ uri: moz_anno_favicon_prefix + testURI.spec,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ });
+ channel.asyncOpen2(new streamListener("image/png"));
+ do_test_pending();
+}
diff --git a/toolkit/components/places/tests/favicons/test_page-icon_protocol.js b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js
new file mode 100644
index 000000000..5533d5135
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_page-icon_protocol.js
@@ -0,0 +1,66 @@
+const ICON_DATA = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==";
+const TEST_URI = NetUtil.newURI("http://mozilla.org/");
+const ICON_URI = NetUtil.newURI("http://mozilla.org/favicon.ico");
+
+function fetchIconForSpec(spec) {
+ return new Promise((resolve, reject) => {
+ NetUtil.asyncFetch({
+ uri: NetUtil.newURI("page-icon:" + TEST_URI.spec),
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON
+ }, (input, status, request) => {
+ if (!Components.isSuccessCode(status)) {
+ reject(new Error("unable to load icon"));
+ return;
+ }
+
+ try {
+ let data = NetUtil.readInputStreamToString(input, input.available());
+ let contentType = request.QueryInterface(Ci.nsIChannel).contentType;
+ input.close();
+ resolve({ data, contentType });
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+ });
+}
+
+var gDefaultFavicon;
+var gFavicon;
+
+add_task(function* setup() {
+ yield PlacesTestUtils.addVisits({ uri: TEST_URI });
+
+ PlacesUtils.favicons.replaceFaviconDataFromDataURL(
+ ICON_URI, ICON_DATA, (Date.now() + 8640000) * 1000,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ yield new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ TEST_URI, ICON_URI, false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+
+ gDefaultFavicon = yield fetchIconForSpec(PlacesUtils.favicons.defaultFavicon);
+ gFavicon = yield fetchIconForSpec(ICON_DATA);
+});
+
+add_task(function* known_url() {
+ let {data, contentType} = yield fetchIconForSpec(TEST_URI.spec);
+ Assert.equal(contentType, gFavicon.contentType);
+ Assert.ok(data == gFavicon.data, "Got the favicon data");
+});
+
+add_task(function* unknown_url() {
+ let {data, contentType} = yield fetchIconForSpec("http://www.moz.org/");
+ Assert.equal(contentType, gDefaultFavicon.contentType);
+ Assert.ok(data == gDefaultFavicon.data, "Got the default favicon data");
+});
+
+add_task(function* invalid_url() {
+ let {data, contentType} = yield fetchIconForSpec("test");
+ Assert.equal(contentType, gDefaultFavicon.contentType);
+ Assert.ok(data == gDefaultFavicon.data, "Got the default favicon data");
+});
diff --git a/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js
new file mode 100644
index 000000000..df61c22cd
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_query_result_favicon_changed_on_child.js
@@ -0,0 +1,74 @@
+/**
+ * Test for bug 451499 <https://bugzilla.mozilla.org/show_bug.cgi?id=451499>:
+ * Wrong folder icon appears on smart bookmarks.
+ */
+
+"use strict";
+
+const PAGE_URI = NetUtil.newURI("http://example.com/test_query_result");
+
+add_task(function* test_query_result_favicon_changed_on_child() {
+ // Bookmark our test page, so it will appear in the query resultset.
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "test_bookmark",
+ url: PAGE_URI
+ });
+
+ // Get the last 10 bookmarks added to the menu or the toolbar.
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarksMenuFolderId,
+ PlacesUtils.toolbarFolderId], 2);
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.maxResults = 10;
+ options.excludeQueries = 1;
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let resultObserver = {
+ __proto__: NavHistoryResultObserver.prototype,
+ containerStateChanged(aContainerNode, aOldState, aNewState) {
+ if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) {
+ // We set a favicon on PAGE_URI while the container is open. The
+ // favicon for the page must have data associated with it in order for
+ // the icon changed notifications to be sent, so we use a valid image
+ // data URI.
+ PlacesUtils.favicons.setAndFetchFaviconForPage(PAGE_URI,
+ SMALLPNG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ }
+ },
+ nodeIconChanged(aNode) {
+ do_throw("The icon should be set only for the page," +
+ " not for the containing query.");
+ }
+ };
+ result.addObserver(resultObserver, false);
+
+ // Open the container and wait for containerStateChanged. We should start
+ // observing before setting |containerOpen| as that's caused by the
+ // setAndFetchFaviconForPage() call caused by the containerStateChanged
+ // observer above.
+ let promise = promiseFaviconChanged(PAGE_URI, SMALLPNG_DATA_URI);
+ result.root.containerOpen = true;
+ yield promise;
+
+ // We must wait for the asynchronous database thread to finish the
+ // operation, and then for the main thread to process any pending
+ // notifications that came from the asynchronous thread, before we can be
+ // sure that nodeIconChanged was not invoked in the meantime.
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ result.removeObserver(resultObserver);
+
+ // Free the resources immediately.
+ result.root.containerOpen = false;
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconData.js b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js
new file mode 100644
index 000000000..ac53e70e9
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_replaceFaviconData.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for mozIAsyncFavicons::replaceFaviconData()
+ */
+
+var iconsvc = PlacesUtils.favicons;
+var histsvc = PlacesUtils.history;
+var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+var originalFavicon = {
+ file: do_get_file("favicon-normal16.png"),
+ uri: uri(do_get_file("favicon-normal16.png")),
+ data: readFileData(do_get_file("favicon-normal16.png")),
+ mimetype: "image/png"
+};
+
+var uniqueFaviconId = 0;
+function createFavicon(fileName) {
+ let tempdir = Services.dirsvc.get("TmpD", Ci.nsILocalFile);
+
+ // remove any existing file at the path we're about to copy to
+ let outfile = tempdir.clone();
+ outfile.append(fileName);
+ try { outfile.remove(false); } catch (e) {}
+
+ originalFavicon.file.copyToFollowingLinks(tempdir, fileName);
+
+ let stream = Cc["@mozilla.org/network/file-output-stream;1"]
+ .createInstance(Ci.nsIFileOutputStream);
+ stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0);
+
+ // append some data that sniffers/encoders will ignore that will distinguish
+ // the different favicons we'll create
+ uniqueFaviconId++;
+ let uniqueStr = "uid:" + uniqueFaviconId;
+ stream.write(uniqueStr, uniqueStr.length);
+ stream.close();
+
+ do_check_eq(outfile.leafName.substr(0, fileName.length), fileName);
+
+ return {
+ file: outfile,
+ uri: uri(outfile),
+ data: readFileData(outfile),
+ mimetype: "image/png"
+ };
+}
+
+function checkCallbackSucceeded(callbackMimetype, callbackData, sourceMimetype, sourceData) {
+ do_check_eq(callbackMimetype, sourceMimetype);
+ do_check_true(compareArrays(callbackData, sourceData));
+}
+
+function run_test() {
+ // check that the favicon loaded correctly
+ do_check_eq(originalFavicon.data.length, 286);
+ run_next_test();
+}
+
+add_task(function* test_replaceFaviconData_validHistoryURI() {
+ do_print("test replaceFaviconData for valid history uri");
+
+ let pageURI = uri("http://test1.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon1.png");
+
+ iconsvc.replaceFaviconData(favicon.uri, favicon.data, favicon.data.length,
+ favicon.mimetype);
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(pageURI, favicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_validHistoryURI_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, favicon.mimetype, favicon.data);
+ checkFaviconDataForPage(
+ pageURI, favicon.mimetype, favicon.data,
+ function test_replaceFaviconData_validHistoryURI_callback() {
+ favicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, systemPrincipal);
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconData_overrideDefaultFavicon() {
+ do_print("test replaceFaviconData to override a later setAndFetchFaviconForPage");
+
+ let pageURI = uri("http://test2.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon2.png");
+ let secondFavicon = createFavicon("favicon3.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, secondFavicon.data, secondFavicon.data.length,
+ secondFavicon.mimetype);
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_overrideDefaultFavicon_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, systemPrincipal);
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconData_replaceExisting() {
+ do_print("test replaceFaviconData to override a previous setAndFetchFaviconForPage");
+
+ let pageURI = uri("http://test3.bar");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon4.png");
+ let secondFavicon = createFavicon("favicon5.png");
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_replaceExisting_firstSet_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, firstFavicon.mimetype, firstFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, firstFavicon.mimetype, firstFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_firstCallback() {
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, secondFavicon.data, secondFavicon.data.length,
+ secondFavicon.mimetype);
+ PlacesTestUtils.promiseAsyncUpdates().then(() => {
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconData_overrideDefaultFavicon_secondCallback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ }, systemPrincipal);
+ });
+ });
+ }, systemPrincipal);
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconData_unrelatedReplace() {
+ do_print("test replaceFaviconData to not make unrelated changes");
+
+ let pageURI = uri("http://test4.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon6.png");
+ let unrelatedFavicon = createFavicon("favicon7.png");
+
+ iconsvc.replaceFaviconData(
+ unrelatedFavicon.uri, unrelatedFavicon.data, unrelatedFavicon.data.length,
+ unrelatedFavicon.mimetype);
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, favicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_unrelatedReplace_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, favicon.mimetype, favicon.data);
+ checkFaviconDataForPage(
+ pageURI, favicon.mimetype, favicon.data,
+ function test_replaceFaviconData_unrelatedReplace_callback() {
+ favicon.file.remove(false);
+ unrelatedFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, systemPrincipal);
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconData_badInputs() {
+ do_print("test replaceFaviconData to throw on bad inputs");
+
+ let favicon = createFavicon("favicon8.png");
+
+ let ex = null;
+ try {
+ iconsvc.replaceFaviconData(
+ favicon.uri, favicon.data, favicon.data.length, "");
+ } catch (e) {
+ ex = e;
+ } finally {
+ do_check_true(!!ex);
+ }
+
+ ex = null;
+ try {
+ iconsvc.replaceFaviconData(
+ null, favicon.data, favicon.data.length, favicon.mimeType);
+ } catch (e) {
+ ex = e;
+ } finally {
+ do_check_true(!!ex);
+ }
+
+ ex = null;
+ try {
+ iconsvc.replaceFaviconData(
+ favicon.uri, null, 0, favicon.mimeType);
+ } catch (e) {
+ ex = e;
+ } finally {
+ do_check_true(!!ex);
+ }
+
+ favicon.file.remove(false);
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconData_twiceReplace() {
+ do_print("test replaceFaviconData on multiple replacements");
+
+ let pageURI = uri("http://test5.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon9.png");
+ let secondFavicon = createFavicon("favicon10.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, firstFavicon.data, firstFavicon.data.length,
+ firstFavicon.mimetype);
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, secondFavicon.data, secondFavicon.data.length,
+ secondFavicon.mimetype);
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconData_twiceReplace_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconData_twiceReplace_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ }, systemPrincipal);
+ }, systemPrincipal);
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js
new file mode 100644
index 000000000..69a5ba852
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/test_replaceFaviconDataFromDataURL.js
@@ -0,0 +1,352 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for mozIAsyncFavicons::replaceFaviconData()
+ */
+
+var iconsvc = PlacesUtils.favicons;
+var histsvc = PlacesUtils.history;
+
+var originalFavicon = {
+ file: do_get_file("favicon-normal16.png"),
+ uri: uri(do_get_file("favicon-normal16.png")),
+ data: readFileData(do_get_file("favicon-normal16.png")),
+ mimetype: "image/png"
+};
+
+var uniqueFaviconId = 0;
+function createFavicon(fileName) {
+ let tempdir = Services.dirsvc.get("TmpD", Ci.nsILocalFile);
+
+ // remove any existing file at the path we're about to copy to
+ let outfile = tempdir.clone();
+ outfile.append(fileName);
+ try { outfile.remove(false); } catch (e) {}
+
+ originalFavicon.file.copyToFollowingLinks(tempdir, fileName);
+
+ let stream = Cc["@mozilla.org/network/file-output-stream;1"]
+ .createInstance(Ci.nsIFileOutputStream);
+ stream.init(outfile, 0x02 | 0x08 | 0x10, 0o600, 0);
+
+ // append some data that sniffers/encoders will ignore that will distinguish
+ // the different favicons we'll create
+ uniqueFaviconId++;
+ let uniqueStr = "uid:" + uniqueFaviconId;
+ stream.write(uniqueStr, uniqueStr.length);
+ stream.close();
+
+ do_check_eq(outfile.leafName.substr(0, fileName.length), fileName);
+
+ return {
+ file: outfile,
+ uri: uri(outfile),
+ data: readFileData(outfile),
+ mimetype: "image/png"
+ };
+}
+
+function createDataURLForFavicon(favicon) {
+ return "data:" + favicon.mimetype + ";base64," + toBase64(favicon.data);
+}
+
+function checkCallbackSucceeded(callbackMimetype, callbackData, sourceMimetype, sourceData) {
+ do_check_eq(callbackMimetype, sourceMimetype);
+ do_check_true(compareArrays(callbackData, sourceData));
+}
+
+function run_test() {
+ // check that the favicon loaded correctly
+ do_check_eq(originalFavicon.data.length, 286);
+ run_next_test();
+}
+
+add_task(function* test_replaceFaviconDataFromDataURL_validHistoryURI() {
+ do_print("test replaceFaviconDataFromDataURL for valid history uri");
+
+ let pageURI = uri("http://test1.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon1.png");
+ iconsvc.replaceFaviconDataFromDataURL(favicon.uri, createDataURLForFavicon(favicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(pageURI, favicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_validHistoryURI_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, favicon.mimetype, favicon.data);
+ checkFaviconDataForPage(
+ pageURI, favicon.mimetype, favicon.data,
+ function test_replaceFaviconDataFromDataURL_validHistoryURI_callback() {
+ favicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_overrideDefaultFavicon() {
+ do_print("test replaceFaviconDataFromDataURL to override a later setAndFetchFaviconForPage");
+
+ let pageURI = uri("http://test2.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon2.png");
+ let secondFavicon = createFavicon("favicon3.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(secondFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_overrideDefaultFavicon_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_replaceExisting() {
+ do_print("test replaceFaviconDataFromDataURL to override a previous setAndFetchFaviconForPage");
+
+ let pageURI = uri("http://test3.bar");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon4.png");
+ let secondFavicon = createFavicon("favicon5.png");
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_firstSet_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, firstFavicon.mimetype, firstFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, firstFavicon.mimetype, firstFavicon.data,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_firstCallback() {
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(secondFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_replaceExisting_secondCallback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_unrelatedReplace() {
+ do_print("test replaceFaviconDataFromDataURL to not make unrelated changes");
+
+ let pageURI = uri("http://test4.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let favicon = createFavicon("favicon6.png");
+ let unrelatedFavicon = createFavicon("favicon7.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(unrelatedFavicon.uri, createDataURLForFavicon(unrelatedFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, favicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_unrelatedReplace_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, favicon.mimetype, favicon.data);
+ checkFaviconDataForPage(
+ pageURI, favicon.mimetype, favicon.data,
+ function test_replaceFaviconDataFromDataURL_unrelatedReplace_callback() {
+ favicon.file.remove(false);
+ unrelatedFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_badInputs() {
+ do_print("test replaceFaviconDataFromDataURL to throw on bad inputs");
+
+ let favicon = createFavicon("favicon8.png");
+
+ let ex = null;
+ try {
+ iconsvc.replaceFaviconDataFromDataURL(favicon.uri, "", 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ } catch (e) {
+ ex = e;
+ } finally {
+ do_check_true(!!ex);
+ }
+
+ ex = null;
+ try {
+ iconsvc.replaceFaviconDataFromDataURL(null, createDataURLForFavicon(favicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ } catch (e) {
+ ex = e;
+ } finally {
+ do_check_true(!!ex);
+ }
+
+ favicon.file.remove(false);
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_twiceReplace() {
+ do_print("test replaceFaviconDataFromDataURL on multiple replacements");
+
+ let pageURI = uri("http://test5.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon9.png");
+ let secondFavicon = createFavicon("favicon10.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(firstFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(secondFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_twiceReplace_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_twiceReplace_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_afterRegularAssign() {
+ do_print("test replaceFaviconDataFromDataURL after replaceFaviconData");
+
+ let pageURI = uri("http://test6.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon11.png");
+ let secondFavicon = createFavicon("favicon12.png");
+
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, firstFavicon.data, firstFavicon.data.length,
+ firstFavicon.mimetype);
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(secondFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_afterRegularAssign_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_afterRegularAssign_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_replaceFaviconDataFromDataURL_beforeRegularAssign() {
+ do_print("test replaceFaviconDataFromDataURL before replaceFaviconData");
+
+ let pageURI = uri("http://test7.bar/");
+ yield PlacesTestUtils.addVisits(pageURI);
+
+ let firstFavicon = createFavicon("favicon13.png");
+ let secondFavicon = createFavicon("favicon14.png");
+
+ iconsvc.replaceFaviconDataFromDataURL(firstFavicon.uri, createDataURLForFavicon(firstFavicon), 0,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ iconsvc.replaceFaviconData(
+ firstFavicon.uri, secondFavicon.data, secondFavicon.data.length,
+ secondFavicon.mimetype);
+
+ let deferSetAndFetchFavicon = Promise.defer();
+ iconsvc.setAndFetchFaviconForPage(
+ pageURI, firstFavicon.uri, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ function test_replaceFaviconDataFromDataURL_beforeRegularAssign_check(aURI, aDataLen, aData, aMimeType) {
+ checkCallbackSucceeded(aMimeType, aData, secondFavicon.mimetype, secondFavicon.data);
+ checkFaviconDataForPage(
+ pageURI, secondFavicon.mimetype, secondFavicon.data,
+ function test_replaceFaviconDataFromDataURL_beforeRegularAssign_callback() {
+ firstFavicon.file.remove(false);
+ secondFavicon.file.remove(false);
+ deferSetAndFetchFavicon.resolve();
+ });
+ }, Services.scriptSecurityManager.getSystemPrincipal());
+ yield deferSetAndFetchFavicon.promise;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+/* toBase64 copied from image/test/unit/test_encoder_png.js */
+
+/* Convert data (an array of integers) to a Base64 string. */
+const toBase64Table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' +
+ '0123456789+/';
+const base64Pad = '=';
+function toBase64(data) {
+ let result = '';
+ let length = data.length;
+ let i;
+ // Convert every three bytes to 4 ascii characters.
+ for (i = 0; i < (length - 2); i += 3) {
+ result += toBase64Table[data[i] >> 2];
+ result += toBase64Table[((data[i] & 0x03) << 4) + (data[i+1] >> 4)];
+ result += toBase64Table[((data[i+1] & 0x0f) << 2) + (data[i+2] >> 6)];
+ result += toBase64Table[data[i+2] & 0x3f];
+ }
+
+ // Convert the remaining 1 or 2 bytes, pad out to 4 characters.
+ if (length%3) {
+ i = length - (length%3);
+ result += toBase64Table[data[i] >> 2];
+ if ((length%3) == 2) {
+ result += toBase64Table[((data[i] & 0x03) << 4) + (data[i+1] >> 4)];
+ result += toBase64Table[(data[i+1] & 0x0f) << 2];
+ result += base64Pad;
+ } else {
+ result += toBase64Table[(data[i] & 0x03) << 4];
+ result += base64Pad + base64Pad;
+ }
+ }
+
+ return result;
+}
diff --git a/toolkit/components/places/tests/favicons/xpcshell.ini b/toolkit/components/places/tests/favicons/xpcshell.ini
new file mode 100644
index 000000000..851f193c7
--- /dev/null
+++ b/toolkit/components/places/tests/favicons/xpcshell.ini
@@ -0,0 +1,32 @@
+[DEFAULT]
+head = head_favicons.js
+tail =
+skip-if = toolkit == 'android'
+support-files =
+ expected-favicon-big32.jpg.png
+ expected-favicon-big4.jpg.png
+ expected-favicon-big48.ico.png
+ expected-favicon-big64.png.png
+ expected-favicon-scale160x3.jpg.png
+ expected-favicon-scale3x160.jpg.png
+ favicon-big16.ico
+ favicon-big32.jpg
+ favicon-big4.jpg
+ favicon-big48.ico
+ favicon-big64.png
+ favicon-normal16.png
+ favicon-normal32.png
+ favicon-scale160x3.jpg
+ favicon-scale3x160.jpg
+
+[test_expireAllFavicons.js]
+[test_favicons_conversions.js]
+# Bug 676989: test fails consistently on Android
+fail-if = os == "android"
+[test_getFaviconDataForPage.js]
+[test_getFaviconURLForPage.js]
+[test_moz-anno_favicon_mime_type.js]
+[test_page-icon_protocol.js]
+[test_query_result_favicon_changed_on_child.js]
+[test_replaceFaviconData.js]
+[test_replaceFaviconDataFromDataURL.js]
diff --git a/toolkit/components/places/tests/head_common.js b/toolkit/components/places/tests/head_common.js
new file mode 100644
index 000000000..ddb6dcbd7
--- /dev/null
+++ b/toolkit/components/places/tests/head_common.js
@@ -0,0 +1,869 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CURRENT_SCHEMA_VERSION = 35;
+const FIRST_UPGRADABLE_SCHEMA_VERSION = 11;
+
+const NS_APP_USER_PROFILE_50_DIR = "ProfD";
+const NS_APP_PROFILE_DIR_STARTUP = "ProfDS";
+
+// Shortcuts to transitions type.
+const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK;
+const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED;
+const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK;
+const TRANSITION_EMBED = Ci.nsINavHistoryService.TRANSITION_EMBED;
+const TRANSITION_FRAMED_LINK = Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK;
+const TRANSITION_REDIRECT_PERMANENT = Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT;
+const TRANSITION_REDIRECT_TEMPORARY = Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY;
+const TRANSITION_DOWNLOAD = Ci.nsINavHistoryService.TRANSITION_DOWNLOAD;
+const TRANSITION_RELOAD = Ci.nsINavHistoryService.TRANSITION_RELOAD;
+
+const TITLE_LENGTH_MAX = 4096;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
+ "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkJSONUtils",
+ "resource://gre/modules/BookmarkJSONUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkHTMLUtils",
+ "resource://gre/modules/BookmarkHTMLUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups",
+ "resource://gre/modules/PlacesBackups.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTestUtils",
+ "resource://testing-common/PlacesTestUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesTransactions",
+ "resource://gre/modules/PlacesTransactions.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS",
+ "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+
+// This imports various other objects in addition to PlacesUtils.
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "SMALLPNG_DATA_URI", function() {
+ return NetUtil.newURI(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" +
+ "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==");
+});
+XPCOMUtils.defineLazyGetter(this, "SMALLSVG_DATA_URI", function() {
+ return NetUtil.newURI(
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy5" +
+ "3My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBmaWxs" +
+ "PSIjNDI0ZTVhIj4NCiAgPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iN" +
+ "DQiIHN0cm9rZT0iIzQyNGU1YSIgc3Ryb2tlLXdpZHRoPSIxMSIgZmlsbD" +
+ "0ibm9uZSIvPg0KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjI0LjYiIHI9IjY" +
+ "uNCIvPg0KICA8cmVjdCB4PSI0NSIgeT0iMzkuOSIgd2lkdGg9IjEwLjEi" +
+ "IGhlaWdodD0iNDEuOCIvPg0KPC9zdmc%2BDQo%3D");
+});
+
+var gTestDir = do_get_cwd();
+
+// Initialize profile.
+var gProfD = do_get_profile(true);
+
+// Remove any old database.
+clearDB();
+
+/**
+ * Shortcut to create a nsIURI.
+ *
+ * @param aSpec
+ * URLString of the uri.
+ */
+function uri(aSpec) {
+ return NetUtil.newURI(aSpec);
+}
+
+
+/**
+ * Gets the database connection. If the Places connection is invalid it will
+ * try to create a new connection.
+ *
+ * @param [optional] aForceNewConnection
+ * Forces creation of a new connection to the database. When a
+ * connection is asyncClosed it cannot anymore schedule async statements,
+ * though connectionReady will keep returning true (Bug 726990).
+ *
+ * @return The database connection or null if unable to get one.
+ */
+var gDBConn;
+function DBConn(aForceNewConnection) {
+ if (!aForceNewConnection) {
+ let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .DBConnection;
+ if (db.connectionReady)
+ return db;
+ }
+
+ // If the Places database connection has been closed, create a new connection.
+ if (!gDBConn || aForceNewConnection) {
+ let file = Services.dirsvc.get('ProfD', Ci.nsIFile);
+ file.append("places.sqlite");
+ let dbConn = gDBConn = Services.storage.openDatabase(file);
+
+ // Be sure to cleanly close this connection.
+ promiseTopicObserved("profile-before-change").then(() => dbConn.asyncClose());
+ }
+
+ return gDBConn.connectionReady ? gDBConn : null;
+}
+
+/**
+ * Reads data from the provided inputstream.
+ *
+ * @return an array of bytes.
+ */
+function readInputStreamData(aStream) {
+ let bistream = Cc["@mozilla.org/binaryinputstream;1"].
+ createInstance(Ci.nsIBinaryInputStream);
+ try {
+ bistream.setInputStream(aStream);
+ let expectedData = [];
+ let avail;
+ while ((avail = bistream.available())) {
+ expectedData = expectedData.concat(bistream.readByteArray(avail));
+ }
+ return expectedData;
+ } finally {
+ bistream.close();
+ }
+}
+
+/**
+ * Reads the data from the specified nsIFile.
+ *
+ * @param aFile
+ * The nsIFile to read from.
+ * @return an array of bytes.
+ */
+function readFileData(aFile) {
+ let inputStream = Cc["@mozilla.org/network/file-input-stream;1"].
+ createInstance(Ci.nsIFileInputStream);
+ // init the stream as RD_ONLY, -1 == default permissions.
+ inputStream.init(aFile, 0x01, -1, null);
+
+ // Check the returned size versus the expected size.
+ let size = inputStream.available();
+ let bytes = readInputStreamData(inputStream);
+ if (size != bytes.length) {
+ throw "Didn't read expected number of bytes";
+ }
+ return bytes;
+}
+
+/**
+ * Reads the data from the named file, verifying the expected file length.
+ *
+ * @param aFileName
+ * This file should be located in the same folder as the test.
+ * @param aExpectedLength
+ * Expected length of the file.
+ *
+ * @return The array of bytes read from the file.
+ */
+function readFileOfLength(aFileName, aExpectedLength) {
+ let data = readFileData(do_get_file(aFileName));
+ do_check_eq(data.length, aExpectedLength);
+ return data;
+}
+
+
+/**
+ * Returns the base64-encoded version of the given string. This function is
+ * similar to window.btoa, but is available to xpcshell tests also.
+ *
+ * @param aString
+ * Each character in this string corresponds to a byte, and must be a
+ * code point in the range 0-255.
+ *
+ * @return The base64-encoded string.
+ */
+function base64EncodeString(aString) {
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"]
+ .createInstance(Ci.nsIStringInputStream);
+ stream.setData(aString, aString.length);
+ var encoder = Cc["@mozilla.org/scriptablebase64encoder;1"]
+ .createInstance(Ci.nsIScriptableBase64Encoder);
+ return encoder.encodeToString(stream, aString.length);
+}
+
+
+/**
+ * Compares two arrays, and returns true if they are equal.
+ *
+ * @param aArray1
+ * First array to compare.
+ * @param aArray2
+ * Second array to compare.
+ */
+function compareArrays(aArray1, aArray2) {
+ if (aArray1.length != aArray2.length) {
+ print("compareArrays: array lengths differ\n");
+ return false;
+ }
+
+ for (let i = 0; i < aArray1.length; i++) {
+ if (aArray1[i] != aArray2[i]) {
+ print("compareArrays: arrays differ at index " + i + ": " +
+ "(" + aArray1[i] + ") != (" + aArray2[i] +")\n");
+ return false;
+ }
+ }
+
+ return true;
+}
+
+
+/**
+ * Deletes a previously created sqlite file from the profile folder.
+ */
+function clearDB() {
+ try {
+ let file = Services.dirsvc.get('ProfD', Ci.nsIFile);
+ file.append("places.sqlite");
+ if (file.exists())
+ file.remove(false);
+ } catch (ex) { dump("Exception: " + ex); }
+}
+
+
+/**
+ * Dumps the rows of a table out to the console.
+ *
+ * @param aName
+ * The name of the table or view to output.
+ */
+function dump_table(aName)
+{
+ let stmt = DBConn().createStatement("SELECT * FROM " + aName);
+
+ print("\n*** Printing data from " + aName);
+ let count = 0;
+ while (stmt.executeStep()) {
+ let columns = stmt.numEntries;
+
+ if (count == 0) {
+ // Print the column names.
+ for (let i = 0; i < columns; i++)
+ dump(stmt.getColumnName(i) + "\t");
+ dump("\n");
+ }
+
+ // Print the rows.
+ for (let i = 0; i < columns; i++) {
+ switch (stmt.getTypeOfIndex(i)) {
+ case Ci.mozIStorageValueArray.VALUE_TYPE_NULL:
+ dump("NULL\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER:
+ dump(stmt.getInt64(i) + "\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT:
+ dump(stmt.getDouble(i) + "\t");
+ break;
+ case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT:
+ dump(stmt.getString(i) + "\t");
+ break;
+ }
+ }
+ dump("\n");
+
+ count++;
+ }
+ print("*** There were a total of " + count + " rows of data.\n");
+
+ stmt.finalize();
+}
+
+
+/**
+ * Checks if an address is found in the database.
+ * @param aURI
+ * nsIURI or address to look for.
+ * @return place id of the page or 0 if not found
+ */
+function page_in_database(aURI)
+{
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url"
+ );
+ stmt.params.url = url;
+ try {
+ if (!stmt.executeStep())
+ return 0;
+ return stmt.getInt64(0);
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+/**
+ * Checks how many visits exist for a specified page.
+ * @param aURI
+ * nsIURI or address to look for.
+ * @return number of visits found.
+ */
+function visits_in_database(aURI)
+{
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ `SELECT count(*) FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = v.place_id
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = url;
+ try {
+ if (!stmt.executeStep())
+ return 0;
+ return stmt.getInt64(0);
+ }
+ finally {
+ stmt.finalize();
+ }
+}
+
+/**
+ * Checks that we don't have any bookmark
+ */
+function check_no_bookmarks() {
+ let query = PlacesUtils.history.getNewQuery();
+ let folders = [
+ PlacesUtils.bookmarks.toolbarFolder,
+ PlacesUtils.bookmarks.bookmarksMenuFolder,
+ PlacesUtils.bookmarks.unfiledBookmarksFolder,
+ ];
+ query.setFolders(folders, 3);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ if (root.childCount != 0)
+ do_throw("Unable to remove all bookmarks");
+ root.containerOpen = false;
+}
+
+/**
+ * Allows waiting for an observer notification once.
+ *
+ * @param aTopic
+ * Notification topic to observe.
+ *
+ * @return {Promise}
+ * @resolves The array [aSubject, aData] from the observed notification.
+ * @rejects Never.
+ */
+function promiseTopicObserved(aTopic)
+{
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observe(aObsSubject, aObsTopic, aObsData) {
+ Services.obs.removeObserver(observe, aObsTopic);
+ resolve([aObsSubject, aObsData]);
+ }, aTopic, false);
+ });
+}
+
+/**
+ * Simulates a Places shutdown.
+ */
+var shutdownPlaces = function() {
+ do_print("shutdownPlaces: starting");
+ let promise = new Promise(resolve => {
+ Services.obs.addObserver(resolve, "places-connection-closed", false);
+ });
+ let hs = PlacesUtils.history.QueryInterface(Ci.nsIObserver);
+ hs.observe(null, "profile-change-teardown", null);
+ do_print("shutdownPlaces: sent profile-change-teardown");
+ hs.observe(null, "test-simulate-places-shutdown", null);
+ do_print("shutdownPlaces: sent test-simulate-places-shutdown");
+ return promise.then(() => {
+ do_print("shutdownPlaces: complete");
+ });
+};
+
+const FILENAME_BOOKMARKS_HTML = "bookmarks.html";
+const FILENAME_BOOKMARKS_JSON = "bookmarks-" +
+ (PlacesBackups.toISODateString(new Date())) + ".json";
+
+/**
+ * Creates a bookmarks.html file in the profile folder from a given source file.
+ *
+ * @param aFilename
+ * Name of the file to copy to the profile folder. This file must
+ * exist in the directory that contains the test files.
+ *
+ * @return nsIFile object for the file.
+ */
+function create_bookmarks_html(aFilename) {
+ if (!aFilename)
+ do_throw("you must pass a filename to create_bookmarks_html function");
+ remove_bookmarks_html();
+ let bookmarksHTMLFile = gTestDir.clone();
+ bookmarksHTMLFile.append(aFilename);
+ do_check_true(bookmarksHTMLFile.exists());
+ bookmarksHTMLFile.copyTo(gProfD, FILENAME_BOOKMARKS_HTML);
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ do_check_true(profileBookmarksHTMLFile.exists());
+ return profileBookmarksHTMLFile;
+}
+
+
+/**
+ * Remove bookmarks.html file from the profile folder.
+ */
+function remove_bookmarks_html() {
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ if (profileBookmarksHTMLFile.exists()) {
+ profileBookmarksHTMLFile.remove(false);
+ do_check_false(profileBookmarksHTMLFile.exists());
+ }
+}
+
+
+/**
+ * Check bookmarks.html file exists in the profile folder.
+ *
+ * @return nsIFile object for the file.
+ */
+function check_bookmarks_html() {
+ let profileBookmarksHTMLFile = gProfD.clone();
+ profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML);
+ do_check_true(profileBookmarksHTMLFile.exists());
+ return profileBookmarksHTMLFile;
+}
+
+
+/**
+ * Creates a JSON backup in the profile folder folder from a given source file.
+ *
+ * @param aFilename
+ * Name of the file to copy to the profile folder. This file must
+ * exist in the directory that contains the test files.
+ *
+ * @return nsIFile object for the file.
+ */
+function create_JSON_backup(aFilename) {
+ if (!aFilename)
+ do_throw("you must pass a filename to create_JSON_backup function");
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ if (!bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ do_check_true(bookmarksBackupDir.exists());
+ }
+ let profileBookmarksJSONFile = bookmarksBackupDir.clone();
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ if (profileBookmarksJSONFile.exists()) {
+ profileBookmarksJSONFile.remove();
+ }
+ let bookmarksJSONFile = gTestDir.clone();
+ bookmarksJSONFile.append(aFilename);
+ do_check_true(bookmarksJSONFile.exists());
+ bookmarksJSONFile.copyTo(bookmarksBackupDir, FILENAME_BOOKMARKS_JSON);
+ profileBookmarksJSONFile = bookmarksBackupDir.clone();
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ do_check_true(profileBookmarksJSONFile.exists());
+ return profileBookmarksJSONFile;
+}
+
+
+/**
+ * Remove bookmarksbackup dir and all backups from the profile folder.
+ */
+function remove_all_JSON_backups() {
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ if (bookmarksBackupDir.exists()) {
+ bookmarksBackupDir.remove(true);
+ do_check_false(bookmarksBackupDir.exists());
+ }
+}
+
+
+/**
+ * Check a JSON backup file for today exists in the profile folder.
+ *
+ * @param aIsAutomaticBackup The boolean indicates whether it's an automatic
+ * backup.
+ * @return nsIFile object for the file.
+ */
+function check_JSON_backup(aIsAutomaticBackup) {
+ let profileBookmarksJSONFile;
+ if (aIsAutomaticBackup) {
+ let bookmarksBackupDir = gProfD.clone();
+ bookmarksBackupDir.append("bookmarkbackups");
+ let files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ if (PlacesBackups.filenamesRegex.test(entry.leafName)) {
+ profileBookmarksJSONFile = entry;
+ break;
+ }
+ }
+ } else {
+ profileBookmarksJSONFile = gProfD.clone();
+ profileBookmarksJSONFile.append("bookmarkbackups");
+ profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON);
+ }
+ do_check_true(profileBookmarksJSONFile.exists());
+ return profileBookmarksJSONFile;
+}
+
+/**
+ * Returns the frecency of a url.
+ *
+ * @param aURI
+ * The URI or spec to get frecency for.
+ * @return the frecency value.
+ */
+function frecencyForUrl(aURI)
+{
+ let url = aURI;
+ if (aURI instanceof Ci.nsIURI) {
+ url = aURI.spec;
+ } else if (aURI instanceof URL) {
+ url = aURI.href;
+ }
+ let stmt = DBConn().createStatement(
+ "SELECT frecency FROM moz_places WHERE url_hash = hash(?1) AND url = ?1"
+ );
+ stmt.bindByIndex(0, url);
+ try {
+ if (!stmt.executeStep()) {
+ throw new Error("No result for frecency.");
+ }
+ return stmt.getInt32(0);
+ } finally {
+ stmt.finalize();
+ }
+}
+
+/**
+ * Returns the hidden status of a url.
+ *
+ * @param aURI
+ * The URI or spec to get hidden for.
+ * @return @return true if the url is hidden, false otherwise.
+ */
+function isUrlHidden(aURI)
+{
+ let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI;
+ let stmt = DBConn().createStatement(
+ "SELECT hidden FROM moz_places WHERE url_hash = hash(?1) AND url = ?1"
+ );
+ stmt.bindByIndex(0, url);
+ if (!stmt.executeStep())
+ throw new Error("No result for hidden.");
+ let hidden = stmt.getInt32(0);
+ stmt.finalize();
+
+ return !!hidden;
+}
+
+/**
+ * Compares two times in usecs, considering eventual platform timers skews.
+ *
+ * @param aTimeBefore
+ * The older time in usecs.
+ * @param aTimeAfter
+ * The newer time in usecs.
+ * @return true if times are ordered, false otherwise.
+ */
+function is_time_ordered(before, after) {
+ // Windows has an estimated 16ms timers precision, since Date.now() and
+ // PR_Now() use different code atm, the results can be unordered by this
+ // amount of time. See bug 558745 and bug 557406.
+ let isWindows = ("@mozilla.org/windows-registry-key;1" in Cc);
+ // Just to be safe we consider 20ms.
+ let skew = isWindows ? 20000000 : 0;
+ return after - before > -skew;
+}
+
+/**
+ * Shutdowns Places, invoking the callback when the connection has been closed.
+ *
+ * @param aCallback
+ * Function to be called when done.
+ */
+function waitForConnectionClosed(aCallback)
+{
+ promiseTopicObserved("places-connection-closed").then(aCallback);
+ shutdownPlaces();
+}
+
+/**
+ * Tests if a given guid is valid for use in Places or not.
+ *
+ * @param aGuid
+ * The guid to test.
+ * @param [optional] aStack
+ * The stack frame used to report the error.
+ */
+function do_check_valid_places_guid(aGuid,
+ aStack)
+{
+ if (!aStack) {
+ aStack = Components.stack.caller;
+ }
+ do_check_true(/^[a-zA-Z0-9\-_]{12}$/.test(aGuid), aStack);
+}
+
+/**
+ * Retrieves the guid for a given uri.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param [optional] aStack
+ * The stack frame used to report the error.
+ * @return the associated the guid.
+ */
+function do_get_guid_for_uri(aURI,
+ aStack)
+{
+ if (!aStack) {
+ aStack = Components.stack.caller;
+ }
+ let stmt = DBConn().createStatement(
+ `SELECT guid
+ FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = aURI.spec;
+ do_check_true(stmt.executeStep(), aStack);
+ let guid = stmt.row.guid;
+ stmt.finalize();
+ do_check_valid_places_guid(guid, aStack);
+ return guid;
+}
+
+/**
+ * Tests that a guid was set in moz_places for a given uri.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param [optional] aGUID
+ * The expected guid in the database.
+ */
+function do_check_guid_for_uri(aURI,
+ aGUID)
+{
+ let caller = Components.stack.caller;
+ let guid = do_get_guid_for_uri(aURI, caller);
+ if (aGUID) {
+ do_check_valid_places_guid(aGUID, caller);
+ do_check_eq(guid, aGUID, caller);
+ }
+}
+
+/**
+ * Retrieves the guid for a given bookmark.
+ *
+ * @param aId
+ * The bookmark id to check.
+ * @param [optional] aStack
+ * The stack frame used to report the error.
+ * @return the associated the guid.
+ */
+function do_get_guid_for_bookmark(aId,
+ aStack)
+{
+ if (!aStack) {
+ aStack = Components.stack.caller;
+ }
+ let stmt = DBConn().createStatement(
+ `SELECT guid
+ FROM moz_bookmarks
+ WHERE id = :item_id`
+ );
+ stmt.params.item_id = aId;
+ do_check_true(stmt.executeStep(), aStack);
+ let guid = stmt.row.guid;
+ stmt.finalize();
+ do_check_valid_places_guid(guid, aStack);
+ return guid;
+}
+
+/**
+ * Tests that a guid was set in moz_places for a given bookmark.
+ *
+ * @param aId
+ * The bookmark id to check.
+ * @param [optional] aGUID
+ * The expected guid in the database.
+ */
+function do_check_guid_for_bookmark(aId,
+ aGUID)
+{
+ let caller = Components.stack.caller;
+ let guid = do_get_guid_for_bookmark(aId, caller);
+ if (aGUID) {
+ do_check_valid_places_guid(aGUID, caller);
+ do_check_eq(guid, aGUID, caller);
+ }
+}
+
+/**
+ * Compares 2 arrays returning whether they contains the same elements.
+ *
+ * @param a1
+ * First array to compare.
+ * @param a2
+ * Second array to compare.
+ * @param [optional] sorted
+ * Whether the comparison should take in count position of the elements.
+ * @return true if the arrays contain the same elements, false otherwise.
+ */
+function do_compare_arrays(a1, a2, sorted)
+{
+ if (a1.length != a2.length)
+ return false;
+
+ if (sorted) {
+ return a1.every((e, i) => e == a2[i]);
+ }
+ return a1.filter(e => !a2.includes(e)).length == 0 &&
+ a2.filter(e => !a1.includes(e)).length == 0;
+}
+
+/**
+ * Generic nsINavBookmarkObserver that doesn't implement anything, but provides
+ * dummy methods to prevent errors about an object not having a certain method.
+ */
+function NavBookmarkObserver() {}
+
+NavBookmarkObserver.prototype = {
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onItemAdded: function () {},
+ onItemRemoved: function () {},
+ onItemChanged: function () {},
+ onItemVisited: function () {},
+ onItemMoved: function () {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver,
+ ])
+};
+
+/**
+ * Generic nsINavHistoryObserver that doesn't implement anything, but provides
+ * dummy methods to prevent errors about an object not having a certain method.
+ */
+function NavHistoryObserver() {}
+
+NavHistoryObserver.prototype = {
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onVisit: function () {},
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavHistoryObserver,
+ ])
+};
+
+/**
+ * Generic nsINavHistoryResultObserver that doesn't implement anything, but
+ * provides dummy methods to prevent errors about an object not having a certain
+ * method.
+ */
+function NavHistoryResultObserver() {}
+
+NavHistoryResultObserver.prototype = {
+ batching: function () {},
+ containerStateChanged: function () {},
+ invalidateContainer: function () {},
+ nodeAnnotationChanged: function () {},
+ nodeDateAddedChanged: function () {},
+ nodeHistoryDetailsChanged: function () {},
+ nodeIconChanged: function () {},
+ nodeInserted: function () {},
+ nodeKeywordChanged: function () {},
+ nodeLastModifiedChanged: function () {},
+ nodeMoved: function () {},
+ nodeRemoved: function () {},
+ nodeTagsChanged: function () {},
+ nodeTitleChanged: function () {},
+ nodeURIChanged: function () {},
+ sortingChanged: function () {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavHistoryResultObserver,
+ ])
+};
+
+/**
+ * Asynchronously check a url is visited.
+ *
+ * @param aURI The URI.
+ * @return {Promise}
+ * @resolves When the check has been added successfully.
+ * @rejects JavaScript exception.
+ */
+function promiseIsURIVisited(aURI) {
+ let deferred = Promise.defer();
+
+ PlacesUtils.asyncHistory.isURIVisited(aURI, function(unused, aIsVisited) {
+ deferred.resolve(aIsVisited);
+ });
+
+ return deferred.promise;
+}
+
+/**
+ * Asynchronously set the favicon associated with a page.
+ * @param aPageURI
+ * The page's URI
+ * @param aIconURI
+ * The URI of the favicon to be set.
+ */
+function promiseSetIconForPage(aPageURI, aIconURI) {
+ let deferred = Promise.defer();
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ aPageURI, aIconURI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ () => { deferred.resolve(); },
+ Services.scriptSecurityManager.getSystemPrincipal());
+ return deferred.promise;
+}
+
+function checkBookmarkObject(info) {
+ do_check_valid_places_guid(info.guid);
+ do_check_valid_places_guid(info.parentGuid);
+ Assert.ok(typeof info.index == "number", "index should be a number");
+ Assert.ok(info.dateAdded.constructor.name == "Date", "dateAdded should be a Date");
+ Assert.ok(info.lastModified.constructor.name == "Date", "lastModified should be a Date");
+ Assert.ok(info.lastModified >= info.dateAdded, "lastModified should never be smaller than dateAdded");
+ Assert.ok(typeof info.type == "number", "type should be a number");
+}
+
+/**
+ * Reads foreign_count value for a given url.
+ */
+function* foreign_count(url) {
+ if (url instanceof Ci.nsIURI)
+ url = url.spec;
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(
+ `SELECT foreign_count FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url
+ `, { url });
+ return rows.length == 0 ? 0 : rows[0].getResultByName("foreign_count");
+}
diff --git a/toolkit/components/places/tests/history/.eslintrc.js b/toolkit/components/places/tests/history/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/history/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/history/head_history.js b/toolkit/components/places/tests/history/head_history.js
new file mode 100644
index 000000000..870802dc1
--- /dev/null
+++ b/toolkit/components/places/tests/history/head_history.js
@@ -0,0 +1,19 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
diff --git a/toolkit/components/places/tests/history/test_insert.js b/toolkit/components/places/tests/history/test_insert.js
new file mode 100644
index 000000000..e2884af8c
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_insert.js
@@ -0,0 +1,257 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.insert` and `History.insertMany`, as implemented in History.jsm
+
+"use strict";
+
+add_task(function* test_insert_error_cases() {
+ const TEST_URL = "http://mozilla.com";
+
+ Assert.throws(
+ () => PlacesUtils.history.insert(),
+ /TypeError: pageInfo must be an object/,
+ "passing a null into History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert(1),
+ /TypeError: pageInfo must be an object/,
+ "passing a non object into History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({}),
+ /TypeError: PageInfo object must have a url property/,
+ "passing an object without a url to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({url: 123}),
+ /TypeError: Invalid url or guid: 123/,
+ "passing an object with an invalid url to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({url: TEST_URL}),
+ /TypeError: PageInfo object must have an array of visits/,
+ "passing an object without a visits property to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({url: TEST_URL, visits: 1}),
+ /TypeError: PageInfo object must have an array of visits/,
+ "passing an object with a non-array visits property to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({url: TEST_URL, visits: []}),
+ /TypeError: PageInfo object must have an array of visits/,
+ "passing an object with an empty array as the visits property to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK,
+ date: "a"
+ }
+ ]}),
+ /TypeError: Expected a Date, got a/,
+ "passing a visit object with an invalid date to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK
+ },
+ {
+ transition: TRANSITION_LINK,
+ date: "a"
+ }
+ ]}),
+ /TypeError: Expected a Date, got a/,
+ "passing a second visit object with an invalid date to History.insert should throw a TypeError"
+ );
+ let futureDate = new Date();
+ futureDate.setDate(futureDate.getDate() + 1000);
+ Assert.throws(
+ () => PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {
+ transition: TRANSITION_LINK,
+ date: futureDate,
+ }
+ ]}),
+ `TypeError: date: ${futureDate} is not a valid date`,
+ "passing a visit object with a future date to History.insert should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insert({
+ url: TEST_URL,
+ visits: [
+ {transition: "a"}
+ ]}),
+ /TypeError: transition: a is not a valid transition type/,
+ "passing a visit object with an invalid transition to History.insert should throw a TypeError"
+ );
+});
+
+add_task(function* test_history_insert() {
+ const TEST_URL = "http://mozilla.com/";
+
+ let inserter = Task.async(function*(name, filter, referrer, date, transition) {
+ do_print(name);
+ do_print(`filter: ${filter}, referrer: ${referrer}, date: ${date}, transition: ${transition}`);
+
+ let uri = NetUtil.newURI(TEST_URL + Math.random());
+ let title = "Visit " + Math.random();
+
+ let pageInfo = {
+ title,
+ visits: [
+ {transition: transition, referrer: referrer, date: date, }
+ ]
+ };
+
+ pageInfo.url = yield filter(uri);
+
+ let result = yield PlacesUtils.history.insert(pageInfo);
+
+ Assert.ok(PlacesUtils.isValidGuid(result.guid), "guid for pageInfo object is valid");
+ Assert.equal(uri.spec, result.url.href, "url is correct for pageInfo object");
+ Assert.equal(title, result.title, "title is correct for pageInfo object");
+ Assert.equal(TRANSITION_LINK, result.visits[0].transition, "transition is correct for pageInfo object");
+ if (referrer) {
+ Assert.equal(referrer, result.visits[0].referrer.href, "url of referrer for visit is correct");
+ } else {
+ Assert.equal(null, result.visits[0].referrer, "url of referrer for visit is correct");
+ }
+ if (date) {
+ Assert.equal(Number(date),
+ Number(result.visits[0].date),
+ "date of visit is correct");
+ }
+
+ Assert.ok(yield PlacesTestUtils.isPageInDB(uri), "Page was added");
+ Assert.ok(yield PlacesTestUtils.visitsInDB(uri), "Visit was added");
+ });
+
+ try {
+ for (let referrer of [TEST_URL, null]) {
+ for (let date of [new Date(), null]) {
+ for (let transition of [TRANSITION_LINK, null]) {
+ yield inserter("Testing History.insert() with an nsIURI", x => x, referrer, date, transition);
+ yield inserter("Testing History.insert() with a string url", x => x.spec, referrer, date, transition);
+ yield inserter("Testing History.insert() with a URL object", x => new URL(x.spec), referrer, date, transition);
+ }
+ }
+ }
+ } finally {
+ yield PlacesTestUtils.clearHistory();
+ }
+});
+
+add_task(function* test_insert_multiple_error_cases() {
+ let validPageInfo = {
+ url: "http://mozilla.com",
+ visits: [
+ {transition: TRANSITION_LINK}
+ ]
+ };
+
+ Assert.throws(
+ () => PlacesUtils.history.insertMany(),
+ /TypeError: pageInfos must be an array/,
+ "passing a null into History.insertMany should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insertMany([]),
+ /TypeError: pageInfos may not be an empty array/,
+ "passing an empty array into History.insertMany should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.insertMany([validPageInfo, {}]),
+ /TypeError: PageInfo object must have a url property/,
+ "passing a second invalid PageInfo object to History.insertMany should throw a TypeError"
+ );
+});
+
+add_task(function* test_history_insertMany() {
+ const BAD_URLS = ["about:config", "chrome://browser/content/browser.xul"];
+ const GOOD_URLS = [1, 2, 3].map(x => { return `http://mozilla.com/${x}`; });
+
+ let makePageInfos = Task.async(function*(urls, filter = x => x) {
+ let pageInfos = [];
+ for (let url of urls) {
+ let uri = NetUtil.newURI(url);
+
+ let pageInfo = {
+ title: `Visit to ${url}`,
+ visits: [
+ {transition: TRANSITION_LINK}
+ ]
+ };
+
+ pageInfo.url = yield filter(uri);
+ pageInfos.push(pageInfo);
+ }
+ return pageInfos;
+ });
+
+ let inserter = Task.async(function*(name, filter, useCallbacks) {
+ do_print(name);
+ do_print(`filter: ${filter}`);
+ do_print(`useCallbacks: ${useCallbacks}`);
+ yield PlacesTestUtils.clearHistory();
+
+ let result;
+ let allUrls = GOOD_URLS.concat(BAD_URLS);
+ let pageInfos = yield makePageInfos(allUrls, filter);
+
+ if (useCallbacks) {
+ let onResultUrls = [];
+ let onErrorUrls = [];
+ result = yield PlacesUtils.history.insertMany(pageInfos, pageInfo => {
+ let url = pageInfo.url.href;
+ Assert.ok(GOOD_URLS.includes(url), "onResult callback called for correct url");
+ onResultUrls.push(url);
+ Assert.equal(`Visit to ${url}`, pageInfo.title, "onResult callback provides the correct title");
+ Assert.ok(PlacesUtils.isValidGuid(pageInfo.guid), "onResult callback provides a valid guid");
+ }, pageInfo => {
+ let url = pageInfo.url.href;
+ Assert.ok(BAD_URLS.includes(url), "onError callback called for correct uri");
+ onErrorUrls.push(url);
+ Assert.equal(undefined, pageInfo.title, "onError callback provides the correct title");
+ Assert.equal(undefined, pageInfo.guid, "onError callback provides the expected guid");
+ });
+ Assert.equal(GOOD_URLS.sort().toString(), onResultUrls.sort().toString(), "onResult callback was called for each good url");
+ Assert.equal(BAD_URLS.sort().toString(), onErrorUrls.sort().toString(), "onError callback was called for each bad url");
+ } else {
+ result = yield PlacesUtils.history.insertMany(pageInfos);
+ }
+
+ Assert.equal(undefined, result, "insertMany returned undefined");
+
+ for (let url of allUrls) {
+ let expected = GOOD_URLS.includes(url);
+ Assert.equal(expected, yield PlacesTestUtils.isPageInDB(url), `isPageInDB for ${url} is ${expected}`);
+ Assert.equal(expected, yield PlacesTestUtils.visitsInDB(url), `visitsInDB for ${url} is ${expected}`);
+ }
+ });
+
+ try {
+ for (let useCallbacks of [false, true]) {
+ yield inserter("Testing History.insertMany() with an nsIURI", x => x, useCallbacks);
+ yield inserter("Testing History.insertMany() with a string url", x => x.spec, useCallbacks);
+ yield inserter("Testing History.insertMany() with a URL object", x => new URL(x.spec), useCallbacks);
+ }
+ // Test rejection when no items added
+ let pageInfos = yield makePageInfos(BAD_URLS);
+ PlacesUtils.history.insertMany(pageInfos).then(() => {
+ Assert.ok(false, "History.insertMany rejected promise with all bad URLs");
+ }, error => {
+ Assert.equal("No items were added to history.", error.message, "History.insertMany rejected promise with all bad URLs");
+ });
+ } finally {
+ yield PlacesTestUtils.clearHistory();
+ }
+});
diff --git a/toolkit/components/places/tests/history/test_remove.js b/toolkit/components/places/tests/history/test_remove.js
new file mode 100644
index 000000000..7423f6464
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_remove.js
@@ -0,0 +1,360 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.remove`, as implemented in History.jsm
+
+"use strict";
+
+Cu.importGlobalProperties(["URL"]);
+
+
+// Test removing a single page
+add_task(function* test_remove_single() {
+ yield PlacesTestUtils.clearHistory();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+
+ let WITNESS_URI = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove/" + Math.random());
+ yield PlacesTestUtils.addVisits(WITNESS_URI);
+ Assert.ok(page_in_database(WITNESS_URI));
+
+ let remover = Task.async(function*(name, filter, options) {
+ do_print(name);
+ do_print(JSON.stringify(options));
+ do_print("Setting up visit");
+
+ let uri = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove/" + Math.random());
+ let title = "Visit " + Math.random();
+ yield PlacesTestUtils.addVisits({uri: uri, title: title});
+ Assert.ok(visits_in_database(uri), "History entry created");
+
+ let removeArg = yield filter(uri);
+
+ if (options.addBookmark) {
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test bookmark");
+ }
+
+ let shouldRemove = !options.addBookmark;
+ let observer;
+ let promiseObserved = new Promise((resolve, reject) => {
+ observer = {
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function(aUri) {
+ reject(new Error("Unexpected call to onVisit " + aUri.spec));
+ },
+ onTitleChanged: function(aUri) {
+ reject(new Error("Unexpected call to onTitleChanged " + aUri.spec));
+ },
+ onClearHistory: function() {
+ reject("Unexpected call to onClearHistory");
+ },
+ onPageChanged: function(aUri) {
+ reject(new Error("Unexpected call to onPageChanged " + aUri.spec));
+ },
+ onFrecencyChanged: function(aURI) {
+ try {
+ Assert.ok(!shouldRemove, "Observing onFrecencyChanged");
+ Assert.equal(aURI.spec, uri.spec, "Observing effect on the right uri");
+ } finally {
+ resolve();
+ }
+ },
+ onManyFrecenciesChanged: function() {
+ try {
+ Assert.ok(!shouldRemove, "Observing onManyFrecenciesChanged");
+ } finally {
+ resolve();
+ }
+ },
+ onDeleteURI: function(aURI) {
+ try {
+ Assert.ok(shouldRemove, "Observing onDeleteURI");
+ Assert.equal(aURI.spec, uri.spec, "Observing effect on the right uri");
+ } finally {
+ resolve();
+ }
+ },
+ onDeleteVisits: function(aURI) {
+ Assert.equal(aURI.spec, uri.spec, "Observing onDeleteVisits on the right uri");
+ }
+ };
+ });
+ PlacesUtils.history.addObserver(observer, false);
+
+ do_print("Performing removal");
+ let removed = false;
+ if (options.useCallback) {
+ let onRowCalled = false;
+ let guid = do_get_guid_for_uri(uri);
+ removed = yield PlacesUtils.history.remove(removeArg, page => {
+ Assert.equal(onRowCalled, false, "Callback has not been called yet");
+ onRowCalled = true;
+ Assert.equal(page.url.href, uri.spec, "Callback provides the correct url");
+ Assert.equal(page.guid, guid, "Callback provides the correct guid");
+ Assert.equal(page.title, title, "Callback provides the correct title");
+ });
+ Assert.ok(onRowCalled, "Callback has been called");
+ } else {
+ removed = yield PlacesUtils.history.remove(removeArg);
+ }
+
+ yield promiseObserved;
+ PlacesUtils.history.removeObserver(observer);
+
+ Assert.equal(visits_in_database(uri), 0, "History entry has disappeared");
+ Assert.notEqual(visits_in_database(WITNESS_URI), 0, "Witness URI still has visits");
+ Assert.notEqual(page_in_database(WITNESS_URI), 0, "Witness URI is still here");
+ if (shouldRemove) {
+ Assert.ok(removed, "Something was removed");
+ Assert.equal(page_in_database(uri), 0, "Page has disappeared");
+ } else {
+ Assert.ok(!removed, "The page was not removed, as there was a bookmark");
+ Assert.notEqual(page_in_database(uri), 0, "The page is still present");
+ }
+ });
+
+ try {
+ for (let useCallback of [false, true]) {
+ for (let addBookmark of [false, true]) {
+ let options = { useCallback: useCallback, addBookmark: addBookmark };
+ yield remover("Testing History.remove() with a single URI", x => x, options);
+ yield remover("Testing History.remove() with a single string url", x => x.spec, options);
+ yield remover("Testing History.remove() with a single string guid", x => do_get_guid_for_uri(x), options);
+ yield remover("Testing History.remove() with a single URI in an array", x => [x], options);
+ yield remover("Testing History.remove() with a single string url in an array", x => [x.spec], options);
+ yield remover("Testing History.remove() with a single string guid in an array", x => [do_get_guid_for_uri(x)], options);
+ }
+ }
+ } finally {
+ yield PlacesTestUtils.clearHistory();
+ }
+ return;
+});
+
+// Test removing a list of pages
+add_task(function* test_remove_many() {
+ const SIZE = 10;
+
+ yield PlacesTestUtils.clearHistory();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ do_print("Adding a witness page");
+ let WITNESS_URI = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove/" + Math.random());
+ yield PlacesTestUtils.addVisits(WITNESS_URI);
+ Assert.ok(page_in_database(WITNESS_URI), "Witness page added");
+
+ do_print("Generating samples");
+ let pages = [];
+ for (let i = 0; i < SIZE; ++i) {
+ let uri = NetUtil.newURI("http://mozilla.com/test_browserhistory/test_remove?sample=" + i + "&salt=" + Math.random());
+ let title = "Visit " + i + ", " + Math.random();
+ let hasBookmark = i % 3 == 0;
+ let page = {
+ uri: uri,
+ title: title,
+ hasBookmark: hasBookmark,
+ // `true` once `onResult` has been called for this page
+ onResultCalled: false,
+ // `true` once `onDeleteVisits` has been called for this page
+ onDeleteVisitsCalled: false,
+ // `true` once `onFrecencyChangedCalled` has been called for this page
+ onFrecencyChangedCalled: false,
+ // `true` once `onDeleteURI` has been called for this page
+ onDeleteURICalled: false,
+ };
+ do_print("Pushing: " + uri.spec);
+ pages.push(page);
+
+ yield PlacesTestUtils.addVisits(page);
+ page.guid = do_get_guid_for_uri(uri);
+ if (hasBookmark) {
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test bookmark " + i);
+ }
+ Assert.ok(page_in_database(uri), "Page added");
+ }
+
+ do_print("Mixing key types and introducing dangling keys");
+ let keys = [];
+ for (let i = 0; i < SIZE; ++i) {
+ if (i % 4 == 0) {
+ keys.push(pages[i].uri);
+ keys.push(NetUtil.newURI("http://example.org/dangling/nsIURI/" + i));
+ } else if (i % 4 == 1) {
+ keys.push(new URL(pages[i].uri.spec));
+ keys.push(new URL("http://example.org/dangling/URL/" + i));
+ } else if (i % 4 == 2) {
+ keys.push(pages[i].uri.spec);
+ keys.push("http://example.org/dangling/stringuri/" + i);
+ } else {
+ keys.push(pages[i].guid);
+ keys.push(("guid_" + i + "_01234567890").substr(0, 12));
+ }
+ }
+
+ let observer = {
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function(aURI) {
+ Assert.ok(false, "Unexpected call to onVisit " + aURI.spec);
+ },
+ onTitleChanged: function(aURI) {
+ Assert.ok(false, "Unexpected call to onTitleChanged " + aURI.spec);
+ },
+ onClearHistory: function() {
+ Assert.ok(false, "Unexpected call to onClearHistory");
+ },
+ onPageChanged: function(aURI) {
+ Assert.ok(false, "Unexpected call to onPageChanged " + aURI.spec);
+ },
+ onFrecencyChanged: function(aURI) {
+ let origin = pages.find(x => x.uri.spec == aURI.spec);
+ Assert.ok(origin);
+ Assert.ok(origin.hasBookmark, "Observing onFrecencyChanged on a page with a bookmark");
+ origin.onFrecencyChangedCalled = true;
+ // We do not make sure that `origin.onFrecencyChangedCalled` is `false`, as
+ },
+ onManyFrecenciesChanged: function() {
+ Assert.ok(false, "Observing onManyFrecenciesChanges, this is most likely correct but not covered by this test");
+ },
+ onDeleteURI: function(aURI) {
+ let origin = pages.find(x => x.uri.spec == aURI.spec);
+ Assert.ok(origin);
+ Assert.ok(!origin.hasBookmark, "Observing onDeleteURI on a page without a bookmark");
+ Assert.ok(!origin.onDeleteURICalled, "Observing onDeleteURI for the first time");
+ origin.onDeleteURICalled = true;
+ },
+ onDeleteVisits: function(aURI) {
+ let origin = pages.find(x => x.uri.spec == aURI.spec);
+ Assert.ok(origin);
+ Assert.ok(!origin.onDeleteVisitsCalled, "Observing onDeleteVisits for the first time");
+ origin.onDeleteVisitsCalled = true;
+ }
+ };
+ PlacesUtils.history.addObserver(observer, false);
+
+ do_print("Removing the pages and checking the callbacks");
+ let removed = yield PlacesUtils.history.remove(keys, page => {
+ let origin = pages.find(candidate => candidate.uri.spec == page.url.href);
+
+ Assert.ok(origin, "onResult has a valid page");
+ Assert.ok(!origin.onResultCalled, "onResult has not seen this page yet");
+ origin.onResultCalled = true;
+ Assert.equal(page.guid, origin.guid, "onResult has the right guid");
+ Assert.equal(page.title, origin.title, "onResult has the right title");
+ });
+ Assert.ok(removed, "Something was removed");
+
+ PlacesUtils.history.removeObserver(observer);
+
+ do_print("Checking out results");
+ // By now the observers should have been called.
+ for (let i = 0; i < pages.length; ++i) {
+ let page = pages[i];
+ do_print("Page: " + i);
+ Assert.ok(page.onResultCalled, "We have reached the page from the callback");
+ Assert.ok(visits_in_database(page.uri) == 0, "History entry has disappeared");
+ Assert.equal(page_in_database(page.uri) != 0, page.hasBookmark, "Page is present only if it also has bookmarks");
+ Assert.equal(page.onFrecencyChangedCalled, page.onDeleteVisitsCalled, "onDeleteVisits was called iff onFrecencyChanged was called");
+ Assert.ok(page.onFrecencyChangedCalled ^ page.onDeleteURICalled, "Either onFrecencyChanged or onDeleteURI was called");
+ }
+
+ Assert.notEqual(visits_in_database(WITNESS_URI), 0, "Witness URI still has visits");
+ Assert.notEqual(page_in_database(WITNESS_URI), 0, "Witness URI is still here");
+});
+
+add_task(function* cleanup() {
+ yield PlacesTestUtils.clearHistory();
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+// Test the various error cases
+add_task(function* test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.remove(),
+ /TypeError: Invalid url/,
+ "History.remove with no argument should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(null),
+ /TypeError: Invalid url/,
+ "History.remove with `null` should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(undefined),
+ /TypeError: Invalid url/,
+ "History.remove with `undefined` should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove("not a guid, obviously"),
+ /TypeError: .* is not a valid URL/,
+ "History.remove with an ill-formed guid/url argument should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove({"not the kind of object we know how to handle": true}),
+ /TypeError: Invalid url/,
+ "History.remove with an unexpected object should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove([]),
+ /TypeError: Expected at least one page/,
+ "History.remove with an empty array should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove([null]),
+ /TypeError: Invalid url or guid/,
+ "History.remove with an array containing null should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(["http://example.org", "not a guid, obviously"]),
+ /TypeError: .* is not a valid URL/,
+ "History.remove with an array containing an ill-formed guid/url argument should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(["0123456789ab"/* valid guid*/, null]),
+ /TypeError: Invalid url or guid: null/,
+ "History.remove with an array containing a guid and a second argument that is null should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove(["http://example.org", {"not the kind of object we know how to handle": true}]),
+ /TypeError: Invalid url/,
+ "History.remove with an array containing an unexpected objecgt should throw a TypeError"
+ );
+ Assert.throws(
+ () => PlacesUtils.history.remove("http://example.org", "not a function, obviously"),
+ /TypeError: Invalid function/,
+ "History.remove with a second argument that is not a function argument should throw a TypeError"
+ );
+ try {
+ PlacesUtils.history.remove("http://example.org/I/have/clearly/not/been/added", null);
+ Assert.ok(true, "History.remove should ignore `null` as a second argument");
+ } catch (ex) {
+ Assert.ok(false, "History.remove should ignore `null` as a second argument");
+ }
+});
+
+add_task(function* test_orphans() {
+ let uri = NetUtil.newURI("http://moz.org/");
+ yield PlacesTestUtils.addVisits({ uri });
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ uri, SMALLPNG_DATA_URI, true, PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null, Services.scriptSecurityManager.getSystemPrincipal());
+ PlacesUtils.annotations.setPageAnnotation(uri, "test", "restval", 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+
+ yield PlacesUtils.history.remove(uri);
+ Assert.ok(!(yield PlacesTestUtils.isPageInDB(uri)), "Page should have been removed");
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(`SELECT (SELECT count(*) FROM moz_annos) +
+ (SELECT count(*) FROM moz_favicons) AS count`);
+ Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans");
+});
diff --git a/toolkit/components/places/tests/history/test_removeVisits.js b/toolkit/components/places/tests/history/test_removeVisits.js
new file mode 100644
index 000000000..8df0c81a9
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeVisits.js
@@ -0,0 +1,316 @@
+const JS_NOW = Date.now();
+const DB_NOW = JS_NOW * 1000;
+const TEST_URI = uri("http://example.com/");
+const PLACE_URI = uri("place:queryType=0&sort=8&maxResults=10");
+
+function* cleanup() {
+ yield PlacesTestUtils.clearHistory();
+ yield PlacesUtils.bookmarks.eraseEverything();
+ // This is needed to remove place: entries.
+ DBConn().executeSimpleSQL("DELETE FROM moz_places");
+}
+
+add_task(function* remove_visits_outside_unbookmarked_uri() {
+ do_print("*** TEST: Remove some visits outside valid timeframe from an unbookmarked URI");
+
+ do_print("Add 10 visits for the URI from way in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+
+ do_print("Remove visits using timerange outside the URI's visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that all visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 10);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ do_check_eq(visitTime, DB_NOW - 100000 - (i * 1000));
+ }
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return true.");
+ do_check_true(yield promiseIsURIVisited(TEST_URI));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Frecency should be positive.")
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+
+ yield cleanup();
+});
+
+add_task(function* remove_visits_outside_bookmarked_uri() {
+ do_print("*** TEST: Remove some visits outside valid timeframe from a bookmarked URI");
+
+ do_print("Add 10 visits for the URI from way in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+ do_print("Bookmark the URI.");
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Remove visits using timerange outside the URI's visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that all visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 10);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ do_check_eq(visitTime, DB_NOW - 100000 - (i * 1000));
+ }
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return true.");
+ do_check_true(yield promiseIsURIVisited(TEST_URI));
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Frecency should be positive.")
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+
+ yield cleanup();
+});
+
+add_task(function* remove_visits_unbookmarked_uri() {
+ do_print("*** TEST: Remove some visits from an unbookmarked URI");
+
+ do_print("Add 10 visits for the URI from now to 9 usecs in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+
+ do_print("Remove the 5 most recent visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 4),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that only the older 5 visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 5);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ do_check_eq(visitTime, DB_NOW - (i * 1000) - 5000);
+ }
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return true.");
+ do_check_true(yield promiseIsURIVisited(TEST_URI));
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Frecency should be positive.")
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+
+ yield cleanup();
+});
+
+add_task(function* remove_visits_bookmarked_uri() {
+ do_print("*** TEST: Remove some visits from a bookmarked URI");
+
+ do_print("Add 10 visits for the URI from now to 9 usecs in the past.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+ do_print("Bookmark the URI.");
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Remove the 5 most recent visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 4),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that only the older 5 visits still exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 5);
+ for (let i = 0; i < root.childCount; i++) {
+ let visitTime = root.getChild(i).time;
+ do_check_eq(visitTime, DB_NOW - (i * 1000) - 5000);
+ }
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return true.");
+ do_check_true(yield promiseIsURIVisited(TEST_URI));
+ yield PlacesTestUtils.promiseAsyncUpdates()
+
+ do_print("Frecency should be positive.")
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+
+ yield cleanup();
+});
+
+add_task(function* remove_all_visits_unbookmarked_uri() {
+ do_print("*** TEST: Remove all visits from an unbookmarked URI");
+
+ do_print("Add some visits for the URI.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+
+ do_print("Remove all visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should no longer exist in moz_places.");
+ do_check_false(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that no visits exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 0);
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return false.");
+ do_check_false(yield promiseIsURIVisited(TEST_URI));
+
+ yield cleanup();
+});
+
+add_task(function* remove_all_visits_bookmarked_uri() {
+ do_print("*** TEST: Remove all visits from a bookmarked URI");
+
+ do_print("Add some visits for the URI.");
+ let visits = [];
+ for (let i = 0; i < 10; i++) {
+ visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+ do_print("Bookmark the URI.");
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ let initialFrecency = frecencyForUrl(TEST_URI);
+
+ do_print("Remove all visits.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+
+ do_print("Run a history query and check that no visits exist.");
+ let query = PlacesUtils.history.getNewQuery();
+ let opts = PlacesUtils.history.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_VISIT;
+ opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
+ let root = PlacesUtils.history.executeQuery(query, opts).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 0);
+ root.containerOpen = false;
+
+ do_print("asyncHistory.isURIVisited should return false.");
+ do_check_false(yield promiseIsURIVisited(TEST_URI));
+
+ do_print("nsINavBookmarksService.isBookmarked should return true.");
+ do_check_true(PlacesUtils.bookmarks.isBookmarked(TEST_URI));
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Frecency should be smaller.")
+ do_check_true(frecencyForUrl(TEST_URI) < initialFrecency);
+
+ yield cleanup();
+});
+
+add_task(function* remove_all_visits_bookmarked_uri() {
+ do_print("*** TEST: Remove some visits from a zero frecency URI retains zero frecency");
+
+ do_print("Add some visits for the URI.");
+ yield PlacesTestUtils.addVisits([
+ { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: (DB_NOW - 86400000000000) },
+ { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: DB_NOW }
+ ]);
+
+ do_print("Remove newer visit.");
+ let filter = {
+ beginDate: new Date(JS_NOW - 10),
+ endDate: new Date(JS_NOW)
+ };
+ yield PlacesUtils.history.removeVisitsByFilter(filter);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI should still exist in moz_places.");
+ do_check_true(page_in_database(TEST_URI.spec));
+ do_print("Frecency should be zero.")
+ do_check_eq(frecencyForUrl(TEST_URI), 0);
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/history/test_removeVisitsByFilter.js b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js
new file mode 100644
index 000000000..699420e43
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_removeVisitsByFilter.js
@@ -0,0 +1,345 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+// Tests for `History.removeVisitsByFilter`, as implemented in History.jsm
+
+"use strict";
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/PromiseUtils.jsm", this);
+
+add_task(function* test_removeVisitsByFilter() {
+ let referenceDate = new Date(1999, 9, 9, 9, 9);
+
+ // Populate a database with 20 entries, remove a subset of entries,
+ // ensure consistency.
+ let remover = Task.async(function*(options) {
+ do_print("Remover with options " + JSON.stringify(options));
+ let SAMPLE_SIZE = options.sampleSize;
+
+ yield PlacesTestUtils.clearHistory();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // Populate the database.
+ // Create `SAMPLE_SIZE` visits, from the oldest to the newest.
+
+ let bookmarkIndices = new Set(options.bookmarks);
+ let visits = [];
+ let frecencyChangePromises = new Map();
+ let uriDeletePromises = new Map();
+ let getURL = options.url ?
+ i => "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/byurl/" + Math.floor(i / (SAMPLE_SIZE / 5)) + "/" :
+ i => "http://mozilla.com/test_browserhistory/test_removeVisitsByFilter/removeme/" + i + "/" + Math.random();
+ for (let i = 0; i < SAMPLE_SIZE; ++i) {
+ let spec = getURL(i);
+ let uri = NetUtil.newURI(spec);
+ let jsDate = new Date(Number(referenceDate) + 3600 * 1000 * i);
+ let dbDate = jsDate * 1000;
+ let hasBookmark = bookmarkIndices.has(i);
+ let hasOwnBookmark = hasBookmark;
+ if (!hasOwnBookmark && options.url) {
+ // Also mark as bookmarked if one of the earlier bookmarked items has the same URL.
+ hasBookmark =
+ options.bookmarks.filter(n => n < i).some(n => visits[n].uri.spec == spec && visits[n].test.hasBookmark);
+ }
+ do_print("Generating " + uri.spec + ", " + dbDate);
+ let visit = {
+ uri,
+ title: "visit " + i,
+ visitDate: dbDate,
+ test: {
+ // `visitDate`, as a Date
+ jsDate: jsDate,
+ // `true` if we expect that the visit will be removed
+ toRemove: false,
+ // `true` if `onRow` informed of the removal of this visit
+ announcedByOnRow: false,
+ // `true` if there is a bookmark for this URI, i.e. of the page
+ // should not be entirely removed.
+ hasBookmark: hasBookmark,
+ onFrecencyChanged: null,
+ onDeleteURI: null,
+ },
+ };
+ visits.push(visit);
+ if (hasOwnBookmark) {
+ do_print("Adding a bookmark to visit " + i);
+ yield PlacesUtils.bookmarks.insert({
+ url: uri,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "test bookmark"
+ });
+ do_print("Bookmark added");
+ }
+ }
+
+ do_print("Adding visits");
+ yield PlacesTestUtils.addVisits(visits);
+
+ do_print("Preparing filters");
+ let filter = {
+ };
+ let beginIndex = 0;
+ let endIndex = visits.length - 1;
+ if ("begin" in options) {
+ let ms = Number(visits[options.begin].test.jsDate) - 1000;
+ filter.beginDate = new Date(ms);
+ beginIndex = options.begin;
+ }
+ if ("end" in options) {
+ let ms = Number(visits[options.end].test.jsDate) + 1000;
+ filter.endDate = new Date(ms);
+ endIndex = options.end;
+ }
+ if ("limit" in options) {
+ endIndex = beginIndex + options.limit - 1; // -1 because the start index is inclusive.
+ filter.limit = options.limit;
+ }
+ let removedItems = visits.slice(beginIndex);
+ endIndex -= beginIndex;
+ if (options.url) {
+ let rawURL = "";
+ switch (options.url) {
+ case 1:
+ filter.url = new URL(removedItems[0].uri.spec);
+ rawURL = filter.url.href;
+ break;
+ case 2:
+ filter.url = removedItems[0].uri;
+ rawURL = filter.url.spec;
+ break;
+ case 3:
+ filter.url = removedItems[0].uri.spec;
+ rawURL = filter.url;
+ break;
+ }
+ endIndex = Math.min(endIndex, removedItems.findIndex((v, index) => v.uri.spec != rawURL) - 1);
+ }
+ removedItems.splice(endIndex + 1);
+ let remainingItems = visits.filter(v => !removedItems.includes(v));
+ for (let i = 0; i < removedItems.length; i++) {
+ let test = removedItems[i].test;
+ do_print("Marking visit " + (beginIndex + i) + " as expecting removal");
+ test.toRemove = true;
+ if (test.hasBookmark ||
+ (options.url && remainingItems.some(v => v.uri.spec == removedItems[i].uri.spec))) {
+ frecencyChangePromises.set(removedItems[i].uri.spec, PromiseUtils.defer());
+ } else if (!options.url || i == 0) {
+ uriDeletePromises.set(removedItems[i].uri.spec, PromiseUtils.defer());
+ }
+ }
+
+ let observer = {
+ deferred: PromiseUtils.defer(),
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function(uri) {
+ this.deferred.reject(new Error("Unexpected call to onVisit " + uri.spec));
+ },
+ onTitleChanged: function(uri) {
+ this.deferred.reject(new Error("Unexpected call to onTitleChanged " + uri.spec));
+ },
+ onClearHistory: function() {
+ this.deferred.reject("Unexpected call to onClearHistory");
+ },
+ onPageChanged: function(uri) {
+ this.deferred.reject(new Error("Unexpected call to onPageChanged " + uri.spec));
+ },
+ onFrecencyChanged: function(aURI) {
+ do_print("onFrecencyChanged " + aURI.spec);
+ let deferred = frecencyChangePromises.get(aURI.spec);
+ Assert.ok(!!deferred, "Observing onFrecencyChanged");
+ deferred.resolve();
+ },
+ onManyFrecenciesChanged: function() {
+ do_print("Many frecencies changed");
+ for (let [, deferred] of frecencyChangePromises) {
+ deferred.resolve();
+ }
+ },
+ onDeleteURI: function(aURI) {
+ do_print("onDeleteURI " + aURI.spec);
+ let deferred = uriDeletePromises.get(aURI.spec);
+ Assert.ok(!!deferred, "Observing onDeleteURI");
+ deferred.resolve();
+ },
+ onDeleteVisits: function(aURI) {
+ // Not sure we can test anything.
+ }
+ };
+ PlacesUtils.history.addObserver(observer, false);
+
+ let cbarg;
+ if (options.useCallback) {
+ do_print("Setting up callback");
+ cbarg = [info => {
+ for (let visit of visits) {
+ do_print("Comparing " + info.date + " and " + visit.test.jsDate);
+ if (Math.abs(visit.test.jsDate - info.date) < 100) { // Assume rounding errors
+ Assert.ok(!visit.test.announcedByOnRow,
+ "This is the first time we announce the removal of this visit");
+ Assert.ok(visit.test.toRemove,
+ "This is a visit we intended to remove");
+ visit.test.announcedByOnRow = true;
+ return;
+ }
+ }
+ Assert.ok(false, "Could not find the visit we attempt to remove");
+ }];
+ } else {
+ do_print("No callback");
+ cbarg = [];
+ }
+ let result = yield PlacesUtils.history.removeVisitsByFilter(filter, ...cbarg);
+
+ Assert.ok(result, "Removal succeeded");
+
+ // Make sure that we have eliminated exactly the entries we expected
+ // to eliminate.
+ for (let i = 0; i < visits.length; ++i) {
+ let visit = visits[i];
+ do_print("Controlling the results on visit " + i);
+ let remainingVisitsForURI = remainingItems.filter(v => visit.uri.spec == v.uri.spec).length;
+ Assert.equal(
+ visits_in_database(visit.uri),
+ remainingVisitsForURI,
+ "Visit is still present iff expected");
+ if (options.useCallback) {
+ Assert.equal(
+ visit.test.toRemove,
+ visit.test.announcedByOnRow,
+ "Visit removal has been announced by onResult iff expected");
+ }
+ if (visit.test.hasBookmark || remainingVisitsForURI) {
+ Assert.notEqual(page_in_database(visit.uri), 0, "The page should still appear in the db");
+ } else {
+ Assert.equal(page_in_database(visit.uri), 0, "The page should have been removed from the db");
+ }
+ }
+
+ // Make sure that the observer has been called wherever applicable.
+ do_print("Checking URI delete promises.");
+ yield Promise.all(Array.from(uriDeletePromises.values()));
+ do_print("Checking frecency change promises.");
+ yield Promise.all(Array.from(frecencyChangePromises.values()));
+ PlacesUtils.history.removeObserver(observer);
+ });
+
+ let size = 20;
+ for (let range of [
+ {begin: 0},
+ {end: 19},
+ {begin: 0, end: 10},
+ {begin: 3, end: 4},
+ {begin: 5, end: 8, limit: 2},
+ {begin: 10, end: 18, limit: 5},
+ ]) {
+ for (let bookmarks of [[], [5, 6]]) {
+ let options = {
+ sampleSize: size,
+ bookmarks: bookmarks,
+ };
+ if ("begin" in range) {
+ options.begin = range.begin;
+ }
+ if ("end" in range) {
+ options.end = range.end;
+ }
+ if ("limit" in range) {
+ options.limit = range.limit;
+ }
+ yield remover(options);
+ options.url = 1;
+ yield remover(options);
+ options.url = 2;
+ yield remover(options);
+ options.url = 3;
+ yield remover(options);
+ }
+ }
+ yield PlacesTestUtils.clearHistory();
+});
+
+// Test the various error cases
+add_task(function* test_error_cases() {
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter(),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter("obviously, not a filter"),
+ /TypeError: Expected a filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({}),
+ /TypeError: Expected a non-empty filter/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: "now"}),
+ /TypeError: Expected a Date/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: Date.now()}),
+ /TypeError: Expected a Date/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date()}, "obviously, not a callback"),
+ /TypeError: Invalid function/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date(1000), endDate: new Date(0)}),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({limit: {}}),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({limit: -1}),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({limit: 0.1}),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({limit: Infinity}),
+ /Expected a non-zero positive integer as a limit/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({url: {}}),
+ /Expected a valid URL for `url`/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({url: 0}),
+ /Expected a valid URL for `url`/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date(1000), endDate: new Date(0)}),
+ /TypeError: `beginDate` should be at least as old/
+ );
+ Assert.throws(
+ () => PlacesUtils.history.removeVisitsByFilter({beginDate: new Date(1000), endDate: new Date(0)}),
+ /TypeError: `beginDate` should be at least as old/
+ );
+});
+
+add_task(function* test_orphans() {
+ let uri = NetUtil.newURI("http://moz.org/");
+ yield PlacesTestUtils.addVisits({ uri });
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ uri, SMALLPNG_DATA_URI, true, PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null, Services.scriptSecurityManager.getSystemPrincipal());
+ PlacesUtils.annotations.setPageAnnotation(uri, "test", "restval", 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+
+ yield PlacesUtils.history.removeVisitsByFilter({ beginDate: new Date(1999, 9, 9, 9, 9),
+ endDate: new Date() });
+ Assert.ok(!(yield PlacesTestUtils.isPageInDB(uri)), "Page should have been removed");
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(`SELECT (SELECT count(*) FROM moz_annos) +
+ (SELECT count(*) FROM moz_favicons) AS count`);
+ Assert.equal(rows[0].getResultByName("count"), 0, "Should not find orphans");
+});
diff --git a/toolkit/components/places/tests/history/test_updatePlaces_sameUri_titleChanged.js b/toolkit/components/places/tests/history/test_updatePlaces_sameUri_titleChanged.js
new file mode 100644
index 000000000..832df9d9a
--- /dev/null
+++ b/toolkit/components/places/tests/history/test_updatePlaces_sameUri_titleChanged.js
@@ -0,0 +1,52 @@
+// Test that repeated additions of the same URI through updatePlaces, properly
+// update from_visit and notify titleChanged.
+
+add_task(function* test() {
+ let uri = "http://test.com/";
+
+ let promiseTitleChangedNotifications = new Promise(resolve => {
+ let historyObserver = {
+ _count: 0,
+ __proto__: NavHistoryObserver.prototype,
+ onTitleChanged(aURI, aTitle, aGUID) {
+ Assert.equal(aURI.spec, uri, "Should notify the proper url");
+ if (++this._count == 2) {
+ PlacesUtils.history.removeObserver(historyObserver);
+ resolve();
+ }
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+ });
+
+ // This repeats the url on purpose, don't merge it into a single place entry.
+ yield PlacesTestUtils.addVisits([
+ { uri, title: "test" },
+ { uri, referrer: uri, title: "test2" },
+ ]);
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let query = PlacesUtils.history.getNewQuery();
+ query.uri = NetUtil.newURI(uri);
+ options.resultType = options.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ Assert.equal(root.childCount, 2);
+
+ let child = root.getChild(0);
+ Assert.equal(child.visitType, TRANSITION_LINK, "Visit type should be TRANSITION_LINK");
+ Assert.equal(child.visitId, 1, "Visit ID should be 1");
+ Assert.equal(child.fromVisitId, -1, "Should have no referrer visit ID");
+ Assert.equal(child.title, "test2", "Should have the correct title");
+
+ child = root.getChild(1);
+ Assert.equal(child.visitType, TRANSITION_LINK, "Visit type should be TRANSITION_LINK");
+ Assert.equal(child.visitId, 2, "Visit ID should be 2");
+ Assert.equal(child.fromVisitId, 1, "First visit should be the referring visit");
+ Assert.equal(child.title, "test2", "Should have the correct title");
+
+ root.containerOpen = false;
+
+ yield promiseTitleChangedNotifications;
+});
diff --git a/toolkit/components/places/tests/history/xpcshell.ini b/toolkit/components/places/tests/history/xpcshell.ini
new file mode 100644
index 000000000..ee182e090
--- /dev/null
+++ b/toolkit/components/places/tests/history/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+head = head_history.js
+tail =
+
+[test_insert.js]
+[test_remove.js]
+[test_removeVisits.js]
+[test_removeVisitsByFilter.js]
+[test_updatePlaces_sameUri_titleChanged.js]
diff --git a/toolkit/components/places/tests/migration/.eslintrc.js b/toolkit/components/places/tests/migration/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/migration/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/migration/head_migration.js b/toolkit/components/places/tests/migration/head_migration.js
new file mode 100644
index 000000000..1ebecd4c0
--- /dev/null
+++ b/toolkit/components/places/tests/migration/head_migration.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict"
+
+var { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+const DB_FILENAME = "places.sqlite";
+
+/**
+ * Sets the database to use for the given test. This should be the very first
+ * thing in the test, otherwise this database will not be used!
+ *
+ * @param aFileName
+ * The filename of the database to use. This database must exist in
+ * toolkit/components/places/tests/migration!
+ * @return {Promise}
+ */
+var setupPlacesDatabase = Task.async(function* (aFileName) {
+ let currentDir = yield OS.File.getCurrentDirectory();
+
+ let src = OS.Path.join(currentDir, aFileName);
+ Assert.ok((yield OS.File.exists(src)), "Database file found");
+
+ // Ensure that our database doesn't already exist.
+ let dest = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ Assert.ok(!(yield OS.File.exists(dest)), "Database file should not exist yet");
+
+ yield OS.File.copy(src, dest);
+});
+
+// This works provided all tests in this folder use add_task.
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/migration/places_v10.sqlite b/toolkit/components/places/tests/migration/places_v10.sqlite
new file mode 100644
index 000000000..80a8ecd6a
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v10.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v11.sqlite b/toolkit/components/places/tests/migration/places_v11.sqlite
new file mode 100644
index 000000000..bef27d5f5
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v11.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v17.sqlite b/toolkit/components/places/tests/migration/places_v17.sqlite
new file mode 100644
index 000000000..5183cde83
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v17.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v19.sqlite b/toolkit/components/places/tests/migration/places_v19.sqlite
new file mode 100644
index 000000000..11e2e6247
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v19.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v21.sqlite b/toolkit/components/places/tests/migration/places_v21.sqlite
new file mode 100644
index 000000000..f72930826
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v21.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v22.sqlite b/toolkit/components/places/tests/migration/places_v22.sqlite
new file mode 100644
index 000000000..30bf840b0
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v22.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v23.sqlite b/toolkit/components/places/tests/migration/places_v23.sqlite
new file mode 100644
index 000000000..b519b97d2
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v23.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v24.sqlite b/toolkit/components/places/tests/migration/places_v24.sqlite
new file mode 100644
index 000000000..b35f958a6
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v24.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v25.sqlite b/toolkit/components/places/tests/migration/places_v25.sqlite
new file mode 100644
index 000000000..2afd1da1f
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v25.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v26.sqlite b/toolkit/components/places/tests/migration/places_v26.sqlite
new file mode 100644
index 000000000..b4b238179
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v26.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v27.sqlite b/toolkit/components/places/tests/migration/places_v27.sqlite
new file mode 100644
index 000000000..57dfb7562
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v27.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v28.sqlite b/toolkit/components/places/tests/migration/places_v28.sqlite
new file mode 100644
index 000000000..9a27db324
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v28.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v29.sqlite b/toolkit/components/places/tests/migration/places_v29.sqlite
new file mode 100644
index 000000000..f6de0fe8a
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v29.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v30.sqlite b/toolkit/components/places/tests/migration/places_v30.sqlite
new file mode 100644
index 000000000..9cbabe005
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v30.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v31.sqlite b/toolkit/components/places/tests/migration/places_v31.sqlite
new file mode 100644
index 000000000..9d33b9eff
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v31.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v32.sqlite b/toolkit/components/places/tests/migration/places_v32.sqlite
new file mode 100644
index 000000000..239f6c5fe
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v32.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v33.sqlite b/toolkit/components/places/tests/migration/places_v33.sqlite
new file mode 100644
index 000000000..6071dc6a6
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v33.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v34.sqlite b/toolkit/components/places/tests/migration/places_v34.sqlite
new file mode 100644
index 000000000..474628996
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v34.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v35.sqlite b/toolkit/components/places/tests/migration/places_v35.sqlite
new file mode 100644
index 000000000..5e157d778
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v35.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/places_v6.sqlite b/toolkit/components/places/tests/migration/places_v6.sqlite
new file mode 100644
index 000000000..2852a4cf9
--- /dev/null
+++ b/toolkit/components/places/tests/migration/places_v6.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/migration/test_current_from_downgraded.js b/toolkit/components/places/tests/migration/test_current_from_downgraded.js
new file mode 100644
index 000000000..6d36cab14
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_downgraded.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase(`places_v${CURRENT_SCHEMA_VERSION}.sqlite`);
+ // Downgrade the schema version to the first supported one.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path: path });
+ yield db.setSchemaVersion(FIRST_UPGRADABLE_SCHEMA_VERSION);
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v11.js b/toolkit/components/places/tests/migration/test_current_from_v11.js
new file mode 100644
index 000000000..43b8fb1f6
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v11.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v11.sqlite");
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_moz_hosts() {
+ let db = yield PlacesUtils.promiseDBConnection();
+
+ // This will throw if the column does not exist.
+ yield db.execute("SELECT host, frecency, typed, prefix FROM moz_hosts");
+
+ // moz_hosts is populated asynchronously, so we need to wait.
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // check the number of entries in moz_hosts equals the number of
+ // unique rev_host in moz_places
+ let rows = yield db.execute(
+ `SELECT (SELECT COUNT(host) FROM moz_hosts),
+ (SELECT COUNT(DISTINCT rev_host)
+ FROM moz_places
+ WHERE LENGTH(rev_host) > 1)
+ `);
+
+ Assert.equal(rows.length, 1);
+ let mozHostsCount = rows[0].getResultByIndex(0);
+ let mozPlacesCount = rows[0].getResultByIndex(1);
+
+ Assert.ok(mozPlacesCount > 0, "There is some url in the database");
+ Assert.equal(mozPlacesCount, mozHostsCount, "moz_hosts has the expected number of entries");
+});
+
+add_task(function* test_journal() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute("PRAGMA journal_mode");
+ Assert.equal(rows.length, 1);
+ // WAL journal mode should be set on this database.
+ Assert.equal(rows[0].getResultByIndex(0), "wal");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v19.js b/toolkit/components/places/tests/migration/test_current_from_v19.js
new file mode 100644
index 000000000..b8d837e68
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v19.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ANNO_LEGACYGUID = "placesInternal/GUID";
+
+var getTotalGuidAnnotationsCount = Task.async(function* (db) {
+ let rows = yield db.execute(
+ `SELECT count(*)
+ FROM moz_items_annos a
+ JOIN moz_anno_attributes b ON a.anno_attribute_id = b.id
+ WHERE b.name = :attr_name
+ `, { attr_name: ANNO_LEGACYGUID });
+ return rows[0].getResultByIndex(0);
+});
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v19.sqlite");
+});
+
+add_task(function* initial_state() {
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path: path });
+
+ Assert.equal((yield getTotalGuidAnnotationsCount(db)), 1,
+ "There should be 1 obsolete guid annotation");
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_bookmark_guid_annotation_removed()
+{
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield getTotalGuidAnnotationsCount(db)), 0,
+ "There should be no more obsolete GUID annotations.");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v24.js b/toolkit/components/places/tests/migration/test_current_from_v24.js
new file mode 100644
index 000000000..0561b4922
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v24.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v24.sqlite");
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_bookmark_guid_annotation_removed()
+{
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let m = new Map([
+ [PlacesUtils.placesRootId, PlacesUtils.bookmarks.rootGuid],
+ [PlacesUtils.bookmarksMenuFolderId, PlacesUtils.bookmarks.menuGuid],
+ [PlacesUtils.toolbarFolderId, PlacesUtils.bookmarks.toolbarGuid],
+ [PlacesUtils.unfiledBookmarksFolderId, PlacesUtils.bookmarks.unfiledGuid],
+ [PlacesUtils.tagsFolderId, PlacesUtils.bookmarks.tagsGuid],
+ [PlacesUtils.mobileFolderId, PlacesUtils.bookmarks.mobileGuid],
+ ]);
+
+ let rows = yield db.execute(`SELECT id, guid FROM moz_bookmarks`);
+ for (let row of rows) {
+ let id = row.getResultByName("id");
+ let guid = row.getResultByName("guid");
+ Assert.equal(m.get(id), guid, "The root folder has the correct GUID");
+ }
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v25.js b/toolkit/components/places/tests/migration/test_current_from_v25.js
new file mode 100644
index 000000000..b066975fc
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v25.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v25.sqlite");
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_dates_rounded() {
+ let root = yield PlacesUtils.promiseBookmarksTree();
+ function ensureDates(node) {
+ // When/if promiseBookmarksTree returns these as Date objects, switch this
+ // test to use getItemDateAdded and getItemLastModified. And when these
+ // methods are removed, this test can be eliminated altogether.
+ Assert.strictEqual(typeof(node.dateAdded), "number");
+ Assert.strictEqual(typeof(node.lastModified), "number");
+ Assert.strictEqual(node.dateAdded % 1000, 0);
+ Assert.strictEqual(node.lastModified % 1000, 0);
+ if ("children" in node)
+ node.children.forEach(ensureDates);
+ }
+ ensureDates(root);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v26.js b/toolkit/components/places/tests/migration/test_current_from_v26.js
new file mode 100644
index 000000000..7ff4bc352
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v26.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v26.sqlite");
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path });
+ // Add pages.
+ yield db.execute(`INSERT INTO moz_places (url, guid)
+ VALUES ("http://test1.com/", "test1_______")
+ , ("http://test2.com/", "test2_______")
+ , ("http://test3.com/", "test3_______")
+ `);
+ // Add keywords.
+ yield db.execute(`INSERT INTO moz_keywords (keyword)
+ VALUES ("kw1")
+ , ("kw2")
+ , ("kw3")
+ , ("kw4")
+ , ("kw5")
+ `);
+ // Add bookmarks.
+ let now = Date.now() * 1000;
+ let index = 0;
+ yield db.execute(`INSERT INTO moz_bookmarks (type, fk, parent, position, dateAdded, lastModified, keyword_id, guid)
+ VALUES (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw1'), "bookmark1___")
+ /* same uri, different keyword */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw2'), "bookmark2___")
+ /* different uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test2_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw1'), "bookmark3___")
+ /* same uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw1'), "bookmark4___")
+ /* same uri, same keyword as 2 */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test2_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw2'), "bookmark5___")
+ /* different uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test1_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw3'), "bookmark6___")
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test3_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw4'), "bookmark7___")
+ /* same uri and post_data as bookmark7, different keyword */
+ , (1, (SELECT id FROM moz_places WHERE guid = 'test3_______'), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = 'kw5'), "bookmark8___")
+ `);
+ // Add postData.
+ yield db.execute(`INSERT INTO moz_anno_attributes (name)
+ VALUES ("bookmarkProperties/POSTData")
+ , ("someOtherAnno")`);
+ yield db.execute(`INSERT INTO moz_items_annos(anno_attribute_id, item_id, content)
+ VALUES ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark3___"), "postData1")
+ , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "postData2")
+ , ((SELECT id FROM moz_anno_attributes where name = "someOtherAnno"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "zzzzzzzzzz")
+ , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark7___"), "postData3")
+ , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark8___"), "postData3")
+ `);
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_keywords() {
+ // When 2 urls have the same keyword, if one has postData it will be
+ // preferred.
+ let entry1 = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry1.url.href, "http://test2.com/");
+ Assert.equal(entry1.postData, "postData1");
+ let entry2 = yield PlacesUtils.keywords.fetch("kw2");
+ Assert.equal(entry2.url.href, "http://test2.com/");
+ Assert.equal(entry2.postData, "postData2");
+ let entry3 = yield PlacesUtils.keywords.fetch("kw3");
+ Assert.equal(entry3.url.href, "http://test1.com/");
+ Assert.equal(entry3.postData, null);
+ let entry4 = yield PlacesUtils.keywords.fetch("kw4");
+ Assert.equal(entry4, null);
+ let entry5 = yield PlacesUtils.keywords.fetch("kw5");
+ Assert.equal(entry5.url.href, "http://test3.com/");
+ Assert.equal(entry5.postData, "postData3");
+
+ Assert.equal((yield foreign_count("http://test1.com/")), 5); // 4 bookmark2 + 1 keywords
+ Assert.equal((yield foreign_count("http://test2.com/")), 4); // 2 bookmark2 + 2 keywords
+ Assert.equal((yield foreign_count("http://test3.com/")), 3); // 2 bookmark2 + 1 keywords
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v27.js b/toolkit/components/places/tests/migration/test_current_from_v27.js
new file mode 100644
index 000000000..1675901eb
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v27.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v27.sqlite");
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path });
+ // Add pages.
+ yield db.execute(`INSERT INTO moz_places (url, guid)
+ VALUES ("http://test1.com/", "test1_______")
+ , ("http://test2.com/", "test2_______")
+ `);
+ // Add keywords.
+ yield db.execute(`INSERT INTO moz_keywords (keyword, place_id, post_data)
+ VALUES ("kw1", (SELECT id FROM moz_places WHERE guid = "test2_______"), "broken data")
+ , ("kw2", (SELECT id FROM moz_places WHERE guid = "test2_______"), NULL)
+ , ("kw3", (SELECT id FROM moz_places WHERE guid = "test1_______"), "zzzzzzzzzz")
+ `);
+ // Add bookmarks.
+ let now = Date.now() * 1000;
+ let index = 0;
+ yield db.execute(`INSERT INTO moz_bookmarks (type, fk, parent, position, dateAdded, lastModified, keyword_id, guid)
+ VALUES (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark1___")
+ /* same uri, different keyword */
+ , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw2"), "bookmark2___")
+ /* different uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = "test2_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark3___")
+ /* same uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw1"), "bookmark4___")
+ /* same uri, same keyword as 2 */
+ , (1, (SELECT id FROM moz_places WHERE guid = "test2_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw2"), "bookmark5___")
+ /* different uri, same keyword as 1 */
+ , (1, (SELECT id FROM moz_places WHERE guid = "test1_______"), 3, ${index++}, ${now}, ${now},
+ (SELECT id FROM moz_keywords WHERE keyword = "kw3"), "bookmark6___")
+ `);
+ // Add postData.
+ yield db.execute(`INSERT INTO moz_anno_attributes (name)
+ VALUES ("bookmarkProperties/POSTData")
+ , ("someOtherAnno")`);
+ yield db.execute(`INSERT INTO moz_items_annos(anno_attribute_id, item_id, content)
+ VALUES ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark3___"), "postData1")
+ , ((SELECT id FROM moz_anno_attributes where name = "bookmarkProperties/POSTData"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "postData2")
+ , ((SELECT id FROM moz_anno_attributes where name = "someOtherAnno"),
+ (SELECT id FROM moz_bookmarks WHERE guid = "bookmark5___"), "zzzzzzzzzz")
+ `);
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_keywords() {
+ // When 2 urls have the same keyword, if one has postData it will be
+ // preferred.
+ let entry1 = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry1.url.href, "http://test2.com/");
+ Assert.equal(entry1.postData, "postData1");
+ let entry2 = yield PlacesUtils.keywords.fetch("kw2");
+ Assert.equal(entry2.url.href, "http://test2.com/");
+ Assert.equal(entry2.postData, "postData2");
+ let entry3 = yield PlacesUtils.keywords.fetch("kw3");
+ Assert.equal(entry3.url.href, "http://test1.com/");
+ Assert.equal(entry3.postData, null);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v31.js b/toolkit/components/places/tests/migration/test_current_from_v31.js
new file mode 100644
index 000000000..6b9131daa
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v31.js
@@ -0,0 +1,46 @@
+// Add pages.
+let shorturl = "http://example.com/" + "a".repeat(1981);
+let longurl = "http://example.com/" + "a".repeat(1982);
+let bmurl = "http://example.com/" + "a".repeat(1983);
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v31.sqlite");
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path });
+
+ yield db.execute(`INSERT INTO moz_places (url, guid, foreign_count)
+ VALUES (:shorturl, "test1_______", 0)
+ , (:longurl, "test2_______", 0)
+ , (:bmurl, "test3_______", 1)
+ `, { shorturl, longurl, bmurl });
+ // Add visits.
+ yield db.execute(`INSERT INTO moz_historyvisits (place_id)
+ VALUES ((SELECT id FROM moz_places WHERE url = :shorturl))
+ , ((SELECT id FROM moz_places WHERE url = :longurl))
+ `, { shorturl, longurl });
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_longurls() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(`SELECT 1 FROM moz_places where url = :longurl`,
+ { longurl });
+ Assert.equal(rows.length, 0, "Long url should have been removed");
+ rows = yield db.execute(`SELECT 1 FROM moz_places where url = :shorturl`,
+ { shorturl });
+ Assert.equal(rows.length, 1, "Short url should have been retained");
+ rows = yield db.execute(`SELECT 1 FROM moz_places where url = :bmurl`,
+ { bmurl });
+ Assert.equal(rows.length, 1, "Bookmarked url should have been retained");
+ rows = yield db.execute(`SELECT count(*) FROM moz_historyvisits`);
+ Assert.equal(rows.length, 1, "Orphan visists should have been removed");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v34.js b/toolkit/components/places/tests/migration/test_current_from_v34.js
new file mode 100644
index 000000000..115bcec67
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v34.js
@@ -0,0 +1,141 @@
+Cu.importGlobalProperties(["URL", "crypto"]);
+
+const { TYPE_BOOKMARK, TYPE_FOLDER } = Ci.nsINavBookmarksService;
+const { EXPIRE_NEVER, TYPE_INT32 } = Ci.nsIAnnotationService;
+
+function makeGuid() {
+ return ChromeUtils.base64URLEncode(crypto.getRandomValues(new Uint8Array(9)), {
+ pad: false,
+ });
+}
+
+// These queries are more or less copied directly from Bookmarks.jsm, but
+// operate on the old, pre-migration DB. We can't use any of the Places SQL
+// functions yet, because those are only registered for the main connection.
+function* insertItem(db, info) {
+ let [parentInfo] = yield db.execute(`
+ SELECT b.id, (SELECT count(*) FROM moz_bookmarks
+ WHERE parent = b.id) AS childCount
+ FROM moz_bookmarks b
+ WHERE b.guid = :parentGuid`,
+ { parentGuid: info.parentGuid });
+
+ let guid = makeGuid();
+ yield db.execute(`
+ INSERT INTO moz_bookmarks (fk, type, parent, position, guid)
+ VALUES ((SELECT id FROM moz_places WHERE url = :url),
+ :type, :parent, :position, :guid)`,
+ { url: info.url || "nonexistent", type: info.type, guid,
+ // Just append items.
+ position: parentInfo.getResultByName("childCount"),
+ parent: parentInfo.getResultByName("id") });
+
+ let id = (yield db.execute(`
+ SELECT id FROM moz_bookmarks WHERE guid = :guid LIMIT 1`,
+ { guid }))[0].getResultByName("id");
+
+ return { id, guid };
+}
+
+function insertBookmark(db, info) {
+ return db.executeTransaction(function* () {
+ if (info.type == TYPE_BOOKMARK) {
+ // We don't have access to the hash function here, so we omit the
+ // `url_hash` column. These will be fixed up automatically during
+ // migration.
+ let url = new URL(info.url);
+ let placeGuid = makeGuid();
+ yield db.execute(`
+ INSERT INTO moz_places (url, rev_host, hidden, frecency, guid)
+ VALUES (:url, :rev_host, 0, -1, :guid)`,
+ { url: url.href, guid: placeGuid,
+ rev_host: PlacesUtils.getReversedHost(url) });
+ }
+ return yield* insertItem(db, info);
+ });
+}
+
+function* insertAnno(db, itemId, name, value) {
+ yield db.execute(`INSERT OR IGNORE INTO moz_anno_attributes (name)
+ VALUES (:name)`, { name });
+ yield db.execute(`
+ INSERT INTO moz_items_annos
+ (item_id, anno_attribute_id, content, flags,
+ expiration, type, dateAdded, lastModified)
+ VALUES (:itemId,
+ (SELECT id FROM moz_anno_attributes
+ WHERE name = :name),
+ 1, 0, :expiration, :type, 0, 0)
+ `, { itemId, name, expiration: EXPIRE_NEVER, type: TYPE_INT32 });
+}
+
+function insertMobileFolder(db) {
+ return db.executeTransaction(function* () {
+ let item = yield* insertItem(db, {
+ type: TYPE_FOLDER,
+ parentGuid: "root________",
+ });
+ yield* insertAnno(db, item.id, "mobile/bookmarksRoot", 1);
+ return item;
+ });
+}
+
+var mobileId, mobileGuid, fxGuid;
+var dupeMobileId, dupeMobileGuid, tbGuid;
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v34.sqlite");
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path });
+
+ do_print("Create mobile folder with bookmarks");
+ ({ id: mobileId, guid: mobileGuid } = yield insertMobileFolder(db));
+ ({ guid: fxGuid } = yield insertBookmark(db, {
+ type: TYPE_BOOKMARK,
+ url: "http://getfirefox.com",
+ parentGuid: mobileGuid,
+ }));
+
+ // We should only have one mobile folder, but, in case an old version of Sync
+ // did the wrong thing and created multiple mobile folders, we should merge
+ // their contents into the new mobile root.
+ do_print("Create second mobile folder with different bookmarks");
+ ({ id: dupeMobileId, guid: dupeMobileGuid } = yield insertMobileFolder(db));
+ ({ guid: tbGuid } = yield insertBookmark(db, {
+ type: TYPE_BOOKMARK,
+ url: "http://getthunderbird.com",
+ parentGuid: dupeMobileGuid,
+ }));
+
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* test_mobile_root() {
+ let fxBmk = yield PlacesUtils.bookmarks.fetch(fxGuid);
+ equal(fxBmk.parentGuid, PlacesUtils.bookmarks.mobileGuid,
+ "Firefox bookmark should be moved to new mobile root");
+ equal(fxBmk.index, 0, "Firefox bookmark should be first child of new root");
+
+ let tbBmk = yield PlacesUtils.bookmarks.fetch(tbGuid);
+ equal(tbBmk.parentGuid, PlacesUtils.bookmarks.mobileGuid,
+ "Thunderbird bookmark should be moved to new mobile root");
+ equal(tbBmk.index, 1,
+ "Thunderbird bookmark should be second child of new root");
+
+ let mobileRootId = PlacesUtils.promiseItemId(
+ PlacesUtils.bookmarks.mobileGuid);
+ let annoItemIds = PlacesUtils.annotations.getItemsWithAnnotation(
+ PlacesUtils.MOBILE_ROOT_ANNO, {});
+ deepEqual(annoItemIds, [mobileRootId],
+ "Only mobile root should have mobile anno");
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v34_no_roots.js b/toolkit/components/places/tests/migration/test_current_from_v34_no_roots.js
new file mode 100644
index 000000000..871fe8993
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v34_no_roots.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v34.sqlite");
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = yield Sqlite.openConnection({ path });
+ // Remove all the roots.
+ yield db.execute("DELETE FROM moz_bookmarks");
+ yield db.close();
+});
+
+add_task(function* database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
diff --git a/toolkit/components/places/tests/migration/test_current_from_v6.js b/toolkit/components/places/tests/migration/test_current_from_v6.js
new file mode 100644
index 000000000..a3f9dc229
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v6.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests migration from a preliminary schema version 6 that
+ * lacks frecency column and moz_inputhistory table.
+ */
+
+add_task(function* setup() {
+ yield setupPlacesDatabase("places_v6.sqlite");
+});
+
+add_task(function* corrupt_database_not_exists() {
+ let corruptPath = OS.Path.join(OS.Constants.Path.profileDir,
+ "places.sqlite.corrupt");
+ Assert.ok(!(yield OS.File.exists(corruptPath)), "Corrupt file should not exist");
+});
+
+add_task(function* database_is_valid() {
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ Assert.equal((yield db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(function* check_columns() {
+ // Check the database has been replaced, these would throw otherwise.
+ let db = yield PlacesUtils.promiseDBConnection();
+ yield db.execute("SELECT frecency from moz_places");
+ yield db.execute("SELECT 1 from moz_inputhistory");
+});
+
+add_task(function* corrupt_database_exists() {
+ let corruptPath = OS.Path.join(OS.Constants.Path.profileDir,
+ "places.sqlite.corrupt");
+ Assert.ok((yield OS.File.exists(corruptPath)), "Corrupt file should exist");
+});
diff --git a/toolkit/components/places/tests/migration/xpcshell.ini b/toolkit/components/places/tests/migration/xpcshell.ini
new file mode 100644
index 000000000..aae0f75ee
--- /dev/null
+++ b/toolkit/components/places/tests/migration/xpcshell.ini
@@ -0,0 +1,36 @@
+[DEFAULT]
+head = head_migration.js
+tail =
+
+support-files =
+ places_v6.sqlite
+ places_v10.sqlite
+ places_v11.sqlite
+ places_v17.sqlite
+ places_v19.sqlite
+ places_v21.sqlite
+ places_v22.sqlite
+ places_v23.sqlite
+ places_v24.sqlite
+ places_v25.sqlite
+ places_v26.sqlite
+ places_v27.sqlite
+ places_v28.sqlite
+ places_v30.sqlite
+ places_v31.sqlite
+ places_v32.sqlite
+ places_v33.sqlite
+ places_v34.sqlite
+ places_v35.sqlite
+
+[test_current_from_downgraded.js]
+[test_current_from_v6.js]
+[test_current_from_v11.js]
+[test_current_from_v19.js]
+[test_current_from_v24.js]
+[test_current_from_v25.js]
+[test_current_from_v26.js]
+[test_current_from_v27.js]
+[test_current_from_v31.js]
+[test_current_from_v34.js]
+[test_current_from_v34_no_roots.js]
diff --git a/toolkit/components/places/tests/moz.build b/toolkit/components/places/tests/moz.build
new file mode 100644
index 000000000..a40c0e93a
--- /dev/null
+++ b/toolkit/components/places/tests/moz.build
@@ -0,0 +1,67 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+TEST_DIRS += ['cpp']
+
+TESTING_JS_MODULES += [
+ 'PlacesTestUtils.jsm',
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ 'bookmarks/xpcshell.ini',
+ 'expiration/xpcshell.ini',
+ 'favicons/xpcshell.ini',
+ 'history/xpcshell.ini',
+ 'migration/xpcshell.ini',
+ 'queries/xpcshell.ini',
+ 'unifiedcomplete/xpcshell.ini',
+ 'unit/xpcshell.ini',
+]
+
+BROWSER_CHROME_MANIFESTS += ['browser/browser.ini']
+MOCHITEST_CHROME_MANIFESTS += [
+ 'chrome/chrome.ini',
+]
+
+TEST_HARNESS_FILES.xpcshell.toolkit.components.places.tests += [
+ 'head_common.js',
+]
+
+TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.browser += [
+ 'browser/399606-history.go-0.html',
+ 'browser/399606-httprefresh.html',
+ 'browser/399606-location.reload.html',
+ 'browser/399606-location.replace.html',
+ 'browser/399606-window.location.href.html',
+ 'browser/399606-window.location.html',
+ 'browser/461710_iframe.html',
+ 'browser/461710_link_page-2.html',
+ 'browser/461710_link_page-3.html',
+ 'browser/461710_link_page.html',
+ 'browser/461710_visited_page.html',
+ 'browser/begin.html',
+ 'browser/favicon-normal16.png',
+ 'browser/favicon-normal32.png',
+ 'browser/favicon.html',
+ 'browser/final.html',
+ 'browser/history_post.html',
+ 'browser/history_post.sjs',
+ 'browser/redirect-target.html',
+ 'browser/redirect.sjs',
+ 'browser/redirect_once.sjs',
+ 'browser/redirect_twice.sjs',
+ 'browser/title1.html',
+ 'browser/title2.html',
+]
+
+TEST_HARNESS_FILES.testing.mochitest.tests.toolkit.components.places.tests.chrome += [
+ 'chrome/bad_links.atom',
+ 'chrome/link-less-items-no-site-uri.rss',
+ 'chrome/link-less-items.rss',
+ 'chrome/rss_as_html.rss',
+ 'chrome/rss_as_html.rss^headers^',
+ 'chrome/sample_feed.atom',
+]
diff --git a/toolkit/components/places/tests/queries/.eslintrc.js b/toolkit/components/places/tests/queries/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/queries/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/queries/head_queries.js b/toolkit/components/places/tests/queries/head_queries.js
new file mode 100644
index 000000000..d37b3365f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/head_queries.js
@@ -0,0 +1,370 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+
+// Some Useful Date constants - PRTime uses microseconds, so convert
+const DAY_MICROSEC = 86400000000;
+const today = PlacesUtils.toPRTime(Date.now());
+const yesterday = today - DAY_MICROSEC;
+const lastweek = today - (DAY_MICROSEC * 7);
+const daybefore = today - (DAY_MICROSEC * 2);
+const old = today - (DAY_MICROSEC * 3);
+const futureday = today + (DAY_MICROSEC * 3);
+const olderthansixmonths = today - (DAY_MICROSEC * 31 * 7);
+
+
+/**
+ * Generalized function to pull in an array of objects of data and push it into
+ * the database. It does NOT do any checking to see that the input is
+ * appropriate. This function is an asynchronous task, it can be called using
+ * "Task.spawn" or using the "yield" function inside another task.
+ */
+function* task_populateDB(aArray)
+{
+ // Iterate over aArray and execute all instructions.
+ for (let arrayItem of aArray) {
+ try {
+ // make the data object into a query data object in order to create proper
+ // default values for anything left unspecified
+ var qdata = new queryData(arrayItem);
+ if (qdata.isVisit) {
+ // Then we should add a visit for this node
+ yield PlacesTestUtils.addVisits({
+ uri: uri(qdata.uri),
+ transition: qdata.transType,
+ visitDate: qdata.lastVisit,
+ referrer: qdata.referrer ? uri(qdata.referrer) : null,
+ title: qdata.title
+ });
+ if (qdata.visitCount && !qdata.isDetails) {
+ // Set a fake visit_count, this is not a real count but can be used
+ // to test sorting by visit_count.
+ let stmt = DBConn().createAsyncStatement(
+ "UPDATE moz_places SET visit_count = :vc WHERE url_hash = hash(:url) AND url = :url");
+ stmt.params.vc = qdata.visitCount;
+ stmt.params.url = qdata.uri;
+ try {
+ stmt.executeAsync();
+ }
+ catch (ex) {
+ print("Error while setting visit_count.");
+ }
+ finally {
+ stmt.finalize();
+ }
+ }
+ }
+
+ if (qdata.isRedirect) {
+ // This must be async to properly enqueue after the updateFrecency call
+ // done by the visit addition.
+ let stmt = DBConn().createAsyncStatement(
+ "UPDATE moz_places SET hidden = 1 WHERE url_hash = hash(:url) AND url = :url");
+ stmt.params.url = qdata.uri;
+ try {
+ stmt.executeAsync();
+ }
+ catch (ex) {
+ print("Error while setting hidden.");
+ }
+ finally {
+ stmt.finalize();
+ }
+ }
+
+ if (qdata.isDetails) {
+ // Then we add extraneous page details for testing
+ yield PlacesTestUtils.addVisits({
+ uri: uri(qdata.uri),
+ visitDate: qdata.lastVisit,
+ title: qdata.title
+ });
+ }
+
+ if (qdata.markPageAsTyped) {
+ PlacesUtils.history.markPageAsTyped(uri(qdata.uri));
+ }
+
+ if (qdata.isPageAnnotation) {
+ if (qdata.removeAnnotation)
+ PlacesUtils.annotations.removePageAnnotation(uri(qdata.uri),
+ qdata.annoName);
+ else {
+ PlacesUtils.annotations.setPageAnnotation(uri(qdata.uri),
+ qdata.annoName,
+ qdata.annoVal,
+ qdata.annoFlags,
+ qdata.annoExpiration);
+ }
+ }
+
+ if (qdata.isItemAnnotation) {
+ if (qdata.removeAnnotation)
+ PlacesUtils.annotations.removeItemAnnotation(qdata.itemId,
+ qdata.annoName);
+ else {
+ PlacesUtils.annotations.setItemAnnotation(qdata.itemId,
+ qdata.annoName,
+ qdata.annoVal,
+ qdata.annoFlags,
+ qdata.annoExpiration);
+ }
+ }
+
+ if (qdata.isFolder) {
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: qdata.parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: qdata.title,
+ index: qdata.index
+ });
+ }
+
+ if (qdata.isLivemark) {
+ yield PlacesUtils.livemarks.addLivemark({ title: qdata.title
+ , parentId: (yield PlacesUtils.promiseItemId(qdata.parentGuid))
+ , index: qdata.index
+ , feedURI: uri(qdata.feedURI)
+ , siteURI: uri(qdata.uri)
+ });
+ }
+
+ if (qdata.isBookmark) {
+ let data = {
+ parentGuid: qdata.parentGuid,
+ index: qdata.index,
+ title: qdata.title,
+ url: qdata.uri
+ };
+
+ if (qdata.dateAdded) {
+ data.dateAdded = new Date(qdata.dateAdded / 1000);
+ }
+
+ if (qdata.lastModified) {
+ data.lastModified = new Date(qdata.lastModified / 1000);
+ }
+
+ yield PlacesUtils.bookmarks.insert(data);
+
+ if (qdata.keyword) {
+ yield PlacesUtils.keywords.insert({ url: qdata.uri,
+ keyword: qdata.keyword });
+ }
+ }
+
+ if (qdata.isTag) {
+ PlacesUtils.tagging.tagURI(uri(qdata.uri), qdata.tagArray);
+ }
+
+ if (qdata.isSeparator) {
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: qdata.parentGuid,
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ index: qdata.index
+ });
+ }
+ } catch (ex) {
+ // use the arrayItem object here in case instantiation of qdata failed
+ do_print("Problem with this URI: " + arrayItem.uri);
+ do_throw("Error creating database: " + ex + "\n");
+ }
+ }
+}
+
+
+/**
+ * The Query Data Object - this object encapsulates data for our queries and is
+ * used to parameterize our calls to the Places APIs to put data into the
+ * database. It also has some interesting meta functions to determine which APIs
+ * should be called, and to determine if this object should show up in the
+ * resulting query.
+ * Its parameter is an object specifying which attributes you want to set.
+ * For ex:
+ * var myobj = new queryData({isVisit: true, uri:"http://mozilla.com", title="foo"});
+ * Note that it doesn't do any input checking on that object.
+ */
+function queryData(obj) {
+ this.isVisit = obj.isVisit ? obj.isVisit : false;
+ this.isBookmark = obj.isBookmark ? obj.isBookmark: false;
+ this.uri = obj.uri ? obj.uri : "";
+ this.lastVisit = obj.lastVisit ? obj.lastVisit : today;
+ this.referrer = obj.referrer ? obj.referrer : null;
+ this.transType = obj.transType ? obj.transType : Ci.nsINavHistoryService.TRANSITION_TYPED;
+ this.isRedirect = obj.isRedirect ? obj.isRedirect : false;
+ this.isDetails = obj.isDetails ? obj.isDetails : false;
+ this.title = obj.title ? obj.title : "";
+ this.markPageAsTyped = obj.markPageAsTyped ? obj.markPageAsTyped : false;
+ this.isPageAnnotation = obj.isPageAnnotation ? obj.isPageAnnotation : false;
+ this.removeAnnotation= obj.removeAnnotation ? true : false;
+ this.annoName = obj.annoName ? obj.annoName : "";
+ this.annoVal = obj.annoVal ? obj.annoVal : "";
+ this.annoFlags = obj.annoFlags ? obj.annoFlags : 0;
+ this.annoExpiration = obj.annoExpiration ? obj.annoExpiration : 0;
+ this.isItemAnnotation = obj.isItemAnnotation ? obj.isItemAnnotation : false;
+ this.itemId = obj.itemId ? obj.itemId : 0;
+ this.annoMimeType = obj.annoMimeType ? obj.annoMimeType : "";
+ this.isTag = obj.isTag ? obj.isTag : false;
+ this.tagArray = obj.tagArray ? obj.tagArray : null;
+ this.isLivemark = obj.isLivemark ? obj.isLivemark : false;
+ this.parentGuid = obj.parentGuid || PlacesUtils.bookmarks.rootGuid;
+ this.feedURI = obj.feedURI ? obj.feedURI : "";
+ this.index = obj.index ? obj.index : PlacesUtils.bookmarks.DEFAULT_INDEX;
+ this.isFolder = obj.isFolder ? obj.isFolder : false;
+ this.contractId = obj.contractId ? obj.contractId : "";
+ this.lastModified = obj.lastModified ? obj.lastModified : null;
+ this.dateAdded = obj.dateAdded ? obj.dateAdded : null;
+ this.keyword = obj.keyword ? obj.keyword : "";
+ this.visitCount = obj.visitCount ? obj.visitCount : 0;
+ this.isSeparator = obj.hasOwnProperty("isSeparator") && obj.isSeparator;
+
+ // And now, the attribute for whether or not this object should appear in the
+ // resulting query
+ this.isInQuery = obj.isInQuery ? obj.isInQuery : false;
+}
+
+// All attributes are set in the constructor above
+queryData.prototype = { }
+
+
+/**
+ * Helper function to compare an array of query objects with a result set.
+ * It assumes the array of query objects contains the SAME SORT as the result
+ * set. It checks the the uri, title, time, and bookmarkIndex properties of
+ * the results, where appropriate.
+ */
+function compareArrayToResult(aArray, aRoot) {
+ do_print("Comparing Array to Results");
+
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen)
+ aRoot.containerOpen = true;
+
+ // check expected number of results against actual
+ var expectedResultCount = aArray.filter(function(aEl) { return aEl.isInQuery; }).length;
+ if (expectedResultCount != aRoot.childCount) {
+ // Debugging code for failures.
+ dump_table("moz_places");
+ dump_table("moz_historyvisits");
+ do_print("Found children:");
+ for (let i = 0; i < aRoot.childCount; i++) {
+ do_print(aRoot.getChild(i).uri);
+ }
+ do_print("Expected:");
+ for (let i = 0; i < aArray.length; i++) {
+ if (aArray[i].isInQuery)
+ do_print(aArray[i].uri);
+ }
+ }
+ do_check_eq(expectedResultCount, aRoot.childCount);
+
+ var inQueryIndex = 0;
+ for (var i = 0; i < aArray.length; i++) {
+ if (aArray[i].isInQuery) {
+ var child = aRoot.getChild(inQueryIndex);
+ // do_print("testing testData[" + i + "] vs result[" + inQueryIndex + "]");
+ if (!aArray[i].isFolder && !aArray[i].isSeparator) {
+ do_print("testing testData[" + aArray[i].uri + "] vs result[" + child.uri + "]");
+ if (aArray[i].uri != child.uri) {
+ dump_table("moz_places");
+ do_throw("Expected " + aArray[i].uri + " found " + child.uri);
+ }
+ }
+ if (!aArray[i].isSeparator && aArray[i].title != child.title)
+ do_throw("Expected " + aArray[i].title + " found " + child.title);
+ if (aArray[i].hasOwnProperty("lastVisit") &&
+ aArray[i].lastVisit != child.time)
+ do_throw("Expected " + aArray[i].lastVisit + " found " + child.time);
+ if (aArray[i].hasOwnProperty("index") &&
+ aArray[i].index != PlacesUtils.bookmarks.DEFAULT_INDEX &&
+ aArray[i].index != child.bookmarkIndex)
+ do_throw("Expected " + aArray[i].index + " found " + child.bookmarkIndex);
+
+ inQueryIndex++;
+ }
+ }
+
+ if (!wasOpen)
+ aRoot.containerOpen = false;
+ do_print("Comparing Array to Results passes");
+}
+
+
+/**
+ * Helper function to check to see if one object either is or is not in the
+ * result set. It can accept either a queryData object or an array of queryData
+ * objects. If it gets an array, it only compares the first object in the array
+ * to see if it is in the result set.
+ * Returns: True if item is in query set, and false if item is not in query set
+ * If input is an array, returns True if FIRST object in array is in
+ * query set. To compare entire array, use the function above.
+ */
+function isInResult(aQueryData, aRoot) {
+ var rv = false;
+ var uri;
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen)
+ aRoot.containerOpen = true;
+
+ // If we have an array, pluck out the first item. If an object, pluc out the
+ // URI, we just compare URI's here.
+ if ("uri" in aQueryData) {
+ uri = aQueryData.uri;
+ } else {
+ uri = aQueryData[0].uri;
+ }
+
+ for (var i=0; i < aRoot.childCount; i++) {
+ if (uri == aRoot.getChild(i).uri) {
+ rv = true;
+ break;
+ }
+ }
+ if (!wasOpen)
+ aRoot.containerOpen = false;
+ return rv;
+}
+
+
+/**
+ * A nice helper function for debugging things. It prints the contents of a
+ * result set.
+ */
+function displayResultSet(aRoot) {
+
+ var wasOpen = aRoot.containerOpen;
+ if (!wasOpen)
+ aRoot.containerOpen = true;
+
+ if (!aRoot.hasChildren) {
+ // Something wrong? Empty result set?
+ do_print("Result Set Empty");
+ return;
+ }
+
+ for (var i=0; i < aRoot.childCount; ++i) {
+ do_print("Result Set URI: " + aRoot.getChild(i).uri + " Title: " +
+ aRoot.getChild(i).title + " Visit Time: " + aRoot.getChild(i).time);
+ }
+ if (!wasOpen)
+ aRoot.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/queries/readme.txt b/toolkit/components/places/tests/queries/readme.txt
new file mode 100644
index 000000000..19414f96e
--- /dev/null
+++ b/toolkit/components/places/tests/queries/readme.txt
@@ -0,0 +1,16 @@
+These are tests specific to the Places Query API.
+
+We are tracking the coverage of these tests here:
+http://wiki.mozilla.org/QA/TDAI/Projects/Places_Tests
+
+When creating one of these tests, you need to update those tables so that we
+know how well our test coverage is of this area. Furthermore, when adding tests
+ensure to cover live update (changing the query set) by performing the following
+operations on the query set you get after running the query:
+* Adding a new item to the query set
+* Updating an existing item so that it matches the query set
+* Change an existing item so that it does not match the query set
+* Do multiple of the above inside an Update Batch transaction.
+* Try these transactions in different orders.
+
+Use the stub test to help you create a test with the proper structure.
diff --git a/toolkit/components/places/tests/queries/test_415716.js b/toolkit/components/places/tests/queries/test_415716.js
new file mode 100644
index 000000000..754a73e7c
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_415716.js
@@ -0,0 +1,108 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function modHistoryTypes(val) {
+ switch (val % 8) {
+ case 0:
+ case 1:
+ return TRANSITION_LINK;
+ case 2:
+ return TRANSITION_TYPED;
+ case 3:
+ return TRANSITION_BOOKMARK;
+ case 4:
+ return TRANSITION_EMBED;
+ case 5:
+ return TRANSITION_REDIRECT_PERMANENT;
+ case 6:
+ return TRANSITION_REDIRECT_TEMPORARY;
+ case 7:
+ return TRANSITION_DOWNLOAD;
+ case 8:
+ return TRANSITION_FRAMED_LINK;
+ }
+ return TRANSITION_TYPED;
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+/**
+ * Builds a test database by hand using various times, annotations and
+ * visit numbers for this test
+ */
+add_task(function* test_buildTestDatabase()
+{
+ // This is the set of visits that we will match - our min visit is 2 so that's
+ // why we add more visits to the same URIs.
+ let testURI = uri("http://www.foo.com");
+ let places = [];
+
+ for (let i = 0; i < 12; ++i) {
+ places.push({
+ uri: testURI,
+ transition: modHistoryTypes(i),
+ visitDate: today
+ });
+ }
+
+ testURI = uri("http://foo.com/youdontseeme.html");
+ let testAnnoName = "moz-test-places/testing123";
+ let testAnnoVal = "test";
+ for (let i = 0; i < 12; ++i) {
+ places.push({
+ uri: testURI,
+ transition: modHistoryTypes(i),
+ visitDate: today
+ });
+ }
+
+ yield PlacesTestUtils.addVisits(places);
+
+ PlacesUtils.annotations.setPageAnnotation(testURI, testAnnoName,
+ testAnnoVal, 0, 0);
+});
+
+/**
+ * This test will test Queries that use relative Time Range, minVists, maxVisits,
+ * annotation.
+ * The Query:
+ * Annotation == "moz-test-places/testing123" &&
+ * TimeRange == "now() - 2d" &&
+ * minVisits == 2 &&
+ * maxVisits == 10
+ */
+add_task(function test_execute()
+{
+ let query = PlacesUtils.history.getNewQuery();
+ query.annotation = "moz-test-places/testing123";
+ query.beginTime = daybefore * 1000;
+ query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_NOW;
+ query.endTime = today * 1000;
+ query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_NOW;
+ query.minVisits = 2;
+ query.maxVisits = 10;
+
+ // Options
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ // Results
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ dump("----> cc is: " + cc + "\n");
+ for (let i = 0; i < root.childCount; ++i) {
+ let resultNode = root.getChild(i);
+ let accesstime = Date(resultNode.time / 1000);
+ dump("----> result: " + resultNode.uri + " Date: " + accesstime.toLocaleString() + "\n");
+ }
+ do_check_eq(cc, 0);
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js b/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js
new file mode 100644
index 000000000..199fc0865
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_abstime-annotation-domain.js
@@ -0,0 +1,210 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const DAY_MSEC = 86400000;
+const MIN_MSEC = 60000;
+const HOUR_MSEC = 3600000;
+// Jan 6 2008 at 8am is our begin edge of the query
+var beginTimeDate = new Date(2008, 0, 6, 8, 0, 0, 0);
+// Jan 15 2008 at 9:30pm is our ending edge of the query
+var endTimeDate = new Date(2008, 0, 15, 21, 30, 0, 0);
+
+// These as millisecond values
+var beginTime = beginTimeDate.getTime();
+var endTime = endTimeDate.getTime();
+
+// Some range dates inside our query - mult by 1000 to convert to PRTIME
+var jan7_800 = (beginTime + DAY_MSEC) * 1000;
+var jan6_815 = (beginTime + (MIN_MSEC * 15)) * 1000;
+var jan11_800 = (beginTime + (DAY_MSEC * 5)) * 1000;
+var jan14_2130 = (endTime - DAY_MSEC) * 1000;
+var jan15_2045 = (endTime - (MIN_MSEC * 45)) * 1000;
+var jan12_1730 = (endTime - (DAY_MSEC * 3) - (HOUR_MSEC*4)) * 1000;
+
+// Dates outside our query - mult by 1000 to convert to PRTIME
+var jan6_700 = (beginTime - HOUR_MSEC) * 1000;
+var jan5_800 = (beginTime - DAY_MSEC) * 1000;
+var dec27_800 = (beginTime - (DAY_MSEC * 10)) * 1000;
+var jan15_2145 = (endTime + (MIN_MSEC * 15)) * 1000;
+var jan16_2130 = (endTime + (DAY_MSEC)) * 1000;
+var jan25_2130 = (endTime + (DAY_MSEC * 10)) * 1000;
+
+// So that we can easily use these too, convert them to PRTIME
+beginTime *= 1000;
+endTime *= 1000;
+
+/**
+ * Array of objects to build our test database
+ */
+var goodAnnoName = "moz-test-places/testing123";
+var val = "test";
+var badAnnoName = "text/foo";
+
+// The test data for our database, note that the ordering of the results that
+// will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+// see compareArrayToResult in head_queries.js for more info.
+var testData = [
+ // Test ftp protocol - vary the title length
+ {isInQuery: true, isVisit: true, isDetails: true,
+ uri: "ftp://foo.com/ftp", lastVisit: jan12_1730,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo"},
+
+ // Test flat domain with annotation
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://foo.com/", annoName: goodAnnoName, annoVal: val,
+ lastVisit: jan14_2130, title: "moz"},
+
+ // Test subdomain included with isRedirect=true, different transtype
+ {isInQuery: true, isVisit: true, isDetails: true, title: "moz",
+ isRedirect: true, uri: "http://mail.foo.com/redirect", lastVisit: jan11_800,
+ transType: PlacesUtils.history.TRANSITION_LINK},
+
+ // Test subdomain inclued at the leading time edge
+ {isInQuery: true, isVisit: true, isDetails: true,
+ uri: "http://mail.foo.com/yiihah", title: "moz", lastVisit: jan6_815},
+
+ // Test www. style URI is included, with an annotation
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://www.foo.com/yiihah", annoName: goodAnnoName, annoVal: val,
+ lastVisit: jan7_800, title: "moz"},
+
+ // Test https protocol
+ {isInQuery: true, isVisit: true, isDetails: true, title: "moz",
+ uri: "https://foo.com/", lastVisit: jan15_2045},
+
+ // Test begin edge of time
+ {isInQuery: true, isVisit: true, isDetails: true, title: "moz mozilla",
+ uri: "https://foo.com/begin.html", lastVisit: beginTime},
+
+ // Test end edge of time
+ {isInQuery: true, isVisit: true, isDetails: true, title: "moz mozilla",
+ uri: "https://foo.com/end.html", lastVisit: endTime},
+
+ // Test an image link, with annotations
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ title: "mozzie the dino", uri: "https://foo.com/mozzie.png",
+ annoName: goodAnnoName, annoVal: val, lastVisit: jan14_2130},
+
+ // Begin the invalid queries: Test too early
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://foo.com/tooearly.php", lastVisit: jan6_700},
+
+ // Test Bad Annotation
+ {isInQuery: false, isVisit:true, isDetails: true, isPageAnnotation: true,
+ title: "moz", uri: "http://foo.com/badanno.htm", lastVisit: jan12_1730,
+ annoName: badAnnoName, annoVal: val},
+
+ // Test bad URI
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://somefoo.com/justwrong.htm", lastVisit: jan11_800},
+
+ // Test afterward, one to update
+ {isInQuery: false, isVisit:true, isDetails: true, title: "changeme",
+ uri: "http://foo.com/changeme1.htm", lastVisit: jan12_1730},
+
+ // Test invalid title
+ {isInQuery: false, isVisit:true, isDetails: true, title: "changeme2",
+ uri: "http://foo.com/changeme2.htm", lastVisit: jan7_800},
+
+ // Test changing the lastVisit
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://foo.com/changeme3.htm", lastVisit: dec27_800}];
+
+/**
+ * This test will test a Query using several terms and do a bit of negative
+ * testing for items that should be ignored while querying over history.
+ * The Query:WHERE absoluteTime(matches) AND searchTerms AND URI
+ * AND annotationIsNot(match) GROUP BY Domain, Day SORT BY uri,ascending
+ * excludeITems(should be ignored)
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_abstime_annotation_domain()
+{
+ // Initialize database
+ yield task_populateDB(testData);
+
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.beginTime = beginTime;
+ query.endTime = endTime;
+ query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.searchTerms = "moz";
+ query.domain = "foo.com";
+ query.domainIsHost = false;
+ query.annotation = "text/foo";
+ query.annotationIsNot = true;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+ // The next two options should be ignored
+ // can't use this one, breaks test - bug 419779
+ // options.excludeItems = true;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ // Ensure the result set is correct
+ compareArrayToResult(testData, root);
+
+ // Make some changes to the result set
+ // Let's add something first
+ var addItem = [{isInQuery: true, isVisit: true, isDetails: true, title: "moz",
+ uri: "http://www.foo.com/i-am-added.html", lastVisit: jan11_800}];
+ yield task_populateDB(addItem);
+ do_print("Adding item foo.com/i-am-added.html");
+ do_check_eq(isInResult(addItem, root), true);
+
+ // Let's update something by title
+ var change1 = [{isDetails: true, uri: "http://foo.com/changeme1",
+ lastVisit: jan12_1730, title: "moz moz mozzie"}];
+ yield task_populateDB(change1);
+ do_print("LiveUpdate by changing title");
+ do_check_eq(isInResult(change1, root), true);
+
+ // Let's update something by annotation
+ // Updating a page by removing an annotation does not cause it to join this
+ // query set. I tend to think that it should cause that page to join this
+ // query set, because this visit fits all theother specified criteria once the
+ // annotation is removed. Uncommenting this will fail the test.
+ // Bug 424050
+ /* var change2 = [{isPageAnnotation: true, uri: "http://foo.com/badannotaion.html",
+ annoName: "text/mozilla", annoVal: "test"}];
+ yield task_populateDB(change2);
+ do_print("LiveUpdate by removing annotation");
+ do_check_eq(isInResult(change2, root), true);*/
+
+ // Let's update by adding a visit in the time range for an existing URI
+ var change3 = [{isDetails: true, uri: "http://foo.com/changeme3.htm",
+ title: "moz", lastVisit: jan15_2045}];
+ yield task_populateDB(change3);
+ do_print("LiveUpdate by adding visit within timerange");
+ do_check_eq(isInResult(change3, root), true);
+
+ // And delete something from the result set - using annotation
+ // Once again, bug 424050 prevents this from passing
+ /* var change4 = [{isPageAnnotation: true, uri: "ftp://foo.com/ftp",
+ annoVal: "test", annoName: badAnnoName}];
+ yield task_populateDB(change4);
+ do_print("LiveUpdate by deleting item from set by adding annotation");
+ do_check_eq(isInResult(change4, root), false);*/
+
+ // Delete something by changing the title
+ var change5 = [{isDetails: true, uri: "http://foo.com/end.html", title: "deleted"}];
+ yield task_populateDB(change5);
+ do_print("LiveUpdate by deleting item by changing title");
+ do_check_eq(isInResult(change5, root), false);
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js b/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js
new file mode 100644
index 000000000..145d2cb59
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_abstime-annotation-uri.js
@@ -0,0 +1,162 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const DAY_MSEC = 86400000;
+const MIN_MSEC = 60000;
+const HOUR_MSEC = 3600000;
+// Jan 6 2008 at 8am is our begin edge of the query
+var beginTimeDate = new Date(2008, 0, 6, 8, 0, 0, 0);
+// Jan 15 2008 at 9:30pm is our ending edge of the query
+var endTimeDate = new Date(2008, 0, 15, 21, 30, 0, 0);
+
+// These as millisecond values
+var beginTime = beginTimeDate.getTime();
+var endTime = endTimeDate.getTime();
+
+// Some range dates inside our query - mult by 1000 to convert to PRTIME
+var jan7_800 = (beginTime + DAY_MSEC) * 1000;
+var jan6_815 = (beginTime + (MIN_MSEC * 15)) * 1000;
+var jan11_800 = (beginTime + (DAY_MSEC * 5)) * 1000;
+var jan14_2130 = (endTime - DAY_MSEC) * 1000;
+var jan15_2045 = (endTime - (MIN_MSEC * 45)) * 1000;
+var jan12_1730 = (endTime - (DAY_MSEC * 3) - (HOUR_MSEC*4)) * 1000;
+
+// Dates outside our query - mult by 1000 to convert to PRTIME
+var jan6_700 = (beginTime - HOUR_MSEC) * 1000;
+var jan5_800 = (beginTime - DAY_MSEC) * 1000;
+var dec27_800 = (beginTime - (DAY_MSEC * 10)) * 1000;
+var jan15_2145 = (endTime + (MIN_MSEC * 15)) * 1000;
+var jan16_2130 = (endTime + (DAY_MSEC)) * 1000;
+var jan25_2130 = (endTime + (DAY_MSEC * 10)) * 1000;
+
+// So that we can easily use these too, convert them to PRTIME
+beginTime *= 1000;
+endTime *= 1000;
+
+/**
+ * Array of objects to build our test database
+ */
+var goodAnnoName = "moz-test-places/testing123";
+var val = "test";
+var badAnnoName = "text/foo";
+
+// The test data for our database, note that the ordering of the results that
+// will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+// see compareArrayToResult in head_queries.js for more info.
+var testData = [
+
+ // Test flat domain with annotation
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://foo.com/", annoName: goodAnnoName, annoVal: val,
+ lastVisit: jan14_2130, title: "moz"},
+
+ // Begin the invalid queries:
+ // Test www. style URI is not included, with an annotation
+ {isInQuery: false, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://www.foo.com/yiihah", annoName: goodAnnoName, annoVal: val,
+ lastVisit: jan7_800, title: "moz"},
+
+ // Test subdomain not inclued at the leading time edge
+ {isInQuery: false, isVisit: true, isDetails: true,
+ uri: "http://mail.foo.com/yiihah", title: "moz", lastVisit: jan6_815},
+
+ // Test https protocol
+ {isInQuery: false, isVisit: true, isDetails: true, title: "moz",
+ uri: "https://foo.com/", lastVisit: jan15_2045},
+
+ // Test ftp protocol
+ {isInQuery: false, isVisit: true, isDetails: true,
+ uri: "ftp://foo.com/ftp", lastVisit: jan12_1730,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo"},
+
+ // Test too early
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://foo.com/tooearly.php", lastVisit: jan6_700},
+
+ // Test Bad Annotation
+ {isInQuery: false, isVisit:true, isDetails: true, isPageAnnotation: true,
+ title: "moz", uri: "http://foo.com/badanno.htm", lastVisit: jan12_1730,
+ annoName: badAnnoName, annoVal: val},
+
+ // Test afterward, one to update
+ {isInQuery: false, isVisit:true, isDetails: true, title: "changeme",
+ uri: "http://foo.com/changeme1.htm", lastVisit: jan12_1730},
+
+ // Test invalid title
+ {isInQuery: false, isVisit:true, isDetails: true, title: "changeme2",
+ uri: "http://foo.com/changeme2.htm", lastVisit: jan7_800},
+
+ // Test changing the lastVisit
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://foo.com/changeme3.htm", lastVisit: dec27_800}];
+
+/**
+ * This test will test a Query using several terms and do a bit of negative
+ * testing for items that should be ignored while querying over history.
+ * The Query:WHERE absoluteTime(matches) AND searchTerms AND URI
+ * AND annotationIsNot(match) GROUP BY Domain, Day SORT BY uri,ascending
+ * excludeITems(should be ignored)
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_abstime_annotation_uri()
+{
+ // Initialize database
+ yield task_populateDB(testData);
+
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.beginTime = beginTime;
+ query.endTime = endTime;
+ query.beginTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.endTimeReference = PlacesUtils.history.TIME_RELATIVE_EPOCH;
+ query.searchTerms = "moz";
+ query.uri = uri("http://foo.com");
+ query.annotation = "text/foo";
+ query.annotationIsNot = true;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+ // The next two options should be ignored
+ // can't use this one, breaks test - bug 419779
+ // options.excludeItems = true;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ // Ensure the result set is correct
+ compareArrayToResult(testData, root);
+
+ // live update.
+ do_print("change title");
+ var change1 = [{isDetails: true, uri:"http://foo.com/",
+ title: "mo"}, ];
+ yield task_populateDB(change1);
+ do_check_false(isInResult({uri: "http://foo.com/"}, root));
+
+ var change2 = [{isDetails: true, uri:"http://foo.com/",
+ title: "moz", lastvisit: endTime}, ];
+ yield task_populateDB(change2);
+ dump_table("moz_places");
+ do_check_false(isInResult({uri: "http://foo.com/"}, root));
+
+ // Let's delete something from the result set - using annotation
+ var change3 = [{isPageAnnotation: true,
+ uri: "http://foo.com/",
+ annoName: badAnnoName, annoVal: "test"}];
+ yield task_populateDB(change3);
+ do_print("LiveUpdate by removing annotation");
+ do_check_false(isInResult({uri: "http://foo.com/"}, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_async.js b/toolkit/components/places/tests/queries/test_async.js
new file mode 100644
index 000000000..0ec99f8fc
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_async.js
@@ -0,0 +1,371 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [
+ {
+ desc: "nsNavHistoryFolderResultNode: Basic test, asynchronously open and " +
+ "close container with a single child",
+
+ loading: function (node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ },
+
+ opened: function (node, newState, oldState) {
+ this.checkStateChanged("opened", 1);
+ this.checkState("loading", 1);
+ this.checkArgs("opened", node, oldState, node.STATE_LOADING);
+
+ print("Checking node children");
+ compareArrayToResult(this.data, node);
+
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ closed: function (node, newState, oldState) {
+ this.checkStateChanged("closed", 1);
+ this.checkState("opened", 1);
+ this.checkArgs("closed", node, oldState, node.STATE_OPENED);
+ this.success();
+ }
+ },
+
+ {
+ desc: "nsNavHistoryFolderResultNode: After async open and no changes, " +
+ "second open should be synchronous",
+
+ loading: function (node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkState("closed", 0);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ },
+
+ opened: function (node, newState, oldState) {
+ let cnt = this.checkStateChanged("opened", 1, 2);
+ let expectOldState = cnt === 1 ? node.STATE_LOADING : node.STATE_CLOSED;
+ this.checkArgs("opened", node, oldState, expectOldState);
+
+ print("Checking node children");
+ compareArrayToResult(this.data, node);
+
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ closed: function (node, newState, oldState) {
+ let cnt = this.checkStateChanged("closed", 1, 2);
+ this.checkArgs("closed", node, oldState, node.STATE_OPENED);
+
+ switch (cnt) {
+ case 1:
+ node.containerOpen = true;
+ break;
+ case 2:
+ this.success();
+ break;
+ }
+ }
+ },
+
+ {
+ desc: "nsNavHistoryFolderResultNode: After closing container in " +
+ "loading(), opened() should not be called",
+
+ loading: function (node, newState, oldState) {
+ this.checkStateChanged("loading", 1);
+ this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
+ print("Closing container");
+ node.containerOpen = false;
+ },
+
+ opened: function (node, newState, oldState) {
+ do_throw("opened should not be called");
+ },
+
+ closed: function (node, newState, oldState) {
+ this.checkStateChanged("closed", 1);
+ this.checkState("loading", 1);
+ this.checkArgs("closed", node, oldState, node.STATE_LOADING);
+ this.success();
+ }
+ }
+];
+
+
+/**
+ * Instances of this class become the prototypes of the test objects above.
+ * Each test can therefore use the methods of this class, or they can override
+ * them if they want. To run a test, call setup() and then run().
+ */
+function Test() {
+ // This maps a state name to the number of times it's been observed.
+ this.stateCounts = {};
+ // Promise object resolved when the next test can be run.
+ this.deferNextTest = Promise.defer();
+}
+
+Test.prototype = {
+ /**
+ * Call this when an observer observes a container state change to sanity
+ * check the arguments.
+ *
+ * @param aNewState
+ * The name of the new state. Used only for printing out helpful info.
+ * @param aNode
+ * The node argument passed to containerStateChanged.
+ * @param aOldState
+ * The old state argument passed to containerStateChanged.
+ * @param aExpectOldState
+ * The expected old state.
+ */
+ checkArgs: function (aNewState, aNode, aOldState, aExpectOldState) {
+ print("Node passed on " + aNewState + " should be result.root");
+ do_check_eq(this.result.root, aNode);
+ print("Old state passed on " + aNewState + " should be " + aExpectOldState);
+
+ // aOldState comes from xpconnect and will therefore be defined. It may be
+ // zero, though, so use strict equality just to make sure aExpectOldState is
+ // also defined.
+ do_check_true(aOldState === aExpectOldState);
+ },
+
+ /**
+ * Call this when an observer observes a container state change. It registers
+ * the state change and ensures that it has been observed the given number
+ * of times. See checkState for parameter explanations.
+ *
+ * @return The number of times aState has been observed, including the new
+ * observation.
+ */
+ checkStateChanged: function (aState, aExpectedMin, aExpectedMax) {
+ print(aState + " state change observed");
+ if (!this.stateCounts.hasOwnProperty(aState))
+ this.stateCounts[aState] = 0;
+ this.stateCounts[aState]++;
+ return this.checkState(aState, aExpectedMin, aExpectedMax);
+ },
+
+ /**
+ * Ensures that the state has been observed the given number of times.
+ *
+ * @param aState
+ * The name of the state.
+ * @param aExpectedMin
+ * The state must have been observed at least this number of times.
+ * @param aExpectedMax
+ * The state must have been observed at most this number of times.
+ * This parameter is optional. If undefined, it's set to
+ * aExpectedMin.
+ * @return The number of times aState has been observed, including the new
+ * observation.
+ */
+ checkState: function (aState, aExpectedMin, aExpectedMax) {
+ let cnt = this.stateCounts[aState] || 0;
+ if (aExpectedMax === undefined)
+ aExpectedMax = aExpectedMin;
+ if (aExpectedMin === aExpectedMax) {
+ print(aState + " should be observed only " + aExpectedMin +
+ " times (actual = " + cnt + ")");
+ }
+ else {
+ print(aState + " should be observed at least " + aExpectedMin +
+ " times and at most " + aExpectedMax + " times (actual = " +
+ cnt + ")");
+ }
+ do_check_true(cnt >= aExpectedMin && cnt <= aExpectedMax);
+ return cnt;
+ },
+
+ /**
+ * Asynchronously opens the root of the test's result.
+ */
+ openContainer: function () {
+ // Set up the result observer. It delegates to this object's callbacks and
+ // wraps them in a try-catch so that errors don't get eaten.
+ let self = this;
+ this.observer = {
+ containerStateChanged: function (container, oldState, newState) {
+ print("New state passed to containerStateChanged() should equal the " +
+ "container's current state");
+ do_check_eq(newState, container.state);
+
+ try {
+ switch (newState) {
+ case Ci.nsINavHistoryContainerResultNode.STATE_LOADING:
+ self.loading(container, newState, oldState);
+ break;
+ case Ci.nsINavHistoryContainerResultNode.STATE_OPENED:
+ self.opened(container, newState, oldState);
+ break;
+ case Ci.nsINavHistoryContainerResultNode.STATE_CLOSED:
+ self.closed(container, newState, oldState);
+ break;
+ default:
+ do_throw("Unexpected new state! " + newState);
+ }
+ }
+ catch (err) {
+ do_throw(err);
+ }
+ },
+ };
+ this.result.addObserver(this.observer, false);
+
+ print("Opening container");
+ this.result.root.containerOpen = true;
+ },
+
+ /**
+ * Starts the test and returns a promise resolved when the test completes.
+ */
+ run: function () {
+ this.openContainer();
+ return this.deferNextTest.promise;
+ },
+
+ /**
+ * This must be called before run(). It adds a bookmark and sets up the
+ * test's result. Override if need be.
+ */
+ setup: function*() {
+ // Populate the database with different types of bookmark items.
+ this.data = DataHelper.makeDataArray([
+ { type: "bookmark" },
+ { type: "separator" },
+ { type: "folder" },
+ { type: "bookmark", uri: "place:terms=foo" }
+ ]);
+ yield task_populateDB(this.data);
+
+ // Make a query.
+ this.query = PlacesUtils.history.getNewQuery();
+ this.query.setFolders([DataHelper.defaults.bookmark.parent], 1);
+ this.opts = PlacesUtils.history.getNewQueryOptions();
+ this.opts.asyncEnabled = true;
+ this.result = PlacesUtils.history.executeQuery(this.query, this.opts);
+ },
+
+ /**
+ * Call this when the test has succeeded. It cleans up resources and starts
+ * the next test.
+ */
+ success: function () {
+ this.result.removeObserver(this.observer);
+
+ // Resolve the promise object that indicates that the next test can be run.
+ this.deferNextTest.resolve();
+ }
+};
+
+/**
+ * This makes it a little bit easier to use the functions of head_queries.js.
+ */
+var DataHelper = {
+ defaults: {
+ bookmark: {
+ parent: PlacesUtils.bookmarks.unfiledBookmarksFolder,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ uri: "http://example.com/",
+ title: "test bookmark"
+ },
+
+ folder: {
+ parent: PlacesUtils.bookmarks.unfiledBookmarksFolder,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "test folder"
+ },
+
+ separator: {
+ parent: PlacesUtils.bookmarks.unfiledBookmarksFolder,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ }
+ },
+
+ /**
+ * Converts an array of simple bookmark item descriptions to the more verbose
+ * format required by task_populateDB() in head_queries.js.
+ *
+ * @param aData
+ * An array of objects, each of which describes a bookmark item.
+ * @return An array of objects suitable for passing to populateDB().
+ */
+ makeDataArray: function DH_makeDataArray(aData) {
+ let self = this;
+ return aData.map(function (dat) {
+ let type = dat.type;
+ dat = self._makeDataWithDefaults(dat, self.defaults[type]);
+ switch (type) {
+ case "bookmark":
+ return {
+ isBookmark: true,
+ uri: dat.uri,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: dat.title,
+ isInQuery: true
+ };
+ case "separator":
+ return {
+ isSeparator: true,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: true
+ };
+ case "folder":
+ return {
+ isFolder: true,
+ parentGuid: dat.parentGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: dat.title,
+ isInQuery: true
+ };
+ default:
+ do_throw("Unknown data type when populating DB: " + type);
+ return undefined;
+ }
+ });
+ },
+
+ /**
+ * Returns a copy of aData, except that any properties that are undefined but
+ * defined in aDefaults are set to the corresponding values in aDefaults.
+ *
+ * @param aData
+ * An object describing a bookmark item.
+ * @param aDefaults
+ * An object describing the default bookmark item.
+ * @return A copy of aData with defaults values set.
+ */
+ _makeDataWithDefaults: function DH__makeDataWithDefaults(aData, aDefaults) {
+ let dat = {};
+ for (let [prop, val] of Object.entries(aDefaults)) {
+ dat[prop] = aData.hasOwnProperty(prop) ? aData[prop] : val;
+ }
+ return dat;
+ }
+};
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_async()
+{
+ for (let test of tests) {
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ test.__proto__ = new Test();
+ yield test.setup();
+
+ print("------ Running test: " + test.desc);
+ yield test.run();
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ print("All tests done, exiting");
+});
diff --git a/toolkit/components/places/tests/queries/test_containersQueries_sorting.js b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js
new file mode 100644
index 000000000..ab9f2bf90
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_containersQueries_sorting.js
@@ -0,0 +1,411 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Testing behavior of bug 473157
+ * "Want to sort history in container view without sorting the containers"
+ * and regression bug 488783
+ * Tags list no longer sorted (alphabetized).
+ * This test is for global testing sorting containers queries.
+ */
+
+// Globals and Constants
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bh = hs.QueryInterface(Ci.nsIBrowserHistory);
+var tagging = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+
+var resultTypes = [
+ {value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY, name: "RESULTS_AS_DATE_QUERY"},
+ {value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY, name: "RESULTS_AS_SITE_QUERY"},
+ {value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY, name: "RESULTS_AS_DATE_SITE_QUERY"},
+ {value: Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY, name: "RESULTS_AS_TAG_QUERY"},
+];
+
+var sortingModes = [
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING, name: "SORT_BY_TITLE_ASCENDING"},
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING, name: "SORT_BY_TITLE_DESCENDING"},
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING, name: "SORT_BY_DATE_ASCENDING"},
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING, name: "SORT_BY_DATE_DESCENDING"},
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING, name: "SORT_BY_DATEADDED_ASCENDING"},
+ {value: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING, name: "SORT_BY_DATEADDED_DESCENDING"},
+];
+
+// These pages will be added from newest to oldest and from less visited to most
+// visited.
+var pages = [
+ "http://www.mozilla.org/c/",
+ "http://www.mozilla.org/a/",
+ "http://www.mozilla.org/b/",
+ "http://www.mozilla.com/c/",
+ "http://www.mozilla.com/a/",
+ "http://www.mozilla.com/b/",
+];
+
+var tags = [
+ "mozilla",
+ "Development",
+ "test",
+];
+
+// Test Runner
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback)
+{
+ if (aSequences.length === 0)
+ return 0;
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ var seqEltPtrs = aSequences.map(i => 0);
+
+ var numProds = 0;
+ var done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ var prod = [];
+ for (var i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ var seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0)
+ done = true;
+ }
+ else break;
+ }
+ }
+ return numProds;
+}
+
+/**
+ * Test a query based on passed-in options.
+ *
+ * @param aSequence
+ * array of options we will use to query.
+ */
+function test_query_callback(aSequence) {
+ do_check_eq(aSequence.length, 2);
+ var resultType = aSequence[0];
+ var sortingMode = aSequence[1];
+ print("\n\n*** Testing default sorting for resultType (" + resultType.name + ") and sortingMode (" + sortingMode.name + ")");
+
+ // Skip invalid combinations sorting queries by none.
+ if (resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY &&
+ (sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING ||
+ sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING)) {
+ // This is a bookmark query, we can't sort by visit date.
+ sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE;
+ }
+ if (resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) {
+ // This is an history query, we can't sort by date added.
+ if (sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING ||
+ sortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING)
+ sortingMode.value = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE;
+ }
+
+ // Create a new query with required options.
+ var query = PlacesUtils.history.getNewQuery();
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = resultType.value;
+ options.sortingMode = sortingMode.value;
+
+ // Compare resultset with expectedData.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ if (resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(root,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING);
+ }
+ else
+ check_children_sorting(root, sortingMode.value);
+
+ // Now Check sorting of the first child container.
+ var container = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ container.containerOpen = true;
+
+ if (resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) {
+ // Has more than one level of containers, first we check the sorting of
+ // the first level (site containers), those won't inherit sorting...
+ check_children_sorting(container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING);
+ // ...then we check sorting of the contained urls, we can't inherit sorting
+ // since the above level does not inherit it, so they will be sorted by
+ // title ascending.
+ let innerContainer = container.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ innerContainer.containerOpen = true;
+ check_children_sorting(innerContainer,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING);
+ innerContainer.containerOpen = false;
+ }
+ else if (resultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY) {
+ // Sorting mode for tag contents is hardcoded for now, to allow for faster
+ // duplicates filtering.
+ check_children_sorting(container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ }
+ else
+ check_children_sorting(container, sortingMode.value);
+
+ container.containerOpen = false;
+ root.containerOpen = false;
+
+ test_result_sortingMode_change(result, resultType, sortingMode);
+}
+
+/**
+ * Sets sortingMode on aResult and checks for correct sorting of children.
+ * Containers should not change their sorting, while contained uri nodes should.
+ *
+ * @param aResult
+ * nsINavHistoryResult generated by our query.
+ * @param aResultType
+ * required result type.
+ * @param aOriginalSortingMode
+ * the sorting mode from query's options.
+ */
+function test_result_sortingMode_change(aResult, aResultType, aOriginalSortingMode) {
+ var root = aResult.root;
+ // Now we set sortingMode on the result and check that containers are not
+ // sorted while children are.
+ sortingModes.forEach(function sortingModeChecker(aForcedSortingMode) {
+ print("\n* Test setting sortingMode (" + aForcedSortingMode.name + ") " +
+ "on result with resultType (" + aResultType.name + ") " +
+ "currently sorted as (" + aOriginalSortingMode.name + ")");
+
+ aResult.sortingMode = aForcedSortingMode.value;
+ root.containerOpen = true;
+
+ if (aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(root,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING);
+ }
+ else if (aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY &&
+ (aOriginalSortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING ||
+ aOriginalSortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING)) {
+ // Site containers don't have a good time property to sort by.
+ check_children_sorting(root,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ }
+ else
+ check_children_sorting(root, aOriginalSortingMode.value);
+
+ // Now Check sorting of the first child container.
+ var container = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ container.containerOpen = true;
+
+ if (aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY) {
+ // Has more than one level of containers, first we check the sorting of
+ // the first level (site containers), those won't be sorted...
+ check_children_sorting(container,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING);
+ // ...then we check sorting of the second level of containers, result
+ // will sort them through recursiveSort.
+ let innerContainer = container.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ innerContainer.containerOpen = true;
+ check_children_sorting(innerContainer, aForcedSortingMode.value);
+ innerContainer.containerOpen = false;
+ }
+ else {
+ if (aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY ||
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY ||
+ aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY) {
+ // Date containers are always sorted by date descending.
+ check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ }
+ else if (aResultType.value == Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY &&
+ (aOriginalSortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING ||
+ aOriginalSortingMode.value == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING)) {
+ // Site containers don't have a good time property to sort by.
+ check_children_sorting(root, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE);
+ }
+ else
+ check_children_sorting(root, aOriginalSortingMode.value);
+
+ // Children should always be sorted.
+ check_children_sorting(container, aForcedSortingMode.value);
+ }
+
+ container.containerOpen = false;
+ root.containerOpen = false;
+ });
+}
+
+/**
+ * Test if children of aRootNode are correctly sorted.
+ * @param aRootNode
+ * already opened root node from our query's result.
+ * @param aExpectedSortingMode
+ * The sortingMode we expect results to be.
+ */
+function check_children_sorting(aRootNode, aExpectedSortingMode) {
+ var results = [];
+ print("Found children:");
+ for (let i = 0; i < aRootNode.childCount; i++) {
+ results[i] = aRootNode.getChild(i);
+ print(i + " " + results[i].title);
+ }
+
+ // Helper for case insensitive string comparison.
+ function caseInsensitiveStringComparator(a, b) {
+ var aLC = a.toLowerCase();
+ var bLC = b.toLowerCase();
+ if (aLC < bLC)
+ return -1;
+ if (aLC > bLC)
+ return 1;
+ return 0;
+ }
+
+ // Get a comparator based on expected sortingMode.
+ var comparator;
+ switch (aExpectedSortingMode) {
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_NONE:
+ comparator = function (a, b) {
+ return 0;
+ }
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING:
+ comparator = function (a, b) {
+ return caseInsensitiveStringComparator(a.title, b.title);
+ }
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING:
+ comparator = function (a, b) {
+ return -caseInsensitiveStringComparator(a.title, b.title);
+ }
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING:
+ comparator = function (a, b) {
+ return a.time - b.time;
+ }
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING:
+ comparator = function (a, b) {
+ return b.time - a.time;
+ }
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING:
+ comparator = function (a, b) {
+ return a.dateAdded - b.dateAdded;
+ }
+ break;
+ case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING:
+ comparator = function (a, b) {
+ return b.dateAdded - a.dateAdded;
+ }
+ break;
+ default:
+ do_throw("Unknown sorting type: " + aExpectedSortingMode);
+ }
+
+ // Make an independent copy of the results array and sort it.
+ var sortedResults = results.slice();
+ sortedResults.sort(comparator);
+ // Actually compare returned children with our sorted array.
+ for (let i = 0; i < sortedResults.length; i++) {
+ if (sortedResults[i].title != results[i].title)
+ print(i + " index wrong, expected " + sortedResults[i].title +
+ " found " + results[i].title);
+ do_check_eq(sortedResults[i].title, results[i].title);
+ }
+}
+
+// Main
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_containersQueries_sorting()
+{
+ // Add visits, bookmarks and tags to our database.
+ var timeInMilliseconds = Date.now();
+ var visitCount = 0;
+ var dayOffset = 0;
+ var visits = [];
+ pages.forEach(aPageUrl => visits.push(
+ { isVisit: true,
+ isBookmark: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ uri: aPageUrl,
+ title: aPageUrl,
+ // subtract 5 hours per iteration, to expose more than one day container.
+ lastVisit: (timeInMilliseconds - (18000 * 1000 * dayOffset++)) * 1000,
+ visitCount: visitCount++,
+ isTag: true,
+ tagArray: tags,
+ isInQuery: true }));
+ yield task_populateDB(visits);
+
+ cartProd([resultTypes, sortingModes], test_query_callback);
+});
diff --git a/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js
new file mode 100644
index 000000000..fbbacf6c9
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_history_queries_tags_liveUpdate.js
@@ -0,0 +1,200 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that tags changes are correctly live-updated in a history
+// query.
+
+let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+var gTestData = [
+ {
+ isVisit: true,
+ uri: "http://example.com/1/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example1",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example2",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "example3",
+ },
+];
+
+function newQueryWithOptions()
+{
+ return [ PlacesUtils.history.getNewQuery(),
+ PlacesUtils.history.getNewQueryOptions() ];
+}
+
+function testQueryContents(aQuery, aOptions, aCallback)
+{
+ let root = PlacesUtils.history.executeQuery(aQuery, aOptions).root;
+ root.containerOpen = true;
+ aCallback(root);
+ root.containerOpen = false;
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_initialize()
+{
+ yield task_populateDB(gTestData);
+});
+
+add_task(function pages_query()
+{
+ let [query, options] = newQueryWithOptions();
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, null);
+ }
+ });
+});
+
+add_task(function visits_query()
+{
+ let [query, options] = newQueryWithOptions();
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, null);
+ }
+ });
+});
+
+add_task(function bookmarks_query()
+{
+ let [query, options] = newQueryWithOptions();
+ query.setFolders([PlacesUtils.unfiledBookmarksFolderId], 1);
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, null);
+ }
+ });
+});
+
+add_task(function pages_searchterm_query()
+{
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, null);
+ }
+ });
+});
+
+add_task(function visits_searchterm_query()
+{
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.tags, null);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, "test-tag");
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ do_check_eq(node.tags, null);
+ }
+ });
+});
+
+add_task(function pages_searchterm_is_tag_query()
+{
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "test-tag";
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([], root);
+ gTestData.forEach(function (data) {
+ let uri = NetUtil.newURI(data.uri);
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ data.title);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ compareArrayToResult([data], root);
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ compareArrayToResult([], root);
+ });
+ });
+});
+
+add_task(function visits_searchterm_is_tag_query()
+{
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "test-tag";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ testQueryContents(query, options, function (root) {
+ compareArrayToResult([], root);
+ gTestData.forEach(function (data) {
+ let uri = NetUtil.newURI(data.uri);
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ data.title);
+ PlacesUtils.tagging.tagURI(uri, ["test-tag"]);
+ compareArrayToResult([data], root);
+ PlacesUtils.tagging.untagURI(uri, ["test-tag"]);
+ compareArrayToResult([], root);
+ });
+ });
+});
diff --git a/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js
new file mode 100644
index 000000000..eec87fe0e
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_history_queries_titles_liveUpdate.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test ensures that tags changes are correctly live-updated in a history
+// query.
+
+let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+var gTestData = [
+ {
+ isVisit: true,
+ uri: "http://example.com/1/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title1",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title2",
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3/",
+ lastVisit: newTimeInMicroseconds(),
+ isInQuery: true,
+ title: "title3",
+ },
+];
+
+function searchNodeHavingUrl(aRoot, aUrl) {
+ for (let i = 0; i < aRoot.childCount; i++) {
+ if (aRoot.getChild(i).uri == aUrl) {
+ return aRoot.getChild(i);
+ }
+ }
+ return undefined;
+}
+
+function newQueryWithOptions()
+{
+ return [ PlacesUtils.history.getNewQuery(),
+ PlacesUtils.history.getNewQueryOptions() ];
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* pages_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ do_check_eq(node.title, gTestData[i].title);
+ let uri = NetUtil.newURI(node.uri);
+ yield PlacesTestUtils.addVisits({uri: uri, title: "changedTitle"});
+ do_check_eq(node.title, "changedTitle");
+ yield PlacesTestUtils.addVisits({uri: uri, title: gTestData[i].title});
+ do_check_eq(node.title, gTestData[i].title);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* visits_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+
+ for (let testData of gTestData) {
+ let uri = NetUtil.newURI(testData.uri);
+ let node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, testData.title);
+ yield PlacesTestUtils.addVisits({uri: uri, title: "changedTitle"});
+ node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, "changedTitle");
+ yield PlacesTestUtils.addVisits({uri: uri, title: testData.title});
+ node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, testData.title);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* pages_searchterm_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ let uri = NetUtil.newURI(node.uri);
+ do_check_eq(node.title, gTestData[i].title);
+ yield PlacesTestUtils.addVisits({uri: uri, title: "changedTitle"});
+ do_check_eq(node.title, "changedTitle");
+ yield PlacesTestUtils.addVisits({uri: uri, title: gTestData[i].title});
+ do_check_eq(node.title, gTestData[i].title);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* visits_searchterm_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "example";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ compareArrayToResult([gTestData[0], gTestData[1], gTestData[2]], root);
+ for (let testData of gTestData) {
+ let uri = NetUtil.newURI(testData.uri);
+ let node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, testData.title);
+ yield PlacesTestUtils.addVisits({uri: uri, title: "changedTitle"});
+ node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, "changedTitle");
+ yield PlacesTestUtils.addVisits({uri: uri, title: testData.title});
+ node = searchNodeHavingUrl(root, testData.uri);
+ do_check_eq(node.title, testData.title);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* pages_searchterm_is_title_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "match";
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult([], root);
+ for (let data of gTestData) {
+ let uri = NetUtil.newURI(data.uri);
+ let origTitle = data.title;
+ data.title = "match";
+ yield PlacesTestUtils.addVisits({ uri: uri, title: data.title,
+ visitDate: data.lastVisit });
+ compareArrayToResult([data], root);
+ data.title = origTitle;
+ yield PlacesTestUtils.addVisits({ uri: uri, title: data.title,
+ visitDate: data.lastVisit });
+ compareArrayToResult([], root);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* visits_searchterm_is_title_query()
+{
+ yield task_populateDB(gTestData);
+
+ let [query, options] = newQueryWithOptions();
+ query.searchTerms = "match";
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult([], root);
+ for (let data of gTestData) {
+ let uri = NetUtil.newURI(data.uri);
+ let origTitle = data.title;
+ data.title = "match";
+ yield PlacesTestUtils.addVisits({ uri: uri, title: data.title,
+ visitDate: data.lastVisit });
+ compareArrayToResult([data], root);
+ data.title = origTitle;
+ yield PlacesTestUtils.addVisits({ uri: uri, title: data.title,
+ visitDate: data.lastVisit });
+ compareArrayToResult([], root);
+ }
+
+ root.containerOpen = false;
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/queries/test_onlyBookmarked.js b/toolkit/components/places/tests/queries/test_onlyBookmarked.js
new file mode 100644
index 000000000..45704c109
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_onlyBookmarked.js
@@ -0,0 +1,128 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The next thing we do is create a test database for us. Each test runs with
+ * its own database (tail_queries.js will clear it after the run). Take a look
+ * at the queryData object in head_queries.js, and you'll see how this object
+ * works. You can call it anything you like, but I usually use "testData".
+ * I'll include a couple of example entries in the database.
+ *
+ * Note that to use the compareArrayToResult API, you need to put all the
+ * results that are in the query set at the top of the testData list, and those
+ * results MUST be in the same sort order as the items in the resulting query.
+ */
+
+var testData = [
+ // Add a bookmark that should be in the results
+ { isBookmark: true,
+ uri: "http://bookmarked.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: true },
+
+ // Add a bookmark that should not be in the results
+ { isBookmark: true,
+ uri: "http://bookmarked-elsewhere.com/",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: false },
+
+ // Add an un-bookmarked visit
+ { isVisit: true,
+ uri: "http://notbookmarked.com/",
+ isInQuery: false }
+];
+
+
+/**
+ * run_test is where the magic happens. This is automatically run by the test
+ * harness. It is where you do the work of creating the query, running it, and
+ * playing with the result set.
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_onlyBookmarked()
+{
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(testData);
+
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.toolbarFolderId], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_HISTORY;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ // You can use this to compare the data in the array with the result set,
+ // if the array's isInQuery: true items are sorted the same way as the result
+ // set.
+ do_print("begin first test");
+ compareArrayToResult(testData, root);
+ do_print("end first test");
+
+ // Test live-update
+ var liveUpdateTestData = [
+ // Add a bookmark that should show up
+ { isBookmark: true,
+ uri: "http://bookmarked2.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: true },
+
+ // Add a bookmark that should not show up
+ { isBookmark: true,
+ uri: "http://bookmarked-elsewhere2.com/",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ isInQuery: false }
+ ];
+
+ yield task_populateDB(liveUpdateTestData); // add to the db
+
+ // add to the test data
+ testData.push(liveUpdateTestData[0]);
+ testData.push(liveUpdateTestData[1]);
+
+ // re-query and test
+ do_print("begin live-update test");
+ compareArrayToResult(testData, root);
+ do_print("end live-update test");
+/*
+ // we are actually not updating during a batch.
+ // see bug 432706 for details.
+
+ // Here's a batch update
+ var updateBatch = {
+ runBatched: function (aUserData) {
+ liveUpdateTestData[0].uri = "http://bookmarked3.com";
+ liveUpdateTestData[1].uri = "http://bookmarked-elsewhere3.com";
+ populateDB(liveUpdateTestData);
+ testData.push(liveUpdateTestData[0]);
+ testData.push(liveUpdateTestData[1]);
+ }
+ };
+
+ PlacesUtils.history.runInBatchMode(updateBatch, null);
+
+ // re-query and test
+ do_print("begin batched test");
+ compareArrayToResult(testData, root);
+ do_print("end batched test");
+*/
+ // Close the container when finished
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_queryMultipleFolder.js b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js
new file mode 100644
index 000000000..694728a43
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_queryMultipleFolder.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_queryMultipleFolders() {
+ // adding bookmarks in the folders
+ let folderIds = [];
+ let bookmarkIds = [];
+ for (let i = 0; i < 3; ++i) {
+ let folder = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: `Folder${i}`
+ });
+ folderIds.push(yield PlacesUtils.promiseItemId(folder.guid));
+
+ for (let j = 0; j < 7; ++j) {
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: (yield PlacesUtils.promiseItemGuid(folderIds[i])),
+ url: `http://Bookmark${i}_${j}.com`,
+ title: ""
+ });
+ bookmarkIds.push(yield PlacesUtils.promiseItemId(bm.guid));
+ }
+ }
+
+ // using queryStringToQueries
+ let query = {};
+ let options = {};
+ let maxResults = 20;
+ let queryString = "place:" + folderIds.map((id) => {
+ return "folder=" + id;
+ }).join('&') + "&sort=5&maxResults=" + maxResults;
+ PlacesUtils.history.queryStringToQueries(queryString, query, {}, options);
+ let rootNode = PlacesUtils.history.executeQuery(query.value[0], options.value).root;
+ rootNode.containerOpen = true;
+ let resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkIds[i], node.itemId, node.uri);
+ }
+ rootNode.containerOpen = false;
+
+ // using getNewQuery and getNewQueryOptions
+ query = PlacesUtils.history.getNewQuery();
+ options = PlacesUtils.history.getNewQueryOptions();
+ query.setFolders(folderIds, folderIds.length);
+ options.sortingMode = options.SORT_BY_URI_ASCENDING;
+ options.maxResults = maxResults;
+ rootNode = PlacesUtils.history.executeQuery(query, options).root;
+ rootNode.containerOpen = true;
+ resultLength = rootNode.childCount;
+ Assert.equal(resultLength, maxResults);
+ for (let i = 0; i < resultLength; ++i) {
+ let node = rootNode.getChild(i);
+ Assert.equal(bookmarkIds[i], node.itemId, node.uri);
+ }
+ rootNode.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_querySerialization.js b/toolkit/components/places/tests/queries/test_querySerialization.js
new file mode 100644
index 000000000..24cf8aa9b
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_querySerialization.js
@@ -0,0 +1,797 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests Places query serialization. Associated bug is
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=370197
+ *
+ * The simple idea behind this test is to try out different combinations of
+ * query switches and ensure that queries are the same before serialization
+ * as they are after de-serialization.
+ *
+ * In the code below, "switch" refers to a query option -- "option" in a broad
+ * sense, not nsINavHistoryQueryOptions specifically (which is why we refer to
+ * them as switches, not options). Both nsINavHistoryQuery and
+ * nsINavHistoryQueryOptions allow you to specify switches that affect query
+ * strings. nsINavHistoryQuery instances have attributes hasBeginTime,
+ * hasEndTime, hasSearchTerms, and so on. nsINavHistoryQueryOptions instances
+ * have attributes sortingMode, resultType, excludeItems, etc.
+ *
+ * Ideally we would like to test all 2^N subsets of switches, where N is the
+ * total number of switches; switches might interact in erroneous or other ways
+ * we do not expect. However, since N is large (21 at this time), that's
+ * impractical for a single test in a suite.
+ *
+ * Instead we choose all possible subsets of a certain, smaller size. In fact
+ * we begin by choosing CHOOSE_HOW_MANY_SWITCHES_LO and ramp up to
+ * CHOOSE_HOW_MANY_SWITCHES_HI.
+ *
+ * There are two more wrinkles. First, for some switches we'd like to be able to
+ * test multiple values. For example, it seems like a good idea to test both an
+ * empty string and a non-empty string for switch nsINavHistoryQuery.searchTerms.
+ * When switches have more than one value for a test run, we use the Cartesian
+ * product of their values to generate all possible combinations of values.
+ *
+ * Second, we need to also test serialization of multiple nsINavHistoryQuery
+ * objects at once. To do this, we remember the previous NUM_MULTIPLE_QUERIES
+ * queries we tested individually and then serialize them together. We do this
+ * each time we test an individual query. Thus the set of queries we test
+ * together loses one query and gains another each time.
+ *
+ * To summarize, here's how this test works:
+ *
+ * - For n = CHOOSE_HOW_MANY_SWITCHES_LO to CHOOSE_HOW_MANY_SWITCHES_HI:
+ * - From the total set of switches choose all possible subsets of size n.
+ * For each of those subsets s:
+ * - Collect the test runs of each switch in subset s and take their
+ * Cartesian product. For each sequence in the product:
+ * - Create nsINavHistoryQuery and nsINavHistoryQueryOptions objects
+ * with the chosen switches and test run values.
+ * - Serialize the query.
+ * - De-serialize and ensure that the de-serialized query objects equal
+ * the originals.
+ * - For each of the previous NUM_MULTIPLE_QUERIES
+ * nsINavHistoryQueryOptions objects o we created:
+ * - Serialize the previous NUM_MULTIPLE_QUERIES nsINavHistoryQuery
+ * objects together with o.
+ * - De-serialize and ensure that the de-serialized query objects
+ * equal the originals.
+ */
+
+const CHOOSE_HOW_MANY_SWITCHES_LO = 1;
+const CHOOSE_HOW_MANY_SWITCHES_HI = 2;
+
+const NUM_MULTIPLE_QUERIES = 2;
+
+// The switches are represented by objects below, in arrays querySwitches and
+// queryOptionSwitches. Use them to set up test runs.
+//
+// Some switches have special properties (where noted), but all switches must
+// have the following properties:
+//
+// matches: A function that takes two nsINavHistoryQuery objects (in the case
+// of nsINavHistoryQuery switches) or two nsINavHistoryQueryOptions
+// objects (for nsINavHistoryQueryOptions switches) and returns true
+// if the values of the switch in the two objects are equal. This is
+// the foundation of how we determine if two queries are equal.
+// runs: An array of functions. Each function takes an nsINavHistoryQuery
+// object and an nsINavHistoryQueryOptions object. The functions
+// should set the attributes of one of the two objects as appropriate
+// to their switches. This is how switch values are set for each test
+// run.
+//
+// The following properties are optional:
+//
+// desc: An informational string to print out during runs when the switch
+// is chosen. Hopefully helpful if the test fails.
+
+// nsINavHistoryQuery switches
+const querySwitches = [
+ // hasBeginTime
+ {
+ // flag and subswitches are used by the flagSwitchMatches function. Several
+ // of the nsINavHistoryQuery switches (like this one) are really guard flags
+ // that indicate if other "subswitches" are enabled.
+ flag: "hasBeginTime",
+ subswitches: ["beginTime", "beginTimeReference", "absoluteBeginTime"],
+ desc: "nsINavHistoryQuery.hasBeginTime",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.beginTime = Date.now() * 1000;
+ aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.beginTime = Date.now() * 1000;
+ aQuery.beginTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY;
+ }
+ ]
+ },
+ // hasEndTime
+ {
+ flag: "hasEndTime",
+ subswitches: ["endTime", "endTimeReference", "absoluteEndTime"],
+ desc: "nsINavHistoryQuery.hasEndTime",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.endTime = Date.now() * 1000;
+ aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_EPOCH;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.endTime = Date.now() * 1000;
+ aQuery.endTimeReference = Ci.nsINavHistoryQuery.TIME_RELATIVE_TODAY;
+ }
+ ]
+ },
+ // hasSearchTerms
+ {
+ flag: "hasSearchTerms",
+ subswitches: ["searchTerms"],
+ desc: "nsINavHistoryQuery.hasSearchTerms",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.searchTerms = "shrimp and white wine";
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.searchTerms = "";
+ }
+ ]
+ },
+ // hasDomain
+ {
+ flag: "hasDomain",
+ subswitches: ["domain", "domainIsHost"],
+ desc: "nsINavHistoryQuery.hasDomain",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "mozilla.com";
+ aQuery.domainIsHost = false;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "www.mozilla.com";
+ aQuery.domainIsHost = true;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.domain = "";
+ }
+ ]
+ },
+ // hasUri
+ {
+ flag: "hasUri",
+ subswitches: ["uri"],
+ desc: "nsINavHistoryQuery.hasUri",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.uri = uri("http://mozilla.com");
+ },
+ ]
+ },
+ // hasAnnotation
+ {
+ flag: "hasAnnotation",
+ subswitches: ["annotation", "annotationIsNot"],
+ desc: "nsINavHistoryQuery.hasAnnotation",
+ matches: flagSwitchMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.annotation = "bookmarks/toolbarFolder";
+ aQuery.annotationIsNot = false;
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.annotation = "bookmarks/toolbarFolder";
+ aQuery.annotationIsNot = true;
+ }
+ ]
+ },
+ // minVisits
+ {
+ // property is used by function simplePropertyMatches.
+ property: "minVisits",
+ desc: "nsINavHistoryQuery.minVisits",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.minVisits = 0x7fffffff; // 2^31 - 1
+ }
+ ]
+ },
+ // maxVisits
+ {
+ property: "maxVisits",
+ desc: "nsINavHistoryQuery.maxVisits",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.maxVisits = 0x7fffffff; // 2^31 - 1
+ }
+ ]
+ },
+ // onlyBookmarked
+ {
+ property: "onlyBookmarked",
+ desc: "nsINavHistoryQuery.onlyBookmarked",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.onlyBookmarked = true;
+ }
+ ]
+ },
+ // getFolders
+ {
+ desc: "nsINavHistoryQuery.getFolders",
+ matches: function (aQuery1, aQuery2) {
+ var q1Folders = aQuery1.getFolders();
+ var q2Folders = aQuery2.getFolders();
+ if (q1Folders.length !== q2Folders.length)
+ return false;
+ for (let i = 0; i < q1Folders.length; i++) {
+ if (q2Folders.indexOf(q1Folders[i]) < 0)
+ return false;
+ }
+ for (let i = 0; i < q2Folders.length; i++) {
+ if (q1Folders.indexOf(q2Folders[i]) < 0)
+ return false;
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.setFolders([], 0);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setFolders([PlacesUtils.placesRootId], 1);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setFolders([PlacesUtils.placesRootId, PlacesUtils.tagsFolderId], 2);
+ }
+ ]
+ },
+ // tags
+ {
+ desc: "nsINavHistoryQuery.getTags",
+ matches: function (aQuery1, aQuery2) {
+ if (aQuery1.tagsAreNot !== aQuery2.tagsAreNot)
+ return false;
+ var q1Tags = aQuery1.tags;
+ var q2Tags = aQuery2.tags;
+ if (q1Tags.length !== q2Tags.length)
+ return false;
+ for (let i = 0; i < q1Tags.length; i++) {
+ if (q2Tags.indexOf(q1Tags[i]) < 0)
+ return false;
+ }
+ for (let i = 0; i < q2Tags.length; i++) {
+ if (q1Tags.indexOf(q2Tags[i]) < 0)
+ return false;
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [""];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ];
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.tags = [
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ];
+ aQuery.tagsAreNot = true;
+ }
+ ]
+ },
+ // transitions
+ {
+ desc: "tests nsINavHistoryQuery.getTransitions",
+ matches: function (aQuery1, aQuery2) {
+ var q1Trans = aQuery1.getTransitions();
+ var q2Trans = aQuery2.getTransitions();
+ if (q1Trans.length !== q2Trans.length)
+ return false;
+ for (let i = 0; i < q1Trans.length; i++) {
+ if (q2Trans.indexOf(q1Trans[i]) < 0)
+ return false;
+ }
+ for (let i = 0; i < q2Trans.length; i++) {
+ if (q1Trans.indexOf(q2Trans[i]) < 0)
+ return false;
+ }
+ return true;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([], 0);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([Ci.nsINavHistoryService.TRANSITION_DOWNLOAD],
+ 1);
+ },
+ function (aQuery, aQueryOptions) {
+ aQuery.setTransitions([Ci.nsINavHistoryService.TRANSITION_TYPED,
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK], 2);
+ }
+ ]
+ },
+];
+
+// nsINavHistoryQueryOptions switches
+const queryOptionSwitches = [
+ // sortingMode
+ {
+ desc: "nsINavHistoryQueryOptions.sortingMode",
+ matches: function (aOptions1, aOptions2) {
+ if (aOptions1.sortingMode === aOptions2.sortingMode) {
+ switch (aOptions1.sortingMode) {
+ case aOptions1.SORT_BY_ANNOTATION_ASCENDING:
+ case aOptions1.SORT_BY_ANNOTATION_DESCENDING:
+ return aOptions1.sortingAnnotation === aOptions2.sortingAnnotation;
+ }
+ return true;
+ }
+ return false;
+ },
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.sortingMode = aQueryOptions.SORT_BY_DATE_ASCENDING;
+ },
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.sortingMode = aQueryOptions.SORT_BY_ANNOTATION_ASCENDING;
+ aQueryOptions.sortingAnnotation = "bookmarks/toolbarFolder";
+ }
+ ]
+ },
+ // resultType
+ {
+ // property is used by function simplePropertyMatches.
+ property: "resultType",
+ desc: "nsINavHistoryQueryOptions.resultType",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.resultType = aQueryOptions.RESULTS_AS_URI;
+ },
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.resultType = aQueryOptions.RESULTS_AS_FULL_VISIT;
+ }
+ ]
+ },
+ // excludeItems
+ {
+ property: "excludeItems",
+ desc: "nsINavHistoryQueryOptions.excludeItems",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.excludeItems = true;
+ }
+ ]
+ },
+ // excludeQueries
+ {
+ property: "excludeQueries",
+ desc: "nsINavHistoryQueryOptions.excludeQueries",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.excludeQueries = true;
+ }
+ ]
+ },
+ // expandQueries
+ {
+ property: "expandQueries",
+ desc: "nsINavHistoryQueryOptions.expandQueries",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.expandQueries = true;
+ }
+ ]
+ },
+ // includeHidden
+ {
+ property: "includeHidden",
+ desc: "nsINavHistoryQueryOptions.includeHidden",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.includeHidden = true;
+ }
+ ]
+ },
+ // maxResults
+ {
+ property: "maxResults",
+ desc: "nsINavHistoryQueryOptions.maxResults",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.maxResults = 0xffffffff; // 2^32 - 1
+ }
+ ]
+ },
+ // queryType
+ {
+ property: "queryType",
+ desc: "nsINavHistoryQueryOptions.queryType",
+ matches: simplePropertyMatches,
+ runs: [
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_HISTORY;
+ },
+ function (aQuery, aQueryOptions) {
+ aQueryOptions.queryType = aQueryOptions.QUERY_TYPE_UNIFIED;
+ }
+ ]
+ },
+];
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback)
+{
+ if (aSequences.length === 0)
+ return 0;
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ var seqEltPtrs = aSequences.map(i => 0);
+
+ var numProds = 0;
+ var done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ let prod = [];
+ for (let i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ let seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0)
+ done = true;
+ }
+ else break;
+ }
+ }
+ return numProds;
+}
+
+/**
+ * Enumerates all the subsets in aSet of size aHowMany. There are
+ * C(aSet.length, aHowMany) such subsets. aCallback will be passed each subset
+ * as it is generated. Note that aSet and the subsets enumerated are -- even
+ * though they're arrays -- not sequences; the ordering of their elements is not
+ * important. Example:
+ *
+ * choose([1, 2, 3, 4], 2, callback);
+ * // callback is called C(4, 2) = 6 times with the following sets (arrays):
+ * // [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]
+ *
+ * @param aSet
+ * an array from which to choose elements, aSet.length > 0
+ * @param aHowMany
+ * the number of elements to choose, > 0 and <= aSet.length
+ * @return the total number of sets chosen
+ */
+function choose(aSet, aHowMany, aCallback)
+{
+ // ptrs = indices of the elements in aSet we're currently choosing
+ var ptrs = [];
+ for (let i = 0; i < aHowMany; i++) {
+ ptrs.push(i);
+ }
+
+ var numFound = 0;
+ var done = false;
+ while (!done) {
+ numFound++;
+ aCallback(ptrs.map(p => aSet[p]));
+
+ // The next subset to be chosen differs from the current one by just a
+ // single element. Determine which element that is. Advance the "rightmost"
+ // pointer to the "right" by one. If we move past the end of set, move the
+ // next non-adjacent rightmost pointer to the right by one, and reset all
+ // succeeding pointers so that they're adjacent to it. When all pointers
+ // are clustered all the way to the right, we're done.
+
+ // Advance the rightmost pointer.
+ ptrs[ptrs.length - 1]++;
+
+ // The rightmost pointer has gone past the end of set.
+ if (ptrs[ptrs.length - 1] >= aSet.length) {
+ // Find the next rightmost pointer that is not adjacent to the current one.
+ let si = aSet.length - 2; // aSet index
+ let pi = ptrs.length - 2; // ptrs index
+ while (pi >= 0 && ptrs[pi] === si) {
+ pi--;
+ si--;
+ }
+
+ // All pointers are adjacent and clustered all the way to the right.
+ if (pi < 0)
+ done = true;
+ else {
+ // pi = index of rightmost pointer with a gap between it and its
+ // succeeding pointer. Move it right and reset all succeeding pointers
+ // so that they're adjacent to it.
+ ptrs[pi]++;
+ for (let i = 0; i < ptrs.length - pi - 1; i++) {
+ ptrs[i + pi + 1] = ptrs[pi] + i + 1;
+ }
+ }
+ }
+ }
+ return numFound;
+}
+
+/**
+ * Convenience function for nsINavHistoryQuery switches that act as flags. This
+ * is attached to switch objects. See querySwitches array above.
+ *
+ * @param aQuery1
+ * an nsINavHistoryQuery object
+ * @param aQuery2
+ * another nsINavHistoryQuery object
+ * @return true if this switch is the same in both aQuery1 and aQuery2
+ */
+function flagSwitchMatches(aQuery1, aQuery2)
+{
+ if (aQuery1[this.flag] && aQuery2[this.flag]) {
+ for (let p in this.subswitches) {
+ if (p in aQuery1 && p in aQuery2) {
+ if (aQuery1[p] instanceof Ci.nsIURI) {
+ if (!aQuery1[p].equals(aQuery2[p]))
+ return false;
+ }
+ else if (aQuery1[p] !== aQuery2[p])
+ return false;
+ }
+ }
+ }
+ else if (aQuery1[this.flag] || aQuery2[this.flag])
+ return false;
+
+ return true;
+}
+
+/**
+ * Tests if aObj1 and aObj2 are equal. This function is general and may be used
+ * for either nsINavHistoryQuery or nsINavHistoryQueryOptions objects. aSwitches
+ * determines which set of switches is used for comparison. Pass in either
+ * querySwitches or queryOptionSwitches.
+ *
+ * @param aSwitches
+ * determines which set of switches applies to aObj1 and aObj2, either
+ * querySwitches or queryOptionSwitches
+ * @param aObj1
+ * an nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @param aObj2
+ * another nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @return true if aObj1 and aObj2 are equal
+ */
+function queryObjsEqual(aSwitches, aObj1, aObj2)
+{
+ for (let i = 0; i < aSwitches.length; i++) {
+ if (!aSwitches[i].matches(aObj1, aObj2))
+ return false;
+ }
+ return true;
+}
+
+/**
+ * This drives the test runs. See the comment at the top of this file.
+ *
+ * @param aHowManyLo
+ * the size of the switch subsets to start with
+ * @param aHowManyHi
+ * the size of the switch subsets to end with (inclusive)
+ */
+function runQuerySequences(aHowManyLo, aHowManyHi)
+{
+ var allSwitches = querySwitches.concat(queryOptionSwitches);
+ var prevQueries = [];
+ var prevOpts = [];
+
+ // Choose aHowManyLo switches up to aHowManyHi switches.
+ for (let howMany = aHowManyLo; howMany <= aHowManyHi; howMany++) {
+ let numIters = 0;
+ print("CHOOSING " + howMany + " SWITCHES");
+
+ // Choose all subsets of size howMany from allSwitches.
+ choose(allSwitches, howMany, function (chosenSwitches) {
+ print(numIters);
+ numIters++;
+
+ // Collect the runs.
+ // runs = [ [runs from switch 1], ..., [runs from switch howMany] ]
+ var runs = chosenSwitches.map(function (s) {
+ if (s.desc)
+ print(" " + s.desc);
+ return s.runs;
+ });
+
+ // cartProd(runs) => [
+ // [switch 1 run 1, switch 2 run 1, ..., switch howMany run 1 ],
+ // ...,
+ // [switch 1 run 1, switch 2 run 1, ..., switch howMany run N ],
+ // ..., ...,
+ // [switch 1 run N, switch 2 run N, ..., switch howMany run 1 ],
+ // ...,
+ // [switch 1 run N, switch 2 run N, ..., switch howMany run N ],
+ // ]
+ cartProd(runs, function (runSet) {
+ // Create a new query, apply the switches in runSet, and test it.
+ var query = PlacesUtils.history.getNewQuery();
+ var opts = PlacesUtils.history.getNewQueryOptions();
+ for (let i = 0; i < runSet.length; i++) {
+ runSet[i](query, opts);
+ }
+ serializeDeserialize([query], opts);
+
+ // Test the previous NUM_MULTIPLE_QUERIES queries together.
+ prevQueries.push(query);
+ prevOpts.push(opts);
+ if (prevQueries.length >= NUM_MULTIPLE_QUERIES) {
+ // We can serialize multiple nsINavHistoryQuery objects together but
+ // only one nsINavHistoryQueryOptions object with them. So, test each
+ // of the previous NUM_MULTIPLE_QUERIES nsINavHistoryQueryOptions.
+ for (let i = 0; i < prevOpts.length; i++) {
+ serializeDeserialize(prevQueries, prevOpts[i]);
+ }
+ prevQueries.shift();
+ prevOpts.shift();
+ }
+ });
+ });
+ }
+ print("\n");
+}
+
+/**
+ * Serializes the nsINavHistoryQuery objects in aQueryArr and the
+ * nsINavHistoryQueryOptions object aQueryOptions, de-serializes the
+ * serialization, and ensures (using do_check_* functions) that the
+ * de-serialized objects equal the originals.
+ *
+ * @param aQueryArr
+ * an array containing nsINavHistoryQuery objects
+ * @param aQueryOptions
+ * an nsINavHistoryQueryOptions object
+ */
+function serializeDeserialize(aQueryArr, aQueryOptions)
+{
+ var queryStr = PlacesUtils.history.queriesToQueryString(aQueryArr,
+ aQueryArr.length,
+ aQueryOptions);
+ print(" " + queryStr);
+ var queryArr2 = {};
+ var opts2 = {};
+ PlacesUtils.history.queryStringToQueries(queryStr, queryArr2, {}, opts2);
+ queryArr2 = queryArr2.value;
+ opts2 = opts2.value;
+
+ // The two sets of queries cannot be the same if their lengths differ.
+ do_check_eq(aQueryArr.length, queryArr2.length);
+
+ // Although the query serialization code as it is written now practically
+ // ensures that queries appear in the query string in the same order they
+ // appear in both the array to be serialized and the array resulting from
+ // de-serialization, the interface does not guarantee any ordering. So, for
+ // each query in aQueryArr, find its equivalent in queryArr2 and delete it
+ // from queryArr2. If queryArr2 is empty after looping through aQueryArr,
+ // the two sets of queries are equal.
+ for (let i = 0; i < aQueryArr.length; i++) {
+ let j = 0;
+ for (; j < queryArr2.length; j++) {
+ if (queryObjsEqual(querySwitches, aQueryArr[i], queryArr2[j]))
+ break;
+ }
+ if (j < queryArr2.length)
+ queryArr2.splice(j, 1);
+ }
+ do_check_eq(queryArr2.length, 0);
+
+ // Finally check the query options objects.
+ do_check_true(queryObjsEqual(queryOptionSwitches, aQueryOptions, opts2));
+}
+
+/**
+ * Convenience function for switches that have simple values. This is attached
+ * to switch objects. See querySwitches and queryOptionSwitches arrays above.
+ *
+ * @param aObj1
+ * an nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @param aObj2
+ * another nsINavHistoryQuery or nsINavHistoryQueryOptions object
+ * @return true if this switch is the same in both aObj1 and aObj2
+ */
+function simplePropertyMatches(aObj1, aObj2)
+{
+ return aObj1[this.property] === aObj2[this.property];
+}
+
+function run_test()
+{
+ runQuerySequences(CHOOSE_HOW_MANY_SWITCHES_LO, CHOOSE_HOW_MANY_SWITCHES_HI);
+}
diff --git a/toolkit/components/places/tests/queries/test_redirects.js b/toolkit/components/places/tests/queries/test_redirects.js
new file mode 100644
index 000000000..1be5a626f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_redirects.js
@@ -0,0 +1,311 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Array of visits we will add to the database, will be populated later
+// in the test.
+var visits = [];
+
+/**
+ * Takes a sequence of query options, and compare query results obtained through
+ * them with a custom filtered array of visits, based on the values we are
+ * expecting from the query.
+ *
+ * @param aSequence
+ * an array that contains query options in the form:
+ * [includeHidden, maxResults, sortingMode]
+ */
+function check_results_callback(aSequence) {
+ // Sanity check: we should receive 3 parameters.
+ do_check_eq(aSequence.length, 3);
+ let includeHidden = aSequence[0];
+ let maxResults = aSequence[1];
+ let sortingMode = aSequence[2];
+ print("\nTESTING: includeHidden(" + includeHidden + ")," +
+ " maxResults(" + maxResults + ")," +
+ " sortingMode(" + sortingMode + ").");
+
+ function isHidden(aVisit) {
+ return aVisit.transType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK ||
+ aVisit.isRedirect;
+ }
+
+ // Build expectedData array.
+ let expectedData = visits.filter(function (aVisit, aIndex, aArray) {
+ // Embed visits never appear in results.
+ if (aVisit.transType == Ci.nsINavHistoryService.TRANSITION_EMBED)
+ return false;
+
+ if (!includeHidden && isHidden(aVisit)) {
+ // If the page has any non-hidden visit, then it's visible.
+ if (visits.filter(function (refVisit) {
+ return refVisit.uri == aVisit.uri && !isHidden(refVisit);
+ }).length == 0)
+ return false;
+ }
+
+ return true;
+ });
+
+ // Remove duplicates, since queries are RESULTS_AS_URI (unique pages).
+ let seen = [];
+ expectedData = expectedData.filter(function (aData) {
+ if (seen.includes(aData.uri)) {
+ return false;
+ }
+ seen.push(aData.uri);
+ return true;
+ });
+
+ // Sort expectedData.
+ function getFirstIndexFor(aEntry) {
+ for (let i = 0; i < visits.length; i++) {
+ if (visits[i].uri == aEntry.uri)
+ return i;
+ }
+ return undefined;
+ }
+ function comparator(a, b) {
+ if (sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING) {
+ return b.lastVisit - a.lastVisit;
+ }
+ if (sortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING) {
+ return b.visitCount - a.visitCount;
+ }
+ return getFirstIndexFor(a) - getFirstIndexFor(b);
+ }
+ expectedData.sort(comparator);
+
+ // Crop results to maxResults if it's defined.
+ if (maxResults) {
+ expectedData = expectedData.slice(0, maxResults);
+ }
+
+ // Create a new query with required options.
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = includeHidden;
+ options.sortingMode = sortingMode;
+ if (maxResults)
+ options.maxResults = maxResults;
+
+ // Compare resultset with expectedData.
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(expectedData, root);
+ root.containerOpen = false;
+}
+
+/**
+ * Enumerates all the sequences of the cartesian product of the arrays contained
+ * in aSequences. Examples:
+ *
+ * cartProd([[1, 2, 3], ["a", "b"]], callback);
+ * // callback is called 3 * 2 = 6 times with the following arrays:
+ * // [1, "a"], [1, "b"], [2, "a"], [2, "b"], [3, "a"], [3, "b"]
+ *
+ * cartProd([["a"], [1, 2, 3], ["X", "Y"]], callback);
+ * // callback is called 1 * 3 * 2 = 6 times with the following arrays:
+ * // ["a", 1, "X"], ["a", 1, "Y"], ["a", 2, "X"], ["a", 2, "Y"],
+ * // ["a", 3, "X"], ["a", 3, "Y"]
+ *
+ * cartProd([[1], [2], [3], [4]], callback);
+ * // callback is called 1 * 1 * 1 * 1 = 1 time with the following array:
+ * // [1, 2, 3, 4]
+ *
+ * cartProd([], callback);
+ * // callback is 0 times
+ *
+ * cartProd([[1, 2, 3, 4]], callback);
+ * // callback is called 4 times with the following arrays:
+ * // [1], [2], [3], [4]
+ *
+ * @param aSequences
+ * an array that contains an arbitrary number of arrays
+ * @param aCallback
+ * a function that is passed each sequence of the product as it's
+ * computed
+ * @return the total number of sequences in the product
+ */
+function cartProd(aSequences, aCallback)
+{
+ if (aSequences.length === 0)
+ return 0;
+
+ // For each sequence in aSequences, we maintain a pointer (an array index,
+ // really) to the element we're currently enumerating in that sequence
+ let seqEltPtrs = aSequences.map(i => 0);
+
+ let numProds = 0;
+ let done = false;
+ while (!done) {
+ numProds++;
+
+ // prod = sequence in product we're currently enumerating
+ let prod = [];
+ for (let i = 0; i < aSequences.length; i++) {
+ prod.push(aSequences[i][seqEltPtrs[i]]);
+ }
+ aCallback(prod);
+
+ // The next sequence in the product differs from the current one by just a
+ // single element. Determine which element that is. We advance the
+ // "rightmost" element pointer to the "right" by one. If we move past the
+ // end of that pointer's sequence, reset the pointer to the first element
+ // in its sequence and then try the sequence to the "left", and so on.
+
+ // seqPtr = index of rightmost input sequence whose element pointer is not
+ // past the end of the sequence
+ let seqPtr = aSequences.length - 1;
+ while (!done) {
+ // Advance the rightmost element pointer.
+ seqEltPtrs[seqPtr]++;
+
+ // The rightmost element pointer is past the end of its sequence.
+ if (seqEltPtrs[seqPtr] >= aSequences[seqPtr].length) {
+ seqEltPtrs[seqPtr] = 0;
+ seqPtr--;
+
+ // All element pointers are past the ends of their sequences.
+ if (seqPtr < 0)
+ done = true;
+ }
+ else break;
+ }
+ }
+ return numProds;
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+/**
+ * Populate the visits array and add visits to the database.
+ * We will generate visit-chains like:
+ * visit -> redirect_temp -> redirect_perm
+ */
+add_task(function* test_add_visits_to_database()
+{
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ // We don't really bother on this, but we need a time to add visits.
+ let timeInMicroseconds = Date.now() * 1000;
+ let visitCount = 1;
+
+ // Array of all possible transition types we could be redirected from.
+ let t = [
+ Ci.nsINavHistoryService.TRANSITION_LINK,
+ Ci.nsINavHistoryService.TRANSITION_TYPED,
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ // Embed visits are not added to the database and we don't want redirects
+ // to them, thus just avoid addition.
+ // Ci.nsINavHistoryService.TRANSITION_EMBED,
+ Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
+ // Would make hard sorting by visit date because last_visit_date is actually
+ // calculated excluding download transitions, but the query includes
+ // downloads.
+ // TODO: Bug 488966 could fix this behavior.
+ //Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ ];
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds - 1000;
+ return timeInMicroseconds;
+ }
+
+ // we add a visit for each of the above transition types.
+ t.forEach(transition => visits.push(
+ { isVisit: true,
+ transType: transition,
+ uri: "http://" + transition + ".example.com/",
+ title: transition + "-example",
+ isRedirect: true,
+ lastVisit: newTimeInMicroseconds(),
+ visitCount: (transition == Ci.nsINavHistoryService.TRANSITION_EMBED ||
+ transition == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK) ? 0 : visitCount++,
+ isInQuery: true }));
+
+ // Add a REDIRECT_TEMPORARY layer of visits for each of the above visits.
+ t.forEach(transition => visits.push(
+ { isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
+ uri: "http://" + transition + ".redirect.temp.example.com/",
+ title: transition + "-redirect-temp-example",
+ lastVisit: newTimeInMicroseconds(),
+ isRedirect: true,
+ referrer: "http://" + transition + ".example.com/",
+ visitCount: visitCount++,
+ isInQuery: true }));
+
+ // Add a REDIRECT_PERMANENT layer of visits for each of the above redirects.
+ t.forEach(transition => visits.push(
+ { isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ uri: "http://" + transition + ".redirect.perm.example.com/",
+ title: transition + "-redirect-perm-example",
+ lastVisit: newTimeInMicroseconds(),
+ isRedirect: true,
+ referrer: "http://" + transition + ".redirect.temp.example.com/",
+ visitCount: visitCount++,
+ isInQuery: true }));
+
+ // Add a REDIRECT_PERMANENT layer of visits that loop to the first visit.
+ // These entries should not change visitCount or lastVisit, otherwise
+ // guessing an order would be a nightmare.
+ function getLastValue(aURI, aProperty) {
+ for (let i = 0; i < visits.length; i++) {
+ if (visits[i].uri == aURI) {
+ return visits[i][aProperty];
+ }
+ }
+ do_throw("Unknown uri.");
+ return null;
+ }
+ t.forEach(transition => visits.push(
+ { isVisit: true,
+ transType: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ uri: "http://" + transition + ".example.com/",
+ title: getLastValue("http://" + transition + ".example.com/", "title"),
+ lastVisit: getLastValue("http://" + transition + ".example.com/", "lastVisit"),
+ isRedirect: true,
+ referrer: "http://" + transition + ".redirect.perm.example.com/",
+ visitCount: getLastValue("http://" + transition + ".example.com/", "visitCount"),
+ isInQuery: true }));
+
+ // Add an unvisited bookmark in the database, it should never appear.
+ visits.push({ isBookmark: true,
+ uri: "http://unvisited.bookmark.com/",
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "Unvisited Bookmark",
+ isInQuery: false });
+
+ // Put visits in the database.
+ yield task_populateDB(visits);
+});
+
+add_task(function* test_redirects()
+{
+ // Frecency and hidden are updated asynchronously, wait for them.
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // This array will be used by cartProd to generate a matrix of all possible
+ // combinations.
+ let includeHidden_options = [true, false];
+ let maxResults_options = [5, 10, 20, null];
+ // These sortingMode are choosen to toggle using special queries for history
+ // menu and most visited smart bookmark.
+ let sorting_options = [Ci.nsINavHistoryQueryOptions.SORT_BY_NONE,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING,
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING];
+ // Will execute check_results_callback() for each generated combination.
+ cartProd([includeHidden_options, maxResults_options, sorting_options],
+ check_results_callback);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-tag-contents-query.js b/toolkit/components/places/tests/queries/test_results-as-tag-contents-query.js
new file mode 100644
index 000000000..f1cbfd4d8
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-tag-contents-query.js
@@ -0,0 +1,127 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var testData = [
+ { isInQuery: true,
+ isDetails: true,
+ title: "bmoz",
+ uri: "http://foo.com/",
+ isBookmark: true,
+ isTag: true,
+ tagArray: ["bugzilla"] },
+
+ { isInQuery: true,
+ isDetails: true,
+ title: "C Moz",
+ uri: "http://foo.com/changeme1.html",
+ isBookmark: true,
+ isTag: true,
+ tagArray: ["moz", "bugzilla"] },
+
+ { isInQuery: false,
+ isDetails: true,
+ title: "amo",
+ uri: "http://foo2.com/",
+ isBookmark: true,
+ isTag: true,
+ tagArray: ["moz"] },
+
+ { isInQuery: false,
+ isDetails: true,
+ title: "amo",
+ uri: "http://foo.com/changeme2.html",
+ isBookmark: true },
+];
+
+function getIdForTag(aTagName) {
+ var id = -1;
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.tagsFolderId], 1);
+ var options = PlacesUtils.history.getNewQueryOptions();
+ var root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+ do_check_eq(root.childCount, 2);
+ for (let i = 0; i < cc; i++) {
+ let node = root.getChild(i);
+ if (node.title == aTagName) {
+ id = node.itemId;
+ break;
+ }
+ }
+ root.containerOpen = false;
+ return id;
+}
+
+ /**
+ * This test will test Queries that use relative search terms and URI options
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_results_as_tag_contents_query()
+{
+ yield task_populateDB(testData);
+
+ // Get tag id.
+ let tagId = getIdForTag("bugzilla");
+ do_check_true(tagId > 0);
+
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_TAG_CONTENTS;
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([tagId], 1);
+
+ var root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ displayResultSet(root);
+ // Cannot use compare array to results, since results ordering is hardcoded
+ // and depending on lastModified (that could have VM timers issues).
+ testData.forEach(function(aEntry) {
+ if (aEntry.isInResult)
+ do_check_true(isInResult({uri: "http://foo.com/added.html"}, root));
+ });
+
+ // If that passes, check liveupdate
+ // Add to the query set
+ var change1 = { isVisit: true,
+ isDetails: true,
+ uri: "http://foo.com/added.html",
+ title: "mozadded",
+ isBookmark: true,
+ isTag: true,
+ tagArray: ["moz", "bugzilla"] };
+ do_print("Adding item to query");
+ yield task_populateDB([change1]);
+ do_print("These results should have been LIVE UPDATED with the new addition");
+ displayResultSet(root);
+ do_check_true(isInResult(change1, root));
+
+ // Add one by adding a tag, remove one by removing search term.
+ do_print("Updating items");
+ var change2 = [{ isDetails: true,
+ uri: "http://foo3.com/",
+ title: "foo"},
+ { isDetails: true,
+ uri: "http://foo.com/changeme2.html",
+ title: "zydeco",
+ isBookmark:true,
+ isTag: true,
+ tagArray: ["bugzilla", "moz"] }];
+ yield task_populateDB(change2);
+ do_check_false(isInResult({uri: "http://fooz.com/"}, root));
+ do_check_true(isInResult({uri: "http://foo.com/changeme2.html"}, root));
+
+ // Test removing a tag updates us.
+ do_print("Deleting item");
+ PlacesUtils.tagging.untagURI(uri("http://foo.com/changeme2.html"), ["bugzilla"]);
+ do_check_false(isInResult({uri: "http://foo.com/changeme2.html"}, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_results-as-visit.js b/toolkit/components/places/tests/queries/test_results-as-visit.js
new file mode 100644
index 000000000..d0f270bd2
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_results-as-visit.js
@@ -0,0 +1,119 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+var testData = [];
+var timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+}
+
+function createTestData() {
+ function generateVisits(aPage) {
+ for (var i = 0; i < aPage.visitCount; i++) {
+ testData.push({ isInQuery: aPage.inQuery,
+ isVisit: true,
+ title: aPage.title,
+ uri: aPage.uri,
+ lastVisit: newTimeInMicroseconds(),
+ isTag: aPage.tags && aPage.tags.length > 0,
+ tagArray: aPage.tags });
+ }
+ }
+
+ var pages = [
+ { uri: "http://foo.com/", title: "amo", tags: ["moz"], visitCount: 3, inQuery: true },
+ { uri: "http://moilla.com/", title: "bMoz", tags: ["bugzilla"], visitCount: 5, inQuery: true },
+ { uri: "http://foo.mail.com/changeme1.html", title: "c Moz", visitCount: 7, inQuery: true },
+ { uri: "http://foo.mail.com/changeme2.html", tags: ["moz"], title: "", visitCount: 1, inQuery: false },
+ { uri: "http://foo.mail.com/changeme3.html", title: "zydeco", visitCount: 5, inQuery: false },
+ ];
+ pages.forEach(generateVisits);
+}
+
+/**
+ * This test will test Queries that use relative search terms and URI options
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_results_as_visit()
+{
+ createTestData();
+ yield task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.minVisits = 2;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_ASCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_print("Number of items in result set: " + root.childCount);
+ for (let i=0; i < root.childCount; ++i) {
+ do_print("result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title);
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // If that passes, check liveupdate
+ // Add to the query set
+ do_print("Adding item to query")
+ var tmp = [];
+ for (let i=0; i < 2; i++) {
+ tmp.push({ isVisit: true,
+ uri: "http://foo.com/added.html",
+ title: "ab moz" });
+ }
+ yield task_populateDB(tmp);
+ for (let i=0; i < 2; i++)
+ do_check_eq(root.getChild(i).title, "ab moz");
+
+ // Update an existing URI
+ do_print("Updating Item");
+ var change2 = [{ isVisit: true,
+ title: "moz",
+ uri: "http://foo.mail.com/changeme2.html" }];
+ yield task_populateDB(change2);
+ do_check_true(isInResult(change2, root));
+
+ // Update some visits - add one and take one out of query set, and simply
+ // change one so that it still applies to the query.
+ do_print("Updating More Items");
+ var change3 = [{ isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://foo.mail.com/changeme1.html",
+ title: "foo"},
+ { isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://foo.mail.com/changeme3.html",
+ title: "moz",
+ isTag: true,
+ tagArray: ["foo", "moz"] }];
+ yield task_populateDB(change3);
+ do_check_false(isInResult({uri: "http://foo.mail.com/changeme1.html"}, root));
+ do_check_true(isInResult({uri: "http://foo.mail.com/changeme3.html"}, root));
+
+ // And now, delete one
+ do_print("Delete item outside of batch");
+ var change4 = [{ isVisit: true,
+ lastVisit: newTimeInMicroseconds(),
+ uri: "http://moilla.com/",
+ title: "mo,z" }];
+ yield task_populateDB(change4);
+ do_check_false(isInResult(change4, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js
new file mode 100644
index 000000000..038367c0b
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchTerms_includeHidden.js
@@ -0,0 +1,84 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests the interaction of includeHidden and searchTerms search options.
+
+var timeInMicroseconds = Date.now() * 1000;
+
+const VISITS = [
+ { isVisit: true,
+ transType: TRANSITION_TYPED,
+ uri: "http://redirect.example.com/",
+ title: "example",
+ isRedirect: true,
+ lastVisit: timeInMicroseconds--
+ },
+ { isVisit: true,
+ transType: TRANSITION_TYPED,
+ uri: "http://target.example.com/",
+ title: "example",
+ lastVisit: timeInMicroseconds--
+ }
+];
+
+const HIDDEN_VISITS = [
+ { isVisit: true,
+ transType: TRANSITION_FRAMED_LINK,
+ uri: "http://hidden.example.com/",
+ title: "red",
+ lastVisit: timeInMicroseconds--
+ },
+];
+
+const TEST_DATA = [
+ { searchTerms: "example",
+ includeHidden: true,
+ expectedResults: 2
+ },
+ { searchTerms: "example",
+ includeHidden: false,
+ expectedResults: 1
+ },
+ { searchTerms: "red",
+ includeHidden: true,
+ expectedResults: 1
+ }
+];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_initalize()
+{
+ yield task_populateDB(VISITS);
+});
+
+add_task(function* test_searchTerms_includeHidden()
+{
+ for (let data of TEST_DATA) {
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = data.searchTerms;
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.includeHidden = data.includeHidden;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ let cc = root.childCount;
+ // Live update with hidden visits.
+ yield task_populateDB(HIDDEN_VISITS);
+ let cc_update = root.childCount;
+
+ root.containerOpen = false;
+
+ do_check_eq(cc, data.expectedResults);
+ do_check_eq(cc_update, data.expectedResults + (data.includeHidden ? 1 : 0));
+
+ PlacesUtils.bhistory.removePage(uri("http://hidden.example.com/"));
+ }
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js
new file mode 100644
index 000000000..7bd91f057
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-bookmarklets.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Check that bookmarklets are returned by searches with searchTerms.
+
+var testData = [
+ { isInQuery: true
+ , isBookmark: true
+ , title: "bookmark 1"
+ , uri: "http://mozilla.org/script/"
+ },
+
+ { isInQuery: true
+ , isBookmark: true
+ , title: "bookmark 2"
+ , uri: "javascript:alert('moz');"
+ }
+];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_initalize()
+{
+ yield task_populateDB(testData);
+});
+
+add_test(function test_search_by_title()
+{
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "bookmark";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
+
+add_test(function test_search_by_schemeToken()
+{
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "script";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
+
+add_test(function test_search_by_uriAndTitle()
+{
+ let query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(testData, root);
+ root.containerOpen = false;
+
+ run_next_test();
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-domain.js b/toolkit/components/places/tests/queries/test_searchterms-domain.js
new file mode 100644
index 000000000..4f42e7000
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-domain.js
@@ -0,0 +1,125 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ // The test data for our database, note that the ordering of the results that
+ // will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+ // see compareArrayToResult in head_queries.js for more info.
+ var testData = [
+ // Test ftp protocol - vary the title length, embed search term
+ {isInQuery: true, isVisit: true, isDetails: true,
+ uri: "ftp://foo.com/ftp", lastVisit: lastweek,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo"},
+
+ // Test flat domain with annotation, search term in sentence
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://foo.com/", annoName: "moz/test", annoVal: "val",
+ lastVisit: lastweek, title: "you know, moz is cool"},
+
+ // Test subdomain included with isRedirect=true, different transtype
+ {isInQuery: true, isVisit: true, isDetails: true, title: "amozzie",
+ isRedirect: true, uri: "http://mail.foo.com/redirect", lastVisit: old,
+ referrer: "http://myreferrer.com", transType: PlacesUtils.history.TRANSITION_LINK},
+
+ // Test subdomain inclued, search term at end
+ {isInQuery: true, isVisit: true, isDetails: true,
+ uri: "http://mail.foo.com/yiihah", title: "blahmoz", lastVisit: daybefore},
+
+ // Test www. style URI is included, with a tag
+ {isInQuery: true, isVisit: true, isDetails: true, isTag: true,
+ uri: "http://www.foo.com/yiihah", tagArray: ["moz"],
+ lastVisit: yesterday, title: "foo"},
+
+ // Test https protocol
+ {isInQuery: true, isVisit: true, isDetails: true, title: "moz",
+ uri: "https://foo.com/", lastVisit: today},
+
+ // Begin the invalid queries: wrong search term
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m o z",
+ uri: "http://foo.com/tooearly.php", lastVisit: today},
+
+ // Test bad URI
+ {isInQuery: false, isVisit:true, isDetails: true, title: "moz",
+ uri: "http://sffoo.com/justwrong.htm", lastVisit: yesterday},
+
+ // Test what we do with escaping in titles
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m%0o%0z",
+ uri: "http://foo.com/changeme1.htm", lastVisit: yesterday},
+
+ // Test another invalid title - for updating later
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m,oz",
+ uri: "http://foo.com/changeme2.htm", lastVisit: yesterday}];
+
+/**
+ * This test will test Queries that use relative search terms and domain options
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_searchterms_domain()
+{
+ yield task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.domain = "foo.com";
+ query.domainIsHost = false;
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_print("Number of items in result set: " + root.childCount);
+ for (var i=0; i < root.childCount; ++i) {
+ do_print("result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title);
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // If that passes, check liveupdate
+ // Add to the query set
+ do_print("Adding item to query");
+ var change1 = [{isVisit: true, isDetails: true, uri: "http://foo.com/added.htm",
+ title: "moz", transType: PlacesUtils.history.TRANSITION_LINK}];
+ yield task_populateDB(change1);
+ do_check_true(isInResult(change1, root));
+
+ // Update an existing URI
+ do_print("Updating Item");
+ var change2 = [{isDetails: true, uri: "http://foo.com/changeme1.htm",
+ title: "moz" }];
+ yield task_populateDB(change2);
+ do_check_true(isInResult(change2, root));
+
+ // Add one and take one out of query set, and simply change one so that it
+ // still applies to the query.
+ do_print("Updating More Items");
+ var change3 = [{isDetails: true, uri:"http://foo.com/changeme2.htm",
+ title: "moz"},
+ {isDetails: true, uri: "http://mail.foo.com/yiihah",
+ title: "moz now updated"},
+ {isDetails: true, uri: "ftp://foo.com/ftp", title: "gone"}];
+ yield task_populateDB(change3);
+ do_check_true(isInResult({uri: "http://foo.com/changeme2.htm"}, root));
+ do_check_true(isInResult({uri: "http://mail.foo.com/yiihah"}, root));
+ do_check_false(isInResult({uri: "ftp://foo.com/ftp"}, root));
+
+ // And now, delete one
+ do_print("Deleting items");
+ var change4 = [{isDetails: true, uri: "https://foo.com/",
+ title: "mo,z"}];
+ yield task_populateDB(change4);
+ do_check_false(isInResult(change4, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_searchterms-uri.js b/toolkit/components/places/tests/queries/test_searchterms-uri.js
new file mode 100644
index 000000000..af4efe196
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_searchterms-uri.js
@@ -0,0 +1,87 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ // The test data for our database, note that the ordering of the results that
+ // will be returned by the query (the isInQuery: true objects) is IMPORTANT.
+ // see compareArrayToResult in head_queries.js for more info.
+ var testData = [
+ // Test flat domain with annotation, search term in sentence
+ {isInQuery: true, isVisit: true, isDetails: true, isPageAnnotation: true,
+ uri: "http://foo.com/", annoName: "moz/test", annoVal: "val",
+ lastVisit: lastweek, title: "you know, moz is cool"},
+
+ // Test https protocol
+ {isInQuery: false, isVisit: true, isDetails: true, title: "moz",
+ uri: "https://foo.com/", lastVisit: today},
+
+ // Begin the invalid queries: wrong search term
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m o z",
+ uri: "http://foo.com/wrongsearch.php", lastVisit: today},
+
+ // Test subdomain inclued, search term at end
+ {isInQuery: false, isVisit: true, isDetails: true,
+ uri: "http://mail.foo.com/yiihah", title: "blahmoz", lastVisit: daybefore},
+
+ // Test ftp protocol - vary the title length, embed search term
+ {isInQuery: false, isVisit: true, isDetails: true,
+ uri: "ftp://foo.com/ftp", lastVisit: lastweek,
+ title: "hugelongconfmozlagurationofwordswithasearchtermsinit whoo-hoo"},
+
+ // Test what we do with escaping in titles
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m%0o%0z",
+ uri: "http://foo.com/changeme1.htm", lastVisit: yesterday},
+
+ // Test another invalid title - for updating later
+ {isInQuery: false, isVisit:true, isDetails: true, title: "m,oz",
+ uri: "http://foo.com/changeme2.htm", lastVisit: yesterday}];
+
+/**
+ * This test will test Queries that use relative search terms and URI options
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_searchterms_uri()
+{
+ yield task_populateDB(testData);
+ var query = PlacesUtils.history.getNewQuery();
+ query.searchTerms = "moz";
+ query.uri = uri("http://foo.com");
+
+ // Options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_ASCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+
+ // Results
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_print("Number of items in result set: " + root.childCount);
+ for (var i=0; i < root.childCount; ++i) {
+ do_print("result: " + root.getChild(i).uri + " Title: " + root.getChild(i).title);
+ }
+
+ // Check our inital result set
+ compareArrayToResult(testData, root);
+
+ // live update.
+ do_print("change title");
+ var change1 = [{isDetails: true, uri:"http://foo.com/",
+ title: "mo"}, ];
+ yield task_populateDB(change1);
+
+ do_check_false(isInResult({uri: "http://foo.com/"}, root));
+ var change2 = [{isDetails: true, uri:"http://foo.com/",
+ title: "moz"}, ];
+ yield task_populateDB(change2);
+ do_check_true(isInResult({uri: "http://foo.com/"}, root));
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js
new file mode 100644
index 000000000..7ca50e6de
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_sort-date-site-grouping.js
@@ -0,0 +1,225 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+ * ***** END LICENSE BLOCK ***** */
+
+// This test ensures that the date and site type of |place:| query maintains
+// its quantifications correctly. Namely, it ensures that the date part of the
+// query is not lost when the domain queries are made.
+
+// We specifically craft these entries so that if a by Date and Site sorting is
+// applied, we find one domain in the today range, and two domains in the older
+// than six months range.
+// The correspondence between item in |testData| and date range is stored in
+// leveledTestData.
+var testData = [
+ {
+ isVisit: true,
+ uri: "file:///directory/1",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/1",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/2",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "file:///directory/2",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/3",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/4",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true
+ },
+ {
+ isVisit: true,
+ uri: "http://example.net/1",
+ lastVisit: olderthansixmonths + 1000,
+ title: "test visit",
+ isInQuery: true
+ }
+];
+var domainsInRange = [2, 3];
+var leveledTestData = [// Today
+ [[0], // Today, local files
+ [1, 2]], // Today, example.com
+ // Older than six months
+ [[3], // Older than six months, local files
+ [4, 5], // Older than six months, example.com
+ [6] // Older than six months, example.net
+ ]];
+
+// This test data is meant for live updating. The |levels| property indicates
+// date range index and then domain index.
+var testDataAddedLater = [
+ {
+ isVisit: true,
+ uri: "http://example.com/5",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ levels: [1, 1]
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/6",
+ lastVisit: olderthansixmonths,
+ title: "test visit",
+ isInQuery: true,
+ levels: [1, 1]
+ },
+ {
+ isVisit: true,
+ uri: "http://example.com/7",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ levels: [0, 1]
+ },
+ {
+ isVisit: true,
+ uri: "file:///directory/3",
+ lastVisit: today,
+ title: "test visit",
+ isInQuery: true,
+ levels: [0, 0]
+ }
+];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_sort_date_site_grouping()
+{
+ yield task_populateDB(testData);
+
+ // On Linux, the (local files) folder is shown after sites unlike Mac/Windows.
+ // Thus, we avoid running this test on Linux but this should be re-enabled
+ // after bug 624024 is resolved.
+ let isLinux = ("@mozilla.org/gnome-gconf-service;1" in Components.classes);
+ if (isLinux)
+ return;
+
+ // In this test, there are three levels of results:
+ // 1st: Date queries. e.g., today, last week, or older than 6 months.
+ // 2nd: Domain queries restricted to a date. e.g. mozilla.com today.
+ // 3rd: Actual visits. e.g. mozilla.com/index.html today.
+ //
+ // We store all the third level result roots so that we can easily close all
+ // containers and test live updating into specific results.
+ let roots = [];
+
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ // This corresponds to the number of date ranges.
+ do_check_eq(root.childCount, leveledTestData.length);
+
+ // We pass off to |checkFirstLevel| to check the first level of results.
+ for (let index = 0; index < leveledTestData.length; index++) {
+ let node = root.getChild(index);
+ checkFirstLevel(index, node, roots);
+ }
+
+ // Test live updating.
+ for (let visit of testDataAddedLater) {
+ yield task_populateDB([visit]);
+ let oldLength = testData.length;
+ let i = visit.levels[0];
+ let j = visit.levels[1];
+ testData.push(visit);
+ leveledTestData[i][j].push(oldLength);
+ compareArrayToResult(leveledTestData[i][j].
+ map(x => testData[x]), roots[i][j]);
+ }
+
+ for (let i = 0; i < roots.length; i++) {
+ for (let j = 0; j < roots[i].length; j++)
+ roots[i][j].containerOpen = false;
+ }
+
+ root.containerOpen = false;
+});
+
+function checkFirstLevel(index, node, roots) {
+ PlacesUtils.asContainer(node).containerOpen = true;
+
+ do_check_true(PlacesUtils.nodeIsDay(node));
+ PlacesUtils.asQuery(node);
+ let queries = node.getQueries();
+ let options = node.queryOptions;
+
+ do_check_eq(queries.length, 1);
+ let query = queries[0];
+
+ do_check_true(query.hasBeginTime && query.hasEndTime);
+
+ // Here we check the second level of results.
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ roots.push([]);
+ root.containerOpen = true;
+
+ do_check_eq(root.childCount, leveledTestData[index].length);
+ for (var secondIndex = 0; secondIndex < root.childCount; secondIndex++) {
+ let child = PlacesUtils.asQuery(root.getChild(secondIndex));
+ checkSecondLevel(index, secondIndex, child, roots);
+ }
+ root.containerOpen = false;
+ node.containerOpen = false;
+}
+
+function checkSecondLevel(index, secondIndex, child, roots) {
+ let queries = child.getQueries();
+ let options = child.queryOptions;
+
+ do_check_eq(queries.length, 1);
+ let query = queries[0];
+
+ do_check_true(query.hasDomain);
+ do_check_true(query.hasBeginTime && query.hasEndTime);
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ // We should now have that roots[index][secondIndex] is set to the second
+ // level's results root.
+ roots[index].push(root);
+
+ // We pass off to compareArrayToResult to check the third level of
+ // results.
+ root.containerOpen = true;
+ compareArrayToResult(leveledTestData[index][secondIndex].
+ map(x => testData[x]), root);
+ // We close |root|'s container later so that we can test live
+ // updates into it.
+}
diff --git a/toolkit/components/places/tests/queries/test_sorting.js b/toolkit/components/places/tests/queries/test_sorting.js
new file mode 100644
index 000000000..4d8e1146d
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_sorting.js
@@ -0,0 +1,1265 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var tests = [];
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_NONE,
+
+ *setup() {
+ do_print("Sorting test 1: SORT BY NONE");
+
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ keyword: "b",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "z",
+ keyword: "a",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "x",
+ keyword: "c",
+ isInQuery: true },
+ ];
+
+ this._sortedData = this._unsortedData;
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ // no reverse sorting for SORT BY NONE
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 2: SORT BY TITLE");
+
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "z",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "x",
+ isInQuery: true },
+
+ // if titles are equal, should fall back to URI
+ { isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 3: SORT BY DATE");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ uri: "http://example.com/c1",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x1",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ uri: "http://example.com/a",
+ lastVisit: timeInMicroseconds - 1000,
+ title: "z",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ uri: "http://example.com/b",
+ lastVisit: timeInMicroseconds - 3000,
+ title: "y",
+ isInQuery: true },
+
+ // if dates are equal, should fall back to title
+ { isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ uri: "http://example.com/c2",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x2",
+ isInQuery: true },
+
+ // if dates and title are equal, should fall back to bookmark index
+ { isVisit: true,
+ isDetails: true,
+ isBookmark: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ uri: "http://example.com/c2",
+ lastVisit: timeInMicroseconds - 2000,
+ title: "x2",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 4: SORT BY URI");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "x",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "z",
+ isInQuery: true },
+
+ // if URIs are equal, should fall back to date
+ { isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds + 1000,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "x",
+ isInQuery: true },
+
+ // if no URI (e.g., node is a folder), should fall back to title
+ { isFolder: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "a",
+ isInQuery: true },
+
+ // if URIs and dates are equal, should fall back to bookmark index
+ { isBookmark: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds + 1000,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 5,
+ title: "x",
+ isInQuery: true },
+
+ // if no URI and titles are equal, should fall back to bookmark index
+ { isFolder: true,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 6,
+ title: "a",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[4],
+ this._unsortedData[6],
+ this._unsortedData[2],
+ this._unsortedData[0],
+ this._unsortedData[1],
+ this._unsortedData[3],
+ this._unsortedData[5],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 5: SORT BY VISITCOUNT");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ lastVisit: timeInMicroseconds,
+ title: "z",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ lastVisit: timeInMicroseconds,
+ title: "x",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ lastVisit: timeInMicroseconds,
+ title: "y1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ isInQuery: true },
+
+ // if visitCounts are equal, should fall back to date
+ { isBookmark: true,
+ uri: "http://example.com/b2",
+ lastVisit: timeInMicroseconds + 1000,
+ title: "y2a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ isInQuery: true },
+
+ // if visitCounts and dates are equal, should fall back to bookmark index
+ { isBookmark: true,
+ uri: "http://example.com/b2",
+ lastVisit: timeInMicroseconds + 1000,
+ title: "y2b",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[0],
+ this._unsortedData[2],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ // add visits to increase visit count
+ yield PlacesTestUtils.addVisits([
+ { uri: uri("http://example.com/a"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ { uri: uri("http://example.com/b1"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ { uri: uri("http://example.com/b1"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ { uri: uri("http://example.com/b2"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds + 1000 },
+ { uri: uri("http://example.com/b2"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds + 1000 },
+ { uri: uri("http://example.com/c"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ { uri: uri("http://example.com/c"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ { uri: uri("http://example.com/c"), transition: TRANSITION_TYPED, visitDate: timeInMicroseconds },
+ ]);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_KEYWORD_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 6: SORT BY KEYWORD");
+
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "z",
+ keyword: "a",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "x",
+ keyword: "c",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y9",
+ keyword: "b",
+ isInQuery: true },
+
+ // without a keyword, should fall back to title
+ { isBookmark: true,
+ uri: "http://example.com/null2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "null8",
+ keyword: null,
+ isInQuery: true },
+
+ // without a keyword, should fall back to title
+ { isBookmark: true,
+ uri: "http://example.com/null1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "null9",
+ keyword: null,
+ isInQuery: true },
+
+ // if keywords are equal, should fall back to title
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "y8",
+ keyword: "b",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[0],
+ this._unsortedData[5],
+ this._unsortedData[2],
+ this._unsortedData[1],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_KEYWORD_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 7: SORT BY DATEADDED");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y1",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "z",
+ dateAdded: timeInMicroseconds - 2000,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "x",
+ dateAdded: timeInMicroseconds,
+ isInQuery: true },
+
+ // if dateAddeds are equal, should fall back to title
+ { isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "y2",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true },
+
+ // if dateAddeds and titles are equal, should fall back to bookmark index
+ { isBookmark: true,
+ uri: "http://example.com/b3",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "y3",
+ dateAdded: timeInMicroseconds - 1000,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 8: SORT BY LASTMODIFIED");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ var timeAddedInMicroseconds = timeInMicroseconds - 10000;
+
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://example.com/b1",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 0,
+ title: "y1",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/a",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ title: "z",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 2000,
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://example.com/c",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 2,
+ title: "x",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds,
+ isInQuery: true },
+
+ // if lastModifieds are equal, should fall back to title
+ { isBookmark: true,
+ uri: "http://example.com/b2",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 3,
+ title: "y2",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true },
+
+ // if lastModifieds and titles are equal, should fall back to bookmark
+ // index
+ { isBookmark: true,
+ uri: "http://example.com/b3",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 4,
+ title: "y3",
+ dateAdded: timeAddedInMicroseconds,
+ lastModified: timeInMicroseconds - 1000,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[4],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 9: SORT BY TAGS");
+
+ this._unsortedData = [
+ { isBookmark: true,
+ uri: "http://url2.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title x",
+ isTag: true,
+ tagArray: ["x", "y", "z"],
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://url1a.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title y1",
+ isTag: true,
+ tagArray: ["a", "b"],
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://url3a.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title w1",
+ isInQuery: true },
+
+ { isBookmark: true,
+ uri: "http://url0.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title z",
+ isTag: true,
+ tagArray: ["a", "y", "z"],
+ isInQuery: true },
+
+ // if tags are equal, should fall back to title
+ { isBookmark: true,
+ uri: "http://url1b.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title y2",
+ isTag: true,
+ tagArray: ["b", "a"],
+ isInQuery: true },
+
+ // if tags are equal, should fall back to title
+ { isBookmark: true,
+ uri: "http://url3b.com/",
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ title: "title w2",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[2],
+ this._unsortedData[5],
+ this._unsortedData[1],
+ this._unsortedData[4],
+ this._unsortedData[3],
+ this._unsortedData[0],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarks.toolbarFolder], 1);
+ query.onlyBookmarked = true;
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+// SORT_BY_ANNOTATION_* (int32)
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 10: SORT BY ANNOTATION (int32)");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/b1",
+ title: "y1",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 2,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/a",
+ title: "z",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 1,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/c",
+ title: "x",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 3,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ // if annotations are equal, should fall back to title
+ { isVisit: true,
+ isDetails: true,
+ lastVisit: timeInMicroseconds,
+ uri: "http://example.com/b2",
+ title: "y2",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 2,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[3],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingAnnotation = "sorting";
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+// SORT_BY_ANNOTATION_* (int64)
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 11: SORT BY ANNOTATION (int64)");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: timeInMicroseconds,
+ title: "I",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 0xffffffff1,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://is.com/",
+ lastVisit: timeInMicroseconds,
+ title: "love",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 0xffffffff0,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: timeInMicroseconds,
+ title: "moz",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 0xffffffff2,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingAnnotation = "sorting";
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+// SORT_BY_ANNOTATION_* (string)
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 12: SORT BY ANNOTATION (string)");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: timeInMicroseconds,
+ title: "I",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: "a",
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://is.com/",
+ lastVisit: timeInMicroseconds,
+ title: "love",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: "",
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: timeInMicroseconds,
+ title: "moz",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: "z",
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingAnnotation = "sorting";
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+// SORT_BY_ANNOTATION_* (double)
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 13: SORT BY ANNOTATION (double)");
+
+ var timeInMicroseconds = Date.now() * 1000;
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: timeInMicroseconds,
+ title: "I",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 1.2,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://is.com/",
+ lastVisit: timeInMicroseconds,
+ title: "love",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 1.1,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: timeInMicroseconds,
+ title: "moz",
+ isPageAnnotation: true,
+ annoName: "sorting",
+ annoVal: 1.3,
+ annoFlags: 0,
+ annoExpiration: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[1],
+ this._unsortedData[0],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ // Query
+ var query = PlacesUtils.history.getNewQuery();
+
+ // query options
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingAnnotation = "sorting";
+ options.sortingMode = this._sortingMode;
+
+ // Results - this gets the result set and opens it for reading and modification.
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+// SORT_BY_FRECENCY_*
+
+tests.push({
+ _sortingMode: Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_ASCENDING,
+
+ *setup() {
+ do_print("Sorting test 13: SORT BY FRECENCY ");
+
+ let timeInMicroseconds = PlacesUtils.toPRTime(Date.now() - 10000);
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+ }
+
+ this._unsortedData = [
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://moz.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "I",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://is.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "love",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "moz",
+ isInQuery: true },
+
+ { isVisit: true,
+ isDetails: true,
+ uri: "http://best.com/",
+ lastVisit: newTimeInMicroseconds(),
+ title: "moz",
+ isInQuery: true },
+ ];
+
+ this._sortedData = [
+ this._unsortedData[3],
+ this._unsortedData[5],
+ this._unsortedData[2],
+ ];
+
+ // This function in head_queries.js creates our database with the above data
+ yield task_populateDB(this._unsortedData);
+ },
+
+ check: function() {
+ var query = PlacesUtils.history.getNewQuery();
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = this._sortingMode;
+
+ var root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ compareArrayToResult(this._sortedData, root);
+ root.containerOpen = false;
+ },
+
+ check_reverse: function() {
+ this._sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING;
+ this._sortedData.reverse();
+ this.check();
+ }
+});
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_sorting()
+{
+ for (let test of tests) {
+ yield test.setup();
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ test.check();
+ // sorting reversed, usually SORT_BY have ASC and DESC
+ test.check_reverse();
+ // Execute cleanup tasks
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+ }
+});
diff --git a/toolkit/components/places/tests/queries/test_tags.js b/toolkit/components/places/tests/queries/test_tags.js
new file mode 100644
index 000000000..afda3f03f
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_tags.js
@@ -0,0 +1,743 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests bookmark and history queries with tags. See bug 399799.
+ */
+
+"use strict";
+
+add_task(function* tags_getter_setter() {
+ do_print("Tags getter/setter should work correctly");
+ do_print("Without setting tags, tags getter should return empty array");
+ var [query] = makeQuery();
+ do_check_eq(query.tags.length, 0);
+
+ do_print("Setting tags to an empty array, tags getter should return "+
+ "empty array");
+ [query] = makeQuery([]);
+ do_check_eq(query.tags.length, 0);
+
+ do_print("Setting a few tags, tags getter should return correct array");
+ var tags = ["bar", "baz", "foo"];
+ [query] = makeQuery(tags);
+ setsAreEqual(query.tags, tags, true);
+
+ do_print("Setting some dupe tags, tags getter return unique tags");
+ [query] = makeQuery(["foo", "foo", "bar", "foo", "baz", "bar"]);
+ setsAreEqual(query.tags, ["bar", "baz", "foo"], true);
+});
+
+add_task(function* invalid_setter_calls() {
+ do_print("Invalid calls to tags setter should fail");
+ try {
+ var query = PlacesUtils.history.getNewQuery();
+ query.tags = null;
+ do_throw("Passing null to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ query = PlacesUtils.history.getNewQuery();
+ query.tags = "this should not work";
+ do_throw("Passing a string to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery([null]);
+ do_throw("Passing one-element array with null to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery([undefined]);
+ do_throw("Passing one-element array with undefined to SetTags " +
+ "should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery(["foo", null, "bar"]);
+ do_throw("Passing mixture of tags and null to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery(["foo", undefined, "bar"]);
+ do_throw("Passing mixture of tags and undefined to SetTags " +
+ "should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery([1, 2, 3]);
+ do_throw("Passing numbers to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery(["foo", 1, 2, 3]);
+ do_throw("Passing mixture of tags and numbers to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ var str = PlacesUtils.toISupportsString("foo");
+ query = PlacesUtils.history.getNewQuery();
+ query.tags = str;
+ do_throw("Passing nsISupportsString to SetTags should fail");
+ }
+ catch (exc) {}
+
+ try {
+ makeQuery([str]);
+ do_throw("Passing array of nsISupportsStrings to SetTags should fail");
+ }
+ catch (exc) {}
+});
+
+add_task(function* not_setting_tags() {
+ do_print("Not setting tags at all should not affect query URI");
+ checkQueryURI();
+});
+
+add_task(function* empty_array_tags() {
+ do_print("Setting tags with an empty array should not affect query URI");
+ checkQueryURI([]);
+});
+
+add_task(function* set_tags() {
+ do_print("Setting some tags should result in correct query URI");
+ checkQueryURI([
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ]);
+});
+
+add_task(function* no_tags_tagsAreNot() {
+ do_print("Not setting tags at all but setting tagsAreNot should " +
+ "affect query URI");
+ checkQueryURI(null, true);
+});
+
+add_task(function* empty_array_tags_tagsAreNot() {
+ do_print("Setting tags with an empty array and setting tagsAreNot " +
+ "should affect query URI");
+ checkQueryURI([], true);
+});
+
+add_task(function* () {
+ do_print("Setting some tags and setting tagsAreNot should result in " +
+ "correct query URI");
+ checkQueryURI([
+ "foo",
+ "七難",
+ "",
+ "いっぱいおっぱい",
+ "Abracadabra",
+ "123",
+ "Here's a pretty long tag name with some = signs and 1 2 3s and spaces oh jeez will it work I hope so!",
+ "アスキーでございません",
+ "あいうえお",
+ ], true);
+});
+
+add_task(function* tag_to_uri() {
+ do_print("Querying history on tag associated with a URI should return " +
+ "that URI");
+ yield task_doWithVisit(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* tags_to_uri() {
+ do_print("Querying history on many tags associated with a URI should " +
+ "return that URI");
+ yield task_doWithVisit(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bar"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* repeated_tag() {
+ do_print("Specifying the same tag multiple times in a history query " +
+ "should not matter");
+ yield task_doWithVisit(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "foo"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "foo", "foo", "bar", "bar", "baz"]);
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* many_tags_no_uri() {
+ do_print("Querying history on many tags associated with a URI and " +
+ "tags not associated with that URI should not return that URI");
+ yield task_doWithVisit(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "baz", "bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(function* nonexistent_tags() {
+ do_print("Querying history on nonexistent tags should return no results");
+ yield task_doWithVisit(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["bogus"]);
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["bogus", "gnarly"]);
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(function* tag_to_bookmark() {
+ do_print("Querying bookmarks on tag associated with a URI should " +
+ "return that URI");
+ yield task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["baz"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* many_tags_to_bookmark() {
+ do_print("Querying bookmarks on many tags associated with a URI " +
+ "should return that URI");
+ yield task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bar"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "baz"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["bar", "baz"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "bar", "baz"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* repeated_tag_to_bookmarks() {
+ do_print("Specifying the same tag multiple times in a bookmark query " +
+ "should not matter");
+ yield task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "foo"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ [query, opts] = makeQuery(["foo", "foo", "foo", "bar", "bar", "baz"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, [aURI.spec]);
+ });
+});
+
+add_task(function* many_tags_no_bookmark() {
+ do_print("Querying bookmarks on many tags associated with a URI and " +
+ "tags not associated with that URI should not return that URI");
+ yield task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["foo", "bogus"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "bogus"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["foo", "bar", "baz", "bogus"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(function* nonexistent_tags_bookmark() {
+ do_print("Querying bookmarks on nonexistent tag should return no results");
+ yield task_doWithBookmark(["foo", "bar", "baz"], function (aURI) {
+ var [query, opts] = makeQuery(["bogus"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, []);
+ [query, opts] = makeQuery(["bogus", "gnarly"]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ executeAndCheckQueryResults(query, opts, []);
+ });
+});
+
+add_task(function* tagsAreNot_history() {
+ do_print("Querying history using tagsAreNot should work correctly");
+ var urisAndTags = {
+ "http://example.com/1": ["foo", "bar"],
+ "http://example.com/2": ["baz", "qux"],
+ "http://example.com/3": null
+ };
+
+ do_print("Add visits and tag the URIs");
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ yield PlacesTestUtils.addVisits(nsiuri);
+ if (tags)
+ PlacesUtils.tagging.tagURI(nsiuri, tags);
+ }
+
+ do_print(' Querying for "foo" should match only /2 and /3');
+ var [query, opts] = makeQuery(["foo"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "bar" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bar"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "bogus" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bogus"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "baz" should match only /3');
+ [query, opts] = makeQuery(["foo", "baz"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/3"]);
+
+ do_print(' Querying for "bogus" should match all');
+ [query, opts] = makeQuery(["bogus"], true);
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/1",
+ "http://example.com/2",
+ "http://example.com/3"]);
+
+ // Clean up.
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ if (tags)
+ PlacesUtils.tagging.untagURI(nsiuri, tags);
+ }
+ yield task_cleanDatabase();
+});
+
+add_task(function* tagsAreNot_bookmarks() {
+ do_print("Querying bookmarks using tagsAreNot should work correctly");
+ var urisAndTags = {
+ "http://example.com/1": ["foo", "bar"],
+ "http://example.com/2": ["baz", "qux"],
+ "http://example.com/3": null
+ };
+
+ do_print("Add bookmarks and tag the URIs");
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ yield addBookmark(nsiuri);
+ if (tags)
+ PlacesUtils.tagging.tagURI(nsiuri, tags);
+ }
+
+ do_print(' Querying for "foo" should match only /2 and /3');
+ var [query, opts] = makeQuery(["foo"], true);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "bar" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bar"], true);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "bogus" should match only /2 and /3');
+ [query, opts] = makeQuery(["foo", "bogus"], true);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/2", "http://example.com/3"]);
+
+ do_print(' Querying for "foo" and "baz" should match only /3');
+ [query, opts] = makeQuery(["foo", "baz"], true);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/3"]);
+
+ do_print(' Querying for "bogus" should match all');
+ [query, opts] = makeQuery(["bogus"], true);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root,
+ ["http://example.com/1",
+ "http://example.com/2",
+ "http://example.com/3"]);
+
+ // Clean up.
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ if (tags)
+ PlacesUtils.tagging.untagURI(nsiuri, tags);
+ }
+ yield task_cleanDatabase();
+});
+
+add_task(function* duplicate_tags() {
+ do_print("Duplicate existing tags (i.e., multiple tag folders with " +
+ "same name) should not throw off query results");
+ var tagName = "foo";
+
+ do_print("Add bookmark and tag it normally");
+ yield addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);
+
+ do_print("Manually create tag folder with same name as tag and insert " +
+ "bookmark");
+ let dupTag = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: tagName
+ });
+
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: dupTag.guid,
+ title: "title",
+ url: TEST_URI
+ });
+
+ do_print("Querying for tag should match URI");
+ var [query, opts] = makeQuery([tagName]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [TEST_URI.spec]);
+
+ PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
+ yield task_cleanDatabase();
+});
+
+add_task(function* folder_named_as_tag() {
+ do_print("Regular folders with the same name as tag should not throw " +
+ "off query results");
+ var tagName = "foo";
+
+ do_print("Add bookmark and tag it");
+ yield addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, [tagName]);
+
+ do_print("Create folder with same name as tag");
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: tagName
+ });
+
+ do_print("Querying for tag should match URI");
+ var [query, opts] = makeQuery([tagName]);
+ opts.queryType = opts.QUERY_TYPE_BOOKMARKS;
+ queryResultsAre(PlacesUtils.history.executeQuery(query, opts).root, [TEST_URI.spec]);
+
+ PlacesUtils.tagging.untagURI(TEST_URI, [tagName]);
+ yield task_cleanDatabase();
+});
+
+add_task(function* ORed_queries() {
+ do_print("Multiple queries ORed together should work");
+ var urisAndTags = {
+ "http://example.com/1": [],
+ "http://example.com/2": []
+ };
+
+ // Search with lots of tags to make sure tag parameter substitution in SQL
+ // can handle it with more than one query.
+ for (let i = 0; i < 11; i++) {
+ urisAndTags["http://example.com/1"].push("/1 tag " + i);
+ urisAndTags["http://example.com/2"].push("/2 tag " + i);
+ }
+
+ do_print("Add visits and tag the URIs");
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ yield PlacesTestUtils.addVisits(nsiuri);
+ if (tags)
+ PlacesUtils.tagging.tagURI(nsiuri, tags);
+ }
+
+ do_print("Query for /1 OR query for /2 should match both /1 and /2");
+ var [query1, opts] = makeQuery(urisAndTags["http://example.com/1"]);
+ var [query2] = makeQuery(urisAndTags["http://example.com/2"]);
+ var root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1", "http://example.com/2"]);
+
+ do_print("Query for /1 OR query on bogus tag should match only /1");
+ [query1, opts] = makeQuery(urisAndTags["http://example.com/1"]);
+ [query2] = makeQuery(["bogus"]);
+ root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1"]);
+
+ do_print("Query for /1 OR query for /1 should match only /1");
+ [query1, opts] = makeQuery(urisAndTags["http://example.com/1"]);
+ [query2] = makeQuery(urisAndTags["http://example.com/1"]);
+ root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1"]);
+
+ do_print("Query for /1 with tagsAreNot OR query for /2 with tagsAreNot " +
+ "should match both /1 and /2");
+ [query1, opts] = makeQuery(urisAndTags["http://example.com/1"], true);
+ [query2] = makeQuery(urisAndTags["http://example.com/2"], true);
+ root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1", "http://example.com/2"]);
+
+ do_print("Query for /1 OR query for /2 with tagsAreNot should match " +
+ "only /1");
+ [query1, opts] = makeQuery(urisAndTags["http://example.com/1"]);
+ [query2] = makeQuery(urisAndTags["http://example.com/2"], true);
+ root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1"]);
+
+ do_print("Query for /1 OR query for /1 with tagsAreNot should match " +
+ "both URIs");
+ [query1, opts] = makeQuery(urisAndTags["http://example.com/1"]);
+ [query2] = makeQuery(urisAndTags["http://example.com/1"], true);
+ root = PlacesUtils.history.executeQueries([query1, query2], 2, opts).root;
+ queryResultsAre(root, ["http://example.com/1", "http://example.com/2"]);
+
+ // Clean up.
+ for (let [pURI, tags] of Object.entries(urisAndTags)) {
+ let nsiuri = uri(pURI);
+ if (tags)
+ PlacesUtils.tagging.untagURI(nsiuri, tags);
+ }
+ yield task_cleanDatabase();
+});
+
+// The tag keys in query URIs, i.e., "place:tag=foo&!tags=1"
+// --- -----
+const QUERY_KEY_TAG = "tag";
+const QUERY_KEY_NOT_TAGS = "!tags";
+
+const TEST_URI = uri("http://example.com/");
+
+/**
+ * Adds a bookmark.
+ *
+ * @param aURI
+ * URI of the page (an nsIURI)
+ */
+function addBookmark(aURI) {
+ return PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: aURI.spec,
+ url: aURI
+ });
+}
+
+/**
+ * Asynchronous task that removes all pages from history and bookmarks.
+ */
+function* task_cleanDatabase(aCallback) {
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+}
+
+/**
+ * Sets up a query with the specified tags, converts it to a URI, and makes sure
+ * the URI is what we expect it to be.
+ *
+ * @param aTags
+ * The query's tags will be set to those in this array
+ * @param aTagsAreNot
+ * The query's tagsAreNot property will be set to this
+ */
+function checkQueryURI(aTags, aTagsAreNot) {
+ var pairs = (aTags || []).sort().map(t => QUERY_KEY_TAG + "=" + encodeTag(t));
+ if (aTagsAreNot)
+ pairs.push(QUERY_KEY_NOT_TAGS + "=1");
+ var expURI = "place:" + pairs.join("&");
+ var [query, opts] = makeQuery(aTags, aTagsAreNot);
+ var actualURI = queryURI(query, opts);
+ do_print("Query URI should be what we expect for the given tags");
+ do_check_eq(actualURI, expURI);
+}
+
+/**
+ * Asynchronous task that executes a callback task in a "scoped" database state.
+ * A bookmark is added and tagged before the callback is called, and afterward
+ * the database is cleared.
+ *
+ * @param aTags
+ * A bookmark will be added and tagged with this array of tags
+ * @param aCallback
+ * A task function that will be called after the bookmark has been tagged
+ */
+function* task_doWithBookmark(aTags, aCallback) {
+ yield addBookmark(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, aTags);
+ yield aCallback(TEST_URI);
+ PlacesUtils.tagging.untagURI(TEST_URI, aTags);
+ yield task_cleanDatabase();
+}
+
+/**
+ * Asynchronous task that executes a callback function in a "scoped" database
+ * state. A history visit is added and tagged before the callback is called,
+ * and afterward the database is cleared.
+ *
+ * @param aTags
+ * A history visit will be added and tagged with this array of tags
+ * @param aCallback
+ * A function that will be called after the visit has been tagged
+ */
+function* task_doWithVisit(aTags, aCallback) {
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ PlacesUtils.tagging.tagURI(TEST_URI, aTags);
+ yield aCallback(TEST_URI);
+ PlacesUtils.tagging.untagURI(TEST_URI, aTags);
+ yield task_cleanDatabase();
+}
+
+/**
+ * queriesToQueryString() encodes every character in the query URI that doesn't
+ * match /[a-zA-Z]/. There's no simple JavaScript function that does the same,
+ * but encodeURIComponent() comes close, only missing some punctuation. This
+ * function takes care of all of that.
+ *
+ * @param aTag
+ * A tag name to encode
+ * @return A UTF-8 escaped string suitable for inclusion in a query URI
+ */
+function encodeTag(aTag) {
+ return encodeURIComponent(aTag).
+ replace(/[-_.!~*'()]/g, // '
+ s => "%" + s.charCodeAt(0).toString(16));
+}
+
+/**
+ * Executes the given query and compares the results to the given URIs.
+ * See queryResultsAre().
+ *
+ * @param aQuery
+ * An nsINavHistoryQuery
+ * @param aQueryOpts
+ * An nsINavHistoryQueryOptions
+ * @param aExpectedURIs
+ * Array of URIs (as strings) that aResultRoot should contain
+ */
+function executeAndCheckQueryResults(aQuery, aQueryOpts, aExpectedURIs) {
+ var root = PlacesUtils.history.executeQuery(aQuery, aQueryOpts).root;
+ root.containerOpen = true;
+ queryResultsAre(root, aExpectedURIs);
+ root.containerOpen = false;
+}
+
+/**
+ * Returns new query and query options objects. The query's tags will be
+ * set to aTags. aTags may be null, in which case setTags() is not called at
+ * all on the query.
+ *
+ * @param aTags
+ * The query's tags will be set to those in this array
+ * @param aTagsAreNot
+ * The query's tagsAreNot property will be set to this
+ * @return [query, queryOptions]
+ */
+function makeQuery(aTags, aTagsAreNot) {
+ aTagsAreNot = !!aTagsAreNot;
+ do_print("Making a query " +
+ (aTags ?
+ "with tags " + aTags.toSource() :
+ "without calling setTags() at all") +
+ " and with tagsAreNot=" +
+ aTagsAreNot);
+ var query = PlacesUtils.history.getNewQuery();
+ query.tagsAreNot = aTagsAreNot;
+ if (aTags) {
+ query.tags = aTags;
+ var uniqueTags = [];
+ aTags.forEach(function (t) {
+ if (typeof(t) === "string" && uniqueTags.indexOf(t) < 0)
+ uniqueTags.push(t);
+ });
+ uniqueTags.sort();
+ }
+
+ do_print("Made query should be correct for tags and tagsAreNot");
+ if (uniqueTags)
+ setsAreEqual(query.tags, uniqueTags, true);
+ var expCount = uniqueTags ? uniqueTags.length : 0;
+ do_check_eq(query.tags.length, expCount);
+ do_check_eq(query.tagsAreNot, aTagsAreNot);
+
+ return [query, PlacesUtils.history.getNewQueryOptions()];
+}
+
+/**
+ * Ensures that the URIs of aResultRoot are the same as those in aExpectedURIs.
+ *
+ * @param aResultRoot
+ * The nsINavHistoryContainerResultNode root of an nsINavHistoryResult
+ * @param aExpectedURIs
+ * Array of URIs (as strings) that aResultRoot should contain
+ */
+function queryResultsAre(aResultRoot, aExpectedURIs) {
+ var rootWasOpen = aResultRoot.containerOpen;
+ if (!rootWasOpen)
+ aResultRoot.containerOpen = true;
+ var actualURIs = [];
+ for (let i = 0; i < aResultRoot.childCount; i++) {
+ actualURIs.push(aResultRoot.getChild(i).uri);
+ }
+ setsAreEqual(actualURIs, aExpectedURIs);
+ if (!rootWasOpen)
+ aResultRoot.containerOpen = false;
+}
+
+/**
+ * Converts the given query into its query URI.
+ *
+ * @param aQuery
+ * An nsINavHistoryQuery
+ * @param aQueryOpts
+ * An nsINavHistoryQueryOptions
+ * @return The query's URI
+ */
+function queryURI(aQuery, aQueryOpts) {
+ return PlacesUtils.history.queriesToQueryString([aQuery], 1, aQueryOpts);
+}
+
+/**
+ * Ensures that the arrays contain the same elements and, optionally, in the
+ * same order.
+ */
+function setsAreEqual(aArr1, aArr2, aIsOrdered) {
+ do_check_eq(aArr1.length, aArr2.length);
+ if (aIsOrdered) {
+ for (let i = 0; i < aArr1.length; i++) {
+ do_check_eq(aArr1[i], aArr2[i]);
+ }
+ }
+ else {
+ aArr1.forEach(u => do_check_true(aArr2.indexOf(u) >= 0));
+ aArr2.forEach(u => do_check_true(aArr1.indexOf(u) >= 0));
+ }
+}
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/queries/test_transitions.js b/toolkit/components/places/tests/queries/test_transitions.js
new file mode 100644
index 000000000..bbd4c9e01
--- /dev/null
+++ b/toolkit/components/places/tests/queries/test_transitions.js
@@ -0,0 +1,178 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* ***** BEGIN LICENSE BLOCK *****
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+ * ***** END LICENSE BLOCK ***** */
+var beginTime = Date.now();
+var testData = [
+ {
+ isVisit: true,
+ title: "page 0",
+ uri: "http://mozilla.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED
+ },
+ {
+ isVisit: true,
+ title: "page 1",
+ uri: "http://google.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ },
+ {
+ isVisit: true,
+ title: "page 2",
+ uri: "http://microsoft.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ },
+ {
+ isVisit: true,
+ title: "page 3",
+ uri: "http://en.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK
+ },
+ {
+ isVisit: true,
+ title: "page 4",
+ uri: "http://fr.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ },
+ {
+ isVisit: true,
+ title: "page 5",
+ uri: "http://apple.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED
+ },
+ {
+ isVisit: true,
+ title: "page 6",
+ uri: "http://campus-bike-store.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ },
+ {
+ isVisit: true,
+ title: "page 7",
+ uri: "http://uwaterloo.ca/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED
+ },
+ {
+ isVisit: true,
+ title: "page 8",
+ uri: "http://pugcleaner.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK
+ },
+ {
+ isVisit: true,
+ title: "page 9",
+ uri: "http://de.wikipedia.org/",
+ transType: Ci.nsINavHistoryService.TRANSITION_TYPED
+ },
+ {
+ isVisit: true,
+ title: "arewefastyet",
+ uri: "http://arewefastyet.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
+ },
+ {
+ isVisit: true,
+ title: "arewefastyet",
+ uri: "http://arewefastyet.com/",
+ transType: Ci.nsINavHistoryService.TRANSITION_BOOKMARK
+ }];
+// sets of indices of testData array by transition type
+var testDataTyped = [0, 5, 7, 9];
+var testDataDownload = [1, 2, 4, 6, 10];
+var testDataBookmark = [3, 8, 11];
+
+/**
+ * run_test is where the magic happens. This is automatically run by the test
+ * harness. It is where you do the work of creating the query, running it, and
+ * playing with the result set.
+ */
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_transitions()
+{
+ let timeNow = Date.now();
+ for (let item of testData) {
+ yield PlacesTestUtils.addVisits({
+ uri: uri(item.uri),
+ transition: item.transType,
+ visitDate: timeNow++ * 1000,
+ title: item.title
+ });
+ }
+
+ // dump_table("moz_places");
+ // dump_table("moz_historyvisits");
+
+ var numSortFunc = function (a, b) { return (a - b); };
+ var arrs = testDataTyped.concat(testDataDownload).concat(testDataBookmark)
+ .sort(numSortFunc);
+
+ // Four tests which compare the result of a query to an expected set.
+ var data = arrs.filter(function (index) {
+ return (testData[index].uri.match(/arewefastyet\.com/) &&
+ testData[index].transType ==
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD);
+ });
+
+ compareQueryToTestData("place:domain=arewefastyet.com&transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ data.slice());
+
+ compareQueryToTestData("place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ testDataDownload.slice());
+
+ compareQueryToTestData("place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_TYPED,
+ testDataTyped.slice());
+
+ compareQueryToTestData("place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
+ "&transition=" +
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ data);
+
+ // Tests the live update property of transitions.
+ var query = {};
+ var options = {};
+ PlacesUtils.history.
+ queryStringToQueries("place:transition=" +
+ Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ query, {}, options);
+ query = (query.value)[0];
+ options = PlacesUtils.history.getNewQueryOptions();
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_eq(testDataDownload.length, root.childCount);
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://getfirefox.com"),
+ transition: TRANSITION_DOWNLOAD
+ });
+ do_check_eq(testDataDownload.length + 1, root.childCount);
+ root.containerOpen = false;
+});
+
+/*
+ * Takes a query and a set of indices. The indices correspond to elements
+ * of testData that are the result of the query.
+ */
+function compareQueryToTestData(queryStr, data) {
+ var query = {};
+ var options = {};
+ PlacesUtils.history.queryStringToQueries(queryStr, query, {}, options);
+ query = query.value[0];
+ options = options.value;
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ for (var i = 0; i < data.length; i++) {
+ data[i] = testData[data[i]];
+ data[i].isInQuery = true;
+ }
+ compareArrayToResult(data, root);
+}
diff --git a/toolkit/components/places/tests/queries/xpcshell.ini b/toolkit/components/places/tests/queries/xpcshell.ini
new file mode 100644
index 000000000..7ff864679
--- /dev/null
+++ b/toolkit/components/places/tests/queries/xpcshell.ini
@@ -0,0 +1,34 @@
+[DEFAULT]
+head = head_queries.js
+tail =
+skip-if = toolkit == 'android'
+
+[test_415716.js]
+[test_abstime-annotation-domain.js]
+[test_abstime-annotation-uri.js]
+[test_async.js]
+[test_containersQueries_sorting.js]
+[test_history_queries_tags_liveUpdate.js]
+[test_history_queries_titles_liveUpdate.js]
+[test_onlyBookmarked.js]
+[test_queryMultipleFolder.js]
+[test_querySerialization.js]
+[test_redirects.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_results-as-tag-contents-query.js]
+[test_results-as-visit.js]
+[test_searchterms-domain.js]
+[test_searchterms-uri.js]
+[test_searchterms-bookmarklets.js]
+[test_sort-date-site-grouping.js]
+[test_sorting.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_tags.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_transitions.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_searchTerms_includeHidden.js]
diff --git a/toolkit/components/places/tests/unifiedcomplete/.eslintrc.js b/toolkit/components/places/tests/unifiedcomplete/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/unifiedcomplete/data/engine-rel-searchform.xml b/toolkit/components/places/tests/unifiedcomplete/data/engine-rel-searchform.xml
new file mode 100644
index 000000000..f4baad28a
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/data/engine-rel-searchform.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>engine-rel-searchform.xml</ShortName>
+<Url type="text/html" method="GET" template="http://example.com/?search" rel="searchform"/>
+</SearchPlugin>
diff --git a/toolkit/components/places/tests/unifiedcomplete/data/engine-suggestions.xml b/toolkit/components/places/tests/unifiedcomplete/data/engine-suggestions.xml
new file mode 100644
index 000000000..a322a7c86
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/data/engine-suggestions.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>engine-suggestions.xml</ShortName>
+<Url type="application/x-suggestions+json"
+ method="GET"
+ template="http://localhost:9000/suggest?{searchTerms}"/>
+<Url type="text/html"
+ method="GET"
+ template="http://localhost:9000/search"
+ rel="searchform"/>
+</SearchPlugin>
diff --git a/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js b/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js
new file mode 100644
index 000000000..11e917e18
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js
@@ -0,0 +1,505 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+const FRECENCY_DEFAULT = 10000;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
+
+const TITLE_SEARCH_ENGINE_SEPARATOR = " \u00B7\u2013\u00B7 ";
+
+function run_test() {
+ run_next_test();
+}
+
+function* cleanup() {
+ Services.prefs.clearUserPref("browser.urlbar.autocomplete.enabled");
+ Services.prefs.clearUserPref("browser.urlbar.autoFill");
+ Services.prefs.clearUserPref("browser.urlbar.autoFill.typed");
+ Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines");
+ let suggestPrefs = [
+ "history",
+ "bookmark",
+ "history.onlyTyped",
+ "openpage",
+ "searches",
+ ];
+ for (let type of suggestPrefs) {
+ Services.prefs.clearUserPref("browser.urlbar.suggest." + type);
+ }
+ Services.prefs.clearUserPref("browser.search.suggest.enabled");
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+}
+do_register_cleanup(cleanup);
+
+/**
+ * @param aSearches
+ * Array of AutoCompleteSearch names.
+ */
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ popup: {
+ selectedIndex: -1,
+ invalidate: function () {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompletePopup])
+ },
+ popupOpen: false,
+
+ disableAutoComplete: false,
+ completeDefaultIndex: true,
+ completeSelectedIndex: true,
+ forceComplete: false,
+
+ minResultsForPopup: 0,
+ maxRows: 0,
+
+ showCommentColumn: false,
+ showImageColumn: false,
+
+ timeout: 10,
+ searchParam: "",
+
+ get searchCount() {
+ return this.searches.length;
+ },
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ textValue: "",
+ // Text selection range
+ _selStart: 0,
+ _selEnd: 0,
+ get selectionStart() {
+ return this._selStart;
+ },
+ get selectionEnd() {
+ return this._selEnd;
+ },
+ selectTextRange: function(aStart, aEnd) {
+ this._selStart = aStart;
+ this._selEnd = aEnd;
+ },
+
+ onSearchBegin: function () {},
+ onSearchComplete: function () {},
+
+ onTextEntered: () => false,
+ onTextReverted: () => false,
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteInput])
+}
+
+// A helper for check_autocomplete to check a specific match against data from
+// the controller.
+function _check_autocomplete_matches(match, result) {
+ let { uri, title, tags, style } = match;
+ if (tags)
+ title += " \u2013 " + tags.sort().join(", ");
+ if (style)
+ style = style.sort();
+ else
+ style = ["favicon"];
+
+ do_print(`Checking against expected "${uri.spec}", "${title}"`);
+ // Got a match on both uri and title?
+ if (stripPrefix(uri.spec) != stripPrefix(result.value) || title != result.comment) {
+ return false;
+ }
+
+ let actualStyle = result.style.split(/\s+/).sort();
+ if (style)
+ Assert.equal(actualStyle.toString(), style.toString(), "Match should have expected style");
+ if (uri.spec.startsWith("moz-action:")) {
+ Assert.ok(actualStyle.includes("action"), "moz-action results should always have 'action' in their style");
+ }
+
+ if (match.icon)
+ Assert.equal(result.image, match.icon, "Match should have expected image");
+
+ return true;
+}
+
+function* check_autocomplete(test) {
+ // At this point frecency could still be updating due to latest pages
+ // updates.
+ // This is not a problem in real life, but autocomplete tests should
+ // return reliable resultsets, thus we have to wait.
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // Make an AutoCompleteInput that uses our searches and confirms results.
+ let input = new AutoCompleteInput(["unifiedcomplete"]);
+ input.textValue = test.search;
+
+ if (test.searchParam)
+ input.searchParam = test.searchParam;
+
+ // Caret must be at the end for autoFill to happen.
+ let strLen = test.search.length;
+ input.selectTextRange(strLen, strLen);
+ Assert.equal(input.selectionStart, strLen, "Selection starts at end");
+ Assert.equal(input.selectionEnd, strLen, "Selection ends at the end");
+
+ let controller = Cc["@mozilla.org/autocomplete/controller;1"]
+ .getService(Ci.nsIAutoCompleteController);
+ controller.input = input;
+
+ let numSearchesStarted = 0;
+ input.onSearchBegin = () => {
+ do_print("onSearchBegin received");
+ numSearchesStarted++;
+ };
+ let searchCompletePromise = new Promise(resolve => {
+ input.onSearchComplete = () => {
+ do_print("onSearchComplete received");
+ resolve();
+ }
+ });
+ let expectedSearches = 1;
+ if (test.incompleteSearch) {
+ controller.startSearch(test.incompleteSearch);
+ expectedSearches++;
+ }
+
+ do_print("Searching for: '" + test.search + "'");
+ controller.startSearch(test.search);
+ yield searchCompletePromise;
+
+ Assert.equal(numSearchesStarted, expectedSearches, "All searches started");
+
+ // Check to see the expected uris and titles match up. If 'enable-actions'
+ // is specified, we check that the first specified match is the first
+ // controller value (as this is the "special" always selected item), but the
+ // rest can match in any order.
+ // If 'enable-actions' is not specified, they can match in any order.
+ if (test.matches) {
+ // Do not modify the test original matches.
+ let matches = test.matches.slice();
+
+ if (matches.length) {
+ let firstIndexToCheck = 0;
+ if (test.searchParam && test.searchParam.includes("enable-actions")) {
+ firstIndexToCheck = 1;
+ do_print("Checking first match is first autocomplete entry")
+ let result = {
+ value: controller.getValueAt(0),
+ comment: controller.getCommentAt(0),
+ style: controller.getStyleAt(0),
+ image: controller.getImageAt(0),
+ }
+ do_print(`First match is "${result.value}", "${result.comment}"`);
+ Assert.ok(_check_autocomplete_matches(matches[0], result), "first item is correct");
+ do_print("Checking rest of the matches");
+ }
+
+ for (let i = firstIndexToCheck; i < controller.matchCount; i++) {
+ let result = {
+ value: controller.getValueAt(i),
+ comment: controller.getCommentAt(i),
+ style: controller.getStyleAt(i),
+ image: controller.getImageAt(i),
+ }
+ do_print(`Looking for "${result.value}", "${result.comment}" in expected results...`);
+ let lowerBound = test.checkSorting ? i : firstIndexToCheck;
+ let upperBound = test.checkSorting ? i + 1 : matches.length;
+ let found = false;
+ for (let j = lowerBound; j < upperBound; ++j) {
+ // Skip processed expected results
+ if (matches[j] == undefined)
+ continue;
+ if (_check_autocomplete_matches(matches[j], result)) {
+ do_print("Got a match at index " + j + "!");
+ // Make it undefined so we don't process it again
+ matches[j] = undefined;
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ do_throw(`Didn't find the current result ("${result.value}", "${result.comment}") in matches`); // ' (Emacs syntax highlighting fix)
+ }
+ }
+
+ Assert.equal(controller.matchCount, matches.length,
+ "Got as many results as expected");
+
+ // If we expect results, make sure we got matches.
+ do_check_eq(controller.searchStatus, matches.length ?
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH :
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+ }
+
+ if (test.autofilled) {
+ // Check the autoFilled result.
+ Assert.equal(input.textValue, test.autofilled,
+ "Autofilled value is correct");
+
+ // Now force completion and check correct casing of the result.
+ // This ensures the controller is able to do its magic case-preserving
+ // stuff and correct replacement of the user's casing with result's one.
+ controller.handleEnter(false);
+ Assert.equal(input.textValue, test.completed,
+ "Completed value is correct");
+ }
+}
+
+var addBookmark = Task.async(function* (aBookmarkObj) {
+ Assert.ok(!!aBookmarkObj.uri, "Bookmark object contains an uri");
+ let parentId = aBookmarkObj.parentId ? aBookmarkObj.parentId
+ : PlacesUtils.unfiledBookmarksFolderId;
+
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: (yield PlacesUtils.promiseItemGuid(parentId)),
+ title: aBookmarkObj.title || "A bookmark",
+ url: aBookmarkObj.uri
+ });
+ yield PlacesUtils.promiseItemId(bm.guid);
+
+ if (aBookmarkObj.keyword) {
+ yield PlacesUtils.keywords.insert({ keyword: aBookmarkObj.keyword,
+ url: aBookmarkObj.uri.spec,
+ postData: aBookmarkObj.postData
+ });
+ }
+
+ if (aBookmarkObj.tags) {
+ PlacesUtils.tagging.tagURI(aBookmarkObj.uri, aBookmarkObj.tags);
+ }
+});
+
+function addOpenPages(aUri, aCount=1, aUserContextId=0) {
+ let ac = Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"]
+ .getService(Ci.mozIPlacesAutoComplete);
+ for (let i = 0; i < aCount; i++) {
+ ac.registerOpenPage(aUri, aUserContextId);
+ }
+}
+
+function removeOpenPages(aUri, aCount=1, aUserContextId=0) {
+ let ac = Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"]
+ .getService(Ci.mozIPlacesAutoComplete);
+ for (let i = 0; i < aCount; i++) {
+ ac.unregisterOpenPage(aUri, aUserContextId);
+ }
+}
+
+function changeRestrict(aType, aChar) {
+ let branch = "browser.urlbar.";
+ // "title" and "url" are different from everything else, so special case them.
+ if (aType == "title" || aType == "url")
+ branch += "match.";
+ else
+ branch += "restrict.";
+
+ do_print("changing restrict for " + aType + " to '" + aChar + "'");
+ Services.prefs.setCharPref(branch + aType, aChar);
+}
+
+function resetRestrict(aType) {
+ let branch = "browser.urlbar.";
+ // "title" and "url" are different from everything else, so special case them.
+ if (aType == "title" || aType == "url")
+ branch += "match.";
+ else
+ branch += "restrict.";
+
+ Services.prefs.clearUserPref(branch + aType);
+}
+
+/**
+ * Strip prefixes from the URI that we don't care about for searching.
+ *
+ * @param spec
+ * The text to modify.
+ * @return the modified spec.
+ */
+function stripPrefix(spec)
+{
+ ["http://", "https://", "ftp://"].some(scheme => {
+ if (spec.startsWith(scheme)) {
+ spec = spec.slice(scheme.length);
+ return true;
+ }
+ return false;
+ });
+
+ if (spec.startsWith("www.")) {
+ spec = spec.slice(4);
+ }
+ return spec;
+}
+
+function makeActionURI(action, params) {
+ let encodedParams = {};
+ for (let key in params) {
+ encodedParams[key] = encodeURIComponent(params[key]);
+ }
+ let url = "moz-action:" + action + "," + JSON.stringify(encodedParams);
+ return NetUtil.newURI(url);
+}
+
+// Creates a full "match" entry for a search result, suitable for passing as
+// an entry to check_autocomplete.
+function makeSearchMatch(input, extra = {}) {
+ // Note that counter-intuitively, the order the object properties are defined
+ // in the object passed to makeActionURI is important for check_autocomplete
+ // to match them :(
+ let params = {
+ engineName: extra.engineName || "MozSearch",
+ input,
+ searchQuery: "searchQuery" in extra ? extra.searchQuery : input,
+ };
+ if ("alias" in extra) {
+ // May be undefined, which is expected, but in that case make sure it's not
+ // included in the params of the moz-action URL.
+ params.alias = extra.alias;
+ }
+ let style = [ "action", "searchengine" ];
+ if (Array.isArray(extra.style)) {
+ style.push(...extra.style);
+ }
+ if (extra.heuristic) {
+ style.push("heuristic");
+ }
+ return {
+ uri: makeActionURI("searchengine", params),
+ title: params.engineName,
+ style,
+ }
+}
+
+// Creates a full "match" entry for a search result, suitable for passing as
+// an entry to check_autocomplete.
+function makeVisitMatch(input, url, extra = {}) {
+ // Note that counter-intuitively, the order the object properties are defined
+ // in the object passed to makeActionURI is important for check_autocomplete
+ // to match them :(
+ let params = {
+ url,
+ input,
+ }
+ let style = [ "action", "visiturl" ];
+ if (extra.heuristic) {
+ style.push("heuristic");
+ }
+ return {
+ uri: makeActionURI("visiturl", params),
+ title: extra.title || url,
+ style,
+ }
+}
+
+function makeSwitchToTabMatch(url, extra = {}) {
+ return {
+ uri: makeActionURI("switchtab", {url}),
+ title: extra.title || url,
+ style: [ "action", "switchtab" ],
+ }
+}
+
+function makeExtensionMatch(extra = {}) {
+ let style = [ "action", "extension" ];
+ if (extra.heuristic) {
+ style.push("heuristic");
+ }
+
+ return {
+ uri: makeActionURI("extension", {
+ content: extra.content,
+ keyword: extra.keyword,
+ }),
+ title: extra.description,
+ style,
+ };
+}
+
+function setFaviconForHref(href, iconHref) {
+ return new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ NetUtil.newURI(href),
+ NetUtil.newURI(iconHref),
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+}
+
+function makeTestServer(port=-1) {
+ let httpServer = new HttpServer();
+ httpServer.start(port);
+ do_register_cleanup(() => httpServer.stop(() => {}));
+ return httpServer;
+}
+
+function* addTestEngine(basename, httpServer=undefined) {
+ httpServer = httpServer || makeTestServer();
+ httpServer.registerDirectory("/", do_get_cwd());
+ let dataUrl =
+ "http://localhost:" + httpServer.identity.primaryPort + "/data/";
+
+ do_print("Adding engine: " + basename);
+ return yield new Promise(resolve => {
+ Services.obs.addObserver(function obs(subject, topic, data) {
+ let engine = subject.QueryInterface(Ci.nsISearchEngine);
+ do_print("Observed " + data + " for " + engine.name);
+ if (data != "engine-added" || engine.name != basename) {
+ return;
+ }
+
+ Services.obs.removeObserver(obs, "browser-search-engine-modified");
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+ resolve(engine);
+ }, "browser-search-engine-modified", false);
+
+ do_print("Adding engine from URL: " + dataUrl + basename);
+ Services.search.addEngine(dataUrl + basename, null, null, false);
+ });
+}
+
+// Ensure we have a default search engine and the keyword.enabled preference
+// set.
+add_task(function* ensure_search_engine() {
+ // keyword.enabled is necessary for the tests to see keyword searches.
+ Services.prefs.setBoolPref("keyword.enabled", true);
+
+ // Initialize the search service, but first set this geo IP pref to a dummy
+ // string. When the search service is initialized, it contacts the URI named
+ // in this pref, which breaks the test since outside connections aren't
+ // allowed.
+ let geoPref = "browser.search.geoip.url";
+ Services.prefs.setCharPref(geoPref, "");
+ do_register_cleanup(() => Services.prefs.clearUserPref(geoPref));
+ yield new Promise(resolve => {
+ Services.search.init(resolve);
+ });
+
+ // Remove any existing engines before adding ours.
+ for (let engine of Services.search.getEngines()) {
+ Services.search.removeEngine(engine);
+ }
+ Services.search.addEngineWithDetails("MozSearch", "", "", "", "GET",
+ "http://s.example.com/search");
+ let engine = Services.search.getEngineByName("MozSearch");
+ Services.search.currentEngine = engine;
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_416211.js b/toolkit/components/places/tests/unifiedcomplete/test_416211.js
new file mode 100644
index 000000000..e02906ddc
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_416211.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Test bug 416211 to make sure results that match the tag show the bookmark
+ * title instead of the page title.
+ */
+
+add_task(function* test_tag_match_has_bookmark_title() {
+ do_print("Make sure the tag match gives the bookmark title");
+ let uri = NetUtil.newURI("http://theuri/");
+ yield PlacesTestUtils.addVisits({ uri: uri, title: "Page title" });
+ yield addBookmark({ uri: uri,
+ title: "Bookmark title",
+ tags: [ "superTag" ]});
+ yield check_autocomplete({
+ search: "superTag",
+ matches: [ { uri: uri, title: "Bookmark title", tags: [ "superTag" ], style: [ "bookmark-tag" ] } ]
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_416214.js b/toolkit/components/places/tests/unifiedcomplete/test_416214.js
new file mode 100644
index 000000000..a30b3fe74
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_416214.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Test autocomplete for non-English URLs that match the tag bug 416214. Also
+ * test bug 417441 by making sure escaped ascii characters like "+" remain
+ * escaped.
+ *
+ * - add a visit for a page with a non-English URL
+ * - add a tag for the page
+ * - search for the tag
+ * - test number of matches (should be exactly one)
+ * - make sure the url is decoded
+ */
+
+add_task(function* test_tag_match_url() {
+ do_print("Make sure tag matches return the right url as well as '+' remain escaped");
+ let uri1 = NetUtil.newURI("http://escaped/ユニコード");
+ let uri2 = NetUtil.newURI("http://asciiescaped/blocking-firefox3%2B");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" }
+ ]);
+ yield addBookmark({ uri: uri1,
+ title: "title",
+ tags: [ "superTag" ],
+ style: [ "bookmark-tag" ] });
+ yield addBookmark({ uri: uri2,
+ title: "title",
+ tags: [ "superTag" ],
+ style: [ "bookmark-tag" ] });
+ yield check_autocomplete({
+ search: "superTag",
+ matches: [ { uri: uri1, title: "title", tags: [ "superTag" ], style: [ "bookmark-tag" ] },
+ { uri: uri2, title: "title", tags: [ "superTag" ], style: [ "bookmark-tag" ] } ]
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_417798.js b/toolkit/components/places/tests/unifiedcomplete/test_417798.js
new file mode 100644
index 000000000..bed14b2ce
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_417798.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 417798 to make sure javascript: URIs don't show up unless the
+ * user searches for javascript: explicitly.
+ */
+
+add_task(function* test_javascript_match() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false);
+
+ let uri1 = NetUtil.newURI("http://abc/def");
+ let uri2 = NetUtil.newURI("javascript:5");
+ yield PlacesTestUtils.addVisits([ { uri: uri1, title: "Title with javascript:" } ]);
+ yield addBookmark({ uri: uri2,
+ title: "Title with javascript:" });
+
+ do_print("Match non-javascript: with plain search");
+ yield check_autocomplete({
+ search: "a",
+ matches: [ { uri: uri1, title: "Title with javascript:" } ]
+ });
+
+ do_print("Match non-javascript: with almost javascript:");
+ yield check_autocomplete({
+ search: "javascript",
+ matches: [ { uri: uri1, title: "Title with javascript:" } ]
+ });
+
+ do_print("Match javascript:");
+ yield check_autocomplete({
+ search: "javascript:",
+ matches: [ { uri: uri1, title: "Title with javascript:" },
+ { uri: uri2, title: "Title with javascript:", style: [ "bookmark" ]} ]
+ });
+
+ do_print("Match nothing with non-first javascript:");
+ yield check_autocomplete({
+ search: "5 javascript:",
+ matches: [ ]
+ });
+
+ do_print("Match javascript: with multi-word search");
+ yield check_autocomplete({
+ search: "javascript: 5",
+ matches: [ { uri: uri2, title: "Title with javascript:", style: [ "bookmark" ]} ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_418257.js b/toolkit/components/places/tests/unifiedcomplete/test_418257.js
new file mode 100644
index 000000000..323c2a7af
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_418257.js
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 418257 by making sure tags are returned with the title as part of
+ * the "comment" if there are tags even if we didn't match in the tags. They
+ * are separated from the title by a endash.
+ */
+
+add_task(function* test_javascript_match() {
+ let uri1 = NetUtil.newURI("http://page1");
+ let uri2 = NetUtil.newURI("http://page2");
+ let uri3 = NetUtil.newURI("http://page3");
+ let uri4 = NetUtil.newURI("http://page4");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "tagged" },
+ { uri: uri2, title: "tagged" },
+ { uri: uri3, title: "tagged" },
+ { uri: uri4, title: "tagged" }
+ ]);
+ yield addBookmark({ uri: uri1,
+ title: "tagged",
+ tags: [ "tag1" ] });
+ yield addBookmark({ uri: uri2,
+ title: "tagged",
+ tags: [ "tag1", "tag2" ] });
+ yield addBookmark({ uri: uri3,
+ title: "tagged",
+ tags: [ "tag1", "tag3" ] });
+ yield addBookmark({ uri: uri4,
+ title: "tagged",
+ tags: [ "tag1", "tag2", "tag3" ] });
+
+ do_print("Make sure tags come back in the title when matching tags");
+ yield check_autocomplete({
+ search: "page1 tag",
+ matches: [ { uri: uri1, title: "tagged", tags: [ "tag1" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Check tags in title for page2");
+ yield check_autocomplete({
+ search: "page2 tag",
+ matches: [ { uri: uri2, title: "tagged", tags: [ "tag1", "tag2" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Make sure tags appear even when not matching the tag");
+ yield check_autocomplete({
+ search: "page3",
+ matches: [ { uri: uri3, title: "tagged", tags: [ "tag1", "tag3" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Multiple tags come in commas for page4");
+ yield check_autocomplete({
+ search: "page4",
+ matches: [ { uri: uri4, title: "tagged", tags: [ "tag1", "tag2", "tag3" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Extra test just to make sure we match the title");
+ yield check_autocomplete({
+ search: "tag2",
+ matches: [ { uri: uri2, title: "tagged", tags: [ "tag1", "tag2" ], style: [ "bookmark-tag" ] },
+ { uri: uri4, title: "tagged", tags: [ "tag1", "tag2", "tag3" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_422277.js b/toolkit/components/places/tests/unifiedcomplete/test_422277.js
new file mode 100644
index 000000000..df6f7601a
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_422277.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 422277 to make sure bad escaped uris don't get escaped. This makes
+ * sure we don't hit an assertion for "not a UTF8 string".
+ */
+
+add_task(function* test_javascript_match() {
+ do_print("Bad escaped uri stays escaped");
+ let uri1 = NetUtil.newURI("http://site/%EAid");
+ yield PlacesTestUtils.addVisits([ { uri: uri1, title: "title" } ]);
+ yield check_autocomplete({
+ search: "site",
+ matches: [ { uri: uri1, title: "title" } ]
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_functional.js b/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_functional.js
new file mode 100644
index 000000000..cd2dfdb17
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_functional.js
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Functional tests for inline autocomplete
+
+const PREF_AUTOCOMPLETE_ENABLED = "browser.urlbar.autocomplete.enabled";
+
+add_task(function* test_disabling_autocomplete() {
+ do_print("Check disabling autocomplete disables autofill");
+ Services.prefs.setBoolPref(PREF_AUTOCOMPLETE_ENABLED, false);
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://visit.mozilla.org"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "vis",
+ autofilled: "vis",
+ completed: "vis"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_urls_order() {
+ do_print("Add urls, check for correct order");
+ let places = [{ uri: NetUtil.newURI("http://visit1.mozilla.org") },
+ { uri: NetUtil.newURI("http://visit2.mozilla.org"),
+ transition: TRANSITION_TYPED }];
+ yield PlacesTestUtils.addVisits(places);
+ yield check_autocomplete({
+ search: "vis",
+ autofilled: "visit2.mozilla.org/",
+ completed: "visit2.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_ignore_prefix() {
+ do_print("Add urls, make sure www and http are ignored");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://www.visit1.mozilla.org"));
+ yield check_autocomplete({
+ search: "visit1",
+ autofilled: "visit1.mozilla.org/",
+ completed: "visit1.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_after_host() {
+ do_print("Autocompleting after an existing host completes to the url");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://www.visit3.mozilla.org"));
+ yield check_autocomplete({
+ search: "visit3.mozilla.org/",
+ autofilled: "visit3.mozilla.org/",
+ completed: "visit3.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_respect_www() {
+ do_print("Searching for www.me should yield www.me.mozilla.org/");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://www.me.mozilla.org"));
+ yield check_autocomplete({
+ search: "www.me",
+ autofilled: "www.me.mozilla.org/",
+ completed: "www.me.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_bookmark_first() {
+ do_print("With a bookmark and history, the query result should be the bookmark");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield addBookmark({ uri: NetUtil.newURI("http://bookmark1.mozilla.org/") });
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://bookmark1.mozilla.org/foo"));
+ yield check_autocomplete({
+ search: "bookmark",
+ autofilled: "bookmark1.mozilla.org/",
+ completed: "bookmark1.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_full_path() {
+ do_print("Check to make sure we get the proper results with full paths");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ let places = [{ uri: NetUtil.newURI("http://smokey.mozilla.org/foo/bar/baz?bacon=delicious") },
+ { uri: NetUtil.newURI("http://smokey.mozilla.org/foo/bar/baz?bacon=smokey") }];
+ yield PlacesTestUtils.addVisits(places);
+ yield check_autocomplete({
+ search: "smokey",
+ autofilled: "smokey.mozilla.org/",
+ completed: "smokey.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_complete_to_slash() {
+ do_print("Check to make sure we autocomplete to the following '/'");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ let places = [{ uri: NetUtil.newURI("http://smokey.mozilla.org/foo/bar/baz?bacon=delicious") },
+ { uri: NetUtil.newURI("http://smokey.mozilla.org/foo/bar/baz?bacon=smokey") }];
+ yield PlacesTestUtils.addVisits(places);
+ yield check_autocomplete({
+ search: "smokey.mozilla.org/fo",
+ autofilled: "smokey.mozilla.org/foo/",
+ completed: "http://smokey.mozilla.org/foo/",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_complete_to_slash_with_www() {
+ do_print("Check to make sure we autocomplete to the following '/'");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ let places = [{ uri: NetUtil.newURI("http://www.smokey.mozilla.org/foo/bar/baz?bacon=delicious") },
+ { uri: NetUtil.newURI("http://www.smokey.mozilla.org/foo/bar/baz?bacon=smokey") }];
+ yield PlacesTestUtils.addVisits(places);
+ yield check_autocomplete({
+ search: "smokey.mozilla.org/fo",
+ autofilled: "smokey.mozilla.org/foo/",
+ completed: "http://www.smokey.mozilla.org/foo/",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_complete_querystring() {
+ do_print("Check to make sure we autocomplete after ?");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://smokey.mozilla.org/foo?bacon=delicious"));
+ yield check_autocomplete({
+ search: "smokey.mozilla.org/foo?",
+ autofilled: "smokey.mozilla.org/foo?bacon=delicious",
+ completed: "http://smokey.mozilla.org/foo?bacon=delicious",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_complete_fragment() {
+ do_print("Check to make sure we autocomplete after #");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://smokey.mozilla.org/foo?bacon=delicious#bar"));
+ yield check_autocomplete({
+ search: "smokey.mozilla.org/foo?bacon=delicious#bar",
+ autofilled: "smokey.mozilla.org/foo?bacon=delicious#bar",
+ completed: "http://smokey.mozilla.org/foo?bacon=delicious#bar",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_autocomplete_enabled_pref() {
+ Services.prefs.setBoolPref(PREF_AUTOCOMPLETE_ENABLED, false);
+ let types = ["history", "bookmark", "openpage"];
+ for (type of types) {
+ do_check_eq(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false,
+ "suggest." + type + "pref should be false");
+ }
+ Services.prefs.setBoolPref(PREF_AUTOCOMPLETE_ENABLED, true);
+ for (type of types) {
+ do_check_eq(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), true,
+ "suggest." + type + "pref should be true");
+ }
+
+ // Clear prefs.
+ Services.prefs.clearUserPref(PREF_AUTOCOMPLETE_ENABLED);
+ for (type of types) {
+ Services.prefs.clearUserPref("browser.urlbar.suggest." + type);
+ }
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_on_value_removed_479089.js b/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_on_value_removed_479089.js
new file mode 100644
index 000000000..ecc96266b
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_autocomplete_on_value_removed_479089.js
@@ -0,0 +1,39 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Need to test that removing a page from autocomplete actually removes a page
+ * Description From Shawn Wilsher :sdwilsh 2009-02-18 11:29:06 PST
+ * We don't test the code path of onValueRemoved
+ * for the autocomplete implementation
+ * Bug 479089
+ */
+
+add_task(function* test_autocomplete_on_value_removed() {
+ let listener = Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"].
+ getService(Components.interfaces.nsIAutoCompleteSimpleResultListener);
+
+ let testUri = NetUtil.newURI("http://foo.mozilla.com/");
+ yield PlacesTestUtils.addVisits({
+ uri: testUri,
+ referrer: uri("http://mozilla.com/")
+ });
+
+ let query = PlacesUtils.history.getNewQuery();
+ let options = PlacesUtils.history.getNewQueryOptions();
+ // look for this uri only
+ query.uri = testUri;
+
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, 1);
+ // call the untested code path
+ listener.onValueRemoved(null, testUri.spec, true);
+ // make sure it is GONE from the DB
+ Assert.equal(root.childCount, 0);
+ // close the container
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_autofill_default_behavior.js b/toolkit/components/places/tests/unifiedcomplete/test_autofill_default_behavior.js
new file mode 100644
index 000000000..482fcf485
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_autofill_default_behavior.js
@@ -0,0 +1,310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test autoFill for different default behaviors.
+ */
+
+add_task(function* test_default_behavior_host() {
+ let uri1 = NetUtil.newURI("http://typed/");
+ let uri2 = NetUtil.newURI("http://visited/");
+ let uri3 = NetUtil.newURI("http://bookmarked/");
+ let uri4 = NetUtil.newURI("http://tpbk/");
+ let uri5 = NetUtil.newURI("http://tagged/");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "typed", transition: TRANSITION_TYPED },
+ { uri: uri2, title: "visited" },
+ { uri: uri4, title: "tpbk", transition: TRANSITION_TYPED },
+ ]);
+ yield addBookmark( { uri: uri3, title: "bookmarked" } );
+ yield addBookmark( { uri: uri4, title: "tpbk" } );
+ yield addBookmark( { uri: uri5, title: "title", tags: ["foo"] } );
+
+ yield setFaviconForHref(uri1.spec, "chrome://global/skin/icons/information-16.png");
+ yield setFaviconForHref(uri3.spec, "chrome://global/skin/icons/error-16.png");
+
+ // RESTRICT TO HISTORY.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+
+ do_print("Restrict history, common visit, should not autoFill");
+ yield check_autocomplete({
+ search: "vi",
+ matches: [ { uri: uri2, title: "visited" } ],
+ autofilled: "vi",
+ completed: "vi"
+ });
+
+ do_print("Restrict history, typed visit, should autoFill");
+ yield check_autocomplete({
+ search: "ty",
+ matches: [ { uri: uri1, title: "typed", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/information-16.png" } ],
+ autofilled: "typed/",
+ completed: "typed/"
+ });
+
+ // Don't autoFill this one cause it's not typed.
+ do_print("Restrict history, bookmark, should not autoFill");
+ yield check_autocomplete({
+ search: "bo",
+ matches: [ ],
+ autofilled: "bo",
+ completed: "bo"
+ });
+
+ // Note we don't show this one cause it's not typed.
+ do_print("Restrict history, typed bookmark, should autoFill");
+ yield check_autocomplete({
+ search: "tp",
+ matches: [ { uri: uri4, title: "tpbk", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "tpbk/",
+ completed: "tpbk/"
+ });
+
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+
+ // We are not restricting on typed, so we autoFill the bookmark even if we
+ // are restricted to history. We accept that cause not doing that
+ // would be a perf hit and the privacy implications are very weak.
+ do_print("Restrict history, bookmark, autoFill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "bo",
+ matches: [ { uri: uri3, title: "bookmarked", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/error-16.png" } ],
+ autofilled: "bookmarked/",
+ completed: "bookmarked/"
+ });
+
+ do_print("Restrict history, common visit, autoFill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "vi",
+ matches: [ { uri: uri2, title: "visited", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "visited/",
+ completed: "visited/"
+ });
+
+ // RESTRICT TO TYPED.
+ // This should basically ignore autoFill.typed and acts as if it would be set.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", true);
+
+ // Typed behavior basically acts like history, but filters on typed.
+ do_print("Restrict typed, common visit, autoFill.typed = false, should not autoFill");
+ yield check_autocomplete({
+ search: "vi",
+ matches: [ ],
+ autofilled: "vi",
+ completed: "vi"
+ });
+
+ do_print("Restrict typed, typed visit, autofill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "ty",
+ matches: [ { uri: uri1, title: "typed", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/information-16.png"} ],
+ autofilled: "typed/",
+ completed: "typed/"
+ });
+
+ do_print("Restrict typed, bookmark, autofill.typed = false, should not autoFill");
+ yield check_autocomplete({
+ search: "bo",
+ matches: [ ],
+ autofilled: "bo",
+ completed: "bo"
+ });
+
+ do_print("Restrict typed, typed bookmark, autofill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "tp",
+ matches: [ { uri: uri4, title: "tpbk", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "tpbk/",
+ completed: "tpbk/"
+ });
+
+ // RESTRICT BOOKMARKS.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", true);
+
+ do_print("Restrict bookmarks, common visit, should not autoFill");
+ yield check_autocomplete({
+ search: "vi",
+ matches: [ ],
+ autofilled: "vi",
+ completed: "vi"
+ });
+
+ do_print("Restrict bookmarks, typed visit, should not autoFill");
+ yield check_autocomplete({
+ search: "ty",
+ matches: [ ],
+ autofilled: "ty",
+ completed: "ty"
+ });
+
+ // Don't autoFill this one cause it's not typed.
+ do_print("Restrict bookmarks, bookmark, should not autoFill");
+ yield check_autocomplete({
+ search: "bo",
+ matches: [ { uri: uri3, title: "bookmarked", style: [ "bookmark" ],
+ icon: "chrome://global/skin/icons/error-16.png"} ],
+ autofilled: "bo",
+ completed: "bo"
+ });
+
+ // Note we don't show this one cause it's not typed.
+ do_print("Restrict bookmarks, typed bookmark, should autoFill");
+ yield check_autocomplete({
+ search: "tp",
+ matches: [ { uri: uri4, title: "tpbk", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "tpbk/",
+ completed: "tpbk/"
+ });
+
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+
+ do_print("Restrict bookmarks, bookmark, autofill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "bo",
+ matches: [ { uri: uri3, title: "bookmarked", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/error-16.png" } ],
+ autofilled: "bookmarked/",
+ completed: "bookmarked/"
+ });
+
+ // Don't autofill because it's a title.
+ do_print("Restrict bookmarks, title, autofill.typed = false, should not autoFill");
+ yield check_autocomplete({
+ search: "# ta",
+ matches: [ ],
+ autofilled: "# ta",
+ completed: "# ta"
+ });
+
+ // Don't autofill because it's a tag.
+ do_print("Restrict bookmarks, tag, autofill.typed = false, should not autoFill");
+ yield check_autocomplete({
+ search: "+ ta",
+ matches: [ { uri: uri5, title: "title", tags: [ "foo" ], style: [ "tag" ] } ],
+ autofilled: "+ ta",
+ completed: "+ ta"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_default_behavior_url() {
+ let uri1 = NetUtil.newURI("http://typed/ty/");
+ let uri2 = NetUtil.newURI("http://visited/vi/");
+ let uri3 = NetUtil.newURI("http://bookmarked/bo/");
+ let uri4 = NetUtil.newURI("http://tpbk/tp/");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "typed", transition: TRANSITION_TYPED },
+ { uri: uri2, title: "visited" },
+ { uri: uri4, title: "tpbk", transition: TRANSITION_TYPED },
+ ]);
+ yield addBookmark( { uri: uri3, title: "bookmarked" } );
+ yield addBookmark( { uri: uri4, title: "tpbk" } );
+
+ yield setFaviconForHref(uri1.spec, "chrome://global/skin/icons/information-16.png");
+ yield setFaviconForHref(uri3.spec, "chrome://global/skin/icons/error-16.png");
+
+ // RESTRICT TO HISTORY.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", true);
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false);
+
+ do_print("URL: Restrict history, common visit, should not autoFill");
+ yield check_autocomplete({
+ search: "visited/v",
+ matches: [ { uri: uri2, title: "visited" } ],
+ autofilled: "visited/v",
+ completed: "visited/v"
+ });
+
+ do_print("URL: Restrict history, typed visit, should autoFill");
+ yield check_autocomplete({
+ search: "typed/t",
+ matches: [ { uri: uri1, title: "typed/ty/", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/information-16.png"} ],
+ autofilled: "typed/ty/",
+ completed: "http://typed/ty/"
+ });
+
+ // Don't autoFill this one cause it's not typed.
+ do_print("URL: Restrict history, bookmark, should not autoFill");
+ yield check_autocomplete({
+ search: "bookmarked/b",
+ matches: [ ],
+ autofilled: "bookmarked/b",
+ completed: "bookmarked/b"
+ });
+
+ // Note we don't show this one cause it's not typed.
+ do_print("URL: Restrict history, typed bookmark, should autoFill");
+ yield check_autocomplete({
+ search: "tpbk/t",
+ matches: [ { uri: uri4, title: "tpbk/tp/", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "tpbk/tp/",
+ completed: "http://tpbk/tp/"
+ });
+
+ // RESTRICT BOOKMARKS.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+
+ do_print("URL: Restrict bookmarks, common visit, should not autoFill");
+ yield check_autocomplete({
+ search: "visited/v",
+ matches: [ ],
+ autofilled: "visited/v",
+ completed: "visited/v"
+ });
+
+ do_print("URL: Restrict bookmarks, typed visit, should not autoFill");
+ yield check_autocomplete({
+ search: "typed/t",
+ matches: [ ],
+ autofilled: "typed/t",
+ completed: "typed/t"
+ });
+
+ // Don't autoFill this one cause it's not typed.
+ do_print("URL: Restrict bookmarks, bookmark, should not autoFill");
+ yield check_autocomplete({
+ search: "bookmarked/b",
+ matches: [ { uri: uri3, title: "bookmarked", style: [ "bookmark" ],
+ icon: "chrome://global/skin/icons/error-16.png" } ],
+ autofilled: "bookmarked/b",
+ completed: "bookmarked/b"
+ });
+
+ // Note we don't show this one cause it's not typed.
+ do_print("URL: Restrict bookmarks, typed bookmark, should autoFill");
+ yield check_autocomplete({
+ search: "tpbk/t",
+ matches: [ { uri: uri4, title: "tpbk/tp/", style: [ "autofill", "heuristic" ] } ],
+ autofilled: "tpbk/tp/",
+ completed: "http://tpbk/tp/"
+ });
+
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+
+ do_print("URL: Restrict bookmarks, bookmark, autofill.typed = false, should autoFill");
+ yield check_autocomplete({
+ search: "bookmarked/b",
+ matches: [ { uri: uri3, title: "bookmarked/bo/", style: [ "autofill", "heuristic" ],
+ icon: "chrome://global/skin/icons/error-16.png" } ],
+ autofilled: "bookmarked/bo/",
+ completed: "http://bookmarked/bo/"
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_avoid_middle_complete.js b/toolkit/components/places/tests/unifiedcomplete/test_avoid_middle_complete.js
new file mode 100644
index 000000000..54fc343ca
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_avoid_middle_complete.js
@@ -0,0 +1,179 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_prefix_space_noautofill() {
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://moz.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+
+ do_print("Should not try to autoFill if search string contains a space");
+ yield check_autocomplete({
+ search: " mo",
+ autofilled: " mo",
+ completed: " mo"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_trailing_space_noautofill() {
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://moz.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+
+ do_print("Should not try to autoFill if search string contains a space");
+ yield check_autocomplete({
+ search: "mo ",
+ autofilled: "mo ",
+ completed: "mo "
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_autofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("CakeSearch", "", "", "",
+ "GET", "http://cake.search/");
+ let engine = Services.search.getEngineByName("CakeSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ do_print("Should autoFill search engine if search string does not contains a space");
+ yield check_autocomplete({
+ search: "ca",
+ autofilled: "cake.search",
+ completed: "http://cake.search"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_prefix_space_noautofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("CupcakeSearch", "", "", "",
+ "GET", "http://cupcake.search/");
+ let engine = Services.search.getEngineByName("CupcakeSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ do_print("Should not try to autoFill search engine if search string contains a space");
+ yield check_autocomplete({
+ search: " cu",
+ autofilled: " cu",
+ completed: " cu"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_trailing_space_noautofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("BaconSearch", "", "", "",
+ "GET", "http://bacon.search/");
+ let engine = Services.search.getEngineByName("BaconSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ do_print("Should not try to autoFill search engine if search string contains a space");
+ yield check_autocomplete({
+ search: "ba ",
+ autofilled: "ba ",
+ completed: "ba "
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_www_noautofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("HamSearch", "", "", "",
+ "GET", "http://ham.search/");
+ let engine = Services.search.getEngineByName("HamSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ do_print("Should not autoFill search engine if search string contains www. but engine doesn't");
+ yield check_autocomplete({
+ search: "www.ham",
+ autofilled: "www.ham",
+ completed: "www.ham"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_different_scheme_noautofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("PieSearch", "", "", "",
+ "GET", "https://pie.search/");
+ let engine = Services.search.getEngineByName("PieSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ do_print("Should not autoFill search engine if search string has a different scheme.");
+ yield check_autocomplete({
+ search: "http://pie",
+ autofilled: "http://pie",
+ completed: "http://pie"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_matching_prefix_autofill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("BeanSearch", "", "", "",
+ "GET", "http://www.bean.search/");
+ let engine = Services.search.getEngineByName("BeanSearch");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+
+ do_print("Should autoFill search engine if search string has matching prefix.");
+ yield check_autocomplete({
+ search: "http://www.be",
+ autofilled: "http://www.bean.search",
+ completed: "http://www.bean.search"
+ })
+
+ do_print("Should autoFill search engine if search string has www prefix.");
+ yield check_autocomplete({
+ search: "www.be",
+ autofilled: "www.bean.search",
+ completed: "http://www.bean.search"
+ });
+
+ do_print("Should autoFill search engine if search string has matching scheme.");
+ yield check_autocomplete({
+ search: "http://be",
+ autofilled: "http://bean.search",
+ completed: "http://www.bean.search"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_prefix_autofill() {
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://moz.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+
+ do_print("Should not try to autoFill in-the-middle if a search is canceled immediately");
+ yield check_autocomplete({
+ incompleteSearch: "moz",
+ search: "mozi",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_avoid_stripping_to_empty_tokens.js b/toolkit/components/places/tests/unifiedcomplete/test_avoid_stripping_to_empty_tokens.js
new file mode 100644
index 000000000..1fcfe1c75
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_avoid_stripping_to_empty_tokens.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_protocol_trimming() {
+ for (let prot of ["http", "https", "ftp"]) {
+ let visit = {
+ // Include the protocol in the query string to ensure we get matches (see bug 1059395)
+ uri: NetUtil.newURI(prot + "://www.mozilla.org/test/?q=" + prot + encodeURIComponent("://") + "www.foo"),
+ title: "Test title",
+ transition: TRANSITION_TYPED
+ };
+ yield PlacesTestUtils.addVisits(visit);
+ let matches = [{uri: visit.uri, title: visit.title}];
+
+ let inputs = [
+ prot + "://",
+ prot + ":// ",
+ prot + ":// mo",
+ prot + "://mo te",
+ prot + "://www.",
+ prot + "://www. ",
+ prot + "://www. mo",
+ prot + "://www.mo te",
+ "www.",
+ "www. ",
+ "www. mo",
+ "www.mo te"
+ ];
+ for (let input of inputs) {
+ do_print("Searching for: " + input);
+ yield check_autocomplete({
+ search: input,
+ matches: matches
+ });
+ }
+
+ yield cleanup();
+ }
+});
+
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_casing.js b/toolkit/components/places/tests/unifiedcomplete/test_casing.js
new file mode 100644
index 000000000..585b51be1
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_casing.js
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_casing_1() {
+ do_print("Searching for cased entry 1");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "MOZ",
+ autofilled: "MOZilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_casing_2() {
+ do_print("Searching for cased entry 2");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/T",
+ autofilled: "mozilla.org/T",
+ completed: "mozilla.org/T"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_casing_3() {
+ do_print("Searching for cased entry 3");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/T",
+ autofilled: "mozilla.org/Test/",
+ completed: "http://mozilla.org/Test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_casing_4() {
+ do_print("Searching for cased entry 4");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mOzilla.org/t",
+ autofilled: "mOzilla.org/t",
+ completed: "mOzilla.org/t"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_casing_5() {
+ do_print("Searching for cased entry 5");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mOzilla.org/T",
+ autofilled: "mOzilla.org/Test/",
+ completed: "http://mozilla.org/Test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_casing() {
+ do_print("Searching for untrimmed cased entry");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://mOz",
+ autofilled: "http://mOzilla.org/",
+ completed: "http://mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_www_casing() {
+ do_print("Searching for untrimmed cased entry with www");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://www.mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://www.mOz",
+ autofilled: "http://www.mOzilla.org/",
+ completed: "http://www.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_path_casing() {
+ do_print("Searching for untrimmed cased entry with path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://mOzilla.org/t",
+ autofilled: "http://mOzilla.org/t",
+ completed: "http://mOzilla.org/t"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_path_casing_2() {
+ do_print("Searching for untrimmed cased entry with path 2");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://mOzilla.org/T",
+ autofilled: "http://mOzilla.org/Test/",
+ completed: "http://mozilla.org/Test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_path_www_casing() {
+ do_print("Searching for untrimmed cased entry with www and path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://www.mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://www.mOzilla.org/t",
+ autofilled: "http://www.mOzilla.org/t",
+ completed: "http://www.mOzilla.org/t"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_path_www_casing_2() {
+ do_print("Searching for untrimmed cased entry with www and path 2");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://www.mozilla.org/Test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "http://www.mOzilla.org/T",
+ autofilled: "http://www.mOzilla.org/Test/",
+ completed: "http://www.mozilla.org/Test/"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_do_not_trim.js b/toolkit/components/places/tests/unifiedcomplete/test_do_not_trim.js
new file mode 100644
index 000000000..014d74998
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_do_not_trim.js
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Inline should never return matches shorter than the search string, since
+// that largely confuses completeDefaultIndex
+
+add_task(function* test_not_autofill_ws_1() {
+ do_print("Do not autofill whitespaced entry 1");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org ",
+ autofilled: "mozilla.org ",
+ completed: "mozilla.org "
+ });
+ yield cleanup();
+});
+
+add_task(function* test_not_autofill_ws_2() {
+ do_print("Do not autofill whitespaced entry 2");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/ ",
+ autofilled: "mozilla.org/ ",
+ completed: "mozilla.org/ "
+ });
+ yield cleanup();
+});
+
+add_task(function* test_not_autofill_ws_3() {
+ do_print("Do not autofill whitespaced entry 3");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/link ",
+ autofilled: "mozilla.org/link ",
+ completed: "mozilla.org/link "
+ });
+ yield cleanup();
+});
+
+add_task(function* test_not_autofill_ws_4() {
+ do_print("Do not autofill whitespaced entry 4");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/link/ ",
+ autofilled: "mozilla.org/link/ ",
+ completed: "mozilla.org/link/ "
+ });
+ yield cleanup();
+});
+
+
+add_task(function* test_not_autofill_ws_5() {
+ do_print("Do not autofill whitespaced entry 5");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "moz illa ",
+ autofilled: "moz illa ",
+ completed: "moz illa "
+ });
+ yield cleanup();
+});
+
+add_task(function* test_not_autofill_ws_6() {
+ do_print("Do not autofill whitespaced entry 6");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: " mozilla",
+ autofilled: " mozilla",
+ completed: " mozilla"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_download_embed_bookmarks.js b/toolkit/components/places/tests/unifiedcomplete/test_download_embed_bookmarks.js
new file mode 100644
index 000000000..72661d075
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_download_embed_bookmarks.js
@@ -0,0 +1,71 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Tests bug 449406 to ensure that TRANSITION_DOWNLOAD, TRANSITION_EMBED and
+ * TRANSITION_FRAMED_LINK bookmarked uri's show up in the location bar.
+ */
+
+add_task(function* test_download_embed_bookmarks() {
+ let uri1 = NetUtil.newURI("http://download/bookmarked");
+ let uri2 = NetUtil.newURI("http://embed/bookmarked");
+ let uri3 = NetUtil.newURI("http://framed/bookmarked");
+ let uri4 = NetUtil.newURI("http://download");
+ let uri5 = NetUtil.newURI("http://embed");
+ let uri6 = NetUtil.newURI("http://framed");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "download-bookmark", transition: TRANSITION_DOWNLOAD },
+ { uri: uri2, title: "embed-bookmark", transition: TRANSITION_EMBED },
+ { uri: uri3, title: "framed-bookmark", transition: TRANSITION_FRAMED_LINK},
+ { uri: uri4, title: "download2", transition: TRANSITION_DOWNLOAD },
+ { uri: uri5, title: "embed2", transition: TRANSITION_EMBED },
+ { uri: uri6, title: "framed2", transition: TRANSITION_FRAMED_LINK }
+ ]);
+ yield addBookmark({ uri: uri1,
+ title: "download-bookmark" });
+ yield addBookmark({ uri: uri2,
+ title: "embed-bookmark" });
+ yield addBookmark({ uri: uri3,
+ title: "framed-bookmark" });
+
+ do_print("Searching for bookmarked download uri matches");
+ yield check_autocomplete({
+ search: "download-bookmark",
+ matches: [ { uri: uri1, title: "download-bookmark", style: [ "bookmark" ] } ]
+ });
+
+ do_print("Searching for bookmarked embed uri matches");
+ yield check_autocomplete({
+ search: "embed-bookmark",
+ matches: [ { uri: uri2, title: "embed-bookmark", style: [ "bookmark" ] } ]
+ });
+
+ do_print("Searching for bookmarked framed uri matches");
+ yield check_autocomplete({
+ search: "framed-bookmark",
+ matches: [ { uri: uri3, title: "framed-bookmark", style: [ "bookmark" ] } ]
+ });
+
+ do_print("Searching for download uri does not match");
+ yield check_autocomplete({
+ search: "download2",
+ matches: [ ]
+ });
+
+ do_print("Searching for embed uri does not match");
+ yield check_autocomplete({
+ search: "embed2",
+ matches: [ ]
+ });
+
+ do_print("Searching for framed uri does not match");
+ yield check_autocomplete({
+ search: "framed2",
+ matches: [ ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_dupe_urls.js b/toolkit/components/places/tests/unifiedcomplete/test_dupe_urls.js
new file mode 100644
index 000000000..a39c15236
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_dupe_urls.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Ensure inline autocomplete doesn't return zero frecency pages.
+
+add_task(function* test_dupe_urls() {
+ do_print("Searching for urls with dupes should only show one");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/"),
+ transition: TRANSITION_TYPED
+ }, {
+ uri: NetUtil.newURI("http://mozilla.org/?")
+ });
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/",
+ matches: [ { uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "mozilla.org",
+ style: [ "autofill", "heuristic" ] } ]
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_empty_search.js b/toolkit/components/places/tests/unifiedcomplete/test_empty_search.js
new file mode 100644
index 000000000..ef1159705
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_empty_search.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 426864 that makes sure the empty search (drop down list) only
+ * shows typed pages from history.
+ */
+
+add_task(function* test_javascript_match() {
+ let uri1 = NetUtil.newURI("http://t.foo/0");
+ let uri2 = NetUtil.newURI("http://t.foo/1");
+ let uri3 = NetUtil.newURI("http://t.foo/2");
+ let uri4 = NetUtil.newURI("http://t.foo/3");
+ let uri5 = NetUtil.newURI("http://t.foo/4");
+ let uri6 = NetUtil.newURI("http://t.foo/5");
+ let uri7 = NetUtil.newURI("http://t.foo/6");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" },
+ { uri: uri3, title: "title", transition: TRANSITION_TYPED},
+ { uri: uri4, title: "title", transition: TRANSITION_TYPED },
+ { uri: uri6, title: "title", transition: TRANSITION_TYPED },
+ { uri: uri7, title: "title" }
+ ]);
+
+ yield addBookmark({ uri: uri2,
+ title: "title" });
+ yield addBookmark({ uri: uri4,
+ title: "title" });
+ yield addBookmark({ uri: uri5,
+ title: "title" });
+ yield addBookmark({ uri: uri6,
+ title: "title" });
+
+ addOpenPages(uri7, 1);
+
+ // Now remove page 6 from history, so it is an unvisited bookmark.
+ PlacesUtils.history.removePage(uri6);
+
+ do_print("Match everything");
+ yield check_autocomplete({
+ search: "foo",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("foo", { heuristic: true }),
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title", style: ["bookmark"] },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "title", style: ["bookmark"] },
+ { uri: uri5, title: "title", style: ["bookmark"] },
+ { uri: uri6, title: "title", style: ["bookmark"] },
+ makeSwitchToTabMatch("http://t.foo/6", { title: "title" }),
+ ]
+ });
+
+ // Note the next few tests do *not* get a search result as enable-actions
+ // isn't specified.
+ do_print("Match only typed history");
+ yield check_autocomplete({
+ search: "foo ^ ~",
+ matches: [ { uri: uri3, title: "title" },
+ { uri: uri4, title: "title" } ]
+ });
+
+ do_print("Drop-down empty search matches only typed history");
+ yield check_autocomplete({
+ search: "",
+ matches: [ { uri: uri3, title: "title" },
+ { uri: uri4, title: "title" } ]
+ });
+
+ do_print("Drop-down empty search matches only bookmarks");
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ yield check_autocomplete({
+ search: "",
+ matches: [ { uri: uri2, title: "title", style: ["bookmark"] },
+ { uri: uri4, title: "title", style: ["bookmark"] },
+ { uri: uri5, title: "title", style: ["bookmark"] },
+ { uri: uri6, title: "title", style: ["bookmark"] } ]
+ });
+
+ do_print("Drop-down empty search matches only open tabs");
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+ yield check_autocomplete({
+ search: "",
+ searchParam: "enable-actions",
+ matches: [
+ makeSwitchToTabMatch("http://t.foo/6", { title: "title" }),
+ ]
+ });
+
+ Services.prefs.clearUserPref("browser.urlbar.suggest.history");
+ Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark");
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_enabled.js b/toolkit/components/places/tests/unifiedcomplete/test_enabled.js
new file mode 100644
index 000000000..dee8df8ec
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_enabled.js
@@ -0,0 +1,68 @@
+add_task(function* test_enabled() {
+ // Test for bug 471903 to make sure searching in autocomplete can be turned on
+ // and off. Also test bug 463535 for pref changing search.
+ let uri = NetUtil.newURI("http://url/0");
+ yield PlacesTestUtils.addVisits([ { uri: uri, title: "title" } ]);
+
+ do_print("plain search");
+ yield check_autocomplete({
+ search: "url",
+ matches: [ { uri: uri, title: "title" } ]
+ });
+
+ do_print("search disabled");
+ Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", false);
+ yield check_autocomplete({
+ search: "url",
+ matches: [ ]
+ });
+
+ do_print("resume normal search");
+ Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", true);
+ yield check_autocomplete({
+ search: "url",
+ matches: [ { uri: uri, title: "title" } ]
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_sync_enabled() {
+ // Initialize unified complete.
+ Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"]
+ .getService(Ci.mozIPlacesAutoComplete);
+
+ let types = [ "history", "bookmark", "openpage", "searches" ];
+
+ // Test the service keeps browser.urlbar.autocomplete.enabled synchronized
+ // with browser.urlbar.suggest prefs.
+ for (let type of types) {
+ Services.prefs.setBoolPref("browser.urlbar.suggest." + type, true);
+ }
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.autocomplete.enabled"), true);
+
+ // Disable autocomplete and check all the suggest prefs are set to false.
+ Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", false);
+ for (let type of types) {
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false);
+ }
+
+ // Setting even a single suggest pref to true should enable autocomplete.
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ for (let type of types.filter(t => t != "history")) {
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false);
+ }
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.autocomplete.enabled"), true);
+
+ // Disable autocoplete again, then re-enable it and check suggest prefs
+ // have been reset.
+ Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", false);
+ Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", true);
+ for (let type of types.filter(t => t != "history")) {
+ if (type == "searches") {
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false);
+ } else {
+ Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), true);
+ }
+ }
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_escape_self.js b/toolkit/components/places/tests/unifiedcomplete/test_escape_self.js
new file mode 100644
index 000000000..ff6e5f929
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_escape_self.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 422698 to make sure searches with urls from the location bar
+ * correctly match itself when it contains escaped characters.
+ */
+
+add_task(function* test_escape() {
+ let uri1 = NetUtil.newURI("http://unescapeduri/");
+ let uri2 = NetUtil.newURI("http://escapeduri/%40/");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" }
+ ]);
+
+ do_print("Unescaped location matches itself");
+ yield check_autocomplete({
+ search: "http://unescapeduri/",
+ matches: [ { uri: uri1, title: "title" } ]
+ });
+
+ do_print("Escaped location matches itself");
+ yield check_autocomplete({
+ search: "http://escapeduri/%40/",
+ matches: [ { uri: uri2, title: "title" } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
new file mode 100644
index 000000000..76af20558
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
@@ -0,0 +1,384 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/ExtensionSearchHandler.jsm");
+
+let controller = Cc["@mozilla.org/autocomplete/controller;1"].getService(Ci.nsIAutoCompleteController);
+
+add_task(function* test_correct_errors_are_thrown() {
+ let keyword = "foo";
+ let anotherKeyword = "bar";
+ let unregisteredKeyword = "baz";
+
+ // Register a keyword.
+ ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} });
+
+ // Try registering the keyword again.
+ Assert.throws(() => ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }));
+
+ // Register a different keyword.
+ ExtensionSearchHandler.registerKeyword(anotherKeyword, { emit: () => {} });
+
+ // Try calling handleSearch for an unregistered keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `, () => {}));
+
+ // Try calling handleSearch without a callback.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `));
+
+ // Try getting the description for a keyword which isn't registered.
+ Assert.throws(() => ExtensionSearchHandler.getDescription(unregisteredKeyword));
+
+ // Try getting the extension name for a keyword which isn't registered.
+ Assert.throws(() => ExtensionSearchHandler.getExtensionName(unregisteredKeyword));
+
+ // Try setting the default suggestion for a keyword which isn't registered.
+ Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, "suggestion"));
+
+ // Try calling handleInputCancelled when there is no active input session.
+ Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
+
+ // Try calling handleInputEntered when there is no active input session.
+ Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab"));
+
+ // Start a session by calling handleSearch with the registered keyword.
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {});
+
+ // Try providing suggestions for an unregistered keyword.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 0, []));
+
+ // Try providing suggestions for an inactive keyword.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(anotherKeyword, 0, []));
+
+ // Try calling handleSearch for an inactive keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} `, () => {}));
+
+ // Try calling addSuggestions with an old callback ID.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 0, []));
+
+ // Add suggestions with a valid callback ID.
+ ExtensionSearchHandler.addSuggestions(keyword, 1, []);
+
+ // Add suggestions again with a valid callback ID.
+ ExtensionSearchHandler.addSuggestions(keyword, 1, []);
+
+ // Try calling addSuggestions with a future callback ID.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+
+ // End the input session by calling handleInputCancelled.
+ ExtensionSearchHandler.handleInputCancelled();
+
+ // Try calling handleInputCancelled after the session has ended.
+ Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
+
+ // Try calling handleSearch that doesn't have a space after the keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword}`, () => {}));
+
+ // Try calling handleSearch with text starting with the wrong keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${keyword} test`, () => {}));
+
+ // Start a new session by calling handleSearch with a different keyword
+ ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} test`, () => {});
+
+ // Try adding suggestions again with the same callback ID now that the input session has ended.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 1, []));
+
+ // Add suggestions with a valid callback ID.
+ ExtensionSearchHandler.addSuggestions(anotherKeyword, 2, []);
+
+ // Try adding suggestions with a valid callback ID but a different keyword.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+
+ // Try adding suggestions with a valid callback ID but an unregistered keyword.
+ Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 2, []));
+
+ // Set the default suggestion.
+ ExtensionSearchHandler.setDefaultSuggestion(anotherKeyword, {description: "test result"});
+
+ // Try ending the session using handleInputEntered with a different keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleInputEntered(keyword, `${keyword} test`, "tab"));
+
+ // Try calling handleInputEntered with invalid text.
+ Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, ` test`, "tab"));
+
+ // Try calling handleInputEntered with an invalid disposition.
+ Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "invalid"));
+
+ // End the session by calling handleInputEntered.
+ ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab");
+
+ // Try calling handleInputEntered after the session has ended.
+ Assert.throws(() => ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab"));
+
+ // Unregister the keyword.
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+
+ // Try setting the default suggestion for the unregistered keyword.
+ Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(keyword, {description: "test"}));
+
+ // Try handling a search with the unregistered keyword.
+ Assert.throws(() => ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {}));
+
+ // Try unregistering the keyword again.
+ Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(keyword));
+
+ // Unregister the other keyword.
+ ExtensionSearchHandler.unregisterKeyword(anotherKeyword);
+
+ // Try unregistering the word which was never registered.
+ Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(unregisteredKeyword));
+
+ // Try setting the default suggestion for a word that was never registered.
+ Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, {description: "test"}));
+
+ yield cleanup();
+});
+
+add_task(function* test_correct_events_are_emitted() {
+ let events = [];
+ function checkEvents(expectedEvents) {
+ Assert.equal(events.length, expectedEvents.length, "The correct number of events fired");
+ expectedEvents.forEach((e, i) => Assert.equal(e, events[i], `Expected "${e}" event to fire`));
+ events = [];
+ }
+
+ let mockExtension = { emit: message => events.push(message) };
+
+ let keyword = "foo";
+ let anotherKeyword = "bar";
+
+ ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+ ExtensionSearchHandler.registerKeyword(anotherKeyword, mockExtension);
+
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+ checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED]);
+
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {});
+ checkEvents([ExtensionSearchHandler.MSG_INPUT_CHANGED]);
+
+ ExtensionSearchHandler.handleInputEntered(keyword, `${keyword} f`, "tab");
+ checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]);
+
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {});
+ checkEvents([
+ ExtensionSearchHandler.MSG_INPUT_STARTED,
+ ExtensionSearchHandler.MSG_INPUT_CHANGED
+ ]);
+
+ ExtensionSearchHandler.handleInputCancelled();
+ checkEvents([ExtensionSearchHandler.MSG_INPUT_CANCELLED]);
+
+ ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} baz`, () => {});
+ checkEvents([
+ ExtensionSearchHandler.MSG_INPUT_STARTED,
+ ExtensionSearchHandler.MSG_INPUT_CHANGED
+ ]);
+
+ ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} baz`, "tab");
+ checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]);
+
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+});
+
+add_task(function* test_removes_suggestion_if_its_content_is_typed_in() {
+ let keyword = "test";
+ let extensionName = "Foo Bar";
+
+ let mockExtension = {
+ name: extensionName,
+ emit(message, text, id) {
+ if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+ ExtensionSearchHandler.addSuggestions(keyword, id, [
+ {content: "foo", description: "first suggestion"},
+ {content: "bar", description: "second suggestion"},
+ {content: "baz", description: "third suggestion"},
+ ]);
+ controller.stopSearch();
+ }
+ }
+ };
+
+ ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+ yield check_autocomplete({
+ search: `${keyword} unmatched`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} unmatched`}),
+ makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+ ]
+ });
+
+ yield check_autocomplete({
+ search: `${keyword} foo`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} foo`}),
+ makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+ ]
+ });
+
+ yield check_autocomplete({
+ search: `${keyword} bar`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} bar`}),
+ makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"})
+ ]
+ });
+
+ yield check_autocomplete({
+ search: `${keyword} baz`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} baz`}),
+ makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"})
+ ]
+ });
+
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+ yield cleanup();
+});
+
+add_task(function* test_extension_results_should_come_first() {
+ let keyword = "test";
+ let extensionName = "Omnibox Example";
+
+ let uri = NetUtil.newURI(`http://a.com/b`);
+ yield PlacesTestUtils.addVisits([
+ { uri, title: `${keyword} -` },
+ ]);
+
+ let mockExtension = {
+ name: extensionName,
+ emit(message, text, id) {
+ if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+ ExtensionSearchHandler.addSuggestions(keyword, id, [
+ {content: "foo", description: "first suggestion"},
+ {content: "bar", description: "second suggestion"},
+ {content: "baz", description: "third suggestion"},
+ ]);
+ }
+ controller.stopSearch();
+ }
+ };
+
+ ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+ // Start an input session before testing MSG_INPUT_CHANGED.
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+
+ yield check_autocomplete({
+ search: `${keyword} -`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} -`}),
+ makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}),
+ { uri, title: `${keyword} -` }
+ ]
+ });
+
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+ yield cleanup();
+});
+
+add_task(function* test_setting_the_default_suggestion() {
+ let keyword = "test";
+ let extensionName = "Omnibox Example";
+
+ let mockExtension = {
+ name: extensionName,
+ emit(message, text, id) {
+ if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+ ExtensionSearchHandler.addSuggestions(keyword, id, []);
+ }
+ controller.stopSearch();
+ }
+ };
+
+ ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+ ExtensionSearchHandler.setDefaultSuggestion(keyword, {
+ description: "hello world"
+ });
+
+ let searchString = `${keyword} search query`;
+ yield check_autocomplete({
+ search: searchString,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: "hello world", content: searchString}),
+ ]
+ });
+
+ ExtensionSearchHandler.setDefaultSuggestion(keyword, {
+ description: "foo bar"
+ });
+
+ yield check_autocomplete({
+ search: searchString,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: "foo bar", content: searchString}),
+ ]
+ });
+
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+ yield cleanup();
+});
+
+add_task(function* test_maximum_number_of_suggestions_is_enforced() {
+ let keyword = "test";
+ let extensionName = "Omnibox Example";
+
+ let mockExtension = {
+ name: extensionName,
+ emit(message, text, id) {
+ if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) {
+ ExtensionSearchHandler.addSuggestions(keyword, id, [
+ {content: "a", description: "first suggestion"},
+ {content: "b", description: "second suggestion"},
+ {content: "c", description: "third suggestion"},
+ {content: "d", description: "fourth suggestion"},
+ {content: "e", description: "fifth suggestion"},
+ {content: "f", description: "sixth suggestion"},
+ {content: "g", description: "seventh suggestion"},
+ {content: "h", description: "eigth suggestion"},
+ {content: "i", description: "ninth suggestion"},
+ {content: "j", description: "tenth suggestion"},
+ ]);
+ controller.stopSearch();
+ }
+ }
+ };
+
+ ExtensionSearchHandler.registerKeyword(keyword, mockExtension);
+
+ // Start an input session before testing MSG_INPUT_CHANGED.
+ ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {});
+
+ yield check_autocomplete({
+ search: `${keyword} #`,
+ searchParam: "enable-actions",
+ matches: [
+ makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} #`}),
+ makeExtensionMatch({keyword, content: `${keyword} a`, description: "first suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} b`, description: "second suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} c`, description: "third suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} d`, description: "fourth suggestion"}),
+ makeExtensionMatch({keyword, content: `${keyword} e`, description: "fifth suggestion"}),
+ ]
+ });
+
+ ExtensionSearchHandler.unregisterKeyword(keyword);
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_ignore_protocol.js b/toolkit/components/places/tests/unifiedcomplete/test_ignore_protocol.js
new file mode 100644
index 000000000..92e7f601a
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_ignore_protocol.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 424509 to make sure searching for "h" doesn't match "http" of urls.
+ */
+
+add_task(function* test_escape() {
+ let uri1 = NetUtil.newURI("http://site/");
+ let uri2 = NetUtil.newURI("http://happytimes/");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" }
+ ]);
+
+ do_print("Searching for h matches site and not http://");
+ yield check_autocomplete({
+ search: "h",
+ matches: [ { uri: uri2, title: "title" } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js b/toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js
new file mode 100644
index 000000000..12b7fea77
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_keyword_search.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 392143 that puts keyword results into the autocomplete. Makes
+ * sure that multiple parameter queries get spaces converted to +, + converted
+ * to %2B, non-ascii become escaped, and pages in history that match the
+ * keyword uses the page's title.
+ *
+ * Also test for bug 249468 by making sure multiple keyword bookmarks with the
+ * same keyword appear in the list.
+ */
+
+add_task(function* test_keyword_searc() {
+ let uri1 = NetUtil.newURI("http://abc/?search=%s");
+ let uri2 = NetUtil.newURI("http://abc/?search=ThisPageIsInHistory");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "Generic page title" },
+ { uri: uri2, title: "Generic page title" }
+ ]);
+ yield addBookmark({ uri: uri1, title: "Bookmark title", keyword: "key"});
+
+ do_print("Plain keyword query");
+ yield check_autocomplete({
+ search: "key term",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=term"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Plain keyword UC");
+ yield check_autocomplete({
+ search: "key TERM",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=TERM"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Multi-word keyword query");
+ yield check_autocomplete({
+ search: "key multi word",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=multi%20word"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Keyword query with +");
+ yield check_autocomplete({
+ search: "key blocking+",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=blocking%2B"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Unescaped term in query");
+ yield check_autocomplete({
+ search: "key ユニコード",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=ユニコード"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Keyword that happens to match a page");
+ yield check_autocomplete({
+ search: "key ThisPageIsInHistory",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=ThisPageIsInHistory"), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Keyword without query (without space)");
+ yield check_autocomplete({
+ search: "key",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search="), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Keyword without query (with space)");
+ yield check_autocomplete({
+ search: "key ",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search="), title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_keyword_search_actions.js b/toolkit/components/places/tests/unifiedcomplete/test_keyword_search_actions.js
new file mode 100644
index 000000000..61d98f72d
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_keyword_search_actions.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 392143 that puts keyword results into the autocomplete. Makes
+ * sure that multiple parameter queries get spaces converted to +, + converted
+ * to %2B, non-ascii become escaped, and pages in history that match the
+ * keyword uses the page's title.
+ *
+ * Also test for bug 249468 by making sure multiple keyword bookmarks with the
+ * same keyword appear in the list.
+ */
+
+add_task(function* test_keyword_search() {
+ let uri1 = NetUtil.newURI("http://abc/?search=%s");
+ let uri2 = NetUtil.newURI("http://abc/?search=ThisPageIsInHistory");
+ let uri3 = NetUtil.newURI("http://abc/?search=%s&raw=%S");
+ let uri4 = NetUtil.newURI("http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1");
+ yield PlacesTestUtils.addVisits([{ uri: uri1 },
+ { uri: uri2 },
+ { uri: uri3 }]);
+ yield addBookmark({ uri: uri1, title: "Keyword", keyword: "key"});
+ yield addBookmark({ uri: uri1, title: "Post", keyword: "post", postData: "post_search=%s"});
+ yield addBookmark({ uri: uri3, title: "Encoded", keyword: "encoded"});
+ yield addBookmark({ uri: uri4, title: "Charset", keyword: "charset"});
+ yield addBookmark({ uri: uri2, title: "Noparam", keyword: "noparam"});
+ yield addBookmark({ uri: uri2, title: "Noparam-Post", keyword: "post_noparam", postData: "noparam=1"});
+
+ do_print("Plain keyword query");
+ yield check_autocomplete({
+ search: "key term",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=term", input: "key term"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Plain keyword UC");
+ yield check_autocomplete({
+ search: "key TERM",
+ matches: [ { uri: NetUtil.newURI("http://abc/?search=TERM"),
+ title: "abc", style: ["keyword", "heuristic"] } ]
+ });
+
+ do_print("Multi-word keyword query");
+ yield check_autocomplete({
+ search: "key multi word",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=multi%20word", input: "key multi word"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Keyword query with +");
+ yield check_autocomplete({
+ search: "key blocking+",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=blocking%2B", input: "key blocking+"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Unescaped term in query");
+ // ... but note that UnifiedComplete calls encodeURIComponent() on the query
+ // string when it builds the URL, so the expected result will have the
+ // ユニコード substring encoded in the URL.
+ yield check_autocomplete({
+ search: "key ユニコード",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=" + encodeURIComponent("ユニコード"), input: "key ユニコード"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Keyword that happens to match a page");
+ yield check_autocomplete({
+ search: "key ThisPageIsInHistory",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=ThisPageIsInHistory", input: "key ThisPageIsInHistory"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Keyword without query (without space)");
+ yield check_autocomplete({
+ search: "key",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=", input: "key"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Keyword without query (with space)");
+ yield check_autocomplete({
+ search: "key ",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=", input: "key "}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("POST Keyword");
+ yield check_autocomplete({
+ search: "post foo",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=foo", input: "post foo", postData: "post_search=foo"}),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Bug 420328: no-param keyword with a param");
+ yield check_autocomplete({
+ search: "noparam foo",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("noparam foo", { heuristic: true }) ]
+ });
+ yield check_autocomplete({
+ search: "post_noparam foo",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("post_noparam foo", { heuristic: true }) ]
+ });
+
+ do_print("escaping with default UTF-8 charset");
+ yield check_autocomplete({
+ search: "encoded foé",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=fo%C3%A9&raw=foé", input: "encoded foé" }),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("escaping with forced ISO-8859-1 charset");
+ yield check_autocomplete({
+ search: "charset foé",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=fo%E9&raw=foé", input: "charset foé" }),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Bug 359809: escaping +, / and @ with default UTF-8 charset");
+ yield check_autocomplete({
+ search: "encoded +/@",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=%2B%2F%40&raw=+/@", input: "encoded +/@" }),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ do_print("Bug 359809: escaping +, / and @ with forced ISO-8859-1 charset");
+ yield check_autocomplete({
+ search: "charset +/@",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("keyword", {url: "http://abc/?search=%2B%2F%40&raw=+/@", input: "charset +/@" }),
+ title: "abc", style: [ "action", "keyword", "heuristic" ] } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_keywords.js b/toolkit/components/places/tests/unifiedcomplete/test_keywords.js
new file mode 100644
index 000000000..93e8d7a6f
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_keywords.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_non_keyword() {
+ do_print("Searching for non-keyworded entry should autoFill it");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield addBookmark({ uri: NetUtil.newURI("http://mozilla.org/test/") });
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_keyword() {
+ do_print("Searching for keyworded entry should not autoFill it");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield addBookmark({ uri: NetUtil.newURI("http://mozilla.org/test/"), keyword: "moz" });
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "moz",
+ completed: "moz",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_more_than_keyword() {
+ do_print("Searching for more than keyworded entry should autoFill it");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield addBookmark({ uri: NetUtil.newURI("http://mozilla.org/test/"), keyword: "moz" });
+ yield check_autocomplete({
+ search: "mozi",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_less_than_keyword() {
+ do_print("Searching for less than keyworded entry should autoFill it");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield addBookmark({ uri: NetUtil.newURI("http://mozilla.org/test/"), keyword: "moz" });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/",
+ });
+ yield cleanup();
+});
+
+add_task(function* test_keyword_casing() {
+ do_print("Searching for keyworded entry is case-insensitive");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield addBookmark({ uri: NetUtil.newURI("http://mozilla.org/test/"), keyword: "moz" });
+ yield check_autocomplete({
+ search: "MoZ",
+ autofilled: "MoZ",
+ completed: "MoZ"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_match_beginning.js b/toolkit/components/places/tests/unifiedcomplete/test_match_beginning.js
new file mode 100644
index 000000000..57a1efaeb
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_match_beginning.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 451760 which allows matching only at the beginning of urls or
+ * titles to simulate Firefox 2 functionality.
+ */
+
+add_task(function* test_match_beginning() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false);
+
+ let uri1 = NetUtil.newURI("http://x.com/y");
+ let uri2 = NetUtil.newURI("https://y.com/x");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "a b" },
+ { uri: uri2, title: "b a" }
+ ]);
+
+ do_print("Match at the beginning of titles");
+ Services.prefs.setIntPref("browser.urlbar.matchBehavior", 3);
+ yield check_autocomplete({
+ search: "a",
+ matches: [ { uri: uri1, title: "a b" } ]
+ });
+
+ do_print("Match at the beginning of titles");
+ yield check_autocomplete({
+ search: "b",
+ matches: [ { uri: uri2, title: "b a" } ]
+ });
+
+ do_print("Match at the beginning of urls");
+ yield check_autocomplete({
+ search: "x",
+ matches: [ { uri: uri1, title: "a b" } ]
+ });
+
+ do_print("Match at the beginning of urls");
+ yield check_autocomplete({
+ search: "y",
+ matches: [ { uri: uri2, title: "b a" } ]
+ });
+
+ do_print("Sanity check that matching anywhere finds more");
+ Services.prefs.setIntPref("browser.urlbar.matchBehavior", 1);
+ yield check_autocomplete({
+ search: "a",
+ matches: [ { uri: uri1, title: "a b" },
+ { uri: uri2, title: "b a" } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_multi_word_search.js b/toolkit/components/places/tests/unifiedcomplete/test_multi_word_search.js
new file mode 100644
index 000000000..c6c9e952e
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_multi_word_search.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 401869 to allow multiple words separated by spaces to match in
+ * the page title, page url, or bookmark title to be considered a match. All
+ * terms must match but not all terms need to be in the title, etc.
+ *
+ * Test bug 424216 by making sure bookmark titles are always shown if one is
+ * available. Also bug 425056 makes sure matches aren't found partially in the
+ * page title and partially in the bookmark.
+ */
+
+add_task(function* test_match_beginning() {
+ let uri1 = NetUtil.newURI("http://a.b.c/d-e_f/h/t/p");
+ let uri2 = NetUtil.newURI("http://d.e.f/g-h_i/h/t/p");
+ let uri3 = NetUtil.newURI("http://g.h.i/j-k_l/h/t/p");
+ let uri4 = NetUtil.newURI("http://j.k.l/m-n_o/h/t/p");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "f(o)o b<a>r" },
+ { uri: uri2, title: "b(a)r b<a>z" },
+ { uri: uri3, title: "f(o)o b<a>r" },
+ { uri: uri4, title: "f(o)o b<a>r" }
+ ]);
+ yield addBookmark({ uri: uri3, title: "f(o)o b<a>r" });
+ yield addBookmark({ uri: uri4, title: "b(a)r b<a>z" });
+
+ do_print("Match 2 terms all in url");
+ yield check_autocomplete({
+ search: "c d",
+ matches: [ { uri: uri1, title: "f(o)o b<a>r" } ]
+ });
+
+ do_print("Match 1 term in url and 1 term in title");
+ yield check_autocomplete({
+ search: "b e",
+ matches: [ { uri: uri1, title: "f(o)o b<a>r" },
+ { uri: uri2, title: "b(a)r b<a>z" } ]
+ });
+
+ do_print("Match 3 terms all in title; display bookmark title if matched");
+ yield check_autocomplete({
+ search: "b a z",
+ matches: [ { uri: uri2, title: "b(a)r b<a>z" },
+ { uri: uri4, title: "b(a)r b<a>z", style: [ "bookmark" ] } ]
+ });
+
+ do_print("Match 2 terms in url and 1 in title; make sure bookmark title is used for search");
+ yield check_autocomplete({
+ search: "k f t",
+ matches: [ { uri: uri3, title: "f(o)o b<a>r", style: [ "bookmark" ] } ]
+ });
+
+ do_print("Match 3 terms in url and 1 in title");
+ yield check_autocomplete({
+ search: "d i g z",
+ matches: [ { uri: uri2, title: "b(a)r b<a>z" } ]
+ });
+
+ do_print("Match nothing");
+ yield check_autocomplete({
+ search: "m o z i",
+ matches: [ ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_query_url.js b/toolkit/components/places/tests/unifiedcomplete/test_query_url.js
new file mode 100644
index 000000000..915ba770e
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_query_url.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_no_slash() {
+ do_print("Searching for host match without slash should match host");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://file.org/test/"),
+ transition: TRANSITION_TYPED
+ }, {
+ uri: NetUtil.newURI("file:///c:/test.html"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "file",
+ autofilled: "file.org/",
+ completed: "file.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_w_slash() {
+ do_print("Searching match with slash at the end should do nothing");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://file.org/test/"),
+ transition: TRANSITION_TYPED
+ }, {
+ uri: NetUtil.newURI("file:///c:/test.html"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "file.org/",
+ autofilled: "file.org/",
+ completed: "file.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_middle() {
+ do_print("Searching match with slash in the middle should match url");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://file.org/test/"),
+ transition: TRANSITION_TYPED
+ }, {
+ uri: NetUtil.newURI("file:///c:/test.html"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "file.org/t",
+ autofilled: "file.org/test/",
+ completed: "http://file.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_nonhost() {
+ do_print("Searching for non-host match without slash should not match url");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("file:///c:/test.html"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "file",
+ autofilled: "file",
+ completed: "file"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_remote_tab_matches.js b/toolkit/components/places/tests/unifiedcomplete/test_remote_tab_matches.js
new file mode 100644
index 000000000..56998d4d6
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_remote_tab_matches.js
@@ -0,0 +1,203 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+*/
+"use strict";
+
+Cu.import("resource://services-sync/main.js");
+
+Services.prefs.setCharPref("services.sync.username", "someone@somewhere.com");
+
+// A mock "Tabs" engine which autocomplete will use instead of the real
+// engine. We pass a constructor that Sync creates.
+function MockTabsEngine() {
+ this.clients = null; // We'll set this dynamically
+}
+
+MockTabsEngine.prototype = {
+ name: "tabs",
+
+ getAllClients() {
+ return this.clients;
+ },
+}
+
+// A clients engine that doesn't need to be a constructor.
+let MockClientsEngine = {
+ isMobile(guid) {
+ Assert.ok(guid.endsWith("desktop") || guid.endsWith("mobile"));
+ return guid.endsWith("mobile");
+ },
+}
+
+// Tell Sync about the mocks.
+Weave.Service.engineManager.register(MockTabsEngine);
+Weave.Service.clientsEngine = MockClientsEngine;
+
+// Tell the Sync XPCOM service it is initialized.
+let weaveXPCService = Cc["@mozilla.org/weave/service;1"]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+weaveXPCService.ready = true;
+
+// Configure the singleton engine for a test.
+function configureEngine(clients) {
+ // Configure the instance Sync created.
+ let engine = Weave.Service.engineManager.get("tabs");
+ engine.clients = clients;
+ // Send an observer that pretends the engine just finished a sync.
+ Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs");
+}
+
+// Make a match object suitable for passing to check_autocomplete.
+function makeRemoteTabMatch(url, deviceName, extra = {}) {
+ return {
+ uri: makeActionURI("remotetab", {url, deviceName}),
+ title: extra.title || url,
+ style: [ "action", "remotetab" ],
+ icon: extra.icon,
+ }
+}
+
+// The tests.
+add_task(function* test_nomatch() {
+ // Nothing matches.
+ configureEngine({
+ guid_desktop: {
+ clientName: "My Desktop",
+ tabs: [{
+ urlHistory: ["http://foo.com/"],
+ }],
+ }
+ });
+
+ // No remote tabs match here, so we only expect search results.
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }) ],
+ });
+});
+
+add_task(function* test_minimal() {
+ // The minimal client and tabs info we can get away with.
+ configureEngine({
+ guid_desktop: {
+ clientName: "My Desktop",
+ tabs: [{
+ urlHistory: ["http://example.com/"],
+ }],
+ }
+ });
+
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }),
+ makeRemoteTabMatch("http://example.com/", "My Desktop") ],
+ });
+});
+
+add_task(function* test_maximal() {
+ // Every field that could possibly exist on a remote record.
+ configureEngine({
+ guid_mobile: {
+ clientName: "My Phone",
+ tabs: [{
+ urlHistory: ["http://example.com/"],
+ title: "An Example",
+ icon: "http://favicon",
+ }],
+ }
+ });
+
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }),
+ makeRemoteTabMatch("http://example.com/", "My Phone",
+ { title: "An Example",
+ icon: "moz-anno:favicon:http://favicon/"
+ }),
+ ],
+ });
+});
+
+add_task(function* test_noShowIcons() {
+ Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteIcons", false);
+ configureEngine({
+ guid_mobile: {
+ clientName: "My Phone",
+ tabs: [{
+ urlHistory: ["http://example.com/"],
+ title: "An Example",
+ icon: "http://favicon",
+ }],
+ }
+ });
+
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }),
+ makeRemoteTabMatch("http://example.com/", "My Phone",
+ { title: "An Example",
+ // expecting the default favicon due to that pref.
+ icon: "",
+ }),
+ ],
+ });
+ Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteIcons");
+});
+
+add_task(function* test_matches_title() {
+ // URL doesn't match search expression, should still match the title.
+ configureEngine({
+ guid_mobile: {
+ clientName: "My Phone",
+ tabs: [{
+ urlHistory: ["http://foo.com/"],
+ title: "An Example",
+ }],
+ }
+ });
+
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }),
+ makeRemoteTabMatch("http://foo.com/", "My Phone",
+ { title: "An Example" }),
+ ],
+ });
+});
+
+add_task(function* test_localtab_matches_override() {
+ // We have an open tab to the same page on a remote device, only "switch to
+ // tab" should appear as duplicate detection removed the remote one.
+
+ // First setup Sync to have the page as a remote tab.
+ configureEngine({
+ guid_mobile: {
+ clientName: "My Phone",
+ tabs: [{
+ urlHistory: ["http://foo.com/"],
+ title: "An Example",
+ }],
+ }
+ });
+
+ // Setup Places to think the tab is open locally.
+ let uri = NetUtil.newURI("http://foo.com/");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri, title: "An Example" },
+ ]);
+ addOpenPages(uri, 1);
+
+ yield check_autocomplete({
+ search: "ex",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("ex", { heuristic: true }),
+ makeSwitchToTabMatch("http://foo.com/", { title: "An Example" }),
+ ],
+ });
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js
new file mode 100644
index 000000000..f35242e21
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+
+add_task(function*() {
+ // Note that head_autocomplete.js has already added a MozSearch engine.
+ // Here we add another engine with a search alias.
+ Services.search.addEngineWithDetails("AliasedGETMozSearch", "", "get", "",
+ "GET", "http://s.example.com/search");
+ Services.search.addEngineWithDetails("AliasedPOSTMozSearch", "", "post", "",
+ "POST", "http://s.example.com/search");
+
+ for (let alias of ["get", "post"]) {
+ yield check_autocomplete({
+ search: alias,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(alias, { engineName: `Aliased${alias.toUpperCase()}MozSearch`,
+ searchQuery: "", alias, heuristic: true }) ]
+ });
+
+ yield check_autocomplete({
+ search: `${alias} `,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(`${alias} `, { engineName: `Aliased${alias.toUpperCase()}MozSearch`,
+ searchQuery: "", alias, heuristic: true }) ]
+ });
+
+ yield check_autocomplete({
+ search: `${alias} mozilla`,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(`${alias} mozilla`, { engineName: `Aliased${alias.toUpperCase()}MozSearch`,
+ searchQuery: "mozilla", alias, heuristic: true }) ]
+ });
+
+ yield check_autocomplete({
+ search: `${alias} MoZiLlA`,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(`${alias} MoZiLlA`, { engineName: `Aliased${alias.toUpperCase()}MozSearch`,
+ searchQuery: "MoZiLlA", alias, heuristic: true }) ]
+ });
+
+ yield check_autocomplete({
+ search: `${alias} mozzarella mozilla`,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(`${alias} mozzarella mozilla`, { engineName: `Aliased${alias.toUpperCase()}MozSearch`,
+ searchQuery: "mozzarella mozilla", alias, heuristic: true }) ]
+ });
+ }
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_search_engine_current.js b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_current.js
new file mode 100644
index 000000000..b41d9884b
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_current.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+
+add_task(function*() {
+ // Note that head_autocomplete.js has already added a MozSearch engine.
+ // Here we add another engine with a search alias.
+ Services.search.addEngineWithDetails("AliasedMozSearch", "", "doit", "",
+ "GET", "http://s.example.com/search");
+
+ do_print("search engine");
+ yield check_autocomplete({
+ search: "mozilla",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("mozilla", { heuristic: true }) ]
+ });
+
+ do_print("search engine, uri-like input");
+ yield check_autocomplete({
+ search: "http:///",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("http:///", { heuristic: true }) ]
+ });
+
+ do_print("search engine, multiple words");
+ yield check_autocomplete({
+ search: "mozzarella cheese",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("mozzarella cheese", { heuristic: true }) ]
+ });
+
+ do_print("search engine, after current engine has changed");
+ Services.search.addEngineWithDetails("MozSearch2", "", "", "", "GET",
+ "http://s.example.com/search2");
+ engine = Services.search.getEngineByName("MozSearch2");
+ notEqual(Services.search.currentEngine, engine, "New engine shouldn't be the current engine yet");
+ Services.search.currentEngine = engine;
+ yield check_autocomplete({
+ search: "mozilla",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("mozilla", { engineName: "MozSearch2", heuristic: true }) ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_search_engine_host.js b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_host.js
new file mode 100644
index 000000000..61b9826f7
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_host.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(function* test_searchEngine_autoFill() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true);
+ Services.search.addEngineWithDetails("MySearchEngine", "", "", "",
+ "GET", "http://my.search.com/");
+ let engine = Services.search.getEngineByName("MySearchEngine");
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ // Add an uri that matches the search string with high frecency.
+ let uri = NetUtil.newURI("http://www.example.com/my/");
+ let visits = [];
+ for (let i = 0; i < 100; ++i) {
+ visits.push({ uri, title: "Terms - SearchEngine Search" });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+ yield addBookmark({ uri: uri, title: "Example bookmark" });
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ ok(frecencyForUrl(uri) > 10000, "Added URI should have expected high frecency");
+
+ do_print("Check search domain is autoFilled even if there's an higher frecency match");
+ yield check_autocomplete({
+ search: "my",
+ autofilled: "my.search.com",
+ completed: "http://my.search.com"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_searchEngine_noautoFill() {
+ let engineName = "engine-rel-searchform.xml";
+ let engine = yield addTestEngine(engineName);
+ equal(engine.searchForm, "http://example.com/?search");
+
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://example.com/my/"));
+
+ do_print("Check search domain is not autoFilled if it matches a visited domain");
+ yield check_autocomplete({
+ search: "example",
+ autofilled: "example.com/",
+ completed: "example.com/"
+ });
+
+ yield cleanup();
+});
+
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_search_engine_restyle.js b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_restyle.js
new file mode 100644
index 000000000..2a5f2d78e
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_restyle.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_searchEngine() {
+ Services.search.addEngineWithDetails("SearchEngine", "", "", "",
+ "GET", "http://s.example.com/search");
+ let engine = Services.search.getEngineByName("SearchEngine");
+ engine.addParam("q", "{searchTerms}", null);
+ do_register_cleanup(() => Services.search.removeEngine(engine));
+
+ let uri1 = NetUtil.newURI("http://s.example.com/search?q=Terms&client=1");
+ let uri2 = NetUtil.newURI("http://s.example.com/search?q=Terms&client=2");
+ yield PlacesTestUtils.addVisits({ uri: uri1, title: "Terms - SearchEngine Search" });
+ yield addBookmark({ uri: uri2, title: "Terms - SearchEngine Search" });
+
+ do_print("Past search terms should be styled, unless bookmarked");
+ Services.prefs.setBoolPref("browser.urlbar.restyleSearches", true);
+ yield check_autocomplete({
+ search: "term",
+ matches: [
+ makeSearchMatch("Terms", {
+ engineName: "SearchEngine",
+ style: ["favicon"]
+ }),
+ {
+ uri: uri2,
+ title: "Terms - SearchEngine Search",
+ style: ["bookmark"]
+ }
+ ]
+ });
+
+ do_print("Past search terms should not be styled if restyling is disabled");
+ Services.prefs.setBoolPref("browser.urlbar.restyleSearches", false);
+ yield check_autocomplete({
+ search: "term",
+ matches: [ { uri: uri1, title: "Terms - SearchEngine Search" },
+ { uri: uri2, title: "Terms - SearchEngine Search", style: ["bookmark"] } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_search_suggestions.js b/toolkit/components/places/tests/unifiedcomplete/test_search_suggestions.js
new file mode 100644
index 000000000..63b428cd4
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_suggestions.js
@@ -0,0 +1,651 @@
+Cu.import("resource://gre/modules/FormHistory.jsm");
+
+const ENGINE_NAME = "engine-suggestions.xml";
+const SERVER_PORT = 9000;
+const SUGGEST_PREF = "browser.urlbar.suggest.searches";
+const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled";
+const SUGGEST_RESTRICT_TOKEN = "$";
+
+var suggestionsFn;
+var previousSuggestionsFn;
+
+function setSuggestionsFn(fn) {
+ previousSuggestionsFn = suggestionsFn;
+ suggestionsFn = fn;
+}
+
+function* cleanUpSuggestions() {
+ yield cleanup();
+ if (previousSuggestionsFn) {
+ suggestionsFn = previousSuggestionsFn;
+ previousSuggestionsFn = null;
+ }
+}
+
+add_task(function* setUp() {
+ // Set up a server that provides some suggestions by appending strings onto
+ // the search query.
+ let server = makeTestServer(SERVER_PORT);
+ server.registerPathHandler("/suggest", (req, resp) => {
+ // URL query params are x-www-form-urlencoded, which converts spaces into
+ // plus signs, so un-convert any plus signs back to spaces.
+ let searchStr = decodeURIComponent(req.queryString.replace(/\+/g, " "));
+ let suggestions = suggestionsFn(searchStr);
+ let data = [searchStr, suggestions];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+ });
+ setSuggestionsFn(searchStr => {
+ let suffixes = ["foo", "bar"];
+ return suffixes.map(s => searchStr + " " + s);
+ });
+
+ // Install the test engine.
+ let oldCurrentEngine = Services.search.currentEngine;
+ do_register_cleanup(() => Services.search.currentEngine = oldCurrentEngine);
+ let engine = yield addTestEngine(ENGINE_NAME, server);
+ Services.search.currentEngine = engine;
+});
+
+add_task(function* disabled_urlbarSuggestions() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, false);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ ],
+ });
+ yield cleanUpSuggestions();
+});
+
+add_task(function* disabled_allSuggestions() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false);
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ ],
+ });
+ yield cleanUpSuggestions();
+});
+
+add_task(function* disabled_privateWindow() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "private-window enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ ],
+ });
+ yield cleanUpSuggestions();
+});
+
+add_task(function* singleWordQuery() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ { uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello foo",
+ searchQuery: "hello",
+ searchSuggestion: "hello foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }, {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello bar",
+ searchQuery: "hello",
+ searchSuggestion: "hello bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* multiWordQuery() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+
+ yield check_autocomplete({
+ search: "hello world",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello world", { engineName: ENGINE_NAME, heuristic: true }),
+ { uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello world foo",
+ searchQuery: "hello world",
+ searchSuggestion: "hello world foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }, {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello world bar",
+ searchQuery: "hello world",
+ searchSuggestion: "hello world bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* suffixMatch() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+
+ setSuggestionsFn(searchStr => {
+ let prefixes = ["baz", "quux"];
+ return prefixes.map(p => p + " " + searchStr);
+ });
+
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ { uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "baz hello",
+ searchQuery: "hello",
+ searchSuggestion: "baz hello",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }, {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "quux hello",
+ searchQuery: "hello",
+ searchSuggestion: "quux hello",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* queryIsNotASubstring() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+
+ setSuggestionsFn(searchStr => {
+ return ["aaa", "bbb"];
+ });
+
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ { uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "aaa",
+ searchQuery: "hello",
+ searchSuggestion: "aaa",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }, {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "bbb",
+ searchQuery: "hello",
+ searchSuggestion: "bbb",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* restrictToken() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+ Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true);
+
+ // Add a visit and a bookmark. Actually, make the bookmark visited too so
+ // that it's guaranteed, with its higher frecency, to appear above the search
+ // suggestions.
+ yield PlacesTestUtils.addVisits([
+ {
+ uri: NetUtil.newURI("http://example.com/hello-visit"),
+ title: "hello visit",
+ },
+ {
+ uri: NetUtil.newURI("http://example.com/hello-bookmark"),
+ title: "hello bookmark",
+ },
+ ]);
+
+ yield addBookmark({
+ uri: NetUtil.newURI("http://example.com/hello-bookmark"),
+ title: "hello bookmark",
+ });
+
+ // Do an unrestricted search to make sure everything appears in it, including
+ // the visit and bookmark.
+ yield check_autocomplete({
+ search: "hello",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("hello", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: NetUtil.newURI("http://example.com/hello-visit"),
+ title: "hello visit",
+ },
+ {
+ uri: NetUtil.newURI("http://example.com/hello-bookmark"),
+ title: "hello bookmark",
+ style: ["bookmark"],
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello foo",
+ searchQuery: "hello",
+ searchSuggestion: "hello foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello bar",
+ searchQuery: "hello",
+ searchSuggestion: "hello bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ ],
+ });
+
+ // Now do a restricted search to make sure only suggestions appear.
+ yield check_autocomplete({
+ search: SUGGEST_RESTRICT_TOKEN + " hello",
+ searchParam: "enable-actions",
+ matches: [
+ // TODO (bug 1177895) This is wrong.
+ makeSearchMatch(SUGGEST_RESTRICT_TOKEN + " hello", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello foo",
+ searchQuery: "hello",
+ searchSuggestion: "hello foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "hello bar",
+ searchQuery: "hello",
+ searchSuggestion: "hello bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ }
+ ],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* mixup_frecency() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+
+ // Add a visit and a bookmark. Actually, make the bookmark visited too so
+ // that it's guaranteed, with its higher frecency, to appear above the search
+ // suggestions.
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://example.com/lo0"),
+ title: "low frecency 0" },
+ { uri: NetUtil.newURI("http://example.com/lo1"),
+ title: "low frecency 1" },
+ { uri: NetUtil.newURI("http://example.com/lo2"),
+ title: "low frecency 2" },
+ { uri: NetUtil.newURI("http://example.com/lo3"),
+ title: "low frecency 3" },
+ { uri: NetUtil.newURI("http://example.com/lo4"),
+ title: "low frecency 4" },
+ ]);
+
+ for (let i = 0; i < 4; i++) {
+ let href = `http://example.com/lo${i}`;
+ let frecency = frecencyForUrl(href);
+ Assert.ok(frecency < FRECENCY_DEFAULT,
+ `frecency for ${href}: ${frecency}, should be lower than ${FRECENCY_DEFAULT}`);
+ }
+
+ for (let i = 0; i < 5; i++) {
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://example.com/hi0"),
+ title: "high frecency 0",
+ transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://example.com/hi1"),
+ title: "high frecency 1",
+ transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://example.com/hi2"),
+ title: "high frecency 2",
+ transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://example.com/hi3"),
+ title: "high frecency 3",
+ transition: TRANSITION_TYPED },
+ ]);
+ }
+
+ for (let i = 0; i < 4; i++) {
+ let href = `http://example.com/hi${i}`;
+ yield addBookmark({ uri: href, title: `high frecency ${i}` });
+ let frecency = frecencyForUrl(href);
+ Assert.ok(frecency > FRECENCY_DEFAULT,
+ `frecency for ${href}: ${frecency}, should be higher than ${FRECENCY_DEFAULT}`);
+ }
+
+ // Do an unrestricted search to make sure everything appears in it, including
+ // the visit and bookmark.
+ yield check_autocomplete({
+ checkSorting: true,
+ search: "frecency",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("frecency", { engineName: ENGINE_NAME, heuristic: true }),
+ { uri: NetUtil.newURI("http://example.com/hi3"),
+ title: "high frecency 3",
+ style: [ "bookmark" ] },
+ { uri: NetUtil.newURI("http://example.com/hi2"),
+ title: "high frecency 2",
+ style: [ "bookmark" ] },
+ { uri: NetUtil.newURI("http://example.com/hi1"),
+ title: "high frecency 1",
+ style: [ "bookmark" ] },
+ { uri: NetUtil.newURI("http://example.com/hi0"),
+ title: "high frecency 0",
+ style: [ "bookmark" ] },
+ { uri: NetUtil.newURI("http://example.com/lo4"),
+ title: "low frecency 4" },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "frecency foo",
+ searchQuery: "frecency",
+ searchSuggestion: "frecency foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "frecency bar",
+ searchQuery: "frecency",
+ searchSuggestion: "frecency bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ { uri: NetUtil.newURI("http://example.com/lo3"),
+ title: "low frecency 3" },
+ { uri: NetUtil.newURI("http://example.com/lo2"),
+ title: "low frecency 2" },
+ { uri: NetUtil.newURI("http://example.com/lo1"),
+ title: "low frecency 1" },
+ { uri: NetUtil.newURI("http://example.com/lo0"),
+ title: "low frecency 0" },
+ ],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* prohibit_suggestions() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+
+ yield check_autocomplete({
+ search: "localhost",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("localhost", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost foo",
+ searchQuery: "localhost",
+ searchSuggestion: "localhost foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost bar",
+ searchQuery: "localhost",
+ searchSuggestion: "localhost bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ ],
+ });
+ Services.prefs.setBoolPref("browser.fixup.domainwhitelist.localhost", true);
+ do_register_cleanup(() => {
+ Services.prefs.clearUserPref("browser.fixup.domainwhitelist.localhost");
+ });
+ yield check_autocomplete({
+ search: "localhost",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("localhost", "http://localhost/", { heuristic: true }),
+ makeSearchMatch("localhost", { engineName: ENGINE_NAME, heuristic: false })
+ ],
+ });
+
+ // When using multiple words, we should still get suggestions:
+ yield check_autocomplete({
+ search: "localhost other",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("localhost other", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost other foo",
+ searchQuery: "localhost other",
+ searchSuggestion: "localhost other foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost other bar",
+ searchQuery: "localhost other",
+ searchSuggestion: "localhost other bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ ],
+ });
+
+ // Clear the whitelist for localhost, and try preferring DNS for any single
+ // word instead:
+ Services.prefs.clearUserPref("browser.fixup.domainwhitelist.localhost");
+ Services.prefs.setBoolPref("browser.fixup.dns_first_for_single_words", true);
+ do_register_cleanup(() => {
+ Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words");
+ });
+
+ yield check_autocomplete({
+ search: "localhost",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("localhost", "http://localhost/", { heuristic: true }),
+ makeSearchMatch("localhost", { engineName: ENGINE_NAME, heuristic: false })
+ ],
+ });
+
+ yield check_autocomplete({
+ search: "somethingelse",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("somethingelse", "http://somethingelse/", { heuristic: true }),
+ makeSearchMatch("somethingelse", { engineName: ENGINE_NAME, heuristic: false })
+ ],
+ });
+
+ // When using multiple words, we should still get suggestions:
+ yield check_autocomplete({
+ search: "localhost other",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("localhost other", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost other foo",
+ searchQuery: "localhost other",
+ searchSuggestion: "localhost other foo",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "localhost other bar",
+ searchQuery: "localhost other",
+ searchSuggestion: "localhost other bar",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ ],
+ });
+
+ Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words");
+
+ yield check_autocomplete({
+ search: "1.2.3.4",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("1.2.3.4", "http://1.2.3.4/", { heuristic: true }),
+ ],
+ });
+ yield check_autocomplete({
+ search: "[2001::1]:30",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("[2001::1]:30", "http://[2001::1]:30/", { heuristic: true }),
+ ],
+ });
+ yield check_autocomplete({
+ search: "user:pass@test",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("user:pass@test", "http://user:pass@test/", { heuristic: true }),
+ ],
+ });
+ yield check_autocomplete({
+ search: "test/test",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("test/test", "http://test/test", { heuristic: true }),
+ ],
+ });
+ yield check_autocomplete({
+ search: "data:text/plain,Content",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("data:text/plain,Content", "data:text/plain,Content", { heuristic: true }),
+ ],
+ });
+
+ yield check_autocomplete({
+ search: "a",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("a", { engineName: ENGINE_NAME, heuristic: true }),
+ ],
+ });
+
+ yield cleanUpSuggestions();
+});
+
+add_task(function* avoid_url_suggestions() {
+ Services.prefs.setBoolPref(SUGGEST_PREF, true);
+
+ setSuggestionsFn(searchStr => {
+ let suffixes = [".com", "/test", ":1]", "@test", ". com"];
+ return suffixes.map(s => searchStr + s);
+ });
+
+ yield check_autocomplete({
+ search: "test",
+ searchParam: "enable-actions",
+ matches: [
+ makeSearchMatch("test", { engineName: ENGINE_NAME, heuristic: true }),
+ {
+ uri: makeActionURI(("searchengine"), {
+ engineName: ENGINE_NAME,
+ input: "test. com",
+ searchQuery: "test",
+ searchSuggestion: "test. com",
+ }),
+ title: ENGINE_NAME,
+ style: ["action", "searchengine"],
+ icon: "",
+ },
+ ],
+ });
+
+ yield cleanUpSuggestions();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_special_search.js b/toolkit/components/places/tests/unifiedcomplete/test_special_search.js
new file mode 100644
index 000000000..21df7046c
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_special_search.js
@@ -0,0 +1,447 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 395161 that allows special searches that restrict results to
+ * history/bookmark/tagged items and title/url matches.
+ *
+ * Test 485122 by making sure results don't have tags when restricting result
+ * to just history either by default behavior or dynamic query restrict.
+ */
+
+function setSuggestPrefsToFalse() {
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+}
+
+add_task(function* test_special_searches() {
+ let uri1 = NetUtil.newURI("http://url/");
+ let uri2 = NetUtil.newURI("http://url/2");
+ let uri3 = NetUtil.newURI("http://foo.bar/");
+ let uri4 = NetUtil.newURI("http://foo.bar/2");
+ let uri5 = NetUtil.newURI("http://url/star");
+ let uri6 = NetUtil.newURI("http://url/star/2");
+ let uri7 = NetUtil.newURI("http://foo.bar/star");
+ let uri8 = NetUtil.newURI("http://foo.bar/star/2");
+ let uri9 = NetUtil.newURI("http://url/tag");
+ let uri10 = NetUtil.newURI("http://url/tag/2");
+ let uri11 = NetUtil.newURI("http://foo.bar/tag");
+ let uri12 = NetUtil.newURI("http://foo.bar/tag/2");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title", transition: TRANSITION_TYPED },
+ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar", transition: TRANSITION_TYPED },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", transition: TRANSITION_TYPED }
+ ]);
+ yield addBookmark( { uri: uri5, title: "title" } );
+ yield addBookmark( { uri: uri6, title: "foo.bar" } );
+ yield addBookmark( { uri: uri7, title: "title" } );
+ yield addBookmark( { uri: uri8, title: "foo.bar" } );
+ yield addBookmark( { uri: uri9, title: "title", tags: [ "foo.bar" ] } );
+ yield addBookmark( { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ] } );
+ yield addBookmark( { uri: uri11, title: "title", tags: [ "foo.bar" ] } );
+ yield addBookmark( { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ] } );
+
+ // Test restricting searches
+ do_print("History restrict");
+ yield check_autocomplete({
+ search: "^",
+ matches: [ { uri: uri1, title: "title" },
+ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("Star restrict");
+ yield check_autocomplete({
+ search: "*",
+ matches: [ { uri: uri5, title: "title", style: [ "bookmark" ] },
+ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar"], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Tag restrict");
+ yield check_autocomplete({
+ search: "+",
+ matches: [ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ // Test specials as any word position
+ do_print("Special as first word");
+ yield check_autocomplete({
+ search: "^ foo bar",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("Special as middle word");
+ yield check_autocomplete({
+ search: "foo ^ bar",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("Special as last word");
+ yield check_autocomplete({
+ search: "foo bar ^",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ // Test restricting and matching searches with a term
+ do_print("foo ^ -> history");
+ yield check_autocomplete({
+ search: "foo ^",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo | -> history (change pref)");
+ changeRestrict("history", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo * -> is star");
+ resetRestrict("history");
+ yield check_autocomplete({
+ search: "foo *",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo | -> is star (change pref)");
+ changeRestrict("bookmark", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo # -> in title");
+ resetRestrict("bookmark");
+ yield check_autocomplete({
+ search: "foo #",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo | -> in title (change pref)");
+ changeRestrict("title", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo @ -> in url");
+ resetRestrict("title");
+ yield check_autocomplete({
+ search: "foo @",
+ matches: [ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo | -> in url (change pref)");
+ changeRestrict("url", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo + -> is tag");
+ resetRestrict("url");
+ yield check_autocomplete({
+ search: "foo +",
+ matches: [ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo | -> is tag (change pref)");
+ changeRestrict("tag", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo ~ -> is typed");
+ resetRestrict("tag");
+ yield check_autocomplete({
+ search: "foo ~",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo | -> is typed (change pref)");
+ changeRestrict("typed", "|");
+ yield check_autocomplete({
+ search: "foo |",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ // Test various pairs of special searches
+ do_print("foo ^ * -> history, is star");
+ resetRestrict("typed");
+ yield check_autocomplete({
+ search: "foo ^ *",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo ^ # -> history, in title");
+ yield check_autocomplete({
+ search: "foo ^ #",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo ^ @ -> history, in url");
+ yield check_autocomplete({
+ search: "foo ^ @",
+ matches: [ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo ^ + -> history, is tag");
+ yield check_autocomplete({
+ search: "foo ^ +",
+ matches: [ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo ^ ~ -> history, is typed");
+ yield check_autocomplete({
+ search: "foo ^ ~",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo * # -> is star, in title");
+ yield check_autocomplete({
+ search: "foo * #",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo * @ -> is star, in url");
+ yield check_autocomplete({
+ search: "foo * @",
+ matches: [ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo * + -> same as +");
+ yield check_autocomplete({
+ search: "foo * +",
+ matches: [ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo * ~ -> is star, is typed");
+ yield check_autocomplete({
+ search: "foo * ~",
+ matches: [ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo # @ -> in title, in url");
+ yield check_autocomplete({
+ search: "foo # @",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo # + -> in title, is tag");
+ yield check_autocomplete({
+ search: "foo # +",
+ matches: [ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo # ~ -> in title, is typed");
+ yield check_autocomplete({
+ search: "foo # ~",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo @ + -> in url, is tag");
+ yield check_autocomplete({
+ search: "foo @ +",
+ matches: [ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo @ ~ -> in url, is typed");
+ yield check_autocomplete({
+ search: "foo @ ~",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ do_print("foo + ~ -> is tag, is typed");
+ yield check_autocomplete({
+ search: "foo + ~",
+ matches: [ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "tag" ] } ]
+ });
+
+ // Disable autoFill for the next tests, see test_autoFill_default_behavior.js
+ // for specific tests.
+ Services.prefs.setBoolPref("browser.urlbar.autoFill", false);
+
+ // Test default usage by setting certain browser.urlbar.suggest.* prefs
+ do_print("foo -> default history");
+ setSuggestPrefsToFalse();
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ yield check_autocomplete({
+ search: "foo",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: ["foo.bar"], style: [ "tag" ] } ]
+ });
+
+ do_print("foo -> default history, is star");
+ setSuggestPrefsToFalse();
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ yield check_autocomplete({
+ search: "foo",
+ matches: [ { uri: uri2, title: "foo.bar" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "foo.bar" },
+ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar"], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo -> default history, is star, is typed");
+ setSuggestPrefsToFalse();
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ yield check_autocomplete({
+ search: "foo",
+ matches: [ { uri: uri4, title: "foo.bar" },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo -> is star");
+ setSuggestPrefsToFalse();
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ yield check_autocomplete({
+ search: "foo",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("foo -> is star, is typed");
+ setSuggestPrefsToFalse();
+ // only typed should be ignored
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ yield check_autocomplete({
+ search: "foo",
+ matches: [ { uri: uri6, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri7, title: "title", style: [ "bookmark" ] },
+ { uri: uri8, title: "foo.bar", style: [ "bookmark" ] },
+ { uri: uri9, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri11, title: "title", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] },
+ { uri: uri12, title: "foo.bar", tags: [ "foo.bar" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_swap_protocol.js b/toolkit/components/places/tests/unifiedcomplete/test_swap_protocol.js
new file mode 100644
index 000000000..89ccc3206
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_swap_protocol.js
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 424717 to make sure searching with an existing location like
+ * http://site/ also matches https://site/ or ftp://site/. Same thing for
+ * ftp://site/ and https://site/.
+ *
+ * Test bug 461483 to make sure a search for "w" doesn't match the "www." from
+ * site subdomains.
+ */
+
+add_task(function* test_swap_protocol() {
+ let uri1 = NetUtil.newURI("http://www.site/");
+ let uri2 = NetUtil.newURI("http://site/");
+ let uri3 = NetUtil.newURI("ftp://ftp.site/");
+ let uri4 = NetUtil.newURI("ftp://site/");
+ let uri5 = NetUtil.newURI("https://www.site/");
+ let uri6 = NetUtil.newURI("https://site/");
+ let uri7 = NetUtil.newURI("http://woohoo/");
+ let uri8 = NetUtil.newURI("http://wwwwwwacko/");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "title" },
+ { uri: uri5, title: "title" },
+ { uri: uri6, title: "title" },
+ { uri: uri7, title: "title" },
+ { uri: uri8, title: "title" }
+ ]);
+
+ let allMatches = [
+ { uri: uri1, title: "title" },
+ { uri: uri2, title: "title" },
+ { uri: uri3, title: "title" },
+ { uri: uri4, title: "title" },
+ { uri: uri5, title: "title" },
+ { uri: uri6, title: "title" }
+ ];
+
+ // Disable autoFill to avoid handling the first result.
+ Services.prefs.setBoolPref("browser.urlbar.autoFill", "false");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false);
+
+ do_print("http://www.site matches all site");
+ yield check_autocomplete({
+ search: "http://www.site",
+ matches: allMatches
+ });
+
+ do_print("http://site matches all site");
+ yield check_autocomplete({
+ search: "http://site",
+ matches: allMatches
+ });
+
+ do_print("ftp://ftp.site matches itself");
+ yield check_autocomplete({
+ search: "ftp://ftp.site",
+ matches: [ { uri: uri3, title: "title" } ]
+ });
+
+ do_print("ftp://site matches all site");
+ yield check_autocomplete({
+ search: "ftp://site",
+ matches: allMatches
+ });
+
+ do_print("https://www.site matches all site");
+ yield check_autocomplete({
+ search: "https://www.site",
+ matches: allMatches
+ });
+
+ do_print("https://site matches all site");
+ yield check_autocomplete({
+ search: "https://site",
+ matches: allMatches
+ });
+
+ do_print("www.site matches all site");
+ yield check_autocomplete({
+ search: "www.site",
+ matches: allMatches
+ });
+
+ do_print("w matches none of www.");
+ yield check_autocomplete({
+ search: "w",
+ matches: [ { uri: uri7, title: "title" },
+ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://w matches none of www.");
+ yield check_autocomplete({
+ search: "http://w",
+ matches: [ { uri: uri7, title: "title" },
+ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://w matches none of www.");
+ yield check_autocomplete({
+ search: "http://www.w",
+ matches: [ { uri: uri7, title: "title" },
+ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("ww matches none of www.");
+ yield check_autocomplete({
+ search: "ww",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("ww matches none of www.");
+ yield check_autocomplete({
+ search: "ww",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://ww matches none of www.");
+ yield check_autocomplete({
+ search: "http://ww",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://www.ww matches none of www.");
+ yield check_autocomplete({
+ search: "http://www.ww",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("www matches none of www.");
+ yield check_autocomplete({
+ search: "www",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://www matches none of www.");
+ yield check_autocomplete({
+ search: "http://www",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ do_print("http://www.www matches none of www.");
+ yield check_autocomplete({
+ search: "http://www.www",
+ matches: [ { uri: uri8, title: "title" } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_tab_matches.js b/toolkit/components/places/tests/unifiedcomplete/test_tab_matches.js
new file mode 100644
index 000000000..740b8d8ed
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_tab_matches.js
@@ -0,0 +1,164 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gTabRestrictChar = "%";
+
+add_task(function* test_tab_matches() {
+ let uri1 = NetUtil.newURI("http://abc.com/");
+ let uri2 = NetUtil.newURI("http://xyz.net/");
+ let uri3 = NetUtil.newURI("about:mozilla");
+ let uri4 = NetUtil.newURI("data:text/html,test");
+ let uri5 = NetUtil.newURI("http://foobar.org");
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "ABC rocks" },
+ { uri: uri2, title: "xyz.net - we're better than ABC" },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ" }
+ ]);
+ addOpenPages(uri1, 1);
+ // Pages that cannot be registered in history.
+ addOpenPages(uri3, 1);
+ addOpenPages(uri4, 1);
+
+ do_print("two results, normal result is a tab match");
+ yield check_autocomplete({
+ search: "abc.com",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("abc.com", "http://abc.com/", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ makeSearchMatch("abc.com", { heuristic: false }) ]
+ });
+
+ do_print("three results, one tab match");
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("three results, both normal results are tab matches");
+ addOpenPages(uri2, 1);
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ makeSwitchToTabMatch("http://xyz.net/", { title: "xyz.net - we're better than ABC" }),
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("a container tab is not visible in 'switch to tab'");
+ addOpenPages(uri5, 1, /* userContextId: */ 3);
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ makeSwitchToTabMatch("http://xyz.net/", { title: "xyz.net - we're better than ABC" }),
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("a container tab should not see 'switch to tab' for other container tabs");
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions user-context-id:3",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://foobar.org/", { title: "foobar.org - much better than ABC, definitely better than XYZ" }),
+ { uri: uri1, title: "ABC rocks", style: [ "favicon" ] },
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] } ]
+ });
+
+ do_print("a different container tab should not see any 'switch to tab'");
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions user-context-id:2",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ { uri: uri1, title: "ABC rocks", style: [ "favicon" ] },
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("three results, both normal results are tab matches, one has multiple tabs");
+ addOpenPages(uri2, 5);
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ makeSwitchToTabMatch("http://xyz.net/", { title: "xyz.net - we're better than ABC" }),
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("three results, no tab matches (disable-private-actions)");
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions disable-private-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ { uri: uri1, title: "ABC rocks", style: [ "favicon" ] },
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("two results (actions disabled)");
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "",
+ matches: [ { uri: uri1, title: "ABC rocks", style: [ "favicon" ] },
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("three results, no tab matches");
+ removeOpenPages(uri1, 1);
+ removeOpenPages(uri2, 6);
+ yield check_autocomplete({
+ search: "abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("abc", { heuristic: true }),
+ { uri: uri1, title: "ABC rocks", style: [ "favicon" ] },
+ { uri: uri2, title: "xyz.net - we're better than ABC", style: [ "favicon" ] },
+ { uri: uri5, title: "foobar.org - much better than ABC, definitely better than XYZ", style: [ "favicon" ] } ]
+ });
+
+ do_print("tab match search with restriction character");
+ addOpenPages(uri1, 1);
+ yield check_autocomplete({
+ search: gTabRestrictChar + " abc",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(gTabRestrictChar + " abc", { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }) ]
+ });
+
+ do_print("tab match with not-addable pages");
+ yield check_autocomplete({
+ search: "mozilla",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("mozilla", { heuristic: true }),
+ makeSwitchToTabMatch("about:mozilla") ]
+ });
+
+ do_print("tab match with not-addable pages and restriction character");
+ yield check_autocomplete({
+ search: gTabRestrictChar + " mozilla",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(gTabRestrictChar + " mozilla", { heuristic: true }),
+ makeSwitchToTabMatch("about:mozilla") ]
+ });
+
+ do_print("tab match with not-addable pages and only restriction character");
+ yield check_autocomplete({
+ search: gTabRestrictChar,
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch(gTabRestrictChar, { heuristic: true }),
+ makeSwitchToTabMatch("http://abc.com/", { title: "ABC rocks" }),
+ makeSwitchToTabMatch("about:mozilla"),
+ makeSwitchToTabMatch("data:text/html,test") ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_trimming.js b/toolkit/components/places/tests/unifiedcomplete/test_trimming.js
new file mode 100644
index 000000000..e55b009ff
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_trimming.js
@@ -0,0 +1,313 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(function* test_untrimmed_secure_www() {
+ do_print("Searching for untrimmed https://www entry");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("https://www.mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "https://www.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_secure_www_path() {
+ do_print("Searching for untrimmed https://www entry with path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("https://www.mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/t",
+ autofilled: "mozilla.org/test/",
+ completed: "https://www.mozilla.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_secure() {
+ do_print("Searching for untrimmed https:// entry");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("https://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "https://mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_secure_path() {
+ do_print("Searching for untrimmed https:// entry with path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("https://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/t",
+ autofilled: "mozilla.org/test/",
+ completed: "https://mozilla.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_www() {
+ do_print("Searching for untrimmed http://www entry");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://www.mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "www.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_www_path() {
+ do_print("Searching for untrimmed http://www entry with path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://www.mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/t",
+ autofilled: "mozilla.org/test/",
+ completed: "http://www.mozilla.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_ftp() {
+ do_print("Searching for untrimmed ftp:// entry");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("ftp://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "ftp://mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untrimmed_ftp_path() {
+ do_print("Searching for untrimmed ftp:// entry with path");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("ftp://mozilla.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/t",
+ autofilled: "mozilla.org/test/",
+ completed: "ftp://mozilla.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_priority_1() {
+ do_print("Ensuring correct priority 1");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("https://www.mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("https://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("ftp://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://www.mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.org/test/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_periority_2() {
+ do_print( "Ensuring correct priority 2");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("https://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("ftp://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://www.mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.org/test/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_periority_3() {
+ do_print("Ensuring correct priority 3");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("ftp://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://www.mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.org/test/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_periority_4() {
+ do_print("Ensuring correct priority 4");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://www.mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.org/test/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_priority_5() {
+ do_print("Ensuring correct priority 5");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("ftp://mozilla.org/test/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("ftp://www.mozilla.org/test/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "ftp://mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_priority_6() {
+ do_print("Ensuring correct priority 6");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://www.mozilla.org/test1/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://www.mozilla.org/test2/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.org/",
+ completed: "www.mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_longer_domain() {
+ do_print("Ensuring longer domain can't match");
+ // The .co should be preferred, but should not get the https from the .com.
+ // The .co domain must be added later to activate the trigger bug.
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("https://mozilla.com/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.co/"), transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://mozilla.co/"), transition: TRANSITION_TYPED }
+ ]);
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "mozilla.co/",
+ completed: "mozilla.co/"
+ });
+
+ yield cleanup();
+});
+
+add_task(function* test_escaped_chars() {
+ do_print("Searching for URL with characters that are normally escaped");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("https://www.mozilla.org/啊-test"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "https://www.mozilla.org/啊-test",
+ autofilled: "https://www.mozilla.org/啊-test",
+ completed: "https://www.mozilla.org/啊-test"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_unsecure_secure() {
+ do_print("Don't return unsecure URL when searching for secure ones");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://test.moz.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "https://test.moz.org/t",
+ autofilled: "https://test.moz.org/test/",
+ completed: "https://test.moz.org/test/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_unsecure_secure_domain() {
+ do_print("Don't return unsecure domain when searching for secure ones");
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://test.moz.org/test/"),
+ transition: TRANSITION_TYPED
+ });
+ yield check_autocomplete({
+ search: "https://test.moz",
+ autofilled: "https://test.moz.org/",
+ completed: "https://test.moz.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untyped_www() {
+ do_print("Untyped is not accounted for www");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("http://www.moz.org/test/") });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "moz.org/",
+ completed: "moz.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untyped_ftp() {
+ do_print("Untyped is not accounted for ftp");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("ftp://moz.org/test/") });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "moz.org/",
+ completed: "moz.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untyped_secure() {
+ do_print("Untyped is not accounted for https");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("https://moz.org/test/") });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "moz.org/",
+ completed: "moz.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untyped_secure_www() {
+ do_print("Untyped is not accounted for https://www");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("https://www.moz.org/test/") });
+ yield check_autocomplete({
+ search: "mo",
+ autofilled: "moz.org/",
+ completed: "moz.org/"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_typed.js b/toolkit/components/places/tests/unifiedcomplete/test_typed.js
new file mode 100644
index 000000000..72f76159c
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_typed.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// First do searches with typed behavior forced to false, so later tests will
+// ensure autocomplete is able to dinamically switch behavior.
+
+const FAVICON_HREF = NetUtil.newURI(do_get_file("../favicons/favicon-normal16.png")).spec;
+
+add_task(function* test_domain() {
+ do_print("Searching for domain should autoFill it");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://mozilla.org/link/"));
+ yield setFaviconForHref("http://mozilla.org/link/", FAVICON_HREF);
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/",
+ icon: "moz-anno:favicon:" + FAVICON_HREF
+ });
+ yield cleanup();
+});
+
+add_task(function* test_url() {
+ do_print("Searching for url should autoFill it");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://mozilla.org/link/"));
+ yield setFaviconForHref("http://mozilla.org/link/", FAVICON_HREF);
+ yield check_autocomplete({
+ search: "mozilla.org/li",
+ autofilled: "mozilla.org/link/",
+ completed: "http://mozilla.org/link/",
+ icon: "moz-anno:favicon:" + FAVICON_HREF
+ });
+ yield cleanup();
+});
+
+// Now do searches with typed behavior forced to true.
+
+add_task(function* test_untyped_domain() {
+ do_print("Searching for non-typed domain should not autoFill it");
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://mozilla.org/link/"));
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "moz",
+ completed: "moz"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_typed_domain() {
+ do_print("Searching for typed domain should autoFill it");
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("http://mozilla.org/typed/"),
+ transition: TRANSITION_TYPED });
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "mozilla.org/",
+ completed: "mozilla.org/"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_untyped_url() {
+ do_print("Searching for non-typed url should not autoFill it");
+ yield PlacesTestUtils.addVisits(NetUtil.newURI("http://mozilla.org/link/"));
+ yield check_autocomplete({
+ search: "mozilla.org/li",
+ autofilled: "mozilla.org/li",
+ completed: "mozilla.org/li"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_typed_url() {
+ do_print("Searching for typed url should autoFill it");
+ yield PlacesTestUtils.addVisits({ uri: NetUtil.newURI("http://mozilla.org/link/"),
+ transition: TRANSITION_TYPED });
+ yield check_autocomplete({
+ search: "mozilla.org/li",
+ autofilled: "mozilla.org/link/",
+ completed: "http://mozilla.org/link/"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_visit_url.js b/toolkit/components/places/tests/unifiedcomplete/test_visit_url.js
new file mode 100644
index 000000000..eaccb23e5
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_visit_url.js
@@ -0,0 +1,186 @@
+add_task(function*() {
+ do_print("visit url, no protocol");
+ yield check_autocomplete({
+ search: "mozilla.org",
+ searchParam: "enable-actions",
+ matches: [
+ { uri: makeActionURI("visiturl", {url: "http://mozilla.org/", input: "mozilla.org"}), title: "http://mozilla.org/", style: [ "action", "visiturl", "heuristic" ] },
+ { uri: makeActionURI("searchengine", {engineName: "MozSearch", input: "mozilla.org", searchQuery: "mozilla.org"}), title: "MozSearch", style: ["action", "searchengine"] }
+ ]
+ });
+
+ do_print("visit url, no protocol but with 2 dots");
+ yield check_autocomplete({
+ search: "www.mozilla.org",
+ searchParam: "enable-actions",
+ matches: [
+ { uri: makeActionURI("visiturl", {url: "http://www.mozilla.org/", input: "www.mozilla.org"}), title: "http://www.mozilla.org/", style: [ "action", "visiturl", "heuristic" ] },
+ { uri: makeActionURI("searchengine", {engineName: "MozSearch", input: "www.mozilla.org", searchQuery: "www.mozilla.org"}), title: "MozSearch", style: ["action", "searchengine"] }
+ ]
+ });
+
+ do_print("visit url, no protocol but with 3 dots");
+ yield check_autocomplete({
+ search: "www.mozilla.org.tw",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("visiturl", {url: "http://www.mozilla.org.tw/", input: "www.mozilla.org.tw"}), title: "http://www.mozilla.org.tw/", style: [ "action", "visiturl", "heuristic" ] } ]
+ });
+
+ do_print("visit url, with protocol but with 2 dots");
+ yield check_autocomplete({
+ search: "https://www.mozilla.org",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("visiturl", {url: "https://www.mozilla.org/", input: "https://www.mozilla.org"}), title: "https://www.mozilla.org/", style: [ "action", "visiturl", "heuristic" ] } ]
+ });
+
+ do_print("visit url, with protocol but with 3 dots");
+ yield check_autocomplete({
+ search: "https://www.mozilla.org.tw",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("visiturl", {url: "https://www.mozilla.org.tw/", input: "https://www.mozilla.org.tw"}), title: "https://www.mozilla.org.tw/", style: [ "action", "visiturl", "heuristic" ] } ]
+ });
+
+ do_print("visit url, with protocol");
+ yield check_autocomplete({
+ search: "https://mozilla.org",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("visiturl", {url: "https://mozilla.org/", input: "https://mozilla.org"}), title: "https://mozilla.org/", style: [ "action", "visiturl", "heuristic" ] } ]
+ });
+
+ do_print("visit url, about: protocol (no host)");
+ yield check_autocomplete({
+ search: "about:config",
+ searchParam: "enable-actions",
+ matches: [ { uri: makeActionURI("visiturl", {url: "about:config", input: "about:config"}), title: "about:config", style: [ "action", "visiturl", "heuristic" ] } ]
+ });
+
+ // This is distinct because of how we predict being able to url autofill via
+ // host lookups.
+ do_print("visit url, host matching visited host but not visited url");
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://mozilla.org/wine/"), title: "Mozilla Wine", transition: TRANSITION_TYPED },
+ ]);
+ yield check_autocomplete({
+ search: "mozilla.org/rum",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("mozilla.org/rum", "http://mozilla.org/rum", { heuristic: true }) ]
+ });
+
+ // And hosts with no dot in them are special, due to requiring whitelisting.
+ do_print("non-whitelisted host");
+ yield check_autocomplete({
+ search: "firefox",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("firefox", { heuristic: true }) ]
+ });
+
+ do_print("url with non-whitelisted host");
+ yield check_autocomplete({
+ search: "firefox/get",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("firefox/get", "http://firefox/get", { heuristic: true }) ]
+ });
+
+ Services.prefs.setBoolPref("browser.fixup.domainwhitelist.firefox", true);
+ do_register_cleanup(() => {
+ Services.prefs.clearUserPref("browser.fixup.domainwhitelist.firefox");
+ });
+
+ do_print("whitelisted host");
+ yield check_autocomplete({
+ search: "firefox",
+ searchParam: "enable-actions",
+ matches: [
+ makeVisitMatch("firefox", "http://firefox/", { heuristic: true }),
+ makeSearchMatch("firefox", { heuristic: false })
+ ]
+ });
+
+ do_print("url with whitelisted host");
+ yield check_autocomplete({
+ search: "firefox/get",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("firefox/get", "http://firefox/get", { heuristic: true }) ]
+ });
+
+ do_print("visit url, host matching visited host but not visited url, whitelisted host");
+ Services.prefs.setBoolPref("browser.fixup.domainwhitelist.mozilla", true);
+ do_register_cleanup(() => {
+ Services.prefs.clearUserPref("browser.fixup.domainwhitelist.mozilla");
+ });
+ yield check_autocomplete({
+ search: "mozilla/rum",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("mozilla/rum", "http://mozilla/rum", { heuristic: true }) ]
+ });
+
+ // ipv4 and ipv6 literal addresses should offer to visit.
+ do_print("visit url, ipv4 literal");
+ yield check_autocomplete({
+ search: "127.0.0.1",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("127.0.0.1", "http://127.0.0.1/", { heuristic: true }) ]
+ });
+
+ do_print("visit url, ipv6 literal");
+ yield check_autocomplete({
+ search: "[2001:db8::1]",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("[2001:db8::1]", "http://[2001:db8::1]/", { heuristic: true }) ]
+ });
+
+ // Setting keyword.enabled to false should always try to visit.
+ let keywordEnabled = Services.prefs.getBoolPref("keyword.enabled");
+ Services.prefs.setBoolPref("keyword.enabled", false);
+ do_register_cleanup(() => {
+ Services.prefs.clearUserPref("keyword.enabled");
+ });
+ do_print("visit url, keyword.enabled = false");
+ yield check_autocomplete({
+ search: "bacon",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("bacon", "http://bacon/", { heuristic: true }) ]
+ });
+ do_print("visit two word query, keyword.enabled = false");
+ yield check_autocomplete({
+ search: "bacon lovers",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("bacon lovers", "bacon lovers", { heuristic: true }) ]
+ });
+ Services.prefs.setBoolPref("keyword.enabled", keywordEnabled);
+
+ do_print("visit url, scheme+host");
+ yield check_autocomplete({
+ search: "http://example",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("http://example", "http://example/", { heuristic: true }) ]
+ });
+
+ do_print("visit url, scheme+host");
+ yield check_autocomplete({
+ search: "ftp://example",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("ftp://example", "ftp://example/", { heuristic: true }) ]
+ });
+
+ do_print("visit url, host+port");
+ yield check_autocomplete({
+ search: "example:8080",
+ searchParam: "enable-actions",
+ matches: [ makeVisitMatch("example:8080", "http://example:8080/", { heuristic: true }) ]
+ });
+
+ do_print("numerical operations that look like urls should search");
+ yield check_autocomplete({
+ search: "123/12",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("123/12", { heuristic: true }) ]
+ });
+
+ do_print("numerical operations that look like urls should search");
+ yield check_autocomplete({
+ search: "123.12/12.1",
+ searchParam: "enable-actions",
+ matches: [ makeSearchMatch("123.12/12.1", { heuristic: true }) ]
+ });
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_word_boundary_search.js b/toolkit/components/places/tests/unifiedcomplete/test_word_boundary_search.js
new file mode 100644
index 000000000..f79573ae6
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_word_boundary_search.js
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 393678 to make sure matches against the url, title, tags are only
+ * made on word boundaries instead of in the middle of words.
+ *
+ * Make sure we don't try matching one after a CamelCase because the upper-case
+ * isn't really a word boundary. (bug 429498)
+ *
+ * Bug 429531 provides switching between "must match on word boundary" and "can
+ * match," so leverage "must match" pref for checking word boundary logic and
+ * make sure "can match" matches anywhere.
+ */
+
+var katakana = ["\u30a8", "\u30c9"]; // E, Do
+var ideograph = ["\u4efb", "\u5929", "\u5802"]; // Nin Ten Do
+
+add_task(function* test_escape() {
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false);
+
+ let uri1 = NetUtil.newURI("http://matchme/");
+ let uri2 = NetUtil.newURI("http://dontmatchme/");
+ let uri3 = NetUtil.newURI("http://title/1");
+ let uri4 = NetUtil.newURI("http://title/2");
+ let uri5 = NetUtil.newURI("http://tag/1");
+ let uri6 = NetUtil.newURI("http://tag/2");
+ let uri7 = NetUtil.newURI("http://crazytitle/");
+ let uri8 = NetUtil.newURI("http://katakana/");
+ let uri9 = NetUtil.newURI("http://ideograph/");
+ let uri10 = NetUtil.newURI("http://camel/pleaseMatchMe/");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri1, title: "title1" },
+ { uri: uri2, title: "title1" },
+ { uri: uri3, title: "matchme2" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri5, title: "title1" },
+ { uri: uri6, title: "title1" },
+ { uri: uri7, title: "!@#$%^&*()_+{}|:<>?word" },
+ { uri: uri8, title: katakana.join("") },
+ { uri: uri9, title: ideograph.join("") },
+ { uri: uri10, title: "title1" }
+ ]);
+ yield addBookmark( { uri: uri5, title: "title1", tags: [ "matchme2" ] } );
+ yield addBookmark( { uri: uri6, title: "title1", tags: [ "dontmatchme3" ] } );
+
+ // match only on word boundaries
+ Services.prefs.setIntPref("browser.urlbar.matchBehavior", 2);
+
+ do_print("Match 'match' at the beginning or after / or on a CamelCase");
+ yield check_autocomplete({
+ search: "match",
+ matches: [ { uri: uri1, title: "title1" },
+ { uri: uri3, title: "matchme2" },
+ { uri: uri5, title: "title1", tags: [ "matchme2" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "title1" } ]
+ });
+
+ do_print("Match 'dont' at the beginning or after /");
+ yield check_autocomplete({
+ search: "dont",
+ matches: [ { uri: uri2, title: "title1" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri6, title: "title1", tags: [ "dontmatchme3" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Match 'match' at the beginning or after / or on a CamelCase");
+ yield check_autocomplete({
+ search: "2",
+ matches: [ { uri: uri3, title: "matchme2" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri5, title: "title1", tags: [ "matchme2" ], style: [ "bookmark-tag" ] },
+ { uri: uri6, title: "title1", tags: [ "dontmatchme3" ], style: [ "bookmark-tag" ] } ]
+ });
+
+ do_print("Match 't' at the beginning or after /");
+ yield check_autocomplete({
+ search: "t",
+ matches: [ { uri: uri1, title: "title1" },
+ { uri: uri2, title: "title1" },
+ { uri: uri3, title: "matchme2" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri5, title: "title1", tags: [ "matchme2" ], style: [ "bookmark-tag" ] },
+ { uri: uri6, title: "title1", tags: [ "dontmatchme3" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "title1" } ]
+ });
+
+ do_print("Match 'word' after many consecutive word boundaries");
+ yield check_autocomplete({
+ search: "word",
+ matches: [ { uri: uri7, title: "!@#$%^&*()_+{}|:<>?word" } ]
+ });
+
+ do_print("Match a word boundary '/' for everything");
+ yield check_autocomplete({
+ search: "/",
+ matches: [ { uri: uri1, title: "title1" },
+ { uri: uri2, title: "title1" },
+ { uri: uri3, title: "matchme2" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri5, title: "title1", tags: [ "matchme2" ], style: [ "bookmark-tag" ] },
+ { uri: uri6, title: "title1", tags: [ "dontmatchme3" ], style: [ "bookmark-tag" ] },
+ { uri: uri7, title: "!@#$%^&*()_+{}|:<>?word" },
+ { uri: uri8, title: katakana.join("") },
+ { uri: uri9, title: ideograph.join("") },
+ { uri: uri10, title: "title1" } ]
+ });
+
+ do_print("Match word boundaries '()_+' that are among word boundaries");
+ yield check_autocomplete({
+ search: "()_+",
+ matches: [ { uri: uri7, title: "!@#$%^&*()_+{}|:<>?word" } ]
+ });
+
+ do_print("Katakana characters form a string, so match the beginning");
+ yield check_autocomplete({
+ search: katakana[0],
+ matches: [ { uri: uri8, title: katakana.join("") } ]
+ });
+
+/*
+ do_print("Middle of a katakana word shouldn't be matched");
+ yield check_autocomplete({
+ search: katakana[1],
+ matches: [ ]
+ });
+*/
+ do_print("Ideographs are treated as words so 'nin' is one word");
+ yield check_autocomplete({
+ search: ideograph[0],
+ matches: [ { uri: uri9, title: ideograph.join("") } ]
+ });
+
+ do_print("Ideographs are treated as words so 'ten' is another word");
+ yield check_autocomplete({
+ search: ideograph[1],
+ matches: [ { uri: uri9, title: ideograph.join("") } ]
+ });
+
+ do_print("Ideographs are treated as words so 'do' is yet another word");
+ yield check_autocomplete({
+ search: ideograph[2],
+ matches: [ { uri: uri9, title: ideograph.join("") } ]
+ });
+
+ do_print("Extra negative assert that we don't match in the middle");
+ yield check_autocomplete({
+ search: "ch",
+ matches: [ ]
+ });
+
+ do_print("Don't match one character after a camel-case word boundary (bug 429498)");
+ yield check_autocomplete({
+ search: "atch",
+ matches: [ ]
+ });
+
+ // match against word boundaries and anywhere
+ Services.prefs.setIntPref("browser.urlbar.matchBehavior", 1);
+
+ yield check_autocomplete({
+ search: "tch",
+ matches: [ { uri: uri1, title: "title1" },
+ { uri: uri2, title: "title1" },
+ { uri: uri3, title: "matchme2" },
+ { uri: uri4, title: "dontmatchme3" },
+ { uri: uri5, title: "title1", tags: [ "matchme2" ], style: [ "bookmark-tag" ] },
+ { uri: uri6, title: "title1", tags: [ "dontmatchme3" ], style: [ "bookmark-tag" ] },
+ { uri: uri10, title: "title1" } ]
+ });
+
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/test_zero_frecency.js b/toolkit/components/places/tests/unifiedcomplete/test_zero_frecency.js
new file mode 100644
index 000000000..adf638886
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/test_zero_frecency.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Ensure inline autocomplete doesn't return zero frecency pages.
+
+add_task(function* test_zzero_frec_domain() {
+ do_print("Searching for zero frecency domain should not autoFill it");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/framed_link/"),
+ transition: TRANSITION_FRAMED_LINK
+ });
+ yield check_autocomplete({
+ search: "moz",
+ autofilled: "moz",
+ completed: "moz"
+ });
+ yield cleanup();
+});
+
+add_task(function* test_zzero_frec_url() {
+ do_print("Searching for zero frecency url should not autoFill it");
+ Services.prefs.setBoolPref("browser.urlbar.autoFill.typed", false);
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://mozilla.org/framed_link/"),
+ transition: TRANSITION_FRAMED_LINK
+ });
+ yield check_autocomplete({
+ search: "mozilla.org/f",
+ autofilled: "mozilla.org/f",
+ completed: "mozilla.org/f"
+ });
+ yield cleanup();
+});
diff --git a/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
new file mode 100644
index 000000000..60ef8c48a
--- /dev/null
+++ b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini
@@ -0,0 +1,49 @@
+[DEFAULT]
+head = head_autocomplete.js
+tail =
+skip-if = toolkit == 'android'
+support-files =
+ data/engine-rel-searchform.xml
+ data/engine-suggestions.xml
+ !/toolkit/components/places/tests/favicons/favicon-normal16.png
+
+[test_416211.js]
+[test_416214.js]
+[test_417798.js]
+[test_418257.js]
+[test_422277.js]
+[test_autocomplete_functional.js]
+[test_autocomplete_on_value_removed_479089.js]
+[test_autofill_default_behavior.js]
+[test_avoid_middle_complete.js]
+[test_avoid_stripping_to_empty_tokens.js]
+[test_casing.js]
+[test_do_not_trim.js]
+[test_download_embed_bookmarks.js]
+[test_dupe_urls.js]
+[test_empty_search.js]
+[test_enabled.js]
+[test_escape_self.js]
+[test_extension_matches.js]
+[test_ignore_protocol.js]
+[test_keyword_search.js]
+[test_keyword_search_actions.js]
+[test_keywords.js]
+[test_match_beginning.js]
+[test_multi_word_search.js]
+[test_query_url.js]
+[test_remote_tab_matches.js]
+skip-if = !sync
+[test_search_engine_alias.js]
+[test_search_engine_current.js]
+[test_search_engine_host.js]
+[test_search_engine_restyle.js]
+[test_search_suggestions.js]
+[test_special_search.js]
+[test_swap_protocol.js]
+[test_tab_matches.js]
+[test_trimming.js]
+[test_typed.js]
+[test_visit_url.js]
+[test_word_boundary_search.js]
+[test_zero_frecency.js]
diff --git a/toolkit/components/places/tests/unit/.eslintrc.js b/toolkit/components/places/tests/unit/.eslintrc.js
new file mode 100644
index 000000000..d35787cd2
--- /dev/null
+++ b/toolkit/components/places/tests/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ "extends": [
+ "../../../../../testing/xpcshell/xpcshell.eslintrc.js"
+ ]
+};
diff --git a/toolkit/components/places/tests/unit/bookmarks.corrupt.html b/toolkit/components/places/tests/unit/bookmarks.corrupt.html
new file mode 100644
index 000000000..3cf43367f
--- /dev/null
+++ b/toolkit/components/places/tests/unit/bookmarks.corrupt.html
@@ -0,0 +1,36 @@
+<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<!-- This is an automatically generated file.
+ It will be read and overwritten.
+ DO NOT EDIT! -->
+<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+<TITLE>Bookmarks</TITLE>
+<H1 LAST_MODIFIED="1177541029">Bookmarks</H1>
+
+<DL><p>
+ <DT><H3 ID="rdf:#$ZvPhC3">Mozilla Firefox</H3>
+ <DL><p>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$22iCK1">Help and Tutorials</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/customize/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$32iCK1">Customize Firefox</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/community/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$42iCK1">Get Involved</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/about/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A>
+ <DT><A HREF="b0rked" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A>
+ </DL><p>
+ <DT><H3 ADD_DATE="1177541020" LAST_MODIFIED="1177541050" ID="rdf:#$74Gpx2">test</H3>
+<DD>folder test comment
+ <DL><p>
+ <DT><A HREF="http://test/post" ADD_DATE="1177375336" LAST_MODIFIED="1177375423" SHORTCUTURL="test" WEB_PANEL="true" POST_DATA="hidden1%3Dbar&amp;text1%3D%25s" LAST_CHARSET="ISO-8859-1" ID="rdf:#$pYFe7">test post keyword</A>
+<DD>item description
+ </DL>
+ <DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
+ <DL><p>
+ <DT><A HREF="http://example.tld">Example.tld</A>
+ </DL><p>
+ <DT><H3 LAST_MODIFIED="1177541040" PERSONAL_TOOLBAR_FOLDER="true" ID="rdf:#$FvPhC3">Bookmarks Toolbar Folder</H3>
+<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar
+ <DL><p>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/central/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$GvPhC3">Getting Started</A>
+ <DT><A HREF="http://en-US.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" LAST_MODIFIED="1177541035" FEEDURL="http://en-US.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" ID="rdf:#$HvPhC3">Latest Headlines</A>
+ <DT><A HREF="http://bogus-icon.mozilla.com/" ICON="b0rked" ID="rdf:#$GvPhC3">Getting Started</A>
+<DD>Livemark test comment
+ </DL><p>
+</DL><p>
diff --git a/toolkit/components/places/tests/unit/bookmarks.json b/toolkit/components/places/tests/unit/bookmarks.json
new file mode 100644
index 000000000..afe62abae
--- /dev/null
+++ b/toolkit/components/places/tests/unit/bookmarks.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","id":1,"dateAdded":1361551978957783,"lastModified":1361551978957783,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","id":2,"parent":1,"dateAdded":1361551978957783,"lastModified":1361551979382837,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"OCyeUO5uu9FF","title":"Mozilla Firefox","id":6,"parent":2,"dateAdded":1361551979350273,"lastModified":1361551979376699,"type":"text/x-moz-place-container","children":[{"guid":"OCyeUO5uu9FG","title":"Help and Tutorials","id":7,"parent":6,"dateAdded":1361551979356436,"lastModified":1361551979362718,"type":"text/x-moz-place","uri":"http://en-us.www.mozilla.com/en-US/firefox/help/", "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="},{"guid":"OCyeUO5uu9FH","index":1,"title":"Customize Firefox","id":8,"parent":6,"dateAdded":1361551979365662,"lastModified":1361551979368077,"type":"text/x-moz-place","uri":"http://en-us.www.mozilla.com/en-US/firefox/customize/", "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="},{"guid":"OCyeUO5uu9FI","index":2,"title":"Get Involved","id":9,"parent":6,"dateAdded":1361551979371071,"lastModified":1361551979373745,"type":"text/x-moz-place","uri":"http://en-us.www.mozilla.com/en-US/firefox/community/", "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="},{"guid":"OCyeUO5uu9FJ","index":3,"title":"About Us","id":10,"parent":6,"dateAdded":1361551979376699,"lastModified":1361551979379060,"type":"text/x-moz-place","uri":"http://en-us.www.mozilla.com/en-US/about/", "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="}]},{"guid":"OCyeUO5uu9FK","index":1,"title":"","id":11,"parent":2,"dateAdded":1361551979380988,"lastModified":1361551979380988,"type":"text/x-moz-place-separator"},{"guid":"OCyeUO5uu9FL","index":2,"title":"test","id":12,"parent":2,"dateAdded":1177541020000000,"lastModified":1177541050000000,"annos":[{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"folder test comment"}],"type":"text/x-moz-place-container","children":[{"guid":"OCyeUO5uu9GX","title":"test post keyword","id":13,"parent":12,"dateAdded":1177375336000000,"lastModified":1177375423000000,"annos":[{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"item description"},{"name":"bookmarkProperties/POSTData","flags":0,"expires":4,"mimeType":null,"type":3,"value":"hidden1%3Dbar&text1%3D%25s"},{"name":"bookmarkProperties/loadInSidebar","flags":0,"expires":4,"mimeType":null,"type":1,"value":1}],"type":"text/x-moz-place","uri":"http://test/post","keyword":"test","charset":"ISO-8859-1"}]}]},{"index":1,"title":"Bookmarks Toolbar","id":3,"parent":1,"dateAdded":1361551978957783,"lastModified":1177541050000000,"annos":[{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar"}],"type":"text/x-moz-place-container","root":"toolbarFolder","children":[{"guid":"OCyeUO5uu9FB","title":"Getting Started","id":15,"parent":3,"dateAdded":1361551979409695,"lastModified":1361551979412080,"type":"text/x-moz-place","uri":"http://en-us.www.mozilla.com/en-US/firefox/central/", "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="},{"guid":"OCyeUO5uu9FR","index":1,"title":"Latest Headlines","id":16,"parent":3,"dateAdded":1361551979451584,"lastModified":1361551979457086,"livemark":1,"annos":[{"name":"placesInternal/READ_ONLY","flags":0,"expires":4,"mimeType":null,"type":1,"value":1},{"name":"livemark/feedURI","flags":0,"expires":4,"mimeType":null,"type":3,"value":"http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml"},{"name":"livemark/siteURI","flags":0,"expires":4,"mimeType":null,"type":3,"value":"http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/"}],"type":"text/x-moz-place-container","children":[]}]},{"index":2,"title":"Tags","id":4,"parent":1,"dateAdded":1361551978957783,"lastModified":1361551978957783,"type":"text/x-moz-place-container","root":"tagsFolder","children":[]},{"index":3,"title":"Unsorted Bookmarks","id":5,"parent":1,"dateAdded":1361551978957783,"lastModified":1177541050000000,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder","children":[{"guid":"OCyeUO5uu9FW","title":"Example.tld","id":14,"parent":5,"dateAdded":1361551979401846,"lastModified":1361551979402952,"type":"text/x-moz-place","uri":"http://example.tld/"}]}]}
diff --git a/toolkit/components/places/tests/unit/bookmarks.preplaces.html b/toolkit/components/places/tests/unit/bookmarks.preplaces.html
new file mode 100644
index 000000000..2e5a1baf0
--- /dev/null
+++ b/toolkit/components/places/tests/unit/bookmarks.preplaces.html
@@ -0,0 +1,35 @@
+<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<!-- This is an automatically generated file.
+ It will be read and overwritten.
+ DO NOT EDIT! -->
+<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+<TITLE>Bookmarks</TITLE>
+<H1 LAST_MODIFIED="1177541029">Bookmarks</H1>
+
+<DL><p>
+ <DT><H3 ID="rdf:#$ZvPhC3">Mozilla Firefox</H3>
+ <DL><p>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/help/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$22iCK1">Help and Tutorials</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/customize/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$32iCK1">Customize Firefox</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/community/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$42iCK1">Get Involved</A>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/about/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$52iCK1">About Us</A>
+ </DL><p>
+ <HR>
+ <DT><H3 ADD_DATE="1177541020" LAST_MODIFIED="1177541050" ID="rdf:#$74Gpx2">test</H3>
+<DD>folder test comment
+ <DL><p>
+ <DT><A HREF="http://test/post" ADD_DATE="1177375336" LAST_MODIFIED="1177375423" SHORTCUTURL="test" WEB_PANEL="true" POST_DATA="hidden1%3Dbar&amp;text1%3D%25s" LAST_CHARSET="ISO-8859-1" ID="rdf:#$pYFe7">test post keyword</A>
+<DD>item description
+ </DL>
+ <DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
+ <DL><p>
+ <DT><A HREF="http://example.tld">Example.tld</A>
+ </DL><p>
+ <DT><H3 LAST_MODIFIED="1177541040" PERSONAL_TOOLBAR_FOLDER="true" ID="rdf:#$FvPhC3">Bookmarks Toolbar Folder</H3>
+<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar
+ <DL><p>
+ <DT><A HREF="http://en-US.www.mozilla.com/en-US/firefox/central/" ICON="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg==" ID="rdf:#$GvPhC3">Getting Started</A>
+ <DT><A HREF="http://en-US.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/" LAST_MODIFIED="1177541035" FEEDURL="http://en-US.fxfeeds.mozilla.com/en-US/firefox/headlines.xml" ID="rdf:#$HvPhC3">Latest Headlines</A>
+<DD>Livemark test comment
+ </DL><p>
+</DL><p>
diff --git a/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html b/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html
new file mode 100644
index 000000000..9fe662f32
--- /dev/null
+++ b/toolkit/components/places/tests/unit/bookmarks_html_singleframe.html
@@ -0,0 +1,10 @@
+<!DOCTYPE NETSCAPE-Bookmark-file-1>
+ <HTML>
+ <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+ <Title>Bookmarks</Title>
+ <H1>Bookmarks</H1>
+ <DT><H3>Subtitle</H3>
+ <DL><p>
+ <DT><A HREF="http://www.mozilla.org/">Mozilla</A>
+ </DL><p>
+</HTML>
diff --git a/toolkit/components/places/tests/unit/bug476292.sqlite b/toolkit/components/places/tests/unit/bug476292.sqlite
new file mode 100644
index 000000000..43130cb51
--- /dev/null
+++ b/toolkit/components/places/tests/unit/bug476292.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/unit/corruptDB.sqlite b/toolkit/components/places/tests/unit/corruptDB.sqlite
new file mode 100644
index 000000000..b234246ca
--- /dev/null
+++ b/toolkit/components/places/tests/unit/corruptDB.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/unit/default.sqlite b/toolkit/components/places/tests/unit/default.sqlite
new file mode 100644
index 000000000..8fbd3bc9a
--- /dev/null
+++ b/toolkit/components/places/tests/unit/default.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/unit/head_bookmarks.js b/toolkit/components/places/tests/unit/head_bookmarks.js
new file mode 100644
index 000000000..842a66b31
--- /dev/null
+++ b/toolkit/components/places/tests/unit/head_bookmarks.js
@@ -0,0 +1,20 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var Ci = Components.interfaces;
+var Cc = Components.classes;
+var Cr = Components.results;
+var Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+// Import common head.
+{
+ let commonFile = do_get_file("../head_common.js", false);
+ let uri = Services.io.newFileURI(commonFile);
+ Services.scriptloader.loadSubScript(uri.spec, this);
+}
+
+// Put any other stuff relative to this test folder below.
diff --git a/toolkit/components/places/tests/unit/livemark.xml b/toolkit/components/places/tests/unit/livemark.xml
new file mode 100644
index 000000000..db2ea9023
--- /dev/null
+++ b/toolkit/components/places/tests/unit/livemark.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+ <title>Livemark Feed</title>
+ <link href="https://example.com/"/>
+ <updated>2016-08-09T19:51:45.147Z</updated>
+ <author>
+ <name>John Doe</name>
+ </author>
+ <id>urn:uuid:e7947414-6ee0-4009-ae75-8b0ad3c6894b</id>
+ <entry>
+ <title>Some awesome article</title>
+ <link href="https://example.com/some-article"/>
+ <id>urn:uuid:d72ce019-0a56-4a0b-ac03-f66117d78141</id>
+ <updated>2016-08-09T19:57:22.178Z</updated>
+ <summary>My great article summary.</summary>
+ </entry>
+</feed>
diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json
new file mode 100644
index 000000000..38762b3f1
--- /dev/null
+++ b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_import.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","index":0,"dateAdded":1475084731479000,"lastModified":1475084731479000,"id":1,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","index":0,"dateAdded":1475084731479000,"lastModified":1475084731768000,"id":2,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"X6lUyOspVYwi","title":"Test Pilot","index":0,"dateAdded":1475084731768000,"lastModified":1475084731768000,"id":3,"type":"text/x-moz-place","uri":"https://testpilot.firefox.com/"},{"guid":"XF4yRP6bTuil","title":"Mobile bookmarks query","index":1,"dateAdded":1475084731768000,"lastModified":1475084731768000,"id":11,"type":"text/x-moz-place","uri":"place:folder=101"}]},{"guid":"toolbar_____","title":"Bookmarks Toolbar","index":1,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":4,"type":"text/x-moz-place-container","root":"toolbarFolder","children":[{"guid":"buy7711R3ZgE","title":"MDN","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":5,"type":"text/x-moz-place","uri":"https://developer.mozilla.org"}]},{"guid":"3qmd_imziEBE","title":"Mobile Bookmarks","index":5,"dateAdded":1475084731479000,"lastModified":1475084731770000,"id":101,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1},{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"A description of the mobile folder that should be ignored on import"}],"type":"text/x-moz-place-container","children":[{"guid":"_o8e1_zxTJFg","title":"Get Firefox!","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":7,"type":"text/x-moz-place","uri":"http://getfirefox.com/"},{"guid":"QCtSqkVYUbXB","title":"Get Thunderbird!","index":1,"dateAdded":1475084731770000,"lastModified":1475084731770000,"id":8,"type":"text/x-moz-place","uri":"http://getthunderbird.com/"}]},{"guid":"unfiled_____","title":"Other Bookmarks","index":3,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":9,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder","children":[{"guid":"KIa9iKZab2Z5","title":"Add-ons","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":10,"type":"text/x-moz-place","uri":"https://addons.mozilla.org"}]}]} \ No newline at end of file
diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json
new file mode 100644
index 000000000..7319a3a52
--- /dev/null
+++ b/toolkit/components/places/tests/unit/mobile_bookmarks_folder_merge.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","index":0,"dateAdded":1475084731479000,"lastModified":1475084731479000,"id":1,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","index":0,"dateAdded":1475084731479000,"lastModified":1475084731768000,"id":2,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"Utodo9b0oVws","title":"Firefox Accounts","index":0,"dateAdded":1475084731955000,"lastModified":1475084731955000,"id":3,"type":"text/x-moz-place","uri":"https://accounts.firefox.com/"}]},{"guid":"toolbar_____","title":"Bookmarks Toolbar","index":1,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":4,"type":"text/x-moz-place-container","root":"toolbarFolder"},{"guid":"3qmd_imziEBE","title":"Mobile Bookmarks","index":5,"dateAdded":1475084731479000,"lastModified":1475084731770000,"id":5,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1},{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"A description of the mobile folder that should be ignored on import"}],"type":"text/x-moz-place-container","children":[{"guid":"a17yW6-nTxEJ","title":"Mozilla","index":0,"dateAdded":1475084731959000,"lastModified":1475084731959000,"id":6,"type":"text/x-moz-place","uri":"https://mozilla.org/"},{"guid":"xV10h9Wi3FBM","title":"Bugzilla","index":1,"dateAdded":1475084731961000,"lastModified":1475084731961000,"id":7,"type":"text/x-moz-place","uri":"https://bugzilla.mozilla.org/"}]},{"guid":"unfiled_____","title":"Other Bookmarks","index":3,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":8,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder"}]} \ No newline at end of file
diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json b/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json
new file mode 100644
index 000000000..afe13c975
--- /dev/null
+++ b/toolkit/components/places/tests/unit/mobile_bookmarks_multiple_folders.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","index":0,"dateAdded":1475084731479000,"lastModified":1475084731479000,"id":1,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","index":0,"dateAdded":1475084731479000,"lastModified":1475084731768000,"id":2,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"buy7711R3ZgE","title":"MDN","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":3,"type":"text/x-moz-place","uri":"https://developer.mozilla.org"},{"guid":"F_LBgd1fS_uQ","title":"Mobile bookmarks query for first folder","index":1,"dateAdded":1475084731768000,"lastModified":1475084731768000,"id":11,"type":"text/x-moz-place","uri":"place:folder=101"},{"guid":"oIpmQXMWsXvY","title":"Mobile bookmarks query for second folder","index":2,"dateAdded":1475084731768000,"lastModified":1475084731768000,"id":12,"type":"text/x-moz-place","uri":"place:folder=102"}]},{"guid":"3qmd_imziEBE","title":"Mobile Bookmarks","index":5,"dateAdded":1475084731479000,"lastModified":1475084731770000,"id":101,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1},{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"A description of the mobile folder that should be ignored on import"}],"type":"text/x-moz-place-container","children":[{"guid":"a17yW6-nTxEJ","title":"Mozilla","index":0,"dateAdded":1475084731959000,"lastModified":1475084731959000,"id":5,"type":"text/x-moz-place","uri":"https://mozilla.org/"}]},{"guid":"toolbar_____","title":"Bookmarks Toolbar","index":1,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":6,"type":"text/x-moz-place-container","root":"toolbarFolder","children":[{"guid":"Utodo9b0oVws","title":"Firefox Accounts","index":0,"dateAdded":1475084731955000,"lastModified":1475084731955000,"id":7,"type":"text/x-moz-place","uri":"https://accounts.firefox.com/"}]},{"guid":"o4YjJpgsufU-","title":"Mobile Bookmarks","index":7,"dateAdded":1475084731479000,"lastModified":1475084731770000,"id":102,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1}],"type":"text/x-moz-place-container","children":[{"guid":"sSZ86WT9WbN3","title":"DXR","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":9,"type":"text/x-moz-place","uri":"https://dxr.mozilla.org"}]},{"guid":"unfiled_____","title":"Other Bookmarks","index":3,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":10,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder","children":[{"guid":"xV10h9Wi3FBM","title":"Bugzilla","index":1,"dateAdded":1475084731961000,"lastModified":1475084731961000,"id":11,"type":"text/x-moz-place","uri":"https://bugzilla.mozilla.org/"}]}]} \ No newline at end of file
diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json b/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json
new file mode 100644
index 000000000..27f5825ec
--- /dev/null
+++ b/toolkit/components/places/tests/unit/mobile_bookmarks_root_import.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","index":0,"dateAdded":1475084731479000,"lastModified":1475084731479000,"id":1,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","index":0,"dateAdded":1475084731479000,"lastModified":1475084731768000,"id":2,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"X6lUyOspVYwi","title":"Test Pilot","index":0,"dateAdded":1475084731768000,"lastModified":1475084731768000,"id":3,"type":"text/x-moz-place","uri":"https://testpilot.firefox.com/"}]},{"guid":"toolbar_____","title":"Bookmarks Toolbar","index":1,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":4,"type":"text/x-moz-place-container","root":"toolbarFolder"},{"guid":"unfiled_____","title":"Other Bookmarks","index":3,"dateAdded":1475084731479000,"lastModified":1475084731742000,"id":5,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder"},{"guid":"mobile______","title":"Mobile Bookmarks","index":4,"dateAdded":1475084731479000,"lastModified":1475084731770000,"id":6,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1}],"type":"text/x-moz-place-container","root":"mobileFolder","children":[{"guid":"_o8e1_zxTJFg","title":"Get Firefox!","index":0,"dateAdded":1475084731769000,"lastModified":1475084731769000,"id":7,"type":"text/x-moz-place","uri":"http://getfirefox.com/"},{"guid":"QCtSqkVYUbXB","title":"Get Thunderbird!","index":1,"dateAdded":1475084731770000,"lastModified":1475084731770000,"id":8,"type":"text/x-moz-place","uri":"http://getthunderbird.com/"}]}]} \ No newline at end of file
diff --git a/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json b/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json
new file mode 100644
index 000000000..85721f2fa
--- /dev/null
+++ b/toolkit/components/places/tests/unit/mobile_bookmarks_root_merge.json
@@ -0,0 +1 @@
+{"guid":"root________","title":"","index":0,"dateAdded":1475084731479000,"lastModified":1475084731479000,"id":1,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"guid":"menu________","title":"Bookmarks Menu","index":0,"dateAdded":1475084731479000,"lastModified":1475084731955000,"id":2,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"guid":"Utodo9b0oVws","title":"Firefox Accounts","index":0,"dateAdded":1475084731955000,"lastModified":1475084731955000,"id":3,"type":"text/x-moz-place","uri":"https://accounts.firefox.com/"}]},{"guid":"toolbar_____","title":"Bookmarks Toolbar","index":1,"dateAdded":1475084731479000,"lastModified":1475084731938000,"id":4,"type":"text/x-moz-place-container","root":"toolbarFolder"},{"guid":"unfiled_____","title":"Other Bookmarks","index":3,"dateAdded":1475084731479000,"lastModified":1475084731938000,"id":5,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder"},{"guid":"mobile______","title":"Mobile Bookmarks","index":4,"dateAdded":1475084731479000,"lastModified":1475084731961000,"id":6,"annos":[{"name":"mobile/bookmarksRoot","flags":0,"expires":4,"value":1}],"type":"text/x-moz-place-container","root":"mobileFolder","children":[{"guid":"a17yW6-nTxEJ","title":"Mozilla","index":0,"dateAdded":1475084731959000,"lastModified":1475084731959000,"id":7,"type":"text/x-moz-place","uri":"https://mozilla.org/"},{"guid":"xV10h9Wi3FBM","title":"Bugzilla","index":1,"dateAdded":1475084731961000,"lastModified":1475084731961000,"id":8,"type":"text/x-moz-place","uri":"https://bugzilla.mozilla.org/"}]}]} \ No newline at end of file
diff --git a/toolkit/components/places/tests/unit/nsDummyObserver.js b/toolkit/components/places/tests/unit/nsDummyObserver.js
new file mode 100644
index 000000000..9049d04b3
--- /dev/null
+++ b/toolkit/components/places/tests/unit/nsDummyObserver.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+// Dummy boomark/history observer
+function DummyObserver() {
+ Services.obs.notifyObservers(null, "dummy-observer-created", null);
+}
+
+DummyObserver.prototype = {
+ // history observer
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID, aTransitionType) {
+ Services.obs.notifyObservers(null, "dummy-observer-visited", null);
+ },
+ onTitleChanged: function () {},
+ onDeleteURI: function () {},
+ onClearHistory: function () {},
+ onPageChanged: function () {},
+ onDeleteVisits: function () {},
+
+ // bookmark observer
+ // onBeginUpdateBatch: function() {},
+ // onEndUpdateBatch: function() {},
+ onItemAdded: function(aItemId, aParentId, aIndex, aItemType, aURI) {
+ Services.obs.notifyObservers(null, "dummy-observer-item-added", null);
+ },
+ onItemChanged: function () {},
+ onItemRemoved: function() {},
+ onItemVisited: function() {},
+ onItemMoved: function() {},
+
+ classID: Components.ID("62e221d3-68c3-4e1a-8943-a27beb5005fe"),
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver,
+ Ci.nsINavHistoryObserver,
+ ])
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DummyObserver]);
diff --git a/toolkit/components/places/tests/unit/nsDummyObserver.manifest b/toolkit/components/places/tests/unit/nsDummyObserver.manifest
new file mode 100644
index 000000000..ed4d87fff
--- /dev/null
+++ b/toolkit/components/places/tests/unit/nsDummyObserver.manifest
@@ -0,0 +1,4 @@
+component 62e221d3-68c3-4e1a-8943-a27beb5005fe nsDummyObserver.js
+contract @mozilla.org/places/test/dummy-observer;1 62e221d3-68c3-4e1a-8943-a27beb5005fe
+category bookmark-observers nsDummyObserver @mozilla.org/places/test/dummy-observer;1
+category history-observers nsDummyObserver @mozilla.org/places/test/dummy-observer;1
diff --git a/toolkit/components/places/tests/unit/places.sparse.sqlite b/toolkit/components/places/tests/unit/places.sparse.sqlite
new file mode 100644
index 000000000..915089021
--- /dev/null
+++ b/toolkit/components/places/tests/unit/places.sparse.sqlite
Binary files differ
diff --git a/toolkit/components/places/tests/unit/test_000_frecency.js b/toolkit/components/places/tests/unit/test_000_frecency.js
new file mode 100644
index 000000000..0a7347a02
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_000_frecency.js
@@ -0,0 +1,273 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+
+Autocomplete Frecency Tests
+
+- add a visit for each score permutation
+- search
+- test number of matches
+- test each item's location in results
+
+*/
+
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ var prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+} catch (ex) {
+ do_throw("Could not get services\n");
+}
+
+var bucketPrefs = [
+ [ "firstBucketCutoff", "firstBucketWeight"],
+ [ "secondBucketCutoff", "secondBucketWeight"],
+ [ "thirdBucketCutoff", "thirdBucketWeight"],
+ [ "fourthBucketCutoff", "fourthBucketWeight"],
+ [ null, "defaultBucketWeight"]
+];
+
+var bonusPrefs = {
+ embedVisitBonus: Ci.nsINavHistoryService.TRANSITION_EMBED,
+ framedLinkVisitBonus: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
+ linkVisitBonus: Ci.nsINavHistoryService.TRANSITION_LINK,
+ typedVisitBonus: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ bookmarkVisitBonus: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ downloadVisitBonus: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ permRedirectVisitBonus: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ tempRedirectVisitBonus: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
+ reloadVisitBonus: Ci.nsINavHistoryService.TRANSITION_RELOAD,
+};
+
+// create test data
+var searchTerm = "frecency";
+var results = [];
+var matchCount = 0;
+var now = Date.now();
+var prefPrefix = "places.frecency.";
+
+function* task_initializeBucket(bucket) {
+ let [cutoffName, weightName] = bucket;
+ // get pref values
+ var weight = 0, cutoff = 0;
+ try {
+ weight = prefs.getIntPref(prefPrefix + weightName);
+ } catch (ex) {}
+ try {
+ cutoff = prefs.getIntPref(prefPrefix + cutoffName);
+ } catch (ex) {}
+
+ if (cutoff < 1)
+ return;
+
+ // generate a date within the cutoff period
+ var dateInPeriod = (now - ((cutoff - 1) * 86400 * 1000)) * 1000;
+
+ for (let [bonusName, visitType] of Object.entries(bonusPrefs)) {
+ var frecency = -1;
+ var calculatedURI = null;
+ var matchTitle = "";
+ var bonusValue = prefs.getIntPref(prefPrefix + bonusName);
+ // unvisited (only for first cutoff date bucket)
+ if (bonusName == "unvisitedBookmarkBonus" || bonusName == "unvisitedTypedBonus") {
+ if (cutoffName == "firstBucketCutoff") {
+ let points = Math.ceil(bonusValue / parseFloat(100.0) * weight);
+ var visitCount = 1; // bonusName == "unvisitedBookmarkBonus" ? 1 : 0;
+ frecency = Math.ceil(visitCount * points);
+ calculatedURI = uri("http://" + searchTerm + ".com/" +
+ bonusName + ":" + bonusValue + "/cutoff:" + cutoff +
+ "/weight:" + weight + "/frecency:" + frecency);
+ if (bonusName == "unvisitedBookmarkBonus") {
+ matchTitle = searchTerm + "UnvisitedBookmark";
+ bmsvc.insertBookmark(bmsvc.unfiledBookmarksFolder, calculatedURI, bmsvc.DEFAULT_INDEX, matchTitle);
+ }
+ else {
+ matchTitle = searchTerm + "UnvisitedTyped";
+ yield PlacesTestUtils.addVisits({
+ uri: calculatedURI,
+ title: matchTitle,
+ transition: visitType,
+ visitDate: now
+ });
+ histsvc.markPageAsTyped(calculatedURI);
+ }
+ }
+ }
+ else {
+ // visited
+ // visited bookmarks get the visited bookmark bonus twice
+ if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK)
+ bonusValue = bonusValue * 2;
+
+ let points = Math.ceil(1 * ((bonusValue / parseFloat(100.000000)).toFixed(6) * weight) / 1);
+ if (!points) {
+ if (visitType == Ci.nsINavHistoryService.TRANSITION_EMBED ||
+ visitType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK ||
+ visitType == Ci.nsINavHistoryService.TRANSITION_DOWNLOAD ||
+ visitType == Ci.nsINavHistoryService.TRANSITION_RELOAD ||
+ bonusName == "defaultVisitBonus")
+ frecency = 0;
+ else
+ frecency = -1;
+ }
+ else
+ frecency = points;
+ calculatedURI = uri("http://" + searchTerm + ".com/" +
+ bonusName + ":" + bonusValue + "/cutoff:" + cutoff +
+ "/weight:" + weight + "/frecency:" + frecency);
+ if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK) {
+ matchTitle = searchTerm + "Bookmarked";
+ bmsvc.insertBookmark(bmsvc.unfiledBookmarksFolder, calculatedURI, bmsvc.DEFAULT_INDEX, matchTitle);
+ }
+ else
+ matchTitle = calculatedURI.spec.substr(calculatedURI.spec.lastIndexOf("/")+1);
+ yield PlacesTestUtils.addVisits({
+ uri: calculatedURI,
+ transition: visitType,
+ visitDate: dateInPeriod
+ });
+ }
+
+ if (calculatedURI && frecency) {
+ results.push([calculatedURI, frecency, matchTitle]);
+ yield PlacesTestUtils.addVisits({
+ uri: calculatedURI,
+ title: matchTitle,
+ transition: visitType,
+ visitDate: dateInPeriod
+ });
+ }
+ }
+}
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+add_task(function* test_frecency()
+{
+ // Disable autoFill for this test.
+ Services.prefs.setBoolPref("browser.urlbar.autoFill", false);
+ do_register_cleanup(() => Services.prefs.clearUserPref("browser.urlbar.autoFill"));
+ for (let bucket of bucketPrefs) {
+ yield task_initializeBucket(bucket);
+ }
+
+ // sort results by frecency
+ results.sort((a, b) => b[1] - a[1]);
+ // Make sure there's enough results returned
+ prefs.setIntPref("browser.urlbar.maxRichResults", results.length);
+
+ // DEBUG
+ // results.every(function(el) { dump("result: " + el[1] + ": " + el[0].spec + " (" + el[2] + ")\n"); return true; })
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ var controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
+ getService(Components.interfaces.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ // always search in history + bookmarks, no matter what the default is
+ prefs.setIntPref("browser.urlbar.search.sources", 3);
+ prefs.setIntPref("browser.urlbar.default.behavior", 0);
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ let deferred = Promise.defer();
+ input.onSearchComplete = function() {
+ do_check_eq(numSearchesStarted, 1);
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+
+ // test that all records with non-zero frecency were matched
+ do_check_eq(controller.matchCount, results.length);
+
+ // test that matches are sorted by frecency
+ for (var i = 0; i < controller.matchCount; i++) {
+ let searchURL = controller.getValueAt(i);
+ let expectURL = results[i][0].spec;
+ if (searchURL == expectURL) {
+ do_check_eq(controller.getValueAt(i), results[i][0].spec);
+ do_check_eq(controller.getCommentAt(i), results[i][2]);
+ } else {
+ // If the results didn't match exactly, perhaps it's still the right
+ // frecency just in the wrong "order" (order of same frecency is
+ // undefined), so check if frecency matches. This is okay because we
+ // can still ensure the correct number of expected frecencies.
+ let getFrecency = aURL => aURL.match(/frecency:(-?\d+)$/)[1];
+ print("### checking for same frecency between '" + searchURL +
+ "' and '" + expectURL + "'");
+ do_check_eq(getFrecency(searchURL), getFrecency(expectURL));
+ }
+ }
+ deferred.resolve();
+ };
+
+ controller.startSearch(searchTerm);
+
+ yield deferred.promise;
+});
diff --git a/toolkit/components/places/tests/unit/test_1085291.js b/toolkit/components/places/tests/unit/test_1085291.js
new file mode 100644
index 000000000..3159ff8bc
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_1085291.js
@@ -0,0 +1,42 @@
+add_task(function* () {
+ // test that nodes inserted by incremental update for bookmarks of all types
+ // have the extra bookmark properties (bookmarkGuid, dateAdded, lastModified).
+
+ // getFolderContents opens the root node.
+ let root = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+
+ function* insertAndTest(bmInfo) {
+ bmInfo = yield PlacesUtils.bookmarks.insert(bmInfo);
+ let node = root.getChild(root.childCount - 1);
+ Assert.equal(node.bookmarkGuid, bmInfo.guid);
+ Assert.equal(node.dateAdded, bmInfo.dateAdded * 1000);
+ Assert.equal(node.lastModified, bmInfo.lastModified * 1000);
+ }
+
+ // Normal bookmark.
+ yield insertAndTest({ parentGuid: root.bookmarkGuid
+ , type: PlacesUtils.bookmarks.TYPE_BOOKMARK
+ , title: "Test Bookmark"
+ , url: "http://test.url.tld" });
+
+ // place: query
+ yield insertAndTest({ parentGuid: root.bookmarkGuid
+ , type: PlacesUtils.bookmarks.TYPE_BOOKMARK
+ , title: "Test Query"
+ , url: "place:folder=BOOKMARKS_MENU" });
+
+ // folder
+ yield insertAndTest({ parentGuid: root.bookmarkGuid
+ , type: PlacesUtils.bookmarks.TYPE_FOLDER
+ , title: "Test Folder" });
+
+ // separator
+ yield insertAndTest({ parentGuid: root.bookmarkGuid
+ , type: PlacesUtils.bookmarks.TYPE_SEPARATOR });
+
+ root.containerOpen = false;
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_1105208.js b/toolkit/components/places/tests/unit/test_1105208.js
new file mode 100644
index 000000000..39a27c95f
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_1105208.js
@@ -0,0 +1,24 @@
+// Test that result node for folder shortcuts get the target folder title if
+// the shortcut itself has no title set.
+add_task(function* () {
+ let shortcutInfo = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "place:folder=TOOLBAR"
+ });
+
+ let unfiledRoot =
+ PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1);
+ Assert.equal(shortcutNode.bookmarkGuid, shortcutInfo.guid);
+
+ let toolbarInfo =
+ yield PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.toolbarGuid);
+ Assert.equal(shortcutNode.title, toolbarInfo.title);
+
+ unfiledRoot.containerOpen = false;
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_1105866.js b/toolkit/components/places/tests/unit/test_1105866.js
new file mode 100644
index 000000000..eb376bbe2
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_1105866.js
@@ -0,0 +1,63 @@
+add_task(function* test_folder_shortcuts() {
+ let shortcutInfo = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "place:folder=TOOLBAR"
+ });
+
+ let unfiledRoot =
+ PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1);
+ Assert.strictEqual(shortcutNode.itemId,
+ yield PlacesUtils.promiseItemId(shortcutInfo.guid));
+ Assert.strictEqual(PlacesUtils.asQuery(shortcutNode).folderItemId,
+ PlacesUtils.toolbarFolderId);
+ Assert.strictEqual(shortcutNode.bookmarkGuid, shortcutInfo.guid);
+ Assert.strictEqual(PlacesUtils.asQuery(shortcutNode).targetFolderGuid,
+ PlacesUtils.bookmarks.toolbarGuid);
+
+ // test that a node added incrementally also behaves just as well.
+ shortcutInfo = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: "place:folder=BOOKMARKS_MENU"
+ });
+ shortcutNode = unfiledRoot.getChild(unfiledRoot.childCount - 1);
+ Assert.strictEqual(shortcutNode.itemId,
+ yield PlacesUtils.promiseItemId(shortcutInfo.guid));
+ Assert.strictEqual(PlacesUtils.asQuery(shortcutNode).folderItemId,
+ PlacesUtils.bookmarksMenuFolderId);
+ Assert.strictEqual(shortcutNode.bookmarkGuid, shortcutInfo.guid);
+ Assert.strictEqual(PlacesUtils.asQuery(shortcutNode).targetFolderGuid,
+ PlacesUtils.bookmarks.menuGuid);
+
+ unfiledRoot.containerOpen = false;
+});
+
+add_task(function* test_plain_folder() {
+ let folderInfo = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ });
+
+ let unfiledRoot =
+ PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ let lastChild = unfiledRoot.getChild(unfiledRoot.childCount - 1);
+ Assert.strictEqual(lastChild.bookmarkGuid, folderInfo.guid);
+ Assert.strictEqual(PlacesUtils.asQuery(lastChild).targetFolderGuid,
+ folderInfo.guid);
+});
+
+add_task(function* test_non_item_query() {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let root = PlacesUtils.history.executeQuery(
+ PlacesUtils.history.getNewQuery(), options).root;
+ Assert.strictEqual(root.itemId, -1);
+ Assert.strictEqual(PlacesUtils.asQuery(root).folderItemId, -1);
+ Assert.strictEqual(root.bookmarkGuid, "");
+ Assert.strictEqual(PlacesUtils.asQuery(root).targetFolderGuid, "");
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_317472.js b/toolkit/components/places/tests/unit/test_317472.js
new file mode 100644
index 000000000..a08651916
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_317472.js
@@ -0,0 +1,65 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const charset = "UTF-8";
+const CHARSET_ANNO = "URIProperties/characterSet";
+
+const TEST_URI = uri("http://foo.com");
+const TEST_BOOKMARKED_URI = uri("http://bar.com");
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ // add pages to history
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ yield PlacesTestUtils.addVisits(TEST_BOOKMARKED_URI);
+
+ // create bookmarks on TEST_BOOKMARKED_URI
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId,
+ TEST_BOOKMARKED_URI, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ TEST_BOOKMARKED_URI.spec);
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.toolbarFolderId,
+ TEST_BOOKMARKED_URI, PlacesUtils.bookmarks.DEFAULT_INDEX,
+ TEST_BOOKMARKED_URI.spec);
+
+ // set charset on not-bookmarked page
+ yield PlacesUtils.setCharsetForURI(TEST_URI, charset);
+ // set charset on bookmarked page
+ yield PlacesUtils.setCharsetForURI(TEST_BOOKMARKED_URI, charset);
+
+ // check that we have created a page annotation
+ do_check_eq(PlacesUtils.annotations.getPageAnnotation(TEST_URI, CHARSET_ANNO), charset);
+
+ // get charset from not-bookmarked page
+ do_check_eq((yield PlacesUtils.getCharsetForURI(TEST_URI)), charset);
+
+ // get charset from bookmarked page
+ do_check_eq((yield PlacesUtils.getCharsetForURI(TEST_BOOKMARKED_URI)), charset);
+
+ yield PlacesTestUtils.clearHistory();
+
+ // ensure that charset has gone for not-bookmarked page
+ do_check_neq((yield PlacesUtils.getCharsetForURI(TEST_URI)), charset);
+
+ // check that page annotation has been removed
+ try {
+ PlacesUtils.annotations.getPageAnnotation(TEST_URI, CHARSET_ANNO);
+ do_throw("Charset page annotation has not been removed correctly");
+ } catch (e) {}
+
+ // ensure that charset still exists for bookmarked page
+ do_check_eq((yield PlacesUtils.getCharsetForURI(TEST_BOOKMARKED_URI)), charset);
+
+ // remove charset from bookmark and check that has gone
+ yield PlacesUtils.setCharsetForURI(TEST_BOOKMARKED_URI, "");
+ do_check_neq((yield PlacesUtils.getCharsetForURI(TEST_BOOKMARKED_URI)), charset);
+});
diff --git a/toolkit/components/places/tests/unit/test_331487.js b/toolkit/components/places/tests/unit/test_331487.js
new file mode 100644
index 000000000..55d41aebf
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_331487.js
@@ -0,0 +1,95 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history service
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService(Ci.nsINavHistoryService);
+} catch (ex) {
+ do_throw("Could not get history service\n");
+}
+
+var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+// main
+function run_test() {
+ // add a folder
+ var folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder", bmsvc.DEFAULT_INDEX);
+
+ // add a bookmark to the folder
+ var b1 = bmsvc.insertBookmark(folder, uri("http://a1.com/"),
+ bmsvc.DEFAULT_INDEX, "1 title");
+ // add a subfolder
+ var sf1 = bmsvc.createFolder(folder, "subfolder 1", bmsvc.DEFAULT_INDEX);
+
+ // add a bookmark to the subfolder
+ var b2 = bmsvc.insertBookmark(sf1, uri("http://a2.com/"),
+ bmsvc.DEFAULT_INDEX, "2 title");
+
+ // add a subfolder to the subfolder
+ var sf2 = bmsvc.createFolder(sf1, "subfolder 2", bmsvc.DEFAULT_INDEX);
+
+ // add a bookmark to the subfolder of the subfolder
+ var b3 = bmsvc.insertBookmark(sf2, uri("http://a3.com/"),
+ bmsvc.DEFAULT_INDEX, "3 title");
+
+ // bookmark query that should result in the "hierarchical" result
+ // because there is one query, one folder,
+ // no begin time, no end time, no domain, no uri, no search term
+ // and no max results. See GetSimpleBookmarksQueryFolder()
+ // for more details.
+ var options = histsvc.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ var query = histsvc.getNewQuery();
+ query.setFolders([folder], 1);
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.getChild(0).itemId, b1);
+ do_check_eq(root.getChild(1).itemId, sf1);
+
+ // check the contents of the subfolder
+ var sf1Node = root.getChild(1);
+ sf1Node = sf1Node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ sf1Node.containerOpen = true;
+ do_check_eq(sf1Node.childCount, 2);
+ do_check_eq(sf1Node.getChild(0).itemId, b2);
+ do_check_eq(sf1Node.getChild(1).itemId, sf2);
+
+ // check the contents of the subfolder's subfolder
+ var sf2Node = sf1Node.getChild(1);
+ sf2Node = sf2Node.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ sf2Node.containerOpen = true;
+ do_check_eq(sf2Node.childCount, 1);
+ do_check_eq(sf2Node.getChild(0).itemId, b3);
+
+ sf2Node.containerOpen = false;
+ sf1Node.containerOpen = false;
+ root.containerOpen = false;
+
+ // bookmark query that should result in a flat list
+ // because we specified max results
+ options = histsvc.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.maxResults = 10;
+ query = histsvc.getNewQuery();
+ query.setFolders([folder], 1);
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 3);
+ do_check_eq(root.getChild(0).itemId, b1);
+ do_check_eq(root.getChild(1).itemId, b2);
+ do_check_eq(root.getChild(2).itemId, b3);
+ root.containerOpen = false;
+
+ // XXX TODO
+ // test that if we have: more than one query,
+ // multiple folders, a begin time, an end time, a domain, a uri
+ // or a search term, that we get the (correct) flat list results
+ // (like we do when specified maxResults)
+}
diff --git a/toolkit/components/places/tests/unit/test_384370.js b/toolkit/components/places/tests/unit/test_384370.js
new file mode 100644
index 000000000..ec6f43683
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_384370.js
@@ -0,0 +1,173 @@
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+
+var tagData = [
+ { uri: uri("http://slint.us"), tags: ["indie", "kentucky", "music"] },
+ { uri: uri("http://en.wikipedia.org/wiki/Diplodocus"), tags: ["dinosaur", "dj", "rad word"] }
+];
+
+var bookmarkData = [
+ { uri: uri("http://slint.us"), title: "indie, kentucky, music" },
+ { uri: uri("http://en.wikipedia.org/wiki/Diplodocus"), title: "dinosaur, dj, rad word" }
+];
+
+function run_test() {
+ run_next_test();
+}
+
+/*
+ HTML+FEATURES SUMMARY:
+ - import legacy bookmarks
+ - export as json, import, test (tests integrity of html > json)
+ - export as html, import, test (tests integrity of json > html)
+
+ BACKUP/RESTORE SUMMARY:
+ - create a bookmark in each root
+ - tag multiple URIs with multiple tags
+ - export as json, import, test
+*/
+add_task(function* () {
+ // Remove eventual bookmarks.exported.json.
+ let jsonFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.exported.json");
+ if ((yield OS.File.exists(jsonFile)))
+ yield OS.File.remove(jsonFile);
+
+ // Test importing a pre-Places canonical bookmarks file.
+ // Note: we do not empty the db before this import to catch bugs like 380999
+ let htmlFile = OS.Path.join(do_get_cwd().path, "bookmarks.preplaces.html");
+ yield BookmarkHTMLUtils.importFromFile(htmlFile, true);
+
+ // Populate the database.
+ for (let { uri, tags } of tagData) {
+ PlacesUtils.tagging.tagURI(uri, tags);
+ }
+ for (let { uri, title } of bookmarkData) {
+ yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: uri,
+ title });
+ }
+ for (let { uri, title } of bookmarkData) {
+ yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: uri,
+ title });
+ }
+
+ yield validate();
+
+ // Test exporting a Places canonical json file.
+ // 1. export to bookmarks.exported.json
+ yield BookmarkJSONUtils.exportToFile(jsonFile);
+ do_print("exported json");
+
+ // 2. empty bookmarks db
+ // 3. import bookmarks.exported.json
+ yield BookmarkJSONUtils.importFromFile(jsonFile, true);
+ do_print("imported json");
+
+ // 4. run the test-suite
+ yield validate();
+ do_print("validated import");
+});
+
+function* validate() {
+ yield testMenuBookmarks();
+ yield testToolbarBookmarks();
+ testUnfiledBookmarks();
+ testTags();
+ yield PlacesTestUtils.promiseAsyncUpdates();
+}
+
+// Tests a bookmarks datastore that has a set of bookmarks, etc
+// that flex each supported field and feature.
+function* testMenuBookmarks() {
+ let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarksMenuFolderId).root;
+ Assert.equal(root.childCount, 3);
+
+ let separatorNode = root.getChild(1);
+ Assert.equal(separatorNode.type, separatorNode.RESULT_TYPE_SEPARATOR);
+
+ let folderNode = root.getChild(2);
+ Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER);
+ Assert.equal(folderNode.title, "test");
+ let folder = yield PlacesUtils.bookmarks.fetch(folderNode.bookmarkGuid);
+ Assert.equal(folder.dateAdded.getTime(), 1177541020000);
+
+ Assert.equal(PlacesUtils.asQuery(folderNode).hasChildren, true);
+
+ Assert.equal("folder test comment",
+ PlacesUtils.annotations.getItemAnnotation(folderNode.itemId,
+ DESCRIPTION_ANNO));
+
+ // open test folder, and test the children
+ folderNode.containerOpen = true;
+ Assert.equal(folderNode.childCount, 1);
+
+ let bookmarkNode = folderNode.getChild(0);
+ Assert.equal("http://test/post", bookmarkNode.uri);
+ Assert.equal("test post keyword", bookmarkNode.title);
+ Assert.ok(PlacesUtils.annotations.itemHasAnnotation(bookmarkNode.itemId,
+ LOAD_IN_SIDEBAR_ANNO));
+ Assert.equal(bookmarkNode.dateAdded, 1177375336000000);
+
+ let entry = yield PlacesUtils.keywords.fetch({ url: bookmarkNode.uri });
+ Assert.equal("test", entry.keyword);
+ Assert.equal("hidden1%3Dbar&text1%3D%25s", entry.postData);
+
+ Assert.equal("ISO-8859-1",
+ (yield PlacesUtils.getCharsetForURI(NetUtil.newURI(bookmarkNode.uri))));
+ Assert.equal("item description",
+ PlacesUtils.annotations.getItemAnnotation(bookmarkNode.itemId,
+ DESCRIPTION_ANNO));
+
+ folderNode.containerOpen = false;
+ root.containerOpen = false;
+}
+
+function* testToolbarBookmarks() {
+ let root = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+
+ // child count (add 2 for pre-existing items)
+ Assert.equal(root.childCount, bookmarkData.length + 2);
+
+ let livemarkNode = root.getChild(1);
+ Assert.equal("Latest Headlines", livemarkNode.title);
+
+ let livemark = yield PlacesUtils.livemarks.getLivemark({ id: livemarkNode.itemId });
+ Assert.equal("http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/",
+ livemark.siteURI.spec);
+ Assert.equal("http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml",
+ livemark.feedURI.spec);
+
+ // test added bookmark data
+ let bookmarkNode = root.getChild(2);
+ Assert.equal(bookmarkNode.uri, bookmarkData[0].uri.spec);
+ Assert.equal(bookmarkNode.title, bookmarkData[0].title);
+ bookmarkNode = root.getChild(3);
+ Assert.equal(bookmarkNode.uri, bookmarkData[1].uri.spec);
+ Assert.equal(bookmarkNode.title, bookmarkData[1].title);
+
+ root.containerOpen = false;
+}
+
+function testUnfiledBookmarks() {
+ let root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ // child count (add 1 for pre-existing item)
+ Assert.equal(root.childCount, bookmarkData.length + 1);
+ for (let i = 1; i < root.childCount; ++i) {
+ let child = root.getChild(i);
+ Assert.equal(child.uri, bookmarkData[i - 1].uri.spec);
+ Assert.equal(child.title, bookmarkData[i - 1].title);
+ if (child.tags)
+ Assert.equal(child.tags, bookmarkData[i - 1].title);
+ }
+ root.containerOpen = false;
+}
+
+function testTags() {
+ for (let { uri, tags } of tagData) {
+ do_print("Test tags for " + uri.spec + ": " + tags + "\n");
+ let foundTags = PlacesUtils.tagging.getTagsForURI(uri);
+ Assert.equal(foundTags.length, tags.length);
+ Assert.ok(tags.every(tag => foundTags.includes(tag)));
+ }
+}
diff --git a/toolkit/components/places/tests/unit/test_385397.js b/toolkit/components/places/tests/unit/test_385397.js
new file mode 100644
index 000000000..4b60d4768
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_385397.js
@@ -0,0 +1,142 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TOTAL_SITES = 20;
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ let now = (Date.now() - 10000) * 1000;
+
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let site = "http://www.test-" + i + ".com/";
+ let testURI = uri(site);
+ let testImageURI = uri(site + "blank.gif");
+ let when = now + (i * TOTAL_SITES * 1000);
+ yield PlacesTestUtils.addVisits([
+ { uri: testURI, visitDate: when, transition: TRANSITION_TYPED },
+ { uri: testImageURI, visitDate: when + 1000, transition: TRANSITION_EMBED },
+ { uri: testImageURI, visitDate: when + 2000, transition: TRANSITION_FRAMED_LINK },
+ { uri: testURI, visitDate: when + 3000, transition: TRANSITION_LINK },
+ ]);
+ }
+
+ // verify our visits AS_VISIT, ordered by date descending
+ // including hidden
+ // we should get 80 visits:
+ // http://www.test-19.com/
+ // http://www.test-19.com/blank.gif
+ // http://www.test-19.com/
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-0.com/
+ // http://www.test-0.com/blank.gif
+ // http://www.test-0.com/
+ // http://www.test-0.com/
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+ options.includeHidden = true;
+ let root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ // Embed visits are not added to the database, thus they won't appear.
+ do_check_eq(cc, 3 * TOTAL_SITES);
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let index = i * 3;
+ let node = root.getChild(index);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ node = root.getChild(++index);
+ do_check_eq(node.uri, site + "blank.gif");
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ node = root.getChild(++index);
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+
+ // verify our visits AS_VISIT, ordered by date descending
+ // we should get 40 visits:
+ // http://www.test-19.com/
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-0.com/
+ // http://www.test-0.com/
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+ root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ cc = root.childCount;
+ // 2 * TOTAL_SITES because we count the TYPED and LINK, but not EMBED or FRAMED
+ do_check_eq(cc, 2 * TOTAL_SITES);
+ for (let i=0; i < TOTAL_SITES; i++) {
+ let index = i * 2;
+ let node = root.getChild(index);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ node = root.getChild(++index);
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+
+ // test our optimized query for the places menu
+ // place:type=0&sort=4&maxResults=10
+ // verify our visits AS_URI, ordered by date descending
+ // we should get 10 visits:
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-10.com/
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.maxResults = 10;
+ options.resultType = options.RESULTS_AS_URI;
+ root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ cc = root.childCount;
+ do_check_eq(cc, options.maxResults);
+ for (let i=0; i < cc; i++) {
+ let node = root.getChild(i);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+
+ // test without a maxResults, which executes a different query
+ // but the first 10 results should be the same.
+ // verify our visits AS_URI, ordered by date descending
+ // we should get 20 visits, but the first 10 should be
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-10.com/
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+ root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ cc = root.childCount;
+ do_check_eq(cc, TOTAL_SITES);
+ for (let i=0; i < 10; i++) {
+ let node = root.getChild(i);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_399264_query_to_string.js b/toolkit/components/places/tests/unit/test_399264_query_to_string.js
new file mode 100644
index 000000000..6e6cc279c
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_399264_query_to_string.js
@@ -0,0 +1,51 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Obtains the id of the folder obtained from the query.
+ *
+ * @param aFolderID
+ * The id of the folder we want to generate a query for.
+ * @returns the string representation of the query for the given folder.
+ */
+function query_string(aFolderID)
+{
+ var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+ var query = hs.getNewQuery();
+ query.setFolders([aFolderID], 1);
+ var options = hs.getNewQueryOptions();
+ return hs.queriesToQueryString([query], 1, options);
+}
+
+function run_test()
+{
+ var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+ const QUERIES = [
+ "folder=PLACES_ROOT"
+ , "folder=BOOKMARKS_MENU"
+ , "folder=TAGS"
+ , "folder=UNFILED_BOOKMARKS"
+ , "folder=TOOLBAR"
+ ];
+ const FOLDER_IDS = [
+ bs.placesRoot
+ , bs.bookmarksMenuFolder
+ , bs.tagsFolder
+ , bs.unfiledBookmarksFolder
+ , bs.toolbarFolder
+ ];
+
+
+ for (var i = 0; i < QUERIES.length; i++) {
+ var result = query_string(FOLDER_IDS[i]);
+ dump("Looking for '" + QUERIES[i] + "' in '" + result + "'\n");
+ do_check_neq(-1, result.indexOf(QUERIES[i]));
+ }
+}
diff --git a/toolkit/components/places/tests/unit/test_399264_string_to_query.js b/toolkit/components/places/tests/unit/test_399264_string_to_query.js
new file mode 100644
index 000000000..bd29316d9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_399264_string_to_query.js
@@ -0,0 +1,75 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Obtains the id of the folder obtained from the query.
+ *
+ * @param aQuery
+ * The query to obtain the folder id from.
+ * @returns the folder id of the folder of the root node of the query.
+ */
+function folder_id(aQuery)
+{
+ var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+ dump("Checking query '" + aQuery + "'\n");
+ var options = { };
+ var queries = { };
+ var size = { };
+ hs.queryStringToQueries(aQuery, queries, size, options);
+ var result = hs.executeQueries(queries.value, size.value, options.value);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_true(root.hasChildren);
+ var folderID = root.getChild(0).parent.itemId;
+ root.containerOpen = false;
+ return folderID;
+}
+
+function run_test()
+{
+ var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+ const QUERIES = [
+ "place:folder=PLACES_ROOT"
+ , "place:folder=BOOKMARKS_MENU"
+ , "place:folder=TAGS"
+ , "place:folder=UNFILED_BOOKMARKS"
+ , "place:folder=TOOLBAR"
+ ];
+ const FOLDER_IDS = [
+ bs.placesRoot
+ , bs.bookmarksMenuFolder
+ , bs.tagsFolder
+ , bs.unfiledBookmarksFolder
+ , bs.toolbarFolder
+ ];
+
+ // add something in the bookmarks menu folder so a query to it returns results
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://example.com/bmf/"),
+ Ci.nsINavBookmarksService.DEFAULT_INDEX, "bmf");
+
+ // add something to the tags folder
+ var ts = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+ ts.tagURI(uri("http://www.example.com/"), ["tag"]);
+
+ // add something to the unfiled bookmarks folder
+ bs.insertBookmark(bs.unfiledBookmarksFolder, uri("http://example.com/ubf/"),
+ Ci.nsINavBookmarksService.DEFAULT_INDEX, "ubf");
+
+ // add something to the toolbar folder
+ bs.insertBookmark(bs.toolbarFolder, uri("http://example.com/tf/"),
+ Ci.nsINavBookmarksService.DEFAULT_INDEX, "tf");
+
+ for (var i = 0; i < QUERIES.length; i++) {
+ var result = folder_id(QUERIES[i]);
+ dump("expected " + FOLDER_IDS[i] + ", got " + result + "\n");
+ do_check_eq(FOLDER_IDS[i], result);
+ }
+}
diff --git a/toolkit/components/places/tests/unit/test_399266.js b/toolkit/components/places/tests/unit/test_399266.js
new file mode 100644
index 000000000..296d69414
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_399266.js
@@ -0,0 +1,78 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TOTAL_SITES = 20;
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ let places = [];
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ for (let j = 0; j <= i; j++) {
+ places.push({ uri: uri("http://www.test-" + i + ".com/"),
+ transition: TRANSITION_TYPED });
+ // because these are embedded visits, they should not show up on our
+ // query results. If they do, we have a problem.
+ places.push({ uri: uri("http://www.hidden.com/hidden.gif"),
+ transition: TRANSITION_EMBED });
+ places.push({ uri: uri("http://www.alsohidden.com/hidden.gif"),
+ transition: TRANSITION_FRAMED_LINK });
+ }
+ }
+ yield PlacesTestUtils.addVisits(places);
+
+ // test our optimized query for the "Most Visited" item
+ // in the "Smart Bookmarks" folder
+ // place:queryType=0&sort=8&maxResults=10
+ // verify our visits AS_URI, ordered by visit count descending
+ // we should get 10 visits:
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-10.com/
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
+ options.maxResults = 10;
+ options.resultType = options.RESULTS_AS_URI;
+ let root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ do_check_eq(cc, options.maxResults);
+ for (let i = 0; i < cc; i++) {
+ let node = root.getChild(i);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+
+ // test without a maxResults, which executes a different query
+ // but the first 10 results should be the same.
+ // verify our visits AS_URI, ordered by visit count descending
+ // we should get 20 visits, but the first 10 should be
+ // http://www.test-19.com/
+ // ...
+ // http://www.test-10.com/
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
+ options.resultType = options.RESULTS_AS_URI;
+ root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ cc = root.childCount;
+ do_check_eq(cc, TOTAL_SITES);
+ for (let i = 0; i < 10; i++) {
+ let node = root.getChild(i);
+ let site = "http://www.test-" + (TOTAL_SITES - 1 - i) + ".com/";
+ do_check_eq(node.uri, site);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ }
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_402799.js b/toolkit/components/places/tests/unit/test_402799.js
new file mode 100644
index 000000000..263e20aa5
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_402799.js
@@ -0,0 +1,62 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history services
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bhist = histsvc.QueryInterface(Ci.nsIBrowserHistory);
+} catch (ex) {
+ do_throw("Could not get history services\n");
+}
+
+// Get bookmark service
+try {
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+}
+catch (ex) {
+ do_throw("Could not get the nav-bookmarks-service\n");
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+
+// main
+function run_test() {
+ var uri1 = uri("http://foo.bar/");
+
+ // create 2 bookmarks on the same uri
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri1,
+ bmsvc.DEFAULT_INDEX, "title 1");
+ bmsvc.insertBookmark(bmsvc.toolbarFolder, uri1,
+ bmsvc.DEFAULT_INDEX, "title 2");
+ // add some tags
+ tagssvc.tagURI(uri1, ["foo", "bar", "foobar", "foo bar"]);
+
+ // check that a generic bookmark query returns only real bookmarks
+ var options = histsvc.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+
+ var query = histsvc.getNewQuery();
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+
+ root.containerOpen = true;
+ var cc = root.childCount;
+ do_check_eq(cc, 2);
+ var node1 = root.getChild(0);
+ do_check_eq(bmsvc.getFolderIdForItem(node1.itemId), bmsvc.bookmarksMenuFolder);
+ var node2 = root.getChild(1);
+ do_check_eq(bmsvc.getFolderIdForItem(node2.itemId), bmsvc.toolbarFolder);
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_405497.js b/toolkit/components/places/tests/unit/test_405497.js
new file mode 100644
index 000000000..951302b84
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_405497.js
@@ -0,0 +1,57 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+/**
+ * The callback object for runInBatchMode.
+ *
+ * @param aService
+ * Takes a reference to the history service or the bookmark service.
+ * This determines which service should be called when calling the second
+ * runInBatchMode the second time.
+ */
+function callback(aService)
+{
+ this.callCount = 0;
+ this.service = aService;
+}
+callback.prototype = {
+ // nsINavHistoryBatchCallback
+
+ runBatched: function(aUserData)
+ {
+ this.callCount++;
+
+ if (this.callCount == 1) {
+ // We want to call run in batched once more.
+ this.service.runInBatchMode(this, null);
+ return;
+ }
+
+ do_check_eq(this.callCount, 2);
+ do_test_finished();
+ },
+
+ // nsISupports
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryBatchCallback])
+};
+
+function run_test() {
+ // checking the history service
+ do_test_pending();
+ hs.runInBatchMode(new callback(hs), null);
+
+ // checking the bookmark service
+ do_test_pending();
+ bs.runInBatchMode(new callback(bs), null);
+}
diff --git a/toolkit/components/places/tests/unit/test_408221.js b/toolkit/components/places/tests/unit/test_408221.js
new file mode 100644
index 000000000..2b41ce1a2
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_408221.js
@@ -0,0 +1,165 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var current_test = 0;
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+function ensure_tag_results(uris, searchTerm)
+{
+ var controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
+ getService(Components.interfaces.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ // Search is asynchronous, so don't let the test finish immediately
+ do_test_pending();
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ input.onSearchComplete = function() {
+ do_check_eq(numSearchesStarted, 1);
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+ do_check_eq(controller.matchCount, uris.length);
+ let vals = [];
+ for (let i=0; i<controller.matchCount; i++) {
+ // Keep the URL for later because order of tag results is undefined
+ vals.push(controller.getValueAt(i));
+ do_check_eq(controller.getStyleAt(i), "bookmark-tag");
+ }
+ // Sort the results then check if we have the right items
+ vals.sort().forEach((val, i) => do_check_eq(val, uris[i].spec))
+
+ if (current_test < (tests.length - 1)) {
+ current_test++;
+ tests[current_test]();
+ }
+
+ do_test_finished();
+ };
+
+ controller.startSearch(searchTerm);
+}
+
+var uri1 = uri("http://site.tld/1");
+var uri2 = uri("http://site.tld/2");
+var uri3 = uri("http://site.tld/3");
+var uri4 = uri("http://site.tld/4");
+var uri5 = uri("http://site.tld/5");
+var uri6 = uri("http://site.tld/6");
+
+var tests = [function() { ensure_tag_results([uri1, uri2, uri3], "foo"); },
+ function() { ensure_tag_results([uri1, uri2, uri3], "Foo"); },
+ function() { ensure_tag_results([uri1, uri2, uri3], "foO"); },
+ function() { ensure_tag_results([uri4, uri5, uri6], "bar mud"); },
+ function() { ensure_tag_results([uri4, uri5, uri6], "BAR MUD"); },
+ function() { ensure_tag_results([uri4, uri5, uri6], "Bar Mud"); }];
+
+/**
+ * Properly tags a uri adding it to bookmarks.
+ *
+ * @param aURI
+ * The nsIURI to tag.
+ * @param aTags
+ * The tags to add.
+ */
+function tagURI(aURI, aTags) {
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ aURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "A title");
+ tagssvc.tagURI(aURI, aTags);
+}
+
+/**
+ * Test bug #408221
+ */
+function run_test() {
+ // always search in history + bookmarks, no matter what the default is
+ var prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+ prefs.setIntPref("browser.urlbar.search.sources", 3);
+ prefs.setIntPref("browser.urlbar.default.behavior", 0);
+
+ tagURI(uri1, ["Foo"]);
+ tagURI(uri2, ["FOO"]);
+ tagURI(uri3, ["foO"]);
+ tagURI(uri4, ["BAR"]);
+ tagURI(uri4, ["MUD"]);
+ tagURI(uri5, ["bar"]);
+ tagURI(uri5, ["mud"]);
+ tagURI(uri6, ["baR"]);
+ tagURI(uri6, ["muD"]);
+
+ tests[0]();
+}
diff --git a/toolkit/components/places/tests/unit/test_412132.js b/toolkit/components/places/tests/unit/test_412132.js
new file mode 100644
index 000000000..827391f18
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_412132.js
@@ -0,0 +1,136 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * TEST DESCRIPTION:
+ *
+ * Tests patch to Bug 412132:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=412132
+ */
+
+add_task(function* changeuri_unvisited_bookmark()
+{
+ do_print("After changing URI of bookmark, frecency of bookmark's " +
+ "original URI should be zero if original URI is unvisited and " +
+ "no longer bookmarked.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ PlacesUtils.bookmarks.changeBookmarkURI(id, uri("http://example.com/2"));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Unvisited URI no longer bookmarked => frecency should = 0");
+ do_check_eq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* changeuri_visited_bookmark()
+{
+ do_print("After changing URI of bookmark, frecency of bookmark's " +
+ "original URI should not be zero if original URI is visited.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesTestUtils.addVisits(TEST_URI);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ PlacesUtils.bookmarks.changeBookmarkURI(id, uri("http://example.com/2"));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("*Visited* URI no longer bookmarked => frecency should != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* changeuri_bookmark_still_bookmarked()
+{
+ do_print("After changing URI of bookmark, frecency of bookmark's " +
+ "original URI should not be zero if original URI is still " +
+ "bookmarked.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let id1 = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark 1 title");
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark 2 title");
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ PlacesUtils.bookmarks.changeBookmarkURI(id1, uri("http://example.com/2"));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("URI still bookmarked => frecency should != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* changeuri_nonexistent_bookmark()
+{
+ do_print("Changing the URI of a nonexistent bookmark should fail.");
+ function tryChange(itemId)
+ {
+ try {
+ PlacesUtils.bookmarks.changeBookmarkURI(itemId + 1, uri("http://example.com/2"));
+ do_throw("Nonexistent bookmark should throw.");
+ }
+ catch (ex) {}
+ }
+
+ // First try a straight-up bogus item ID, one greater than the current max
+ // ID.
+ let stmt = DBConn().createStatement("SELECT MAX(id) FROM moz_bookmarks");
+ stmt.executeStep();
+ let maxId = stmt.getInt32(0);
+ stmt.finalize();
+ tryChange(maxId + 1);
+
+ // Now add a bookmark, delete it, and check.
+ let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("http://example.com/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+ PlacesUtils.bookmarks.removeItem(id);
+ tryChange(id);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+function run_test()
+{
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_413784.js b/toolkit/components/places/tests/unit/test_413784.js
new file mode 100644
index 000000000..6df4dfbbb
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_413784.js
@@ -0,0 +1,118 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+
+Test autocomplete for non-English URLs
+
+- add a visit for a page with a non-English URL
+- search
+- test number of matches (should be exactly one)
+
+*/
+
+var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+// create test data
+var searchTerm = "ユニコード";
+var decoded = "http://www.foobar.com/" + searchTerm + "/";
+var url = uri(decoded);
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+function run_test()
+{
+ do_test_pending();
+ PlacesTestUtils.addVisits(url).then(continue_test);
+}
+
+function continue_test()
+{
+ var controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
+ getService(Components.interfaces.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ input.onSearchComplete = function() {
+ do_check_eq(numSearchesStarted, 1);
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+
+ // test that we found the entry we added
+ do_check_eq(controller.matchCount, 1);
+
+ // Make sure the url is the same according to spec, so it can be deleted
+ do_check_eq(controller.getValueAt(0), url.spec);
+
+ do_test_finished();
+ };
+
+ controller.startSearch(searchTerm);
+}
diff --git a/toolkit/components/places/tests/unit/test_415460.js b/toolkit/components/places/tests/unit/test_415460.js
new file mode 100644
index 000000000..f2e049f09
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_415460.js
@@ -0,0 +1,43 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+/**
+ * Checks to see that a search has exactly one result in the database.
+ *
+ * @param aTerms
+ * The terms to search for.
+ * @returns true if the search returns one result, false otherwise.
+ */
+function search_has_result(aTerms)
+{
+ var options = hs.getNewQueryOptions();
+ options.maxResults = 1;
+ options.resultType = options.RESULTS_AS_URI;
+ var query = hs.getNewQuery();
+ query.searchTerms = aTerms;
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+ root.containerOpen = false;
+ return (cc == 1);
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ const SEARCH_TERM = "ユニコード";
+ const TEST_URL = "http://example.com/" + SEARCH_TERM + "/";
+ yield PlacesTestUtils.addVisits(uri(TEST_URL));
+ do_check_true(search_has_result(SEARCH_TERM));
+});
diff --git a/toolkit/components/places/tests/unit/test_415757.js b/toolkit/components/places/tests/unit/test_415757.js
new file mode 100644
index 000000000..afd396183
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_415757.js
@@ -0,0 +1,102 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Checks to see that a URI is in the database.
+ *
+ * @param aURI
+ * The URI to check.
+ * @returns true if the URI is in the DB, false otherwise.
+ */
+function uri_in_db(aURI) {
+ var options = PlacesUtils.history.getNewQueryOptions();
+ options.maxResults = 1;
+ options.resultType = options.RESULTS_AS_URI
+ var query = PlacesUtils.history.getNewQuery();
+ query.uri = aURI;
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+ root.containerOpen = false;
+ return (cc == 1);
+}
+
+const TOTAL_SITES = 20;
+
+// main
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ // add pages to global history
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let site = "http://www.test-" + i + ".com/";
+ let testURI = uri(site);
+ let when = Date.now() * 1000 + (i * TOTAL_SITES);
+ yield PlacesTestUtils.addVisits({ uri: testURI, visitDate: when });
+ }
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let site = "http://www.test.com/" + i + "/";
+ let testURI = uri(site);
+ let when = Date.now() * 1000 + (i * TOTAL_SITES);
+ yield PlacesTestUtils.addVisits({ uri: testURI, visitDate: when });
+ }
+
+ // set a page annotation on one of the urls that will be removed
+ var testAnnoDeletedURI = uri("http://www.test.com/1/");
+ var testAnnoDeletedName = "foo";
+ var testAnnoDeletedValue = "bar";
+ PlacesUtils.annotations.setPageAnnotation(testAnnoDeletedURI,
+ testAnnoDeletedName,
+ testAnnoDeletedValue, 0,
+ PlacesUtils.annotations.EXPIRE_WITH_HISTORY);
+
+ // set a page annotation on one of the urls that will NOT be removed
+ var testAnnoRetainedURI = uri("http://www.test-1.com/");
+ var testAnnoRetainedName = "foo";
+ var testAnnoRetainedValue = "bar";
+ PlacesUtils.annotations.setPageAnnotation(testAnnoRetainedURI,
+ testAnnoRetainedName,
+ testAnnoRetainedValue, 0,
+ PlacesUtils.annotations.EXPIRE_WITH_HISTORY);
+
+ // remove pages from www.test.com
+ PlacesUtils.history.removePagesFromHost("www.test.com", false);
+
+ // check that all pages in www.test.com have been removed
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let site = "http://www.test.com/" + i + "/";
+ let testURI = uri(site);
+ do_check_false(uri_in_db(testURI));
+ }
+
+ // check that all pages in www.test-X.com have NOT been removed
+ for (let i = 0; i < TOTAL_SITES; i++) {
+ let site = "http://www.test-" + i + ".com/";
+ let testURI = uri(site);
+ do_check_true(uri_in_db(testURI));
+ }
+
+ // check that annotation on the removed item does not exists
+ try {
+ PlacesUtils.annotations.getPageAnnotation(testAnnoDeletedURI, testAnnoName);
+ do_throw("fetching page-annotation that doesn't exist, should've thrown");
+ } catch (ex) {}
+
+ // check that annotation on the NOT removed item still exists
+ try {
+ var annoVal = PlacesUtils.annotations.getPageAnnotation(testAnnoRetainedURI,
+ testAnnoRetainedName);
+ } catch (ex) {
+ do_throw("The annotation has been removed erroneously");
+ }
+ do_check_eq(annoVal, testAnnoRetainedValue);
+
+});
diff --git a/toolkit/components/places/tests/unit/test_418643_removeFolderChildren.js b/toolkit/components/places/tests/unit/test_418643_removeFolderChildren.js
new file mode 100644
index 000000000..2eed02921
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_418643_removeFolderChildren.js
@@ -0,0 +1,143 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get services.
+try {
+ var histSvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bmSvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ var annoSvc = Cc["@mozilla.org/browser/annotation-service;1"]
+ .getService(Ci.nsIAnnotationService);
+} catch (ex) {
+ do_throw("Could not get services\n");
+}
+
+var validAnnoName = "validAnno";
+var validItemName = "validItem";
+var deletedAnnoName = "deletedAnno";
+var deletedItemName = "deletedItem";
+var bookmarkedURI = uri("http://www.mozilla.org/");
+// set lastModified to the past to prevent VM timing bugs
+var pastDate = Date.now() * 1000 - 1;
+var deletedBookmarkIds = [];
+
+// bookmarks observer
+var observer = {
+ // cached ordered array of notified items
+ _onItemRemovedItemIds: [],
+ onItemRemoved: function(aItemId, aParentId, aIndex) {
+ // We should first get notifications for children, then for their parent
+ do_check_eq(this._onItemRemovedItemIds.indexOf(aParentId), -1);
+ // Ensure we are not wrongly removing 1 level up
+ do_check_neq(aParentId, bmSvc.toolbarFolder);
+ // Removed item must be one of those we have manually deleted
+ do_check_neq(deletedBookmarkIds.indexOf(aItemId), -1);
+ this._onItemRemovedItemIds.push(aItemId);
+ },
+
+ QueryInterface: function(aIID) {
+ if (aIID.equals(Ci.nsINavBookmarkObserver) ||
+ aIID.equals(Ci.nsISupports)) {
+ return this;
+ }
+ throw Cr.NS_ERROR_NO_INTERFACE;
+ }
+};
+
+bmSvc.addObserver(observer, false);
+
+function add_bookmarks() {
+ // This is the folder we will cleanup
+ var validFolderId = bmSvc.createFolder(bmSvc.toolbarFolder,
+ validItemName,
+ bmSvc.DEFAULT_INDEX);
+ annoSvc.setItemAnnotation(validFolderId, validAnnoName,
+ "annotation", 0,
+ annoSvc.EXPIRE_NEVER);
+ bmSvc.setItemLastModified(validFolderId, pastDate);
+
+ // This bookmark should not be deleted
+ var validItemId = bmSvc.insertBookmark(bmSvc.toolbarFolder,
+ bookmarkedURI,
+ bmSvc.DEFAULT_INDEX,
+ validItemName);
+ annoSvc.setItemAnnotation(validItemId, validAnnoName,
+ "annotation", 0, annoSvc.EXPIRE_NEVER);
+
+ // The following contents should be deleted
+ var deletedItemId = bmSvc.insertBookmark(validFolderId,
+ bookmarkedURI,
+ bmSvc.DEFAULT_INDEX,
+ deletedItemName);
+ annoSvc.setItemAnnotation(deletedItemId, deletedAnnoName,
+ "annotation", 0, annoSvc.EXPIRE_NEVER);
+ deletedBookmarkIds.push(deletedItemId);
+
+ var internalFolderId = bmSvc.createFolder(validFolderId,
+ deletedItemName,
+ bmSvc.DEFAULT_INDEX);
+ annoSvc.setItemAnnotation(internalFolderId, deletedAnnoName,
+ "annotation", 0, annoSvc.EXPIRE_NEVER);
+ deletedBookmarkIds.push(internalFolderId);
+
+ deletedItemId = bmSvc.insertBookmark(internalFolderId,
+ bookmarkedURI,
+ bmSvc.DEFAULT_INDEX,
+ deletedItemName);
+ annoSvc.setItemAnnotation(deletedItemId, deletedAnnoName,
+ "annotation", 0, annoSvc.EXPIRE_NEVER);
+ deletedBookmarkIds.push(deletedItemId);
+
+ return validFolderId;
+}
+
+function check_bookmarks(aFolderId) {
+ // check that we still have valid bookmarks
+ var bookmarks = bmSvc.getBookmarkIdsForURI(bookmarkedURI);
+ for (var i = 0; i < bookmarks.length; i++) {
+ do_check_eq(bmSvc.getItemTitle(bookmarks[i]), validItemName);
+ do_check_true(annoSvc.itemHasAnnotation(bookmarks[i], validAnnoName));
+ }
+
+ // check that folder exists and has still its annotation
+ do_check_eq(bmSvc.getItemTitle(aFolderId), validItemName);
+ do_check_true(annoSvc.itemHasAnnotation(aFolderId, validAnnoName));
+
+ // check that folder is empty
+ var options = histSvc.getNewQueryOptions();
+ var query = histSvc.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ var result = histSvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 0);
+ root.containerOpen = false;
+
+ // test that lastModified got updated
+ do_check_true(pastDate < bmSvc.getItemLastModified(aFolderId));
+
+ // test that all children have been deleted, we use annos for that
+ var deletedItems = annoSvc.getItemsWithAnnotation(deletedAnnoName);
+ do_check_eq(deletedItems.length, 0);
+
+ // test that observer has been called for (and only for) deleted items
+ do_check_eq(observer._onItemRemovedItemIds.length, deletedBookmarkIds.length);
+
+ // Sanity check: all roots should be intact
+ do_check_eq(bmSvc.getFolderIdForItem(bmSvc.placesRoot), 0);
+ do_check_eq(bmSvc.getFolderIdForItem(bmSvc.bookmarksMenuFolder), bmSvc.placesRoot);
+ do_check_eq(bmSvc.getFolderIdForItem(bmSvc.tagsFolder), bmSvc.placesRoot);
+ do_check_eq(bmSvc.getFolderIdForItem(bmSvc.unfiledBookmarksFolder), bmSvc.placesRoot);
+ do_check_eq(bmSvc.getFolderIdForItem(bmSvc.toolbarFolder), bmSvc.placesRoot);
+}
+
+// main
+function run_test() {
+ var folderId = add_bookmarks();
+ bmSvc.removeFolderChildren(folderId);
+ check_bookmarks(folderId);
+}
diff --git a/toolkit/components/places/tests/unit/test_419731.js b/toolkit/components/places/tests/unit/test_419731.js
new file mode 100644
index 000000000..b1a434e12
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_419731.js
@@ -0,0 +1,96 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ let uri1 = NetUtil.newURI("http://foo.bar/");
+
+ // create 2 bookmarks
+ let bookmark1id = PlacesUtils.bookmarks
+ .insertBookmark(PlacesUtils.bookmarksMenuFolderId,
+ uri1,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "title 1");
+ let bookmark2id = PlacesUtils.bookmarks
+ .insertBookmark(PlacesUtils.toolbarFolderId,
+ uri1,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "title 2");
+ // add a new tag
+ PlacesUtils.tagging.tagURI(uri1, ["foo"]);
+
+ // get tag folder id
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.tagsFolderId], 1);
+ let result = PlacesUtils.history.executeQuery(query, options);
+ let tagRoot = result.root;
+ tagRoot.containerOpen = true;
+ let tagNode = tagRoot.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ let tagItemId = tagNode.itemId;
+ tagRoot.containerOpen = false;
+
+ // change bookmark 1 title
+ PlacesUtils.bookmarks.setItemTitle(bookmark1id, "new title 1");
+
+ // Workaround timers resolution and time skews.
+ let bookmark2LastMod = PlacesUtils.bookmarks.getItemLastModified(bookmark2id);
+ PlacesUtils.bookmarks.setItemLastModified(bookmark1id, bookmark2LastMod + 1000);
+
+ // Query the tag.
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.resultType = options.RESULTS_AS_TAG_QUERY;
+
+ query = PlacesUtils.history.getNewQuery();
+ result = PlacesUtils.history.executeQuery(query, options);
+ let root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+
+ let theTag = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ // Bug 524219: Check that renaming the tag shows up in the result.
+ do_check_eq(theTag.title, "foo")
+ PlacesUtils.bookmarks.setItemTitle(tagItemId, "bar");
+
+ // Check that the item has been replaced
+ do_check_neq(theTag, root.getChild(0));
+ theTag = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(theTag.title, "bar");
+
+ // Check that tag container contains new title
+ theTag.containerOpen = true;
+ do_check_eq(theTag.childCount, 1);
+ let node = theTag.getChild(0);
+ do_check_eq(node.title, "new title 1");
+ theTag.containerOpen = false;
+ root.containerOpen = false;
+
+ // Change bookmark 2 title.
+ PlacesUtils.bookmarks.setItemTitle(bookmark2id, "new title 2");
+
+ // Workaround timers resolution and time skews.
+ let bookmark1LastMod = PlacesUtils.bookmarks.getItemLastModified(bookmark1id);
+ PlacesUtils.bookmarks.setItemLastModified(bookmark2id, bookmark1LastMod + 1000);
+
+ // Check that tag container contains new title
+ options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.resultType = options.RESULTS_AS_TAG_CONTENTS;
+
+ query = PlacesUtils.history.getNewQuery();
+ query.setFolders([tagItemId], 1);
+ result = PlacesUtils.history.executeQuery(query, options);
+ root = result.root;
+
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+ node = root.getChild(0);
+ do_check_eq(node.title, "new title 2");
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_419792_node_tags_property.js b/toolkit/components/places/tests/unit/test_419792_node_tags_property.js
new file mode 100644
index 000000000..4c726d667
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_419792_node_tags_property.js
@@ -0,0 +1,49 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// get services
+var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+
+function run_test() {
+ // get toolbar node
+ var options = histsvc.getNewQueryOptions();
+ var query = histsvc.getNewQuery();
+ query.setFolders([bmsvc.toolbarFolder], 1);
+ var result = histsvc.executeQuery(query, options);
+ var toolbarNode = result.root;
+ toolbarNode.containerOpen = true;
+
+ // add a bookmark
+ var bookmarkURI = uri("http://foo.com");
+ var bookmarkId = bmsvc.insertBookmark(bmsvc.toolbarFolder, bookmarkURI,
+ bmsvc.DEFAULT_INDEX, "");
+
+ // get the node for the new bookmark
+ var node = toolbarNode.getChild(toolbarNode.childCount-1);
+ do_check_eq(node.itemId, bookmarkId);
+
+ // confirm there's no tags via the .tags property
+ do_check_eq(node.tags, null);
+
+ // add a tag
+ tagssvc.tagURI(bookmarkURI, ["foo"]);
+ do_check_eq(node.tags, "foo");
+
+ // add another tag, to test delimiter and sorting
+ tagssvc.tagURI(bookmarkURI, ["bar"]);
+ do_check_eq(node.tags, "bar, foo");
+
+ // remove the tags, confirming the property is cleared
+ tagssvc.untagURI(bookmarkURI, null);
+ do_check_eq(node.tags, null);
+
+ toolbarNode.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_425563.js b/toolkit/components/places/tests/unit/test_425563.js
new file mode 100644
index 000000000..bee3a4a54
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_425563.js
@@ -0,0 +1,74 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ let count_visited_URIs = ["http://www.test-link.com/",
+ "http://www.test-typed.com/",
+ "http://www.test-bookmark.com/",
+ "http://www.test-redirect-permanent.com/",
+ "http://www.test-redirect-temporary.com/"];
+
+ let notcount_visited_URIs = ["http://www.test-embed.com/",
+ "http://www.test-download.com/",
+ "http://www.test-framed.com/",
+ "http://www.test-reload.com/"];
+
+ // add visits, one for each transition type
+ yield PlacesTestUtils.addVisits([
+ { uri: uri("http://www.test-link.com/"),
+ transition: TRANSITION_LINK },
+ { uri: uri("http://www.test-typed.com/"),
+ transition: TRANSITION_TYPED },
+ { uri: uri("http://www.test-bookmark.com/"),
+ transition: TRANSITION_BOOKMARK },
+ { uri: uri("http://www.test-embed.com/"),
+ transition: TRANSITION_EMBED },
+ { uri: uri("http://www.test-framed.com/"),
+ transition: TRANSITION_FRAMED_LINK },
+ { uri: uri("http://www.test-redirect-permanent.com/"),
+ transition: TRANSITION_REDIRECT_PERMANENT },
+ { uri: uri("http://www.test-redirect-temporary.com/"),
+ transition: TRANSITION_REDIRECT_TEMPORARY },
+ { uri: uri("http://www.test-download.com/"),
+ transition: TRANSITION_DOWNLOAD },
+ { uri: uri("http://www.test-reload.com/"),
+ transition: TRANSITION_RELOAD },
+ ]);
+
+ // check that all links are marked as visited
+ for (let visited_uri of count_visited_URIs) {
+ do_check_true(yield promiseIsURIVisited(uri(visited_uri)));
+ }
+ for (let visited_uri of notcount_visited_URIs) {
+ do_check_true(yield promiseIsURIVisited(uri(visited_uri)));
+ }
+
+ // check that visit_count does not take in count embed and downloads
+ // maxVisits query are directly binded to visit_count
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_VISITCOUNT_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+ options.includeHidden = true;
+ let query = PlacesUtils.history.getNewQuery();
+ query.minVisits = 1;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+
+ root.containerOpen = true;
+ let cc = root.childCount;
+ do_check_eq(cc, count_visited_URIs.length);
+
+ for (let i = 0; i < cc; i++) {
+ let node = root.getChild(i);
+ do_check_neq(count_visited_URIs.indexOf(node.uri), -1);
+ }
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js b/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js
new file mode 100644
index 000000000..e0b6be64c
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_429505_remove_shortcuts.js
@@ -0,0 +1,35 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+
+- add a folder
+- add a folder-shortcut to the new folder
+- query for the shortcut
+- remove the folder-shortcut
+- confirm the shortcut is removed from the query results
+
+*/
+
+function run_test() {
+ const IDX = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ var folderId =
+ PlacesUtils.bookmarks.createFolder(PlacesUtils.toolbarFolderId, "", IDX);
+
+ var queryId =
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.toolbarFolderId,
+ uri("place:folder=" + folderId), IDX, "");
+
+ var root = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId, false, true).root;
+
+ var oldCount = root.childCount;
+
+ PlacesUtils.bookmarks.removeItem(queryId);
+
+ do_check_eq(root.childCount, oldCount-1);
+
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_433317_query_title_update.js b/toolkit/components/places/tests/unit/test_433317_query_title_update.js
new file mode 100644
index 000000000..52558e844
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_433317_query_title_update.js
@@ -0,0 +1,38 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ } catch (ex) {
+ do_throw("Unable to initialize Places services");
+ }
+
+ // create a query bookmark
+ var queryId = bmsvc.insertBookmark(bmsvc.toolbarFolder, uri("place:"),
+ 0 /* first item */, "test query");
+
+ // query for that query
+ var options = histsvc.getNewQueryOptions();
+ var query = histsvc.getNewQuery();
+ query.setFolders([bmsvc.toolbarFolder], 1);
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var queryNode = root.getChild(0);
+ do_check_eq(queryNode.title, "test query");
+
+ // change the title
+ bmsvc.setItemTitle(queryId, "foo");
+
+ // confirm the node was updated
+ do_check_eq(queryNode.title, "foo");
+
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js b/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js
new file mode 100644
index 000000000..92dac0b17
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_433525_hasChildren_crash.js
@@ -0,0 +1,56 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ } catch (ex) {
+ do_throw("Unable to initialize Places services");
+ }
+
+ // add a visit
+ var testURI = uri("http://test");
+ yield PlacesTestUtils.addVisits(testURI);
+
+ // query for the visit
+ var options = histsvc.getNewQueryOptions();
+ options.maxResults = 1;
+ options.resultType = options.RESULTS_AS_URI;
+ var query = histsvc.getNewQuery();
+ query.uri = testURI;
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+
+ // check hasChildren while the container is closed
+ do_check_eq(root.hasChildren, true);
+
+ // now check via the saved search path
+ var queryURI = histsvc.queriesToQueryString([query], 1, options);
+ bmsvc.insertBookmark(bmsvc.toolbarFolder, uri(queryURI),
+ 0 /* first item */, "test query");
+
+ // query for that query
+ options = histsvc.getNewQueryOptions();
+ query = histsvc.getNewQuery();
+ query.setFolders([bmsvc.toolbarFolder], 1);
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ var queryNode = root.getChild(0);
+ do_check_eq(queryNode.title, "test query");
+ queryNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(queryNode.hasChildren, true);
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_452777.js b/toolkit/components/places/tests/unit/test_452777.js
new file mode 100644
index 000000000..97b2852f6
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_452777.js
@@ -0,0 +1,36 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 expandtab
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This test ensures that when removing a folder within a transaction, undoing
+ * the transaction restores it with the same id (as received by the observers).
+ */
+
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+function run_test()
+{
+ const TITLE = "test folder";
+
+ // Create two test folders; remove the first one. This ensures that undoing
+ // the removal will not get the same id by chance (the insert id's can be
+ // reused in SQLite).
+ let id = bs.createFolder(bs.placesRoot, TITLE, -1);
+ bs.createFolder(bs.placesRoot, "test folder 2", -1);
+ let transaction = bs.getRemoveFolderTransaction(id);
+ transaction.doTransaction();
+
+ // Now check to make sure it gets added with the right id
+ bs.addObserver({
+ onItemAdded: function(aItemId, aFolder, aIndex, aItemType, aURI, aTitle)
+ {
+ do_check_eq(aItemId, id);
+ do_check_eq(aTitle, TITLE);
+ }
+ }, false);
+ transaction.undoTransaction();
+}
diff --git a/toolkit/components/places/tests/unit/test_454977.js b/toolkit/components/places/tests/unit/test_454977.js
new file mode 100644
index 000000000..606e83048
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_454977.js
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Cache actual visit_count value, filled by add_visit, used by check_results
+var visit_count = 0;
+
+// Returns the Place ID corresponding to an added visit.
+function* task_add_visit(aURI, aVisitType)
+{
+ // Add the visit asynchronously, and save its visit ID.
+ let deferUpdatePlaces = new Promise((resolve, reject) =>
+ {
+ PlacesUtils.asyncHistory.updatePlaces({
+ uri: aURI,
+ visits: [{ transitionType: aVisitType, visitDate: Date.now() * 1000 }]
+ }, {
+ handleError: function TAV_handleError() {
+ reject(new Error("Unexpected error in adding visit."));
+ },
+ handleResult: function (aPlaceInfo) {
+ this.visitId = aPlaceInfo.visits[0].visitId;
+ },
+ handleCompletion: function TAV_handleCompletion() {
+ resolve(this.visitId);
+ }
+ });
+ });
+
+ let visitId = yield deferUpdatePlaces;
+
+ // Increase visit_count if applicable
+ if (aVisitType != 0 &&
+ aVisitType != TRANSITION_EMBED &&
+ aVisitType != TRANSITION_FRAMED_LINK &&
+ aVisitType != TRANSITION_DOWNLOAD &&
+ aVisitType != TRANSITION_RELOAD) {
+ visit_count ++;
+ }
+
+ // Get the place id
+ if (visitId > 0) {
+ let sql = "SELECT place_id FROM moz_historyvisits WHERE id = ?1";
+ let stmt = DBConn().createStatement(sql);
+ stmt.bindByIndex(0, visitId);
+ do_check_true(stmt.executeStep());
+ let placeId = stmt.getInt64(0);
+ stmt.finalize();
+ do_check_true(placeId > 0);
+ return placeId;
+ }
+ return 0;
+}
+
+/**
+ * Checks for results consistency, using visit_count as constraint
+ * @param aExpectedCount
+ * Number of history results we are expecting (excluded hidden ones)
+ * @param aExpectedCountWithHidden
+ * Number of history results we are expecting (included hidden ones)
+ */
+function check_results(aExpectedCount, aExpectedCountWithHidden)
+{
+ let query = PlacesUtils.history.getNewQuery();
+ // used to check visit_count
+ query.minVisits = visit_count;
+ query.maxVisits = visit_count;
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY;
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ // Children without hidden ones
+ do_check_eq(root.childCount, aExpectedCount);
+ root.containerOpen = false;
+
+ // Execute again with includeHidden = true
+ // This will ensure visit_count is correct
+ options.includeHidden = true;
+ root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ // Children with hidden ones
+ do_check_eq(root.childCount, aExpectedCountWithHidden);
+ root.containerOpen = false;
+}
+
+// main
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ const TEST_URI = uri("http://test.mozilla.org/");
+
+ // Add a visit that force hidden
+ yield task_add_visit(TEST_URI, TRANSITION_EMBED);
+ check_results(0, 0);
+
+ let placeId = yield task_add_visit(TEST_URI, TRANSITION_FRAMED_LINK);
+ check_results(0, 1);
+
+ // Add a visit that force unhide and check the place id.
+ // - We expect that the place gets hidden = 0 while retaining the same
+ // place id and a correct visit_count.
+ do_check_eq((yield task_add_visit(TEST_URI, TRANSITION_TYPED)), placeId);
+ check_results(1, 1);
+
+ // Add a visit that should not increase visit_count
+ do_check_eq((yield task_add_visit(TEST_URI, TRANSITION_RELOAD)), placeId);
+ check_results(1, 1);
+
+ // Add a visit that should not increase visit_count
+ do_check_eq((yield task_add_visit(TEST_URI, TRANSITION_DOWNLOAD)), placeId);
+ check_results(1, 1);
+
+ // Add a visit, check that hidden is not overwritten
+ // - We expect that the place has still hidden = 0, while retaining
+ // correct visit_count.
+ yield task_add_visit(TEST_URI, TRANSITION_EMBED);
+ check_results(1, 1);
+});
diff --git a/toolkit/components/places/tests/unit/test_463863.js b/toolkit/components/places/tests/unit/test_463863.js
new file mode 100644
index 000000000..2f7cece4a
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_463863.js
@@ -0,0 +1,60 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * TEST DESCRIPTION:
+ *
+ * This test checks that in a basic history query all transition types visits
+ * appear but TRANSITION_EMBED and TRANSITION_FRAMED_LINK ones.
+ */
+
+var transitions = [
+ TRANSITION_LINK
+, TRANSITION_TYPED
+, TRANSITION_BOOKMARK
+, TRANSITION_EMBED
+, TRANSITION_FRAMED_LINK
+, TRANSITION_REDIRECT_PERMANENT
+, TRANSITION_REDIRECT_TEMPORARY
+, TRANSITION_DOWNLOAD
+];
+
+function runQuery(aResultType) {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.resultType = aResultType;
+ let root = PlacesUtils.history.executeQuery(PlacesUtils.history.getNewQuery(),
+ options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ do_check_eq(cc, transitions.length - 2);
+
+ for (let i = 0; i < cc; i++) {
+ let node = root.getChild(i);
+ // Check that all transition types but EMBED and FRAMED appear in results
+ do_check_neq(node.uri.substr(6, 1), TRANSITION_EMBED);
+ do_check_neq(node.uri.substr(6, 1), TRANSITION_FRAMED_LINK);
+ }
+ root.containerOpen = false;
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ // add visits, one for each transition type
+ for (let transition of transitions) {
+ yield PlacesTestUtils.addVisits({
+ uri: uri("http://" + transition + ".mozilla.org/"),
+ transition: transition
+ });
+ }
+
+ runQuery(Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT);
+ runQuery(Ci.nsINavHistoryQueryOptions.RESULTS_AS_URI);
+});
diff --git a/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js b/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js
new file mode 100644
index 000000000..873174ffd
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_485442_crash_bug_nsNavHistoryQuery_GetUri.js
@@ -0,0 +1,21 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+function run_test() {
+ var query = hs.getNewQuery();
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULT_TYPE_QUERY;
+ var result = hs.executeQuery(query, options);
+ result.root.containerOpen = true;
+ var rootNode = result.root;
+ rootNode.QueryInterface(Ci.nsINavHistoryQueryResultNode);
+ var queries = rootNode.getQueries();
+ do_check_eq(queries[0].uri, null); // Should be null, instead of crashing the browser
+ rootNode.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js b/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js
new file mode 100644
index 000000000..05f3f83e7
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_486978_sort_by_date_queries.js
@@ -0,0 +1,129 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * TEST DESCRIPTION:
+ *
+ * This test checks that setting a sort on a RESULTS_AS_DATE_QUERY query,
+ * children of inside containers are sorted accordingly.
+ */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+// Will be inserted in this order, so last one will be the newest visit.
+var pages = [
+ "http://a.mozilla.org/1/",
+ "http://a.mozilla.org/2/",
+ "http://a.mozilla.org/3/",
+ "http://a.mozilla.org/4/",
+ "http://b.mozilla.org/5/",
+ "http://b.mozilla.org/6/",
+ "http://b.mozilla.org/7/",
+ "http://b.mozilla.org/8/",
+];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_initialize()
+{
+ var noon = new Date();
+ noon.setHours(12);
+
+ // Add visits.
+ for (let pageIndex = 0; pageIndex < pages.length; ++pageIndex) {
+ let page = pages[pageIndex];
+ yield PlacesTestUtils.addVisits({
+ uri: uri(page),
+ visitDate: noon - (pages.length - pageIndex) * 1000
+ });
+ }
+});
+
+/**
+ * Tests that sorting date query by none will sort by title asc.
+ */
+add_task(function() {
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_QUERY;
+ // This should sort by title asc.
+ options.sortingMode = options.SORT_BY_NONE;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var dayContainer = root.getChild(0).QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayContainer.containerOpen = true;
+
+ var cc = dayContainer.childCount;
+ do_check_eq(cc, pages.length);
+ for (var i = 0; i < cc; i++) {
+ var node = dayContainer.getChild(i);
+ do_check_eq(pages[i], node.uri);
+ }
+
+ dayContainer.containerOpen = false;
+ root.containerOpen = false;
+});
+
+/**
+ * Tests that sorting date query by date will sort accordingly.
+ */
+add_task(function() {
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_QUERY;
+ // This should sort by title asc.
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var dayContainer = root.getChild(0).QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayContainer.containerOpen = true;
+
+ var cc = dayContainer.childCount;
+ do_check_eq(cc, pages.length);
+ for (var i = 0; i < cc; i++) {
+ var node = dayContainer.getChild(i);
+ do_check_eq(pages[pages.length - i - 1], node.uri);
+ }
+
+ dayContainer.containerOpen = false;
+ root.containerOpen = false;
+});
+
+/**
+ * Tests that sorting date site query by date will still sort by title asc.
+ */
+add_task(function() {
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_SITE_QUERY;
+ // This should sort by title asc.
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var dayContainer = root.getChild(0).QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayContainer.containerOpen = true;
+ var siteContainer = dayContainer.getChild(0).QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(siteContainer.title, "a.mozilla.org");
+ siteContainer.containerOpen = true;
+
+ var cc = siteContainer.childCount;
+ do_check_eq(cc, pages.length / 2);
+ for (var i = 0; i < cc / 2; i++) {
+ var node = siteContainer.getChild(i);
+ do_check_eq(pages[i], node.uri);
+ }
+
+ siteContainer.containerOpen = false;
+ dayContainer.containerOpen = false;
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_536081.js b/toolkit/components/places/tests/unit/test_536081.js
new file mode 100644
index 000000000..b61b91866
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_536081.js
@@ -0,0 +1,56 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bh = hs.QueryInterface(Ci.nsIBrowserHistory);
+var db = hs.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+
+const URLS = [
+ { u: "http://www.google.com/search?q=testing%3Bthis&ie=utf-8&oe=utf-8&aq=t&rls=org.mozilla:en-US:unofficial&client=firefox-a",
+ s: "goog" },
+];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ for (let url of URLS) {
+ yield task_test_url(url);
+ }
+});
+
+function* task_test_url(aURL) {
+ print("Testing url: " + aURL.u);
+ yield PlacesTestUtils.addVisits(uri(aURL.u));
+ let query = hs.getNewQuery();
+ query.searchTerms = aURL.s;
+ let options = hs.getNewQueryOptions();
+ let root = hs.executeQuery(query, options).root;
+ root.containerOpen = true;
+ let cc = root.childCount;
+ do_check_eq(cc, 1);
+ print("Checking url is in the query.");
+ let node = root.getChild(0);
+ print("Found " + node.uri);
+ root.containerOpen = false;
+ bh.removePage(uri(node.uri));
+}
+
+function check_empty_table(table_name) {
+ print("Checking url has been removed.");
+ let stmt = db.createStatement("SELECT count(*) FROM " + table_name);
+ try {
+ stmt.executeStep();
+ do_check_eq(stmt.getInt32(0), 0);
+ }
+ finally {
+ stmt.finalize();
+ }
+}
diff --git a/toolkit/components/places/tests/unit/test_PlacesSearchAutocompleteProvider.js b/toolkit/components/places/tests/unit/test_PlacesSearchAutocompleteProvider.js
new file mode 100644
index 000000000..1280ce3e7
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_PlacesSearchAutocompleteProvider.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/PlacesSearchAutocompleteProvider.jsm");
+
+function run_test() {
+ // Tell the search service we are running in the US. This also has the
+ // desired side-effect of preventing our geoip lookup.
+ Services.prefs.setBoolPref("browser.search.isUS", true);
+ Services.prefs.setCharPref("browser.search.countryCode", "US");
+ Services.prefs.setBoolPref("browser.search.geoSpecificDefaults", false);
+ run_next_test();
+}
+
+add_task(function* search_engine_match() {
+ let engine = yield promiseDefaultSearchEngine();
+ let token = engine.getResultDomain();
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByToken(token.substr(0, 1));
+ do_check_eq(match.url, engine.searchForm);
+ do_check_eq(match.engineName, engine.name);
+ do_check_eq(match.iconUrl, engine.iconURI ? engine.iconURI.spec : null);
+});
+
+add_task(function* no_match() {
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByToken("test"));
+});
+
+add_task(function* hide_search_engine_nomatch() {
+ let engine = yield promiseDefaultSearchEngine();
+ let token = engine.getResultDomain();
+ let promiseTopic = promiseSearchTopic("engine-changed");
+ Services.search.removeEngine(engine);
+ yield promiseTopic;
+ do_check_true(engine.hidden);
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByToken(token.substr(0, 1)));
+});
+
+add_task(function* add_search_engine_match() {
+ let promiseTopic = promiseSearchTopic("engine-added");
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByToken("bacon"));
+ Services.search.addEngineWithDetails("bacon", "", "pork", "Search Bacon",
+ "GET", "http://www.bacon.moz/?search={searchTerms}");
+ yield promiseTopic;
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByToken("bacon");
+ do_check_eq(match.url, "http://www.bacon.moz");
+ do_check_eq(match.engineName, "bacon");
+ do_check_eq(match.iconUrl, null);
+});
+
+add_task(function* test_aliased_search_engine_match() {
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByAlias("sober"));
+ // Lower case
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByAlias("pork");
+ do_check_eq(match.engineName, "bacon");
+ do_check_eq(match.alias, "pork");
+ do_check_eq(match.iconUrl, null);
+ // Upper case
+ let match1 = yield PlacesSearchAutocompleteProvider.findMatchByAlias("PORK");
+ do_check_eq(match1.engineName, "bacon");
+ do_check_eq(match1.alias, "pork");
+ do_check_eq(match1.iconUrl, null);
+ // Cap case
+ let match2 = yield PlacesSearchAutocompleteProvider.findMatchByAlias("Pork");
+ do_check_eq(match2.engineName, "bacon");
+ do_check_eq(match2.alias, "pork");
+ do_check_eq(match2.iconUrl, null);
+});
+
+add_task(function* test_aliased_search_engine_match_upper_case_alias() {
+ let promiseTopic = promiseSearchTopic("engine-added");
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByToken("patch"));
+ Services.search.addEngineWithDetails("patch", "", "PR", "Search Patch",
+ "GET", "http://www.patch.moz/?search={searchTerms}");
+ yield promiseTopic;
+ // lower case
+ let match = yield PlacesSearchAutocompleteProvider.findMatchByAlias("pr");
+ do_check_eq(match.engineName, "patch");
+ do_check_eq(match.alias, "PR");
+ do_check_eq(match.iconUrl, null);
+ // Upper case
+ let match1 = yield PlacesSearchAutocompleteProvider.findMatchByAlias("PR");
+ do_check_eq(match1.engineName, "patch");
+ do_check_eq(match1.alias, "PR");
+ do_check_eq(match1.iconUrl, null);
+ // Cap case
+ let match2 = yield PlacesSearchAutocompleteProvider.findMatchByAlias("Pr");
+ do_check_eq(match2.engineName, "patch");
+ do_check_eq(match2.alias, "PR");
+ do_check_eq(match2.iconUrl, null);
+});
+
+add_task(function* remove_search_engine_nomatch() {
+ let engine = Services.search.getEngineByName("bacon");
+ let promiseTopic = promiseSearchTopic("engine-removed");
+ Services.search.removeEngine(engine);
+ yield promiseTopic;
+ do_check_eq(null, yield PlacesSearchAutocompleteProvider.findMatchByToken("bacon"));
+});
+
+add_task(function* test_parseSubmissionURL_basic() {
+ // Most of the logic of parseSubmissionURL is tested in the search service
+ // itself, thus we only do a sanity check of the wrapper here.
+ let engine = yield promiseDefaultSearchEngine();
+ let submissionURL = engine.getSubmission("terms").uri.spec;
+
+ let result = PlacesSearchAutocompleteProvider.parseSubmissionURL(submissionURL);
+ do_check_eq(result.engineName, engine.name);
+ do_check_eq(result.terms, "terms");
+
+ result = PlacesSearchAutocompleteProvider.parseSubmissionURL("http://example.org/");
+ do_check_eq(result, null);
+});
+
+function promiseDefaultSearchEngine() {
+ let deferred = Promise.defer();
+ Services.search.init( () => {
+ deferred.resolve(Services.search.defaultEngine);
+ });
+ return deferred.promise;
+}
+
+function promiseSearchTopic(expectedVerb) {
+ let deferred = Promise.defer();
+ Services.obs.addObserver( function observe(subject, topic, verb) {
+ do_print("browser-search-engine-modified: " + verb);
+ if (verb == expectedVerb) {
+ Services.obs.removeObserver(observe, "browser-search-engine-modified");
+ deferred.resolve();
+ }
+ }, "browser-search-engine-modified", false);
+ return deferred.promise;
+}
diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_asyncGetBookmarkIds.js b/toolkit/components/places/tests/unit/test_PlacesUtils_asyncGetBookmarkIds.js
new file mode 100644
index 000000000..182f75eac
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_PlacesUtils_asyncGetBookmarkIds.js
@@ -0,0 +1,77 @@
+/**
+ * This file tests PlacesUtils.asyncGetBookmarkIds method.
+ */
+
+const TEST_URL = "http://www.example.com/";
+
+var promiseAsyncGetBookmarkIds = Task.async(function* (url) {
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ return new Promise(resolve => {
+ PlacesUtils.asyncGetBookmarkIds(url, (itemIds, uri) => {
+ Assert.equal(uri, url);
+ resolve({ itemIds, url });
+ });
+ });
+});
+
+add_task(function* test_no_bookmark() {
+ let { itemIds, url } = yield promiseAsyncGetBookmarkIds(TEST_URL);
+ Assert.equal(itemIds.length, 0);
+ Assert.equal(url, TEST_URL);
+});
+
+add_task(function* test_one_bookmark() {
+ let bookmark = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URL,
+ title: "test"
+ });
+ let itemId = yield PlacesUtils.promiseItemId(bookmark.guid);
+ {
+ let { itemIds, url } = yield promiseAsyncGetBookmarkIds(NetUtil.newURI(TEST_URL));
+ Assert.equal(itemIds.length, 1);
+ Assert.equal(itemIds[0], itemId);
+ Assert.equal(url.spec, TEST_URL);
+ }
+ {
+ let { itemIds, url } = yield promiseAsyncGetBookmarkIds(TEST_URL);
+ Assert.equal(itemIds.length, 1);
+ Assert.equal(itemIds[0], itemId);
+ Assert.equal(url, TEST_URL);
+ }
+ yield PlacesUtils.bookmarks.remove(bookmark);
+});
+
+add_task(function* test_multiple_bookmarks() {
+ let ids = [];
+ let bookmark1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URL,
+ title: "test"
+ });
+ ids.push((yield PlacesUtils.promiseItemId(bookmark1.guid)));
+ let bookmark2 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URL,
+ title: "test"
+ });
+ ids.push((yield PlacesUtils.promiseItemId(bookmark2.guid)));
+
+ let { itemIds, url } = yield promiseAsyncGetBookmarkIds(TEST_URL);
+ Assert.deepEqual(ids, itemIds);
+ Assert.equal(url, TEST_URL);
+
+ yield PlacesUtils.bookmarks.remove(bookmark1);
+ yield PlacesUtils.bookmarks.remove(bookmark2);
+});
+
+add_task(function* test_cancel() {
+ let pending = PlacesUtils.asyncGetBookmarkIds(TEST_URL, () => {
+ Assert.ok(false, "A canceled pending statement should not be invoked");
+ });
+ pending.cancel();
+
+ let { itemIds, url } = yield promiseAsyncGetBookmarkIds(TEST_URL);
+ Assert.equal(itemIds.length, 0);
+ Assert.equal(url, TEST_URL);
+});
diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js
new file mode 100644
index 000000000..b7906ec5c
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_PlacesUtils_invalidateCachedGuidFor.js
@@ -0,0 +1,25 @@
+add_task(function* () {
+ do_print("Add a bookmark.");
+ let bm = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let id = yield PlacesUtils.promiseItemId(bm.guid);
+ Assert.equal((yield PlacesUtils.promiseItemGuid(id)), bm.guid);
+
+ // Ensure invalidating a non-existent itemId doesn't throw.
+ PlacesUtils.invalidateCachedGuidFor(null);
+ PlacesUtils.invalidateCachedGuidFor(9999);
+
+ do_print("Change the GUID.");
+ yield PlacesUtils.withConnectionWrapper("test", Task.async(function*(db) {
+ yield db.execute("UPDATE moz_bookmarks SET guid = :guid WHERE id = :id",
+ { guid: "123456789012", id});
+ }));
+ // The cache should still point to the wrong id.
+ Assert.equal((yield PlacesUtils.promiseItemGuid(id)), bm.guid);
+
+ do_print("Invalidate the cache.");
+ PlacesUtils.invalidateCachedGuidFor(id);
+ Assert.equal((yield PlacesUtils.promiseItemGuid(id)), "123456789012");
+ Assert.equal((yield PlacesUtils.promiseItemId("123456789012")), id);
+ yield Assert.rejects(PlacesUtils.promiseItemId(bm.guid), /no item found for the given GUID/);
+});
diff --git a/toolkit/components/places/tests/unit/test_PlacesUtils_lazyobservers.js b/toolkit/components/places/tests/unit/test_PlacesUtils_lazyobservers.js
new file mode 100644
index 000000000..f0e9c5517
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_PlacesUtils_lazyobservers.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ do_test_pending();
+
+ const TEST_URI = NetUtil.newURI("http://moz.org/")
+ let observer = {
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver,
+ ]),
+
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onItemAdded: function (aItemId, aParentId, aIndex, aItemType, aURI) {
+ do_check_true(aURI.equals(TEST_URI));
+ PlacesUtils.removeLazyBookmarkObserver(this);
+ do_test_finished();
+ },
+ onItemRemoved: function () {},
+ onItemChanged: function () {},
+ onItemVisited: function () {},
+ onItemMoved: function () {},
+ };
+
+ // Check registration and removal with uninitialized bookmarks service.
+ PlacesUtils.addLazyBookmarkObserver(observer);
+ PlacesUtils.removeLazyBookmarkObserver(observer);
+
+ // Add a proper lazy observer we will test.
+ PlacesUtils.addLazyBookmarkObserver(observer);
+
+ // Check that we don't leak when adding and removing an observer while the
+ // bookmarks service is instantiated but no change happened (bug 721319).
+ PlacesUtils.bookmarks;
+ PlacesUtils.addLazyBookmarkObserver(observer);
+ PlacesUtils.removeLazyBookmarkObserver(observer);
+ try {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ do_throw("Trying to remove a nonexisting observer should throw!");
+ } catch (ex) {}
+
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "Bookmark title");
+}
diff --git a/toolkit/components/places/tests/unit/test_adaptive.js b/toolkit/components/places/tests/unit/test_adaptive.js
new file mode 100644
index 000000000..78ffaedb5
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_adaptive.js
@@ -0,0 +1,406 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 395739 to make sure the feedback to the search results in those
+ * entries getting better ranks. Additionally, exact matches should be ranked
+ * higher. Because the interactions among adaptive rank and visit counts is not
+ * well defined, this test holds one of the two values constant when modifying
+ * the other.
+ *
+ * This also tests bug 395735 for the instrumentation feedback mechanism.
+ *
+ * Bug 411293 is tested to make sure the drop down strongly prefers previously
+ * typed pages that have been selected and are moved to the top with adaptive
+ * learning.
+ */
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ get minResultsForPopup() {
+ return 0;
+ },
+ get timeout() {
+ return 10;
+ },
+ get searchParam() {
+ return "";
+ },
+ get textValue() {
+ return "";
+ },
+ get disableAutoComplete() {
+ return false;
+ },
+ get completeDefaultIndex() {
+ return false;
+ },
+
+ get searchCount() {
+ return this.searches.length;
+ },
+ getSearchAt: function (aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function () {},
+ onSearchComplete: function() {},
+
+ get popupOpen() {
+ return false;
+ },
+ popup: {
+ set selectedIndex(aIndex) {},
+ invalidate: function () {},
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompletePopup])
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteInput])
+}
+
+/**
+ * Checks that autocomplete results are ordered correctly.
+ */
+function ensure_results(expected, searchTerm)
+{
+ let controller = Cc["@mozilla.org/autocomplete/controller;1"].
+ getService(Ci.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete.
+ let input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ input.onSearchComplete = function() {
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+ do_check_eq(controller.matchCount, expected.length);
+ for (let i = 0; i < controller.matchCount; i++) {
+ print("Testing for '" + expected[i].uri.spec + "' got '" + controller.getValueAt(i) + "'");
+ do_check_eq(controller.getValueAt(i), expected[i].uri.spec);
+ do_check_eq(controller.getStyleAt(i), expected[i].style);
+ }
+
+ deferEnsureResults.resolve();
+ };
+
+ controller.startSearch(searchTerm);
+}
+
+/**
+ * Asynchronous task that bumps up the rank for an uri.
+ */
+function* task_setCountRank(aURI, aCount, aRank, aSearch, aBookmark)
+{
+ // Bump up the visit count for the uri.
+ let visits = [];
+ for (let i = 0; i < aCount; i++) {
+ visits.push({ uri: aURI, visitDate: d1, transition: TRANSITION_TYPED });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+
+ // Make a nsIAutoCompleteController and friends for instrumentation feedback.
+ let thing = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteInput,
+ Ci.nsIAutoCompletePopup,
+ Ci.nsIAutoCompleteController]),
+ get popup() {
+ return thing;
+ },
+ get controller() {
+ return thing;
+ },
+ popupOpen: true,
+ selectedIndex: 0,
+ getValueAt: function() {
+ return aURI.spec;
+ },
+ searchString: aSearch
+ };
+
+ // Bump up the instrumentation feedback.
+ for (let i = 0; i < aRank; i++) {
+ Services.obs.notifyObservers(thing, "autocomplete-will-enter-text", null);
+ }
+
+ // If this is supposed to be a bookmark, add it.
+ if (aBookmark) {
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ aURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test_book");
+
+ // And add the tag if we need to.
+ if (aBookmark == "tag") {
+ PlacesUtils.tagging.tagURI(aURI, ["test_tag"]);
+ }
+ }
+}
+
+/**
+ * Decay the adaptive entries by sending the daily idle topic.
+ */
+function doAdaptiveDecay()
+{
+ PlacesUtils.history.runInBatchMode({
+ runBatched: function() {
+ for (let i = 0; i < 10; i++) {
+ PlacesUtils.history.QueryInterface(Ci.nsIObserver)
+ .observe(null, "idle-daily", null);
+ }
+ }
+ }, this);
+}
+
+var uri1 = uri("http://site.tld/1");
+var uri2 = uri("http://site.tld/2");
+
+// d1 is some date for the page visit
+var d1 = new Date(Date.now() - 1000 * 60 * 60) * 1000;
+// c1 is larger (should show up higher) than c2
+var c1 = 10;
+var c2 = 1;
+// s1 is a partial match of s2
+var s0 = "";
+var s1 = "si";
+var s2 = "site";
+
+var observer = {
+ results: null,
+ search: null,
+ runCount: -1,
+ observe: function(aSubject, aTopic, aData)
+ {
+ if (--this.runCount > 0)
+ return;
+ ensure_results(this.results, this.search);
+ }
+};
+Services.obs.addObserver(observer, PlacesUtils.TOPIC_FEEDBACK_UPDATED, false);
+
+/**
+ * Make the result object for a given URI that will be passed to ensure_results.
+ */
+function makeResult(aURI, aStyle = "favicon") {
+ return {
+ uri: aURI,
+ style: aStyle,
+ };
+}
+
+var tests = [
+ // Test things without a search term.
+ function*() {
+ print("Test 0 same count, diff rank, same term; no search");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c1, s2);
+ yield task_setCountRank(uri2, c1, c2, s2);
+ },
+ function*() {
+ print("Test 1 same count, diff rank, same term; no search");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c2, s2);
+ yield task_setCountRank(uri2, c1, c1, s2);
+ },
+ function*() {
+ print("Test 2 diff count, same rank, same term; no search");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri1, c1, c1, s2);
+ yield task_setCountRank(uri2, c2, c1, s2);
+ },
+ function*() {
+ print("Test 3 diff count, same rank, same term; no search");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri1, c2, c1, s2);
+ yield task_setCountRank(uri2, c1, c1, s2);
+ },
+
+ // Test things with a search term (exact match one, partial other).
+ function*() {
+ print("Test 4 same count, same rank, diff term; one exact/one partial search");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri1, c1, c1, s1);
+ yield task_setCountRank(uri2, c1, c1, s2);
+ },
+ function*() {
+ print("Test 5 same count, same rank, diff term; one exact/one partial search");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri1, c1, c1, s2);
+ yield task_setCountRank(uri2, c1, c1, s1);
+ },
+
+ // Test things with a search term (exact match both).
+ function*() {
+ print("Test 6 same count, diff rank, same term; both exact search");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c1, s1);
+ yield task_setCountRank(uri2, c1, c2, s1);
+ },
+ function*() {
+ print("Test 7 same count, diff rank, same term; both exact search");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c2, s1);
+ yield task_setCountRank(uri2, c1, c1, s1);
+ },
+
+ // Test things with a search term (partial match both).
+ function*() {
+ print("Test 8 same count, diff rank, same term; both partial search");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c1, s2);
+ yield task_setCountRank(uri2, c1, c2, s2);
+ },
+ function*() {
+ print("Test 9 same count, diff rank, same term; both partial search");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c2, s2);
+ yield task_setCountRank(uri2, c1, c1, s2);
+ },
+ function*() {
+ print("Test 10 same count, same rank, same term, decay first; exact match");
+ observer.results = [
+ makeResult(uri2),
+ makeResult(uri1),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri1, c1, c1, s1);
+ doAdaptiveDecay();
+ yield task_setCountRank(uri2, c1, c1, s1);
+ },
+ function*() {
+ print("Test 11 same count, same rank, same term, decay second; exact match");
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s1;
+ observer.runCount = c1 + c1;
+ yield task_setCountRank(uri2, c1, c1, s1);
+ doAdaptiveDecay();
+ yield task_setCountRank(uri1, c1, c1, s1);
+ },
+ // Test that bookmarks are hidden if the preferences are set right.
+ function*() {
+ print("Test 12 same count, diff rank, same term; no search; history only");
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false);
+ observer.results = [
+ makeResult(uri1),
+ makeResult(uri2),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c1, s2, "bookmark");
+ yield task_setCountRank(uri2, c1, c2, s2);
+ },
+ // Test that tags are shown if the preferences are set right.
+ function*() {
+ print("Test 13 same count, diff rank, same term; no search; history only with tag");
+ Services.prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false);
+ Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false);
+ observer.results = [
+ makeResult(uri1, "tag"),
+ makeResult(uri2),
+ ];
+ observer.search = s0;
+ observer.runCount = c1 + c2;
+ yield task_setCountRank(uri1, c1, c1, s2, "tag");
+ yield task_setCountRank(uri2, c1, c2, s2);
+ },
+];
+
+/**
+ * This deferred object contains a promise that is resolved when the
+ * ensure_results function has finished its execution.
+ */
+var deferEnsureResults;
+
+/**
+ * Test adaptive autocomplete.
+ */
+add_task(function* test_adaptive()
+{
+ // Disable autoFill for this test.
+ Services.prefs.setBoolPref("browser.urlbar.autoFill", false);
+ do_register_cleanup(() => Services.prefs.clearUserPref("browser.urlbar.autoFill"));
+ for (let test of tests) {
+ // Cleanup.
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId);
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.tagsFolderId);
+ observer.runCount = -1;
+
+ let types = ["history", "bookmark", "openpage"];
+ for (let type of types) {
+ Services.prefs.clearUserPref("browser.urlbar.suggest." + type);
+ }
+
+ yield PlacesTestUtils.clearHistory();
+
+ deferEnsureResults = Promise.defer();
+ yield test();
+ yield deferEnsureResults.promise;
+ }
+
+ Services.obs.removeObserver(observer, PlacesUtils.TOPIC_FEEDBACK_UPDATED);
+});
diff --git a/toolkit/components/places/tests/unit/test_adaptive_bug527311.js b/toolkit/components/places/tests/unit/test_adaptive_bug527311.js
new file mode 100644
index 000000000..024553bba
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_adaptive_bug527311.js
@@ -0,0 +1,141 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_URL = "http://adapt.mozilla.org/";
+const SEARCH_STRING = "adapt";
+const SUGGEST_TYPES = ["history", "bookmark", "openpage"];
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+var os = Cc["@mozilla.org/observer-service;1"].
+ getService(Ci.nsIObserverService);
+var ps = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+
+const PLACES_AUTOCOMPLETE_FEEDBACK_UPDATED_TOPIC =
+ "places-autocomplete-feedback-updated";
+
+function cleanup() {
+ for (let type of SUGGEST_TYPES) {
+ ps.clearUserPref("browser.urlbar.suggest." + type);
+ }
+}
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+ searches: null,
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function ACI_getSearchAt(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchComplete: function ACI_onSearchComplete() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function() {},
+ invalidate: function() {},
+
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ onSearchBegin: function() {},
+
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+
+function check_results() {
+ let controller = Cc["@mozilla.org/autocomplete/controller;1"].
+ getService(Ci.nsIAutoCompleteController);
+ let input = new AutoCompleteInput(["unifiedcomplete"]);
+ controller.input = input;
+
+ input.onSearchComplete = function() {
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+ do_check_eq(controller.matchCount, 0);
+
+ PlacesUtils.bookmarks.eraseEverything().then(() => {
+ cleanup();
+ do_test_finished();
+ });
+ };
+
+ controller.startSearch(SEARCH_STRING);
+}
+
+
+function addAdaptiveFeedback(aUrl, aSearch, aCallback) {
+ let observer = {
+ observe: function(aSubject, aTopic, aData) {
+ os.removeObserver(observer, PLACES_AUTOCOMPLETE_FEEDBACK_UPDATED_TOPIC);
+ do_timeout(0, aCallback);
+ }
+ };
+ os.addObserver(observer, PLACES_AUTOCOMPLETE_FEEDBACK_UPDATED_TOPIC, false);
+
+ let thing = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteInput,
+ Ci.nsIAutoCompletePopup,
+ Ci.nsIAutoCompleteController]),
+ get popup() { return thing; },
+ get controller() { return thing; },
+ popupOpen: true,
+ selectedIndex: 0,
+ getValueAt: () => aUrl,
+ searchString: aSearch
+ };
+
+ os.notifyObservers(thing, "autocomplete-will-enter-text", null);
+}
+
+
+function run_test() {
+ do_test_pending();
+
+ // Add a bookmark to our url.
+ bs.insertBookmark(bs.unfiledBookmarksFolder, uri(TEST_URL),
+ bs.DEFAULT_INDEX, "test_book");
+ // We want to search only history.
+ for (let type of SUGGEST_TYPES) {
+ type == "history" ? ps.setBoolPref("browser.urlbar.suggest." + type, true)
+ : ps.setBoolPref("browser.urlbar.suggest." + type, false);
+ }
+
+ // Add an adaptive entry.
+ addAdaptiveFeedback(TEST_URL, SEARCH_STRING, check_results);
+}
diff --git a/toolkit/components/places/tests/unit/test_analyze.js b/toolkit/components/places/tests/unit/test_analyze.js
new file mode 100644
index 000000000..456270101
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_analyze.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests sqlite_sta1 table exists, it should be created by analyze.
+// Since the bookmark roots are created when the DB is created (bug 704855),
+// the table will contain data.
+
+function run_test() {
+ do_test_pending();
+
+ let stmt = DBConn().createAsyncStatement(
+ "SELECT ROWID FROM sqlite_stat1"
+ );
+ stmt.executeAsync({
+ _gotResult: false,
+ handleResult: function(aResultSet) {
+ this._gotResult = true;
+ },
+ handleError: function(aError) {
+ do_throw("Unexpected error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function(aReason) {
+ do_check_true(this._gotResult);
+ do_test_finished();
+ }
+ });
+ stmt.finalize();
+}
diff --git a/toolkit/components/places/tests/unit/test_annotations.js b/toolkit/components/places/tests/unit/test_annotations.js
new file mode 100644
index 000000000..a37d7e6c9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_annotations.js
@@ -0,0 +1,363 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get bookmark service
+try {
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].getService(Ci.nsINavBookmarksService);
+} catch (ex) {
+ do_throw("Could not get nav-bookmarks-service\n");
+}
+
+// Get annotation service
+try {
+ var annosvc= Cc["@mozilla.org/browser/annotation-service;1"].getService(Ci.nsIAnnotationService);
+} catch (ex) {
+ do_throw("Could not get annotation service\n");
+}
+
+var annoObserver = {
+ PAGE_lastSet_URI: "",
+ PAGE_lastSet_AnnoName: "",
+
+ onPageAnnotationSet: function(aURI, aName) {
+ this.PAGE_lastSet_URI = aURI.spec;
+ this.PAGE_lastSet_AnnoName = aName;
+ },
+
+ ITEM_lastSet_Id: -1,
+ ITEM_lastSet_AnnoName: "",
+ onItemAnnotationSet: function(aItemId, aName) {
+ this.ITEM_lastSet_Id = aItemId;
+ this.ITEM_lastSet_AnnoName = aName;
+ },
+
+ PAGE_lastRemoved_URI: "",
+ PAGE_lastRemoved_AnnoName: "",
+ onPageAnnotationRemoved: function(aURI, aName) {
+ this.PAGE_lastRemoved_URI = aURI.spec;
+ this.PAGE_lastRemoved_AnnoName = aName;
+ },
+
+ ITEM_lastRemoved_Id: -1,
+ ITEM_lastRemoved_AnnoName: "",
+ onItemAnnotationRemoved: function(aItemId, aName) {
+ this.ITEM_lastRemoved_Id = aItemId;
+ this.ITEM_lastRemoved_AnnoName = aName;
+ }
+};
+
+// main
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ var testURI = uri("http://mozilla.com/");
+ var testItemId = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, testURI, -1, "");
+ var testAnnoName = "moz-test-places/annotations";
+ var testAnnoVal = "test";
+
+ annosvc.addObserver(annoObserver);
+ // create new string annotation
+ try {
+ annosvc.setPageAnnotation(testURI, testAnnoName, testAnnoVal, 0, 0);
+ } catch (ex) {
+ do_throw("unable to add page-annotation");
+ }
+ do_check_eq(annoObserver.PAGE_lastSet_URI, testURI.spec);
+ do_check_eq(annoObserver.PAGE_lastSet_AnnoName, testAnnoName);
+
+ // get string annotation
+ do_check_true(annosvc.pageHasAnnotation(testURI, testAnnoName));
+ var storedAnnoVal = annosvc.getPageAnnotation(testURI, testAnnoName);
+ do_check_true(testAnnoVal === storedAnnoVal);
+ // string item-annotation
+ try {
+ var lastModified = bmsvc.getItemLastModified(testItemId);
+ // Verify that lastModified equals dateAdded before we set the annotation.
+ do_check_eq(lastModified, bmsvc.getItemDateAdded(testItemId));
+ // Workaround possible VM timers issues moving last modified to the past.
+ bmsvc.setItemLastModified(testItemId, --lastModified);
+ annosvc.setItemAnnotation(testItemId, testAnnoName, testAnnoVal, 0, 0);
+ var lastModified2 = bmsvc.getItemLastModified(testItemId);
+ // verify that setting the annotation updates the last modified time
+ do_check_true(lastModified2 > lastModified);
+ } catch (ex) {
+ do_throw("unable to add item annotation");
+ }
+ do_check_eq(annoObserver.ITEM_lastSet_Id, testItemId);
+ do_check_eq(annoObserver.ITEM_lastSet_AnnoName, testAnnoName);
+
+ try {
+ var annoVal = annosvc.getItemAnnotation(testItemId, testAnnoName);
+ // verify the anno value
+ do_check_true(testAnnoVal === annoVal);
+ } catch (ex) {
+ do_throw("unable to get item annotation");
+ }
+
+ // test getPagesWithAnnotation
+ var uri2 = uri("http://www.tests.tld");
+ yield PlacesTestUtils.addVisits(uri2);
+ annosvc.setPageAnnotation(uri2, testAnnoName, testAnnoVal, 0, 0);
+ var pages = annosvc.getPagesWithAnnotation(testAnnoName);
+ do_check_eq(pages.length, 2);
+ // Don't rely on the order
+ do_check_false(pages[0].equals(pages[1]));
+ do_check_true(pages[0].equals(testURI) || pages[1].equals(testURI));
+ do_check_true(pages[0].equals(uri2) || pages[1].equals(uri2));
+
+ // test getItemsWithAnnotation
+ var testItemId2 = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri2, -1, "");
+ annosvc.setItemAnnotation(testItemId2, testAnnoName, testAnnoVal, 0, 0);
+ var items = annosvc.getItemsWithAnnotation(testAnnoName);
+ do_check_eq(items.length, 2);
+ // Don't rely on the order
+ do_check_true(items[0] != items[1]);
+ do_check_true(items[0] == testItemId || items[1] == testItemId);
+ do_check_true(items[0] == testItemId2 || items[1] == testItemId2);
+
+ // get annotation that doesn't exist
+ try {
+ annosvc.getPageAnnotation(testURI, "blah");
+ do_throw("fetching page-annotation that doesn't exist, should've thrown");
+ } catch (ex) {}
+ try {
+ annosvc.getItemAnnotation(testURI, "blah");
+ do_throw("fetching item-annotation that doesn't exist, should've thrown");
+ } catch (ex) {}
+
+ // get annotation info
+ var flags = {}, exp = {}, storageType = {};
+ annosvc.getPageAnnotationInfo(testURI, testAnnoName, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ do_check_eq(storageType.value, Ci.nsIAnnotationService.TYPE_STRING);
+ annosvc.getItemAnnotationInfo(testItemId, testAnnoName, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ do_check_eq(storageType.value, Ci.nsIAnnotationService.TYPE_STRING);
+
+ // get annotation names for a uri
+ var annoNames = annosvc.getPageAnnotationNames(testURI);
+ do_check_eq(annoNames.length, 1);
+ do_check_eq(annoNames[0], "moz-test-places/annotations");
+
+ // get annotation names for an item
+ annoNames = annosvc.getItemAnnotationNames(testItemId);
+ do_check_eq(annoNames.length, 1);
+ do_check_eq(annoNames[0], "moz-test-places/annotations");
+
+ // copy annotations to another uri
+ var newURI = uri("http://mozilla.org");
+ yield PlacesTestUtils.addVisits(newURI);
+ annosvc.setPageAnnotation(testURI, "oldAnno", "new", 0, 0);
+ annosvc.setPageAnnotation(newURI, "oldAnno", "old", 0, 0);
+ annoNames = annosvc.getPageAnnotationNames(newURI);
+ do_check_eq(annoNames.length, 1);
+ do_check_eq(annoNames[0], "oldAnno");
+ var oldAnnoNames = annosvc.getPageAnnotationNames(testURI);
+ do_check_eq(oldAnnoNames.length, 2);
+ var copiedAnno = oldAnnoNames[0];
+ annosvc.copyPageAnnotations(testURI, newURI, false);
+ var newAnnoNames = annosvc.getPageAnnotationNames(newURI);
+ do_check_eq(newAnnoNames.length, 2);
+ do_check_true(annosvc.pageHasAnnotation(newURI, "oldAnno"));
+ do_check_true(annosvc.pageHasAnnotation(newURI, copiedAnno));
+ do_check_eq(annosvc.getPageAnnotation(newURI, "oldAnno"), "old");
+ annosvc.setPageAnnotation(newURI, "oldAnno", "new", 0, 0);
+ annosvc.copyPageAnnotations(testURI, newURI, true);
+ newAnnoNames = annosvc.getPageAnnotationNames(newURI);
+ do_check_eq(newAnnoNames.length, 2);
+ do_check_true(annosvc.pageHasAnnotation(newURI, "oldAnno"));
+ do_check_true(annosvc.pageHasAnnotation(newURI, copiedAnno));
+ do_check_eq(annosvc.getPageAnnotation(newURI, "oldAnno"), "new");
+
+
+ // copy annotations to another item
+ newURI = uri("http://mozilla.org");
+ var newItemId = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, newURI, -1, "");
+ var itemId = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, testURI, -1, "");
+ annosvc.setItemAnnotation(itemId, "oldAnno", "new", 0, 0);
+ annosvc.setItemAnnotation(itemId, "testAnno", "test", 0, 0);
+ annosvc.setItemAnnotation(newItemId, "oldAnno", "old", 0, 0);
+ annoNames = annosvc.getItemAnnotationNames(newItemId);
+ do_check_eq(annoNames.length, 1);
+ do_check_eq(annoNames[0], "oldAnno");
+ oldAnnoNames = annosvc.getItemAnnotationNames(itemId);
+ do_check_eq(oldAnnoNames.length, 2);
+ copiedAnno = oldAnnoNames[0];
+ annosvc.copyItemAnnotations(itemId, newItemId, false);
+ newAnnoNames = annosvc.getItemAnnotationNames(newItemId);
+ do_check_eq(newAnnoNames.length, 2);
+ do_check_true(annosvc.itemHasAnnotation(newItemId, "oldAnno"));
+ do_check_true(annosvc.itemHasAnnotation(newItemId, copiedAnno));
+ do_check_eq(annosvc.getItemAnnotation(newItemId, "oldAnno"), "old");
+ annosvc.setItemAnnotation(newItemId, "oldAnno", "new", 0, 0);
+ annosvc.copyItemAnnotations(itemId, newItemId, true);
+ newAnnoNames = annosvc.getItemAnnotationNames(newItemId);
+ do_check_eq(newAnnoNames.length, 2);
+ do_check_true(annosvc.itemHasAnnotation(newItemId, "oldAnno"));
+ do_check_true(annosvc.itemHasAnnotation(newItemId, copiedAnno));
+ do_check_eq(annosvc.getItemAnnotation(newItemId, "oldAnno"), "new");
+
+ // test int32 anno type
+ var int32Key = testAnnoName + "/types/Int32";
+ var int32Val = 23;
+ annosvc.setPageAnnotation(testURI, int32Key, int32Val, 0, 0);
+ do_check_true(annosvc.pageHasAnnotation(testURI, int32Key));
+ flags = {}, exp = {}, storageType = {};
+ annosvc.getPageAnnotationInfo(testURI, int32Key, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ do_check_eq(storageType.value, Ci.nsIAnnotationService.TYPE_INT32);
+ var storedVal = annosvc.getPageAnnotation(testURI, int32Key);
+ do_check_true(int32Val === storedVal);
+ annosvc.setItemAnnotation(testItemId, int32Key, int32Val, 0, 0);
+ do_check_true(annosvc.itemHasAnnotation(testItemId, int32Key));
+ annosvc.getItemAnnotationInfo(testItemId, int32Key, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ storedVal = annosvc.getItemAnnotation(testItemId, int32Key);
+ do_check_true(int32Val === storedVal);
+
+ // test int64 anno type
+ var int64Key = testAnnoName + "/types/Int64";
+ var int64Val = 4294967296;
+ annosvc.setPageAnnotation(testURI, int64Key, int64Val, 0, 0);
+ annosvc.getPageAnnotationInfo(testURI, int64Key, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ storedVal = annosvc.getPageAnnotation(testURI, int64Key);
+ do_check_true(int64Val === storedVal);
+ annosvc.setItemAnnotation(testItemId, int64Key, int64Val, 0, 0);
+ do_check_true(annosvc.itemHasAnnotation(testItemId, int64Key));
+ annosvc.getItemAnnotationInfo(testItemId, int64Key, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ storedVal = annosvc.getItemAnnotation(testItemId, int64Key);
+ do_check_true(int64Val === storedVal);
+
+ // test double anno type
+ var doubleKey = testAnnoName + "/types/Double";
+ var doubleVal = 0.000002342;
+ annosvc.setPageAnnotation(testURI, doubleKey, doubleVal, 0, 0);
+ annosvc.getPageAnnotationInfo(testURI, doubleKey, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ storedVal = annosvc.getPageAnnotation(testURI, doubleKey);
+ do_check_true(doubleVal === storedVal);
+ annosvc.setItemAnnotation(testItemId, doubleKey, doubleVal, 0, 0);
+ do_check_true(annosvc.itemHasAnnotation(testItemId, doubleKey));
+ annosvc.getItemAnnotationInfo(testItemId, doubleKey, flags, exp, storageType);
+ do_check_eq(flags.value, 0);
+ do_check_eq(exp.value, 0);
+ do_check_eq(storageType.value, Ci.nsIAnnotationService.TYPE_DOUBLE);
+ storedVal = annosvc.getItemAnnotation(testItemId, doubleKey);
+ do_check_true(doubleVal === storedVal);
+
+ // test annotation removal
+ annosvc.removePageAnnotation(testURI, int32Key);
+
+ annosvc.setItemAnnotation(testItemId, testAnnoName, testAnnoVal, 0, 0);
+ // verify that removing an annotation updates the last modified date
+ var lastModified3 = bmsvc.getItemLastModified(testItemId);
+ // Workaround possible VM timers issues moving last modified to the past.
+ bmsvc.setItemLastModified(testItemId, --lastModified3);
+ annosvc.removeItemAnnotation(testItemId, int32Key);
+ var lastModified4 = bmsvc.getItemLastModified(testItemId);
+ do_print("verify that removing an annotation updates the last modified date");
+ do_print("lastModified3 = " + lastModified3);
+ do_print("lastModified4 = " + lastModified4);
+ do_check_true(lastModified4 > lastModified3);
+
+ do_check_eq(annoObserver.PAGE_lastRemoved_URI, testURI.spec);
+ do_check_eq(annoObserver.PAGE_lastRemoved_AnnoName, int32Key);
+ do_check_eq(annoObserver.ITEM_lastRemoved_Id, testItemId);
+ do_check_eq(annoObserver.ITEM_lastRemoved_AnnoName, int32Key);
+
+ // test that getItems/PagesWithAnnotation returns an empty array after
+ // removing all items/pages which had the annotation set, see bug 380317.
+ do_check_eq(annosvc.getItemsWithAnnotation(int32Key).length, 0);
+ do_check_eq(annosvc.getPagesWithAnnotation(int32Key).length, 0);
+
+ // Setting item annotations on invalid item ids should throw
+ var invalidIds = [-1, 0, 37643];
+ for (var id of invalidIds) {
+ try {
+ annosvc.setItemAnnotation(id, "foo", "bar", 0, 0);
+ do_throw("setItemAnnotation* should throw for invalid item id: " + id)
+ }
+ catch (ex) { }
+ }
+
+ // setting an annotation with EXPIRE_HISTORY for an item should throw
+ itemId = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, testURI, -1, "");
+ try {
+ annosvc.setItemAnnotation(itemId, "foo", "bar", 0, annosvc.EXPIRE_WITH_HISTORY);
+ do_throw("setting an item annotation with EXPIRE_HISTORY should throw");
+ }
+ catch (ex) {
+ }
+
+ annosvc.removeObserver(annoObserver);
+});
+
+add_test(function test_getAnnotationsHavingName() {
+ let uri = NetUtil.newURI("http://cat.mozilla.org");
+ let id = PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "cat");
+ let fid = PlacesUtils.bookmarks.createFolder(
+ PlacesUtils.unfiledBookmarksFolderId, "pillow",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+
+ const ANNOS = {
+ "int": 7,
+ "double": 7.7,
+ "string": "seven"
+ };
+ for (let name in ANNOS) {
+ PlacesUtils.annotations.setPageAnnotation(
+ uri, name, ANNOS[name], 0,
+ PlacesUtils.annotations.EXPIRE_SESSION);
+ PlacesUtils.annotations.setItemAnnotation(
+ id, name, ANNOS[name], 0,
+ PlacesUtils.annotations.EXPIRE_SESSION);
+ PlacesUtils.annotations.setItemAnnotation(
+ fid, name, ANNOS[name], 0,
+ PlacesUtils.annotations.EXPIRE_SESSION);
+ }
+
+ for (let name in ANNOS) {
+ let results = PlacesUtils.annotations.getAnnotationsWithName(name);
+ do_check_eq(results.length, 3);
+
+ for (let result of results) {
+ do_check_eq(result.annotationName, name);
+ do_check_eq(result.annotationValue, ANNOS[name]);
+ if (result.uri)
+ do_check_true(result.uri.equals(uri));
+ else
+ do_check_true(result.itemId > 0);
+
+ if (result.itemId != -1) {
+ if (result.uri)
+ do_check_eq(result.itemId, id);
+ else
+ do_check_eq(result.itemId, fid);
+ do_check_guid_for_bookmark(result.itemId, result.guid);
+ }
+ else {
+ do_check_guid_for_uri(result.uri, result.guid);
+ }
+ }
+ }
+
+ run_next_test();
+});
diff --git a/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js b/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js
new file mode 100644
index 000000000..7296fe061
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_asyncExecuteLegacyQueries.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This is a test for asyncExecuteLegacyQueries API.
+
+var tests = [
+
+function test_history_query() {
+ let uri = NetUtil.newURI("http://test.visit.mozilla.com/");
+ let title = "Test visit";
+ PlacesTestUtils.addVisits({ uri: uri, title: title }).then(function () {
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
+ let query = PlacesUtils.history.getNewQuery();
+
+ PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .asyncExecuteLegacyQueries([query], 1, options, {
+ handleResult: function (aResultSet) {
+ for (let row; (row = aResultSet.getNextRow());) {
+ try {
+ do_check_eq(row.getResultByIndex(1), uri.spec);
+ do_check_eq(row.getResultByIndex(2), title);
+ } catch (e) {
+ do_throw("Error while fetching page data.");
+ }
+ }
+ },
+ handleError: function (aError) {
+ do_throw("Async execution error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function (aReason) {
+ run_next_test();
+ },
+ });
+ });
+},
+
+function test_bookmarks_query() {
+ let uri = NetUtil.newURI("http://test.bookmark.mozilla.com/");
+ let title = "Test bookmark";
+ bookmark(uri, title);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_LASMODIFIED_DESCENDING;
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let query = PlacesUtils.history.getNewQuery();
+
+ PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
+ .asyncExecuteLegacyQueries([query], 1, options, {
+ handleResult: function (aResultSet) {
+ for (let row; (row = aResultSet.getNextRow());) {
+ try {
+ do_check_eq(row.getResultByIndex(1), uri.spec);
+ do_check_eq(row.getResultByIndex(2), title);
+ } catch (e) {
+ do_throw("Error while fetching page data.");
+ }
+ }
+ },
+ handleError: function (aError) {
+ do_throw("Async execution error (" + aError.result + "): " + aError.message);
+ },
+ handleCompletion: function (aReason) {
+ run_next_test();
+ },
+ });
+},
+
+];
+
+function bookmark(aURI, aTitle)
+{
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ aURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ aTitle);
+}
+
+function run_test()
+{
+ do_test_pending();
+ run_next_test();
+}
+
+function run_next_test() {
+ if (tests.length == 0) {
+ do_test_finished();
+ return;
+ }
+
+ Promise.all([
+ PlacesTestUtils.clearHistory(),
+ PlacesUtils.bookmarks.eraseEverything()
+ ]).then(tests.shift());
+}
diff --git a/toolkit/components/places/tests/unit/test_async_history_api.js b/toolkit/components/places/tests/unit/test_async_history_api.js
new file mode 100644
index 000000000..a012fcda2
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_async_history_api.js
@@ -0,0 +1,1118 @@
+/**
+ * This file tests the async history API exposed by mozIAsyncHistory.
+ */
+
+// Globals
+
+const TEST_DOMAIN = "http://mozilla.org/";
+const URI_VISIT_SAVED = "uri-visit-saved";
+const RECENT_EVENT_THRESHOLD = 15 * 60 * 1000000;
+
+// Helpers
+/**
+ * Object that represents a mozIVisitInfo object.
+ *
+ * @param [optional] aTransitionType
+ * The transition type of the visit. Defaults to TRANSITION_LINK if not
+ * provided.
+ * @param [optional] aVisitTime
+ * The time of the visit. Defaults to now if not provided.
+ */
+function VisitInfo(aTransitionType,
+ aVisitTime) {
+ this.transitionType =
+ aTransitionType === undefined ? TRANSITION_LINK : aTransitionType;
+ this.visitDate = aVisitTime || Date.now() * 1000;
+}
+
+function promiseUpdatePlaces(aPlaces) {
+ return new Promise((resolve, reject) => {
+ PlacesUtils.asyncHistory.updatePlaces(aPlaces, {
+ _errors: [],
+ _results: [],
+ handleError(aResultCode, aPlace) {
+ this._errors.push({ resultCode: aResultCode, info: aPlace});
+ },
+ handleResult(aPlace) {
+ this._results.push(aPlace);
+ },
+ handleCompletion() {
+ resolve({ errors: this._errors, results: this._results });
+ }
+ });
+ });
+}
+
+/**
+ * Listens for a title change notification, and calls aCallback when it gets it.
+ *
+ * @param aURI
+ * The URI of the page we expect a notification for.
+ * @param aExpectedTitle
+ * The expected title of the URI we expect a notification for.
+ * @param aCallback
+ * The method to call when we have gotten the proper notification about
+ * the title changing.
+ */
+function TitleChangedObserver(aURI,
+ aExpectedTitle,
+ aCallback) {
+ this.uri = aURI;
+ this.expectedTitle = aExpectedTitle;
+ this.callback = aCallback;
+}
+TitleChangedObserver.prototype = {
+ __proto__: NavHistoryObserver.prototype,
+ onTitleChanged(aURI, aTitle, aGUID) {
+ do_print("onTitleChanged(" + aURI.spec + ", " + aTitle + ", " + aGUID + ")");
+ if (!this.uri.equals(aURI)) {
+ return;
+ }
+ do_check_eq(aTitle, this.expectedTitle);
+ do_check_guid_for_uri(aURI, aGUID);
+ this.callback();
+ },
+};
+
+/**
+ * Listens for a visit notification, and calls aCallback when it gets it.
+ *
+ * @param aURI
+ * The URI of the page we expect a notification for.
+ * @param aCallback
+ * The method to call when we have gotten the proper notification about
+ * being visited.
+ */
+function VisitObserver(aURI,
+ aGUID,
+ aCallback)
+{
+ this.uri = aURI;
+ this.guid = aGUID;
+ this.callback = aCallback;
+}
+VisitObserver.prototype = {
+ __proto__: NavHistoryObserver.prototype,
+ onVisit: function(aURI,
+ aVisitId,
+ aTime,
+ aSessionId,
+ aReferringId,
+ aTransitionType,
+ aGUID)
+ {
+ do_print("onVisit(" + aURI.spec + ", " + aVisitId + ", " + aTime +
+ ", " + aSessionId + ", " + aReferringId + ", " +
+ aTransitionType + ", " + aGUID + ")");
+ if (!this.uri.equals(aURI) || this.guid != aGUID) {
+ return;
+ }
+ this.callback(aTime, aTransitionType);
+ },
+};
+
+/**
+ * Tests that a title was set properly in the database.
+ *
+ * @param aURI
+ * The uri to check.
+ * @param aTitle
+ * The expected title in the database.
+ */
+function do_check_title_for_uri(aURI,
+ aTitle)
+{
+ let stack = Components.stack.caller;
+ let stmt = DBConn().createStatement(
+ `SELECT title
+ FROM moz_places
+ WHERE url_hash = hash(:url) AND url = :url`
+ );
+ stmt.params.url = aURI.spec;
+ do_check_true(stmt.executeStep(), stack);
+ do_check_eq(stmt.row.title, aTitle, stack);
+ stmt.finalize();
+}
+
+// Test Functions
+
+add_task(function* test_interface_exists() {
+ let history = Cc["@mozilla.org/browser/history;1"].getService(Ci.nsISupports);
+ do_check_true(history instanceof Ci.mozIAsyncHistory);
+});
+
+add_task(function* test_invalid_uri_throws() {
+ // First, test passing in nothing.
+ let place = {
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now, test other bogus things.
+ const TEST_VALUES = [
+ null,
+ undefined,
+ {},
+ [],
+ TEST_DOMAIN + "test_invalid_id_throws",
+ ];
+ for (let i = 0; i < TEST_VALUES.length; i++) {
+ place.uri = TEST_VALUES[i];
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+});
+
+add_task(function* test_invalid_places_throws() {
+ // First, test passing in nothing.
+ try {
+ PlacesUtils.asyncHistory.updatePlaces();
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_XPC_NOT_ENOUGH_ARGS);
+ }
+
+ // Now, test other bogus things.
+ const TEST_VALUES = [
+ null,
+ undefined,
+ {},
+ [],
+ "",
+ ];
+ for (let i = 0; i < TEST_VALUES.length; i++) {
+ let value = TEST_VALUES[i];
+ try {
+ yield promiseUpdatePlaces(value);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+});
+
+add_task(function* test_invalid_guid_throws() {
+ // First check invalid length guid.
+ let place = {
+ guid: "BAD_GUID",
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_invalid_guid_throws"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now check invalid character guid.
+ place.guid = "__BADGUID+__";
+ do_check_eq(place.guid.length, 12);
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_no_visits_throws() {
+ const TEST_URI =
+ NetUtil.newURI(TEST_DOMAIN + "test_no_id_or_guid_no_visits_throws");
+ const TEST_GUID = "_RANDOMGUID_";
+
+ let log_test_conditions = function(aPlace) {
+ let str = "Testing place with " +
+ (aPlace.uri ? "uri" : "no uri") + ", " +
+ (aPlace.guid ? "guid" : "no guid") + ", " +
+ (aPlace.visits ? "visits array" : "no visits array");
+ do_print(str);
+ };
+
+ // Loop through every possible case. Note that we don't actually care about
+ // the case where we have no uri, place id, or guid (covered by another test),
+ // but it is easier to just make sure it too throws than to exclude it.
+ let place = { };
+ for (let uri = 1; uri >= 0; uri--) {
+ place.uri = uri ? TEST_URI : undefined;
+
+ for (let guid = 1; guid >= 0; guid--) {
+ place.guid = guid ? TEST_GUID : undefined;
+
+ for (let visits = 1; visits >= 0; visits--) {
+ place.visits = visits ? [] : undefined;
+
+ log_test_conditions(place);
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+ }
+ }
+ }
+});
+
+add_task(function* test_add_visit_no_date_throws() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit_no_date_throws"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ delete place.visits[0].visitDate;
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_add_visit_no_transitionType_throws() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit_no_transitionType_throws"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ delete place.visits[0].transitionType;
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_add_visit_invalid_transitionType_throws() {
+ // First, test something that has a transition type lower than the first one.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN +
+ "test_add_visit_invalid_transitionType_throws"),
+ visits: [
+ new VisitInfo(TRANSITION_LINK - 1),
+ ],
+ };
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ // Now, test something that has a transition type greater than the last one.
+ place.visits[0] = new VisitInfo(TRANSITION_RELOAD + 1);
+ try {
+ yield promiseUpdatePlaces(place);
+ do_throw("Should have thrown!");
+ }
+ catch (e) {
+ do_check_eq(e.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_non_addable_uri_errors() {
+ // Array of protocols that nsINavHistoryService::canAddURI returns false for.
+ const URLS = [
+ "about:config",
+ "imap://cyrus.andrew.cmu.edu/archive.imap",
+ "news://new.mozilla.org/mozilla.dev.apps.firefox",
+ "mailbox:Inbox",
+ "moz-anno:favicon:http://mozilla.org/made-up-favicon",
+ "view-source:http://mozilla.org",
+ "chrome://browser/content/browser.xul",
+ "resource://gre-resources/hiddenWindow.html",
+ "data:,Hello%2C%20World!",
+ "wyciwyg:/0/http://mozilla.org",
+ "javascript:alert('hello wolrd!');",
+ "blob:foo",
+ ];
+ let places = [];
+ URLS.forEach(function(url) {
+ try {
+ let place = {
+ uri: NetUtil.newURI(url),
+ title: "test for " + url,
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ places.push(place);
+ }
+ catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ // NetUtil.newURI() can throw if e.g. our app knows about imap://
+ // but the account is not set up and so the URL is invalid for us.
+ // Note this in the log but ignore as it's not the subject of this test.
+ do_print("Could not construct URI for '" + url + "'; ignoring");
+ }
+ });
+
+ let placesResult = yield promiseUpdatePlaces(places);
+ if (placesResult.results.length > 0) {
+ do_throw("Unexpected success.");
+ }
+ for (let place of placesResult.errors) {
+ do_print("Checking '" + place.info.uri.spec + "'");
+ do_check_eq(place.resultCode, Cr.NS_ERROR_INVALID_ARG);
+ do_check_false(yield promiseIsURIVisited(place.info.uri));
+ }
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_duplicate_guid_errors() {
+ // This test ensures that trying to add a visit, with a guid already found in
+ // another visit, fails.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_first"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+
+ do_check_false(yield promiseIsURIVisited(place.uri));
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ do_check_true(yield promiseIsURIVisited(placeInfo.uri));
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_duplicate_guid_fails_second"),
+ visits: [
+ new VisitInfo(),
+ ],
+ guid: placeInfo.guid,
+ };
+
+ do_check_false(yield promiseIsURIVisited(badPlace.uri));
+ placesResult = yield promiseUpdatePlaces(badPlace);
+ if (placesResult.results.length > 0) {
+ do_throw("Unexpected success.");
+ }
+ let badPlaceInfo = placesResult.errors[0];
+ do_check_eq(badPlaceInfo.resultCode, Cr.NS_ERROR_STORAGE_CONSTRAINT);
+ do_check_false(yield promiseIsURIVisited(badPlaceInfo.info.uri));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_invalid_referrerURI_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN +
+ "test_invalid_referrerURI_ignored"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ place.visits[0].referrerURI = NetUtil.newURI(place.uri.spec + "_unvisistedURI");
+ do_check_false(yield promiseIsURIVisited(place.uri));
+ do_check_false(yield promiseIsURIVisited(place.visits[0].referrerURI));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ do_check_true(yield promiseIsURIVisited(placeInfo.uri));
+
+ // Check to make sure we do not visit the invalid referrer.
+ do_check_false(yield promiseIsURIVisited(place.visits[0].referrerURI));
+
+ // Check to make sure from_visit is zero in database.
+ let stmt = DBConn().createStatement(
+ `SELECT from_visit
+ FROM moz_historyvisits
+ WHERE id = :visit_id`
+ );
+ stmt.params.visit_id = placeInfo.visits[0].visitId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.from_visit, 0);
+ stmt.finalize();
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_nonnsIURI_referrerURI_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN +
+ "test_nonnsIURI_referrerURI_ignored"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ place.visits[0].referrerURI = place.uri.spec + "_nonnsIURI";
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ do_check_true(yield promiseIsURIVisited(placeInfo.uri));
+
+ // Check to make sure from_visit is zero in database.
+ let stmt = DBConn().createStatement(
+ `SELECT from_visit
+ FROM moz_historyvisits
+ WHERE id = :visit_id`
+ );
+ stmt.params.visit_id = placeInfo.visits[0].visitId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.from_visit, 0);
+ stmt.finalize();
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_old_referrer_ignored() {
+ // This tests that a referrer for a visit which is not recent (specifically,
+ // older than 15 minutes as per RECENT_EVENT_THRESHOLD) is not saved by
+ // updatePlaces.
+ let oldTime = (Date.now() * 1000) - (RECENT_EVENT_THRESHOLD + 1);
+ let referrerPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_referrer"),
+ visits: [
+ new VisitInfo(TRANSITION_LINK, oldTime),
+ ],
+ };
+
+ // First we must add our referrer to the history so that it is not ignored
+ // as being invalid.
+ do_check_false(yield promiseIsURIVisited(referrerPlace.uri));
+ let placesResult = yield promiseUpdatePlaces(referrerPlace);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+
+ // Now that the referrer is added, we can add a page with a valid
+ // referrer to determine if the recency of the referrer is taken into
+ // account.
+ do_check_true(yield promiseIsURIVisited(referrerPlace.uri));
+
+ let visitInfo = new VisitInfo();
+ visitInfo.referrerURI = referrerPlace.uri;
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_old_referrer_ignored_page"),
+ visits: [
+ visitInfo,
+ ],
+ };
+
+ do_check_false(yield promiseIsURIVisited(place.uri));
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ do_check_true(yield promiseIsURIVisited(place.uri));
+
+ // Though the visit will not contain the referrer, we must examine the
+ // database to be sure.
+ do_check_eq(placeInfo.visits[0].referrerURI, null);
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:page_url) AND url = :page_url
+ AND from_visit = 0`
+ );
+ stmt.params.page_url = place.uri.spec;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.count, 1);
+ stmt.finalize();
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_place_id_ignored() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_first"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+
+ do_check_false(yield promiseIsURIVisited(place.uri));
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ do_check_true(yield promiseIsURIVisited(place.uri));
+
+ let placeId = placeInfo.placeId;
+ do_check_neq(placeId, 0);
+
+ let badPlace = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_place_id_ignored_second"),
+ visits: [
+ new VisitInfo(),
+ ],
+ placeId: placeId,
+ };
+
+ do_check_false(yield promiseIsURIVisited(badPlace.uri));
+ placesResult = yield promiseUpdatePlaces(badPlace);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ placeInfo = placesResult.results[0];
+
+ do_check_neq(placeInfo.placeId, placeId);
+ do_check_true(yield promiseIsURIVisited(badPlace.uri));
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_handleCompletion_called_when_complete() {
+ // We test a normal visit, and embeded visit, and a uri that would fail
+ // the canAddURI test to make sure that the notification happens after *all*
+ // of them have had a callback.
+ let places = [
+ { uri: NetUtil.newURI(TEST_DOMAIN +
+ "test_handleCompletion_called_when_complete"),
+ visits: [
+ new VisitInfo(),
+ new VisitInfo(TRANSITION_EMBED),
+ ],
+ },
+ { uri: NetUtil.newURI("data:,Hello%2C%20World!"),
+ visits: [
+ new VisitInfo(),
+ ],
+ },
+ ];
+ do_check_false(yield promiseIsURIVisited(places[0].uri));
+ do_check_false(yield promiseIsURIVisited(places[1].uri));
+
+ const EXPECTED_COUNT_SUCCESS = 2;
+ const EXPECTED_COUNT_FAILURE = 1;
+
+ let {results, errors} = yield promiseUpdatePlaces(places);
+
+ do_check_eq(results.length, EXPECTED_COUNT_SUCCESS);
+ do_check_eq(errors.length, EXPECTED_COUNT_FAILURE);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_add_visit() {
+ const VISIT_TIME = Date.now() * 1000;
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_add_visit"),
+ title: "test_add_visit title",
+ visits: [],
+ };
+ for (let t in PlacesUtils.history.TRANSITIONS) {
+ let transitionType = PlacesUtils.history.TRANSITIONS[t];
+ place.visits.push(new VisitInfo(transitionType, VISIT_TIME));
+ }
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let callbackCount = 0;
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ do_check_true(yield promiseIsURIVisited(place.uri));
+
+ // Check mozIPlaceInfo properties.
+ do_check_true(place.uri.equals(placeInfo.uri));
+ do_check_eq(placeInfo.frecency, -1); // We don't pass frecency here!
+ do_check_eq(placeInfo.title, place.title);
+
+ // Check mozIVisitInfo properties.
+ let visits = placeInfo.visits;
+ do_check_eq(visits.length, 1);
+ let visit = visits[0];
+ do_check_eq(visit.visitDate, VISIT_TIME);
+ do_check_true(Object.values(PlacesUtils.history.TRANSITIONS).includes(visit.transitionType));
+ do_check_true(visit.referrerURI === null);
+
+ // For TRANSITION_EMBED visits, many properties will always be zero or
+ // undefined.
+ if (visit.transitionType == TRANSITION_EMBED) {
+ // Check mozIPlaceInfo properties.
+ do_check_eq(placeInfo.placeId, 0, '//');
+ do_check_eq(placeInfo.guid, null);
+
+ // Check mozIVisitInfo properties.
+ do_check_eq(visit.visitId, 0);
+ }
+ // But they should be valid for non-embed visits.
+ else {
+ // Check mozIPlaceInfo properties.
+ do_check_true(placeInfo.placeId > 0);
+ do_check_valid_places_guid(placeInfo.guid);
+
+ // Check mozIVisitInfo properties.
+ do_check_true(visit.visitId > 0);
+ }
+
+ // If we have had all of our callbacks, continue running tests.
+ if (++callbackCount == place.visits.length) {
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(function* test_properties_saved() {
+ // Check each transition type to make sure it is saved properly.
+ let places = [];
+ for (let t in PlacesUtils.history.TRANSITIONS) {
+ let transitionType = PlacesUtils.history.TRANSITIONS[t];
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_properties_saved/" +
+ transitionType),
+ title: "test_properties_saved test",
+ visits: [
+ new VisitInfo(transitionType),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+ places.push(place);
+ }
+
+ let callbackCount = 0;
+ let placesResult = yield promiseUpdatePlaces(places);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ let uri = placeInfo.uri;
+ do_check_true(yield promiseIsURIVisited(uri));
+ let visit = placeInfo.visits[0];
+ print("TEST-INFO | test_properties_saved | updatePlaces callback for " +
+ "transition type " + visit.transitionType);
+
+ // Note that TRANSITION_EMBED should not be in the database.
+ const EXPECTED_COUNT = visit.transitionType == TRANSITION_EMBED ? 0 : 1;
+
+ // mozIVisitInfo::date
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ JOIN moz_historyvisits v
+ ON h.id = v.place_id
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND v.visit_date = :visit_date`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.visit_date = visit.visitDate;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // mozIVisitInfo::transitionType
+ stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ JOIN moz_historyvisits v
+ ON h.id = v.place_id
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND v.visit_type = :transition_type`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.transition_type = visit.transitionType;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // mozIPlaceInfo::title
+ stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_places h
+ WHERE h.url_hash = hash(:page_url) AND h.url = :page_url
+ AND h.title = :title`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.title = placeInfo.title;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.count, EXPECTED_COUNT);
+ stmt.finalize();
+
+ // If we have had all of our callbacks, continue running tests.
+ if (++callbackCount == places.length) {
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(function* test_guid_saved() {
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_saved"),
+ guid: "__TESTGUID__",
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_valid_places_guid(place.guid);
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ let placeInfo = placesResult.results[0];
+ let uri = placeInfo.uri;
+ do_check_true(yield promiseIsURIVisited(uri));
+ do_check_eq(placeInfo.guid, place.guid);
+ do_check_guid_for_uri(uri, place.guid);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_referrer_saved() {
+ let places = [
+ { uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/referrer"),
+ visits: [
+ new VisitInfo(),
+ ],
+ },
+ { uri: NetUtil.newURI(TEST_DOMAIN + "test_referrer_saved/test"),
+ visits: [
+ new VisitInfo(),
+ ],
+ },
+ ];
+ places[1].visits[0].referrerURI = places[0].uri;
+ do_check_false(yield promiseIsURIVisited(places[0].uri));
+ do_check_false(yield promiseIsURIVisited(places[1].uri));
+
+ let resultCount = 0;
+ let placesResult = yield promiseUpdatePlaces(places);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ for (let placeInfo of placesResult.results) {
+ let uri = placeInfo.uri;
+ do_check_true(yield promiseIsURIVisited(uri));
+ let visit = placeInfo.visits[0];
+
+ // We need to insert all of our visits before we can test conditions.
+ if (++resultCount == places.length) {
+ do_check_true(places[0].uri.equals(visit.referrerURI));
+
+ let stmt = DBConn().createStatement(
+ `SELECT COUNT(1) AS count
+ FROM moz_historyvisits
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:page_url) AND url = :page_url
+ AND from_visit = (
+ SELECT v.id
+ FROM moz_historyvisits v
+ JOIN moz_places h ON h.id = place_id
+ WHERE url_hash = hash(:referrer) AND url = :referrer
+ )`
+ );
+ stmt.params.page_url = uri.spec;
+ stmt.params.referrer = visit.referrerURI.spec;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.count, 1);
+ stmt.finalize();
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ }
+ }
+});
+
+add_task(function* test_guid_change_saved() {
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_guid_change_saved"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ // Then, change the guid with visits.
+ place.guid = "_GUIDCHANGE_";
+ place.visits = [new VisitInfo()];
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ do_check_guid_for_uri(place.uri, place.guid);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_title_change_saved() {
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_saved"),
+ title: "original title",
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+
+ // Now, make sure the empty string clears the title.
+ place.title = "";
+ place.visits = [new VisitInfo()];
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, null);
+
+ // Then, change the title with visits.
+ place.title = "title change";
+ place.visits = [new VisitInfo()];
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, place.title);
+
+ // Lastly, check that the title is cleared if we set it to null.
+ place.title = null;
+ place.visits = [new VisitInfo()];
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, place.title);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_no_title_does_not_clear_title() {
+ const TITLE = "test title";
+ // First, add a visit for it.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_no_title_does_not_clear_title"),
+ title: TITLE,
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ // Now, make sure that not specifying a title does not clear it.
+ delete place.title;
+ place.visits = [new VisitInfo()];
+ placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+ do_check_title_for_uri(place.uri, TITLE);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_title_change_notifies() {
+ // There are three cases to test. The first case is to make sure we do not
+ // get notified if we do not specify a title.
+ let place = {
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_title_change_notifies"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ let silentObserver =
+ new TitleChangedObserver(place.uri, "DO NOT WANT", function() {
+ do_throw("unexpected callback!");
+ });
+
+ PlacesUtils.history.addObserver(silentObserver, false);
+ let placesResult = yield promiseUpdatePlaces(place);
+ if (placesResult.errors.length > 0) {
+ do_throw("Unexpected error.");
+ }
+
+ // The second case to test is that we get the notification when we add
+ // it for the first time. The first case will fail before our callback if it
+ // is busted, so we can do this now.
+ place.uri = NetUtil.newURI(place.uri.spec + "/new-visit-with-title");
+ place.title = "title 1";
+ function promiseTitleChangedObserver(aPlace) {
+ return new Promise((resolve, reject) => {
+ let callbackCount = 0;
+ let observer = new TitleChangedObserver(aPlace.uri, aPlace.title, function() {
+ switch (++callbackCount) {
+ case 1:
+ // The third case to test is to make sure we get a notification when
+ // we change an existing place.
+ observer.expectedTitle = place.title = "title 2";
+ place.visits = [new VisitInfo()];
+ PlacesUtils.asyncHistory.updatePlaces(place);
+ break;
+ case 2:
+ PlacesUtils.history.removeObserver(silentObserver);
+ PlacesUtils.history.removeObserver(observer);
+ resolve();
+ break;
+ }
+ });
+
+ PlacesUtils.history.addObserver(observer, false);
+ PlacesUtils.asyncHistory.updatePlaces(aPlace);
+ });
+ }
+
+ yield promiseTitleChangedObserver(place);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_visit_notifies() {
+ // There are two observers we need to see for each visit. One is an
+ // nsINavHistoryObserver and the other is the uri-visit-saved observer topic.
+ let place = {
+ guid: "abcdefghijkl",
+ uri: NetUtil.newURI(TEST_DOMAIN + "test_visit_notifies"),
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ do_check_false(yield promiseIsURIVisited(place.uri));
+
+ function promiseVisitObserver(aPlace) {
+ return new Promise((resolve, reject) => {
+ let callbackCount = 0;
+ let finisher = function() {
+ if (++callbackCount == 2) {
+ resolve();
+ }
+ }
+ let visitObserver = new VisitObserver(place.uri, place.guid,
+ function(aVisitDate,
+ aTransitionType) {
+ let visit = place.visits[0];
+ do_check_eq(visit.visitDate, aVisitDate);
+ do_check_eq(visit.transitionType, aTransitionType);
+
+ PlacesUtils.history.removeObserver(visitObserver);
+ finisher();
+ });
+ PlacesUtils.history.addObserver(visitObserver, false);
+ let observer = function(aSubject, aTopic, aData) {
+ do_print("observe(" + aSubject + ", " + aTopic + ", " + aData + ")");
+ do_check_true(aSubject instanceof Ci.nsIURI);
+ do_check_true(aSubject.equals(place.uri));
+
+ Services.obs.removeObserver(observer, URI_VISIT_SAVED);
+ finisher();
+ };
+ Services.obs.addObserver(observer, URI_VISIT_SAVED, false);
+ PlacesUtils.asyncHistory.updatePlaces(place);
+ });
+ }
+
+ yield promiseVisitObserver(place);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+// test with empty mozIVisitInfoCallback object
+add_task(function* test_callbacks_not_supplied() {
+ const URLS = [
+ "imap://cyrus.andrew.cmu.edu/archive.imap", // bad URI
+ "http://mozilla.org/" // valid URI
+ ];
+ let places = [];
+ URLS.forEach(function(url) {
+ try {
+ let place = {
+ uri: NetUtil.newURI(url),
+ title: "test for " + url,
+ visits: [
+ new VisitInfo(),
+ ],
+ };
+ places.push(place);
+ }
+ catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ // NetUtil.newURI() can throw if e.g. our app knows about imap://
+ // but the account is not set up and so the URL is invalid for us.
+ // Note this in the log but ignore as it's not the subject of this test.
+ do_print("Could not construct URI for '" + url + "'; ignoring");
+ }
+ });
+
+ PlacesUtils.asyncHistory.updatePlaces(places, {});
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+// Test that we don't wrongly overwrite typed and hidden when adding new visits.
+add_task(function* test_typed_hidden_not_overwritten() {
+ yield PlacesTestUtils.clearHistory();
+ let places = [
+ { uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [
+ new VisitInfo(TRANSITION_TYPED),
+ new VisitInfo(TRANSITION_LINK)
+ ]
+ },
+ { uri: NetUtil.newURI("http://mozilla.org/"),
+ title: "test",
+ visits: [
+ new VisitInfo(TRANSITION_FRAMED_LINK)
+ ]
+ },
+ ];
+ yield promiseUpdatePlaces(places);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(
+ "SELECT hidden, typed FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
+ { url: "http://mozilla.org/" });
+ Assert.equal(rows[0].getResultByName("typed"), 1,
+ "The page should be marked as typed");
+ Assert.equal(rows[0].getResultByName("hidden"), 0,
+ "The page should be marked as not hidden");
+});
+
+function run_test()
+{
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_async_in_batchmode.js b/toolkit/components/places/tests/unit/test_async_in_batchmode.js
new file mode 100644
index 000000000..b39b26519
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_async_in_batchmode.js
@@ -0,0 +1,55 @@
+// This is testing the frankenstein situation Sync forces Places into.
+// Sync does runInBatchMode() and before the callback returns the Places async
+// APIs are used (either by Sync itself, or by any other code in the system)
+// As seen in bug 1197856 and bug 1190131.
+
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+// This function "waits" for a promise to resolve by spinning a nested event
+// loop.
+function waitForPromise(promise) {
+ let thread = Cc["@mozilla.org/thread-manager;1"].getService().currentThread;
+
+ let finalResult, finalException;
+
+ promise.then(result => {
+ finalResult = result;
+ }, err => {
+ finalException = err;
+ });
+
+ // Keep waiting until our callback is triggered (unless the app is quitting).
+ while (!finalResult && !finalException) {
+ thread.processNextEvent(true);
+ }
+ if (finalException) {
+ throw finalException;
+ }
+ return finalResult;
+}
+
+add_test(function() {
+ let testCompleted = false;
+ PlacesUtils.bookmarks.runInBatchMode({
+ runBatched() {
+ // create a bookmark.
+ let info = { parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "http://example.com/" };
+ let insertPromise = PlacesUtils.bookmarks.insert(info);
+ let bookmark = waitForPromise(insertPromise);
+ // Check we got a bookmark (bookmark creation failed completely in
+ // bug 1190131)
+ equal(bookmark.url, info.url);
+ // Check the promiseItemGuid and promiseItemId helpers - failure in these
+ // was the underlying reason for the failure.
+ let id = waitForPromise(PlacesUtils.promiseItemId(bookmark.guid));
+ let guid = waitForPromise(PlacesUtils.promiseItemGuid(id));
+ equal(guid, bookmark.guid, "id and guid round-tripped correctly");
+ testCompleted = true;
+ }
+ }, null);
+ // make sure we tested what we think we tested.
+ ok(testCompleted);
+ run_next_test();
+});
diff --git a/toolkit/components/places/tests/unit/test_async_transactions.js b/toolkit/components/places/tests/unit/test_async_transactions.js
new file mode 100644
index 000000000..edc9abf87
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_async_transactions.js
@@ -0,0 +1,1739 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const bmsvc = PlacesUtils.bookmarks;
+const tagssvc = PlacesUtils.tagging;
+const annosvc = PlacesUtils.annotations;
+const PT = PlacesTransactions;
+const rootGuid = PlacesUtils.bookmarks.rootGuid;
+
+Components.utils.importGlobalProperties(["URL"]);
+
+// Create and add bookmarks observer.
+var observer = {
+ __proto__: NavBookmarkObserver.prototype,
+
+ tagRelatedGuids: new Set(),
+
+ reset: function () {
+ this.itemsAdded = new Map();
+ this.itemsRemoved = new Map();
+ this.itemsChanged = new Map();
+ this.itemsMoved = new Map();
+ this.beginUpdateBatch = false;
+ this.endUpdateBatch = false;
+ },
+
+ onBeginUpdateBatch: function () {
+ this.beginUpdateBatch = true;
+ },
+
+ onEndUpdateBatch: function () {
+ this.endUpdateBatch = true;
+ },
+
+ onItemAdded:
+ function (aItemId, aParentId, aIndex, aItemType, aURI, aTitle, aDateAdded,
+ aGuid, aParentGuid) {
+ // Ignore tag items.
+ if (aParentId == PlacesUtils.tagsFolderId ||
+ (aParentId != PlacesUtils.placesRootId &&
+ bmsvc.getFolderIdForItem(aParentId) == PlacesUtils.tagsFolderId)) {
+ this.tagRelatedGuids.add(aGuid);
+ return;
+ }
+
+ this.itemsAdded.set(aGuid, { itemId: aItemId
+ , parentGuid: aParentGuid
+ , index: aIndex
+ , itemType: aItemType
+ , title: aTitle
+ , url: aURI });
+ },
+
+ onItemRemoved:
+ function (aItemId, aParentId, aIndex, aItemType, aURI, aGuid, aParentGuid) {
+ if (this.tagRelatedGuids.has(aGuid))
+ return;
+
+ this.itemsRemoved.set(aGuid, { parentGuid: aParentGuid
+ , index: aIndex
+ , itemType: aItemType });
+ },
+
+ onItemChanged:
+ function (aItemId, aProperty, aIsAnnoProperty, aNewValue, aLastModified,
+ aItemType, aParentId, aGuid, aParentGuid) {
+ if (this.tagRelatedGuids.has(aGuid))
+ return;
+
+ let changesForGuid = this.itemsChanged.get(aGuid);
+ if (changesForGuid === undefined) {
+ changesForGuid = new Map();
+ this.itemsChanged.set(aGuid, changesForGuid);
+ }
+
+ let newValue = aNewValue;
+ if (aIsAnnoProperty) {
+ if (annosvc.itemHasAnnotation(aItemId, aProperty))
+ newValue = annosvc.getItemAnnotation(aItemId, aProperty);
+ else
+ newValue = null;
+ }
+ let change = { isAnnoProperty: aIsAnnoProperty
+ , newValue: newValue
+ , lastModified: aLastModified
+ , itemType: aItemType };
+ changesForGuid.set(aProperty, change);
+ },
+
+ onItemVisited: () => {},
+
+ onItemMoved:
+ function (aItemId, aOldParent, aOldIndex, aNewParent, aNewIndex, aItemType,
+ aGuid, aOldParentGuid, aNewParentGuid) {
+ this.itemsMoved.set(aGuid, { oldParentGuid: aOldParentGuid
+ , oldIndex: aOldIndex
+ , newParentGuid: aNewParentGuid
+ , newIndex: aNewIndex
+ , itemType: aItemType });
+ }
+};
+observer.reset();
+
+// index at which items should begin
+var bmStartIndex = 0;
+
+function run_test() {
+ bmsvc.addObserver(observer, false);
+ do_register_cleanup(function () {
+ bmsvc.removeObserver(observer);
+ });
+
+ run_next_test();
+}
+
+function sanityCheckTransactionHistory() {
+ do_check_true(PT.undoPosition <= PT.length);
+
+ let check_entry_throws = f => {
+ try {
+ f();
+ do_throw("PT.entry should throw for invalid input");
+ } catch (ex) {}
+ };
+ check_entry_throws( () => PT.entry(-1) );
+ check_entry_throws( () => PT.entry({}) );
+ check_entry_throws( () => PT.entry(PT.length) );
+
+ if (PT.undoPosition < PT.length)
+ do_check_eq(PT.topUndoEntry, PT.entry(PT.undoPosition));
+ else
+ do_check_null(PT.topUndoEntry);
+ if (PT.undoPosition > 0)
+ do_check_eq(PT.topRedoEntry, PT.entry(PT.undoPosition - 1));
+ else
+ do_check_null(PT.topRedoEntry);
+}
+
+function getTransactionsHistoryState() {
+ let history = [];
+ for (let i = 0; i < PT.length; i++) {
+ history.push(PT.entry(i));
+ }
+ return [history, PT.undoPosition];
+}
+
+function ensureUndoState(aExpectedEntries = [], aExpectedUndoPosition = 0) {
+ // ensureUndoState is called in various places during this test, so it's
+ // a good places to sanity-check the transaction-history APIs in all
+ // cases.
+ sanityCheckTransactionHistory();
+
+ let [actualEntries, actualUndoPosition] = getTransactionsHistoryState();
+ do_check_eq(actualEntries.length, aExpectedEntries.length);
+ do_check_eq(actualUndoPosition, aExpectedUndoPosition);
+
+ function checkEqualEntries(aExpectedEntry, aActualEntry) {
+ do_check_eq(aExpectedEntry.length, aActualEntry.length);
+ aExpectedEntry.forEach( (t, i) => do_check_eq(t, aActualEntry[i]) );
+ }
+ aExpectedEntries.forEach( (e, i) => checkEqualEntries(e, actualEntries[i]) );
+}
+
+function ensureItemsAdded(...items) {
+ Assert.equal(observer.itemsAdded.size, items.length);
+ for (let item of items) {
+ Assert.ok(observer.itemsAdded.has(item.guid));
+ let info = observer.itemsAdded.get(item.guid);
+ Assert.equal(info.parentGuid, item.parentGuid);
+ for (let propName of ["title", "index", "itemType"]) {
+ if (propName in item)
+ Assert.equal(info[propName], item[propName]);
+ }
+ if ("url" in item)
+ Assert.ok(info.url.equals(item.url));
+ }
+}
+
+function ensureItemsRemoved(...items) {
+ Assert.equal(observer.itemsRemoved.size, items.length);
+ for (let item of items) {
+ // We accept both guids and full info object here.
+ if (typeof(item) == "string") {
+ Assert.ok(observer.itemsRemoved.has(item));
+ }
+ else {
+ Assert.ok(observer.itemsRemoved.has(item.guid));
+ let info = observer.itemsRemoved.get(item.guid);
+ Assert.equal(info.parentGuid, item.parentGuid);
+ if ("index" in item)
+ Assert.equal(info.index, item.index);
+ }
+ }
+}
+
+function ensureItemsChanged(...items) {
+ for (let item of items) {
+ do_check_true(observer.itemsChanged.has(item.guid));
+ let changes = observer.itemsChanged.get(item.guid);
+ do_check_true(changes.has(item.property));
+ let info = changes.get(item.property);
+ do_check_eq(info.isAnnoProperty, Boolean(item.isAnnoProperty));
+ do_check_eq(info.newValue, item.newValue);
+ if ("url" in item)
+ do_check_true(item.url.equals(info.url));
+ }
+}
+
+function ensureAnnotationsSet(aGuid, aAnnos) {
+ do_check_true(observer.itemsChanged.has(aGuid));
+ let changes = observer.itemsChanged.get(aGuid);
+ for (let anno of aAnnos) {
+ do_check_true(changes.has(anno.name));
+ let changeInfo = changes.get(anno.name);
+ do_check_true(changeInfo.isAnnoProperty);
+ do_check_eq(changeInfo.newValue, anno.value);
+ }
+}
+
+function ensureItemsMoved(...items) {
+ do_check_true(observer.itemsMoved.size, items.length);
+ for (let item of items) {
+ do_check_true(observer.itemsMoved.has(item.guid));
+ let info = observer.itemsMoved.get(item.guid);
+ do_check_eq(info.oldParentGuid, item.oldParentGuid);
+ do_check_eq(info.oldIndex, item.oldIndex);
+ do_check_eq(info.newParentGuid, item.newParentGuid);
+ do_check_eq(info.newIndex, item.newIndex);
+ }
+}
+
+function ensureTimestampsUpdated(aGuid, aCheckDateAdded = false) {
+ do_check_true(observer.itemsChanged.has(aGuid));
+ let changes = observer.itemsChanged.get(aGuid);
+ if (aCheckDateAdded)
+ do_check_true(changes.has("dateAdded"))
+ do_check_true(changes.has("lastModified"));
+}
+
+function ensureTagsForURI(aURI, aTags) {
+ let tagsSet = tagssvc.getTagsForURI(aURI);
+ do_check_eq(tagsSet.length, aTags.length);
+ do_check_true(aTags.every( t => tagsSet.includes(t)));
+}
+
+function createTestFolderInfo(aTitle = "Test Folder") {
+ return { parentGuid: rootGuid, title: "Test Folder" };
+}
+
+function isLivemarkTree(aTree) {
+ return !!aTree.annos &&
+ aTree.annos.some( a => a.name == PlacesUtils.LMANNO_FEEDURI );
+}
+
+function* ensureLivemarkCreatedByAddLivemark(aLivemarkGuid) {
+ // This throws otherwise.
+ yield PlacesUtils.livemarks.getLivemark({ guid: aLivemarkGuid });
+}
+
+// Checks if two bookmark trees (as returned by promiseBookmarksTree) are the
+// same.
+// false value for aCheckParentAndPosition is ignored if aIsRestoredItem is set.
+function* ensureEqualBookmarksTrees(aOriginal,
+ aNew,
+ aIsRestoredItem = true,
+ aCheckParentAndPosition = false) {
+ // Note "id" is not-enumerable, and is therefore skipped by Object.keys (both
+ // ours and the one at deepEqual). This is fine for us because ids are not
+ // restored by Redo.
+ if (aIsRestoredItem) {
+ Assert.deepEqual(aOriginal, aNew);
+ if (isLivemarkTree(aNew))
+ yield ensureLivemarkCreatedByAddLivemark(aNew.guid);
+ return;
+ }
+
+ for (let property of Object.keys(aOriginal)) {
+ if (property == "children") {
+ Assert.equal(aOriginal.children.length, aNew.children.length);
+ for (let i = 0; i < aOriginal.children.length; i++) {
+ yield ensureEqualBookmarksTrees(aOriginal.children[i],
+ aNew.children[i],
+ false,
+ true);
+ }
+ }
+ else if (property == "guid") {
+ // guid shouldn't be copied if the item was not restored.
+ Assert.notEqual(aOriginal.guid, aNew.guid);
+ }
+ else if (property == "dateAdded") {
+ // dateAdded shouldn't be copied if the item was not restored.
+ Assert.ok(is_time_ordered(aOriginal.dateAdded, aNew.dateAdded));
+ }
+ else if (property == "lastModified") {
+ // same same, except for the never-changed case
+ if (!aOriginal.lastModified)
+ Assert.ok(!aNew.lastModified);
+ else
+ Assert.ok(is_time_ordered(aOriginal.lastModified, aNew.lastModified));
+ }
+ else if (aCheckParentAndPosition ||
+ (property != "parentGuid" && property != "index")) {
+ Assert.deepEqual(aOriginal[property], aNew[property]);
+ }
+ }
+
+ if (isLivemarkTree(aNew))
+ yield ensureLivemarkCreatedByAddLivemark(aNew.guid);
+}
+
+function* ensureBookmarksTreeRestoredCorrectly(...aOriginalBookmarksTrees) {
+ for (let originalTree of aOriginalBookmarksTrees) {
+ let restoredTree =
+ yield PlacesUtils.promiseBookmarksTree(originalTree.guid);
+ yield ensureEqualBookmarksTrees(originalTree, restoredTree);
+ }
+}
+
+function* ensureNonExistent(...aGuids) {
+ for (let guid of aGuids) {
+ Assert.strictEqual((yield PlacesUtils.promiseBookmarksTree(guid)), null);
+ }
+}
+
+add_task(function* test_recycled_transactions() {
+ function* ensureTransactThrowsFor(aTransaction) {
+ let [txns, undoPosition] = getTransactionsHistoryState();
+ try {
+ yield aTransaction.transact();
+ do_throw("Shouldn't be able to use the same transaction twice");
+ }
+ catch (ex) { }
+ ensureUndoState(txns, undoPosition);
+ }
+
+ let txn_a = PT.NewFolder(createTestFolderInfo());
+ yield txn_a.transact();
+ ensureUndoState([[txn_a]], 0);
+ yield ensureTransactThrowsFor(txn_a);
+
+ yield PT.undo();
+ ensureUndoState([[txn_a]], 1);
+ ensureTransactThrowsFor(txn_a);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+ ensureTransactThrowsFor(txn_a);
+
+ let txn_b = PT.NewFolder(createTestFolderInfo());
+ yield PT.batch(function* () {
+ try {
+ yield txn_a.transact();
+ do_throw("Shouldn't be able to use the same transaction twice");
+ }
+ catch (ex) { }
+ ensureUndoState();
+ yield txn_b.transact();
+ });
+ ensureUndoState([[txn_b]], 0);
+
+ yield PT.undo();
+ ensureUndoState([[txn_b]], 1);
+ ensureTransactThrowsFor(txn_a);
+ ensureTransactThrowsFor(txn_b);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+ observer.reset();
+});
+
+add_task(function* test_new_folder_with_annotation() {
+ const ANNO = { name: "TestAnno", value: "TestValue" };
+ let folder_info = createTestFolderInfo();
+ folder_info.index = bmStartIndex;
+ folder_info.annotations = [ANNO];
+ ensureUndoState();
+ let txn = PT.NewFolder(folder_info);
+ folder_info.guid = yield txn.transact();
+ let ensureDo = function* (aRedo = false) {
+ ensureUndoState([[txn]], 0);
+ yield ensureItemsAdded(folder_info);
+ ensureAnnotationsSet(folder_info.guid, [ANNO]);
+ if (aRedo)
+ ensureTimestampsUpdated(folder_info.guid, true);
+ observer.reset();
+ };
+
+ let ensureUndo = () => {
+ ensureUndoState([[txn]], 1);
+ ensureItemsRemoved({ guid: folder_info.guid
+ , parentGuid: folder_info.parentGuid
+ , index: bmStartIndex });
+ observer.reset();
+ };
+
+ yield ensureDo();
+ yield PT.undo();
+ yield ensureUndo();
+ yield PT.redo();
+ yield ensureDo(true);
+ yield PT.undo();
+ ensureUndo();
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_new_bookmark() {
+ let bm_info = { parentGuid: rootGuid
+ , url: NetUtil.newURI("http://test_create_item.com")
+ , index: bmStartIndex
+ , title: "Test creating an item" };
+
+ ensureUndoState();
+ let txn = PT.NewBookmark(bm_info);
+ bm_info.guid = yield txn.transact();
+
+ let ensureDo = function* (aRedo = false) {
+ ensureUndoState([[txn]], 0);
+ yield ensureItemsAdded(bm_info);
+ if (aRedo)
+ ensureTimestampsUpdated(bm_info.guid, true);
+ observer.reset();
+ };
+ let ensureUndo = () => {
+ ensureUndoState([[txn]], 1);
+ ensureItemsRemoved({ guid: bm_info.guid
+ , parentGuid: bm_info.parentGuid
+ , index: bmStartIndex });
+ observer.reset();
+ };
+
+ yield ensureDo();
+ yield PT.undo();
+ ensureUndo();
+ yield PT.redo(true);
+ yield ensureDo();
+ yield PT.undo();
+ ensureUndo();
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_merge_create_folder_and_item() {
+ let folder_info = createTestFolderInfo();
+ let bm_info = { url: NetUtil.newURI("http://test_create_item_to_folder.com")
+ , title: "Test Bookmark"
+ , index: bmStartIndex };
+
+ let [folderTxnResult, bkmTxnResult] = yield PT.batch(function* () {
+ let folderTxn = PT.NewFolder(folder_info);
+ folder_info.guid = bm_info.parentGuid = yield folderTxn.transact();
+ let bkmTxn = PT.NewBookmark(bm_info);
+ bm_info.guid = yield bkmTxn.transact();
+ return [folderTxn, bkmTxn];
+ });
+
+ let ensureDo = function* () {
+ ensureUndoState([[bkmTxnResult, folderTxnResult]], 0);
+ yield ensureItemsAdded(folder_info, bm_info);
+ observer.reset();
+ };
+
+ let ensureUndo = () => {
+ ensureUndoState([[bkmTxnResult, folderTxnResult]], 1);
+ ensureItemsRemoved(folder_info, bm_info);
+ observer.reset();
+ };
+
+ yield ensureDo();
+ yield PT.undo();
+ ensureUndo();
+ yield PT.redo();
+ yield ensureDo();
+ yield PT.undo();
+ ensureUndo();
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_move_items_to_folder() {
+ let folder_a_info = createTestFolderInfo("Folder A");
+ let bkm_a_info = { url: new URL("http://test_move_items.com")
+ , title: "Bookmark A" };
+ let bkm_b_info = { url: NetUtil.newURI("http://test_move_items.com")
+ , title: "Bookmark B" };
+
+ // Test moving items within the same folder.
+ let [folder_a_txn_result, bkm_a_txn_result, bkm_b_txn_result] = yield PT.batch(function* () {
+ let folder_a_txn = PT.NewFolder(folder_a_info);
+
+ folder_a_info.guid = bkm_a_info.parentGuid = bkm_b_info.parentGuid =
+ yield folder_a_txn.transact();
+ let bkm_a_txn = PT.NewBookmark(bkm_a_info);
+ bkm_a_info.guid = yield bkm_a_txn.transact();
+ let bkm_b_txn = PT.NewBookmark(bkm_b_info);
+ bkm_b_info.guid = yield bkm_b_txn.transact();
+ return [folder_a_txn, bkm_a_txn, bkm_b_txn];
+ });
+
+ ensureUndoState([[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);
+
+ let moveTxn = PT.Move({ guid: bkm_a_info.guid
+ , newParentGuid: folder_a_info.guid });
+ yield moveTxn.transact();
+
+ let ensureDo = () => {
+ ensureUndoState([[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);
+ ensureItemsMoved({ guid: bkm_a_info.guid
+ , oldParentGuid: folder_a_info.guid
+ , newParentGuid: folder_a_info.guid
+ , oldIndex: 0
+ , newIndex: 1 });
+ observer.reset();
+ };
+ let ensureUndo = () => {
+ ensureUndoState([[moveTxn], [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 1);
+ ensureItemsMoved({ guid: bkm_a_info.guid
+ , oldParentGuid: folder_a_info.guid
+ , newParentGuid: folder_a_info.guid
+ , oldIndex: 1
+ , newIndex: 0 });
+ observer.reset();
+ };
+
+ ensureDo();
+ yield PT.undo();
+ ensureUndo();
+ yield PT.redo();
+ ensureDo();
+ yield PT.undo();
+ ensureUndo();
+
+ yield PT.clearTransactionsHistory(false, true);
+ ensureUndoState([[bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result]], 0);
+
+ // Test moving items between folders.
+ let folder_b_info = createTestFolderInfo("Folder B");
+ let folder_b_txn = PT.NewFolder(folder_b_info);
+ folder_b_info.guid = yield folder_b_txn.transact();
+ ensureUndoState([ [folder_b_txn]
+ , [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 0);
+
+ moveTxn = PT.Move({ guid: bkm_a_info.guid
+ , newParentGuid: folder_b_info.guid
+ , newIndex: bmsvc.DEFAULT_INDEX });
+ yield moveTxn.transact();
+
+ ensureDo = () => {
+ ensureUndoState([ [moveTxn]
+ , [folder_b_txn]
+ , [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 0);
+ ensureItemsMoved({ guid: bkm_a_info.guid
+ , oldParentGuid: folder_a_info.guid
+ , newParentGuid: folder_b_info.guid
+ , oldIndex: 0
+ , newIndex: 0 });
+ observer.reset();
+ };
+ ensureUndo = () => {
+ ensureUndoState([ [moveTxn]
+ , [folder_b_txn]
+ , [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 1);
+ ensureItemsMoved({ guid: bkm_a_info.guid
+ , oldParentGuid: folder_b_info.guid
+ , newParentGuid: folder_a_info.guid
+ , oldIndex: 0
+ , newIndex: 0 });
+ observer.reset();
+ };
+
+ ensureDo();
+ yield PT.undo();
+ ensureUndo();
+ yield PT.redo();
+ ensureDo();
+ yield PT.undo();
+ ensureUndo();
+
+ // Clean up
+ yield PT.undo(); // folder_b_txn
+ yield PT.undo(); // folder_a_txn + the bookmarks;
+ do_check_eq(observer.itemsRemoved.size, 4);
+ ensureUndoState([ [moveTxn]
+ , [folder_b_txn]
+ , [bkm_b_txn_result, bkm_a_txn_result, folder_a_txn_result] ], 3);
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_remove_folder() {
+ let folder_level_1_info = createTestFolderInfo("Folder Level 1");
+ let folder_level_2_info = { title: "Folder Level 2" };
+ let [folder_level_1_txn_result,
+ folder_level_2_txn_result] = yield PT.batch(function* () {
+ let folder_level_1_txn = PT.NewFolder(folder_level_1_info);
+ folder_level_1_info.guid = yield folder_level_1_txn.transact();
+ folder_level_2_info.parentGuid = folder_level_1_info.guid;
+ let folder_level_2_txn = PT.NewFolder(folder_level_2_info);
+ folder_level_2_info.guid = yield folder_level_2_txn.transact();
+ return [folder_level_1_txn, folder_level_2_txn];
+ });
+
+ ensureUndoState([[folder_level_2_txn_result, folder_level_1_txn_result]]);
+ yield ensureItemsAdded(folder_level_1_info, folder_level_2_info);
+ observer.reset();
+
+ let remove_folder_2_txn = PT.Remove(folder_level_2_info);
+ yield remove_folder_2_txn.transact();
+
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ]);
+ yield ensureItemsRemoved(folder_level_2_info);
+
+ // Undo Remove "Folder Level 2"
+ yield PT.undo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
+ yield ensureItemsAdded(folder_level_2_info);
+ ensureTimestampsUpdated(folder_level_2_info.guid, true);
+ observer.reset();
+
+ // Redo Remove "Folder Level 2"
+ yield PT.redo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ]);
+ yield ensureItemsRemoved(folder_level_2_info);
+ observer.reset();
+
+ // Undo it again
+ yield PT.undo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
+ yield ensureItemsAdded(folder_level_2_info);
+ ensureTimestampsUpdated(folder_level_2_info.guid, true);
+ observer.reset();
+
+ // Undo the creation of both folders
+ yield PT.undo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 2);
+ yield ensureItemsRemoved(folder_level_2_info, folder_level_1_info);
+ observer.reset();
+
+ // Redo the creation of both folders
+ yield PT.redo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
+ yield ensureItemsAdded(folder_level_1_info, folder_level_2_info);
+ ensureTimestampsUpdated(folder_level_1_info.guid, true);
+ ensureTimestampsUpdated(folder_level_2_info.guid, true);
+ observer.reset();
+
+ // Redo Remove "Folder Level 2"
+ yield PT.redo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ]);
+ yield ensureItemsRemoved(folder_level_2_info);
+ observer.reset();
+
+ // Undo everything one last time
+ yield PT.undo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 1);
+ yield ensureItemsAdded(folder_level_2_info);
+ observer.reset();
+
+ yield PT.undo();
+ ensureUndoState([ [remove_folder_2_txn]
+ , [folder_level_2_txn_result, folder_level_1_txn_result] ], 2);
+ yield ensureItemsRemoved(folder_level_2_info, folder_level_2_info);
+ observer.reset();
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_add_and_remove_bookmarks_with_additional_info() {
+ const testURI = NetUtil.newURI("http://add.remove.tag");
+ const TAG_1 = "TestTag1";
+ const TAG_2 = "TestTag2";
+ const KEYWORD = "test_keyword";
+ const POST_DATA = "post_data";
+ const ANNO = { name: "TestAnno", value: "TestAnnoValue" };
+
+ let folder_info = createTestFolderInfo();
+ folder_info.guid = yield PT.NewFolder(folder_info).transact();
+ let ensureTags = ensureTagsForURI.bind(null, testURI);
+
+ // Check that the NewBookmark transaction preserves tags.
+ observer.reset();
+ let b1_info = { parentGuid: folder_info.guid, url: testURI, tags: [TAG_1] };
+ b1_info.guid = yield PT.NewBookmark(b1_info).transact();
+ ensureTags([TAG_1]);
+ yield PT.undo();
+ ensureTags([]);
+
+ observer.reset();
+ yield PT.redo();
+ ensureTimestampsUpdated(b1_info.guid, true);
+ ensureTags([TAG_1]);
+
+ // Check if the Remove transaction removes and restores tags of children
+ // correctly.
+ yield PT.Remove(folder_info.guid).transact();
+ ensureTags([]);
+
+ observer.reset();
+ yield PT.undo();
+ ensureTimestampsUpdated(b1_info.guid, true);
+ ensureTags([TAG_1]);
+
+ yield PT.redo();
+ ensureTags([]);
+
+ observer.reset();
+ yield PT.undo();
+ ensureTimestampsUpdated(b1_info.guid, true);
+ ensureTags([TAG_1]);
+
+ // * Check that no-op tagging (the uri is already tagged with TAG_1) is
+ // also a no-op on undo.
+ // * Test the "keyword" property of the NewBookmark transaction.
+ observer.reset();
+ let b2_info = { parentGuid: folder_info.guid
+ , url: testURI, tags: [TAG_1, TAG_2]
+ , keyword: KEYWORD
+ , postData: POST_DATA
+ , annotations: [ANNO] };
+ b2_info.guid = yield PT.NewBookmark(b2_info).transact();
+ let b2_post_creation_changes = [
+ { guid: b2_info.guid
+ , isAnnoProperty: true
+ , property: ANNO.name
+ , newValue: ANNO.value },
+ { guid: b2_info.guid
+ , property: "keyword"
+ , newValue: KEYWORD } ];
+ ensureItemsChanged(...b2_post_creation_changes);
+ ensureTags([TAG_1, TAG_2]);
+
+ observer.reset();
+ yield PT.undo();
+ yield ensureItemsRemoved(b2_info);
+ ensureTags([TAG_1]);
+
+ // Check if Remove correctly restores keywords, tags and annotations.
+ // Since both bookmarks share the same uri, they also share the keyword that
+ // is not removed along with one of the bookmarks.
+ observer.reset();
+ yield PT.redo();
+ ensureItemsChanged({ guid: b2_info.guid
+ , isAnnoProperty: true
+ , property: ANNO.name
+ , newValue: ANNO.value });
+ ensureTags([TAG_1, TAG_2]);
+
+ // Test Remove for multiple items.
+ observer.reset();
+ yield PT.Remove(b1_info.guid).transact();
+ yield PT.Remove(b2_info.guid).transact();
+ yield PT.Remove(folder_info.guid).transact();
+ yield ensureItemsRemoved(b1_info, b2_info, folder_info);
+ ensureTags([]);
+ // There is no keyword removal notification cause all bookmarks are removed
+ // before the keyword itself, so there's no one to notify.
+ let entry = yield PlacesUtils.keywords.fetch(KEYWORD);
+ Assert.equal(entry, null, "keyword has been removed");
+
+ observer.reset();
+ yield PT.undo();
+ yield ensureItemsAdded(folder_info);
+ ensureTags([]);
+
+ observer.reset();
+ yield PT.undo();
+ ensureItemsChanged(...b2_post_creation_changes);
+ ensureTags([TAG_1, TAG_2]);
+
+ observer.reset();
+ yield PT.undo();
+ yield ensureItemsAdded(b1_info);
+ ensureTags([TAG_1, TAG_2]);
+
+ // The redo calls below cleanup everything we did.
+ observer.reset();
+ yield PT.redo();
+ yield ensureItemsRemoved(b1_info);
+ ensureTags([TAG_1, TAG_2]);
+
+ observer.reset();
+ yield PT.redo();
+ yield ensureItemsRemoved(b2_info);
+ ensureTags([]);
+
+ observer.reset();
+ yield PT.redo();
+ yield ensureItemsRemoved(folder_info);
+ ensureTags([]);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_creating_and_removing_a_separator() {
+ let folder_info = createTestFolderInfo();
+ let separator_info = {};
+ let undoEntries = [];
+
+ observer.reset();
+ let create_txns = yield PT.batch(function* () {
+ let folder_txn = PT.NewFolder(folder_info);
+ folder_info.guid = separator_info.parentGuid = yield folder_txn.transact();
+ let separator_txn = PT.NewSeparator(separator_info);
+ separator_info.guid = yield separator_txn.transact();
+ return [separator_txn, folder_txn];
+ });
+ undoEntries.unshift(create_txns);
+ ensureUndoState(undoEntries, 0);
+ ensureItemsAdded(folder_info, separator_info);
+
+ observer.reset();
+ yield PT.undo();
+ ensureUndoState(undoEntries, 1);
+ ensureItemsRemoved(folder_info, separator_info);
+
+ observer.reset();
+ yield PT.redo();
+ ensureUndoState(undoEntries, 0);
+ ensureItemsAdded(folder_info, separator_info);
+
+ observer.reset();
+ let remove_sep_txn = PT.Remove(separator_info);
+ yield remove_sep_txn.transact();
+ undoEntries.unshift([remove_sep_txn]);
+ ensureUndoState(undoEntries, 0);
+ ensureItemsRemoved(separator_info);
+
+ observer.reset();
+ yield PT.undo();
+ ensureUndoState(undoEntries, 1);
+ ensureItemsAdded(separator_info);
+
+ observer.reset();
+ yield PT.undo();
+ ensureUndoState(undoEntries, 2);
+ ensureItemsRemoved(folder_info, separator_info);
+
+ observer.reset();
+ yield PT.redo();
+ ensureUndoState(undoEntries, 1);
+ ensureItemsAdded(folder_info, separator_info);
+
+ // Clear redo entries and check that |redo| does nothing
+ observer.reset();
+ yield PT.clearTransactionsHistory(false, true);
+ undoEntries.shift();
+ ensureUndoState(undoEntries, 0);
+ yield PT.redo();
+ ensureItemsAdded();
+ ensureItemsRemoved();
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureUndoState(undoEntries, 1);
+ ensureItemsRemoved(folder_info, separator_info);
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_add_and_remove_livemark() {
+ let createLivemarkTxn = PT.NewLivemark(
+ { feedUrl: NetUtil.newURI("http://test.remove.livemark")
+ , parentGuid: rootGuid
+ , title: "Test Remove Livemark" });
+ let guid = yield createLivemarkTxn.transact();
+ let originalInfo = yield PlacesUtils.promiseBookmarksTree(guid);
+ Assert.ok(originalInfo);
+ yield ensureLivemarkCreatedByAddLivemark(guid);
+
+ let removeTxn = PT.Remove(guid);
+ yield removeTxn.transact();
+ yield ensureNonExistent(guid);
+ function* undo() {
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 0);
+ yield PT.undo();
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 1);
+ yield ensureBookmarksTreeRestoredCorrectly(originalInfo);
+ yield PT.undo();
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 2);
+ yield ensureNonExistent(guid);
+ }
+ function* redo() {
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 2);
+ yield PT.redo();
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 1);
+ yield ensureBookmarksTreeRestoredCorrectly(originalInfo);
+ yield PT.redo();
+ ensureUndoState([[removeTxn], [createLivemarkTxn]], 0);
+ yield ensureNonExistent(guid);
+ }
+
+ yield undo();
+ yield redo();
+ yield undo();
+ yield redo();
+
+ // Cleanup
+ yield undo();
+ observer.reset();
+ yield PT.clearTransactionsHistory();
+});
+
+add_task(function* test_edit_title() {
+ let bm_info = { parentGuid: rootGuid
+ , url: NetUtil.newURI("http://test_create_item.com")
+ , title: "Original Title" };
+
+ function ensureTitleChange(aCurrentTitle) {
+ ensureItemsChanged({ guid: bm_info.guid
+ , property: "title"
+ , newValue: aCurrentTitle});
+ }
+
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+
+ observer.reset();
+ yield PT.EditTitle({ guid: bm_info.guid, title: "New Title" }).transact();
+ ensureTitleChange("New Title");
+
+ observer.reset();
+ yield PT.undo();
+ ensureTitleChange("Original Title");
+
+ observer.reset();
+ yield PT.redo();
+ ensureTitleChange("New Title");
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureTitleChange("Original Title");
+ yield PT.undo();
+ ensureItemsRemoved(bm_info);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_edit_url() {
+ let oldURI = NetUtil.newURI("http://old.test_editing_item_uri.com/");
+ let newURI = NetUtil.newURI("http://new.test_editing_item_uri.com/");
+ let bm_info = { parentGuid: rootGuid, url: oldURI, tags: ["TestTag"] };
+ function ensureURIAndTags(aPreChangeURI, aPostChangeURI, aOLdURITagsPreserved) {
+ ensureItemsChanged({ guid: bm_info.guid
+ , property: "uri"
+ , newValue: aPostChangeURI.spec });
+ ensureTagsForURI(aPostChangeURI, bm_info.tags);
+ ensureTagsForURI(aPreChangeURI, aOLdURITagsPreserved ? bm_info.tags : []);
+ }
+
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+ ensureTagsForURI(oldURI, bm_info.tags);
+
+ // When there's a single bookmark for the same url, tags should be moved.
+ observer.reset();
+ yield PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact();
+ ensureURIAndTags(oldURI, newURI, false);
+
+ observer.reset();
+ yield PT.undo();
+ ensureURIAndTags(newURI, oldURI, false);
+
+ observer.reset();
+ yield PT.redo();
+ ensureURIAndTags(oldURI, newURI, false);
+
+ observer.reset();
+ yield PT.undo();
+ ensureURIAndTags(newURI, oldURI, false);
+
+ // When there're multiple bookmarks for the same url, tags should be copied.
+ let bm2_info = Object.create(bm_info);
+ bm2_info.guid = yield PT.NewBookmark(bm2_info).transact();
+ let bm3_info = Object.create(bm_info);
+ bm3_info.url = newURI;
+ bm3_info.guid = yield PT.NewBookmark(bm3_info).transact();
+
+ observer.reset();
+ yield PT.EditUrl({ guid: bm_info.guid, url: newURI }).transact();
+ ensureURIAndTags(oldURI, newURI, true);
+
+ observer.reset();
+ yield PT.undo();
+ ensureURIAndTags(newURI, oldURI, true);
+
+ observer.reset();
+ yield PT.redo();
+ ensureURIAndTags(oldURI, newURI, true);
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureURIAndTags(newURI, oldURI, true);
+ yield PT.undo();
+ yield PT.undo();
+ yield PT.undo();
+ ensureItemsRemoved(bm3_info, bm2_info, bm_info);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_edit_keyword() {
+ let bm_info = { parentGuid: rootGuid
+ , url: NetUtil.newURI("http://test.edit.keyword") };
+ const KEYWORD = "test_keyword";
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+ function ensureKeywordChange(aCurrentKeyword = "") {
+ ensureItemsChanged({ guid: bm_info.guid
+ , property: "keyword"
+ , newValue: aCurrentKeyword });
+ }
+
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+
+ observer.reset();
+ yield PT.EditKeyword({ guid: bm_info.guid, keyword: KEYWORD, postData: "postData" }).transact();
+ ensureKeywordChange(KEYWORD);
+ let entry = yield PlacesUtils.keywords.fetch(KEYWORD);
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData");
+
+ observer.reset();
+ yield PT.undo();
+ ensureKeywordChange();
+ entry = yield PlacesUtils.keywords.fetch(KEYWORD);
+ Assert.equal(entry, null);
+
+ observer.reset();
+ yield PT.redo();
+ ensureKeywordChange(KEYWORD);
+ entry = yield PlacesUtils.keywords.fetch(KEYWORD);
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData");
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureKeywordChange();
+ yield PT.undo();
+ ensureItemsRemoved(bm_info);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_edit_specific_keyword() {
+ let bm_info = { parentGuid: rootGuid
+ , url: NetUtil.newURI("http://test.edit.keyword") };
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+ function ensureKeywordChange(aCurrentKeyword = "", aPreviousKeyword = "") {
+ ensureItemsChanged({ guid: bm_info.guid
+ , property: "keyword"
+ , newValue: aCurrentKeyword
+ });
+ }
+
+ yield PlacesUtils.keywords.insert({ keyword: "kw1", url: bm_info.url.spec, postData: "postData1" });
+ yield PlacesUtils.keywords.insert({ keyword: "kw2", url: bm_info.url.spec, postData: "postData2" });
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+
+ observer.reset();
+ yield PT.EditKeyword({ guid: bm_info.guid, keyword: "keyword", oldKeyword: "kw2" }).transact();
+ ensureKeywordChange("keyword", "kw2");
+ let entry = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData1");
+ entry = yield PlacesUtils.keywords.fetch("keyword");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData2");
+ entry = yield PlacesUtils.keywords.fetch("kw2");
+ Assert.equal(entry, null);
+
+ observer.reset();
+ yield PT.undo();
+ ensureKeywordChange("kw2", "keyword");
+ entry = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData1");
+ entry = yield PlacesUtils.keywords.fetch("kw2");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData2");
+ entry = yield PlacesUtils.keywords.fetch("keyword");
+ Assert.equal(entry, null);
+
+ observer.reset();
+ yield PT.redo();
+ ensureKeywordChange("keyword", "kw2");
+ entry = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData1");
+ entry = yield PlacesUtils.keywords.fetch("keyword");
+ Assert.equal(entry.url.href, bm_info.url.spec);
+ Assert.equal(entry.postData, "postData2");
+ entry = yield PlacesUtils.keywords.fetch("kw2");
+ Assert.equal(entry, null);
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureKeywordChange("kw2");
+ yield PT.undo();
+ ensureItemsRemoved(bm_info);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_tag_uri() {
+ // This also tests passing uri specs.
+ let bm_info_a = { url: "http://bookmarked.uri"
+ , parentGuid: rootGuid };
+ let bm_info_b = { url: NetUtil.newURI("http://bookmarked2.uri")
+ , parentGuid: rootGuid };
+ let unbookmarked_uri = NetUtil.newURI("http://un.bookmarked.uri");
+
+ function* promiseIsBookmarked(aURI) {
+ let deferred = Promise.defer();
+ PlacesUtils.asyncGetBookmarkIds(aURI, ids => {
+ deferred.resolve(ids.length > 0);
+ });
+ return deferred.promise;
+ }
+
+ yield PT.batch(function* () {
+ bm_info_a.guid = yield PT.NewBookmark(bm_info_a).transact();
+ bm_info_b.guid = yield PT.NewBookmark(bm_info_b).transact();
+ });
+
+ function* doTest(aInfo) {
+ let urls = "url" in aInfo ? [aInfo.url] : aInfo.urls;
+ let tags = "tag" in aInfo ? [aInfo.tag] : aInfo.tags;
+
+ let ensureURI = url => typeof(url) == "string" ? NetUtil.newURI(url) : url;
+ urls = urls.map(ensureURI);
+
+ let tagWillAlsoBookmark = new Set();
+ for (let url of urls) {
+ if (!(yield promiseIsBookmarked(url))) {
+ tagWillAlsoBookmark.add(url);
+ }
+ }
+
+ function* ensureTagsSet() {
+ for (let url of urls) {
+ ensureTagsForURI(url, tags);
+ Assert.ok(yield promiseIsBookmarked(url));
+ }
+ }
+ function* ensureTagsUnset() {
+ for (let url of urls) {
+ ensureTagsForURI(url, []);
+ if (tagWillAlsoBookmark.has(url))
+ Assert.ok(!(yield promiseIsBookmarked(url)));
+ else
+ Assert.ok(yield promiseIsBookmarked(url));
+ }
+ }
+
+ yield PT.Tag(aInfo).transact();
+ yield ensureTagsSet();
+ yield PT.undo();
+ yield ensureTagsUnset();
+ yield PT.redo();
+ yield ensureTagsSet();
+ yield PT.undo();
+ yield ensureTagsUnset();
+ }
+
+ yield doTest({ url: bm_info_a.url, tags: ["MyTag"] });
+ yield doTest({ urls: [bm_info_a.url], tag: "MyTag" });
+ yield doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A, B"] });
+ yield doTest({ urls: [bm_info_a.url, unbookmarked_uri], tag: "C" });
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureItemsRemoved(bm_info_a, bm_info_b);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_untag_uri() {
+ let bm_info_a = { url: NetUtil.newURI("http://bookmarked.uri")
+ , parentGuid: rootGuid
+ , tags: ["A", "B"] };
+ let bm_info_b = { url: NetUtil.newURI("http://bookmarked2.uri")
+ , parentGuid: rootGuid
+ , tag: "B" };
+
+ yield PT.batch(function* () {
+ bm_info_a.guid = yield PT.NewBookmark(bm_info_a).transact();
+ ensureTagsForURI(bm_info_a.url, bm_info_a.tags);
+ bm_info_b.guid = yield PT.NewBookmark(bm_info_b).transact();
+ ensureTagsForURI(bm_info_b.url, [bm_info_b.tag]);
+ });
+
+ function* doTest(aInfo) {
+ let urls, tagsRemoved;
+ if (aInfo instanceof Ci.nsIURI) {
+ urls = [aInfo];
+ tagsRemoved = [];
+ }
+ else if (Array.isArray(aInfo)) {
+ urls = aInfo;
+ tagsRemoved = [];
+ }
+ else {
+ urls = "url" in aInfo ? [aInfo.url] : aInfo.urls;
+ tagsRemoved = "tag" in aInfo ? [aInfo.tag] : aInfo.tags;
+ }
+
+ let preRemovalTags = new Map();
+ for (let url of urls) {
+ preRemovalTags.set(url, tagssvc.getTagsForURI(url));
+ }
+
+ function ensureTagsSet() {
+ for (let url of urls) {
+ ensureTagsForURI(url, preRemovalTags.get(url));
+ }
+ }
+ function ensureTagsUnset() {
+ for (let url of urls) {
+ let expectedTags = tagsRemoved.length == 0 ?
+ [] : preRemovalTags.get(url).filter(tag => !tagsRemoved.includes(tag));
+ ensureTagsForURI(url, expectedTags);
+ }
+ }
+
+ yield PT.Untag(aInfo).transact();
+ yield ensureTagsUnset();
+ yield PT.undo();
+ yield ensureTagsSet();
+ yield PT.redo();
+ yield ensureTagsUnset();
+ yield PT.undo();
+ yield ensureTagsSet();
+ }
+
+ yield doTest(bm_info_a);
+ yield doTest(bm_info_b);
+ yield doTest(bm_info_a.url);
+ yield doTest(bm_info_b.url);
+ yield doTest([bm_info_a.url, bm_info_b.url]);
+ yield doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["A", "B"] });
+ yield doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "B" });
+ yield doTest({ urls: [bm_info_a.url, bm_info_b.url], tag: "C" });
+ yield doTest({ urls: [bm_info_a.url, bm_info_b.url], tags: ["C"] });
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureItemsRemoved(bm_info_a, bm_info_b);
+
+ yield PT.clearTransactionsHistory();
+ ensureUndoState();
+});
+
+add_task(function* test_annotate() {
+ let bm_info = { url: NetUtil.newURI("http://test.item.annotation")
+ , parentGuid: rootGuid };
+ let anno_info = { name: "TestAnno", value: "TestValue" };
+ function ensureAnnoState(aSet) {
+ ensureAnnotationsSet(bm_info.guid,
+ [{ name: anno_info.name
+ , value: aSet ? anno_info.value : null }]);
+ }
+
+ bm_info.guid = yield PT.NewBookmark(bm_info).transact();
+
+ observer.reset();
+ yield PT.Annotate({ guid: bm_info.guid, annotation: anno_info }).transact();
+ ensureAnnoState(true);
+
+ observer.reset();
+ yield PT.undo();
+ ensureAnnoState(false);
+
+ observer.reset();
+ yield PT.redo();
+ ensureAnnoState(true);
+
+ // Test removing the annotation by not passing the |value| property.
+ observer.reset();
+ yield PT.Annotate({ guid: bm_info.guid,
+ annotation: { name: anno_info.name }}).transact();
+ ensureAnnoState(false);
+
+ observer.reset();
+ yield PT.undo();
+ ensureAnnoState(true);
+
+ observer.reset();
+ yield PT.redo();
+ ensureAnnoState(false);
+
+ // Cleanup
+ yield PT.undo();
+ observer.reset();
+});
+
+add_task(function* test_annotate_multiple() {
+ let guid = yield PT.NewFolder(createTestFolderInfo()).transact();
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+
+ function AnnoObj(aName, aValue) {
+ this.name = aName;
+ this.value = aValue;
+ this.flags = 0;
+ this.expires = Ci.nsIAnnotationService.EXPIRE_NEVER;
+ }
+
+ function annos(a = null, b = null) {
+ return [new AnnoObj("A", a), new AnnoObj("B", b)];
+ }
+
+ function verifyAnnoValues(a = null, b = null) {
+ let currentAnnos = PlacesUtils.getAnnotationsForItem(itemId);
+ let expectedAnnos = [];
+ if (a !== null)
+ expectedAnnos.push(new AnnoObj("A", a));
+ if (b !== null)
+ expectedAnnos.push(new AnnoObj("B", b));
+
+ Assert.deepEqual(currentAnnos, expectedAnnos);
+ }
+
+ yield PT.Annotate({ guid: guid, annotations: annos(1, 2) }).transact();
+ verifyAnnoValues(1, 2);
+ yield PT.undo();
+ verifyAnnoValues();
+ yield PT.redo();
+ verifyAnnoValues(1, 2);
+
+ yield PT.Annotate({ guid: guid
+ , annotation: { name: "A" } }).transact();
+ verifyAnnoValues(null, 2);
+
+ yield PT.Annotate({ guid: guid
+ , annotation: { name: "B", value: 0 } }).transact();
+ verifyAnnoValues(null, 0);
+ yield PT.undo();
+ verifyAnnoValues(null, 2);
+ yield PT.redo();
+ verifyAnnoValues(null, 0);
+ yield PT.undo();
+ verifyAnnoValues(null, 2);
+ yield PT.undo();
+ verifyAnnoValues(1, 2);
+ yield PT.undo();
+ verifyAnnoValues();
+
+ // Cleanup
+ yield PT.undo();
+ observer.reset();
+});
+
+add_task(function* test_sort_folder_by_name() {
+ let folder_info = createTestFolderInfo();
+
+ let url = NetUtil.newURI("http://sort.by.name/");
+ let preSep = ["3", "2", "1"].map(i => ({ title: i, url }));
+ let sep = {};
+ let postSep = ["c", "b", "a"].map(l => ({ title: l, url }));
+ let originalOrder = [...preSep, sep, ...postSep];
+ let sortedOrder = [...preSep.slice(0).reverse(),
+ sep,
+ ...postSep.slice(0).reverse()];
+ yield PT.batch(function* () {
+ folder_info.guid = yield PT.NewFolder(folder_info).transact();
+ for (let info of originalOrder) {
+ info.parentGuid = folder_info.guid;
+ info.guid = yield info == sep ?
+ PT.NewSeparator(info).transact() :
+ PT.NewBookmark(info).transact();
+ }
+ });
+
+ let folderId = yield PlacesUtils.promiseItemId(folder_info.guid);
+ let folderContainer = PlacesUtils.getFolderContents(folderId).root;
+ function ensureOrder(aOrder) {
+ for (let i = 0; i < folderContainer.childCount; i++) {
+ do_check_eq(folderContainer.getChild(i).bookmarkGuid, aOrder[i].guid);
+ }
+ }
+
+ ensureOrder(originalOrder);
+ yield PT.SortByName(folder_info.guid).transact();
+ ensureOrder(sortedOrder);
+ yield PT.undo();
+ ensureOrder(originalOrder);
+ yield PT.redo();
+ ensureOrder(sortedOrder);
+
+ // Cleanup
+ observer.reset();
+ yield PT.undo();
+ ensureOrder(originalOrder);
+ yield PT.undo();
+ ensureItemsRemoved(...originalOrder, folder_info);
+});
+
+add_task(function* test_livemark_txns() {
+ let livemark_info =
+ { feedUrl: NetUtil.newURI("http://test.feed.uri")
+ , parentGuid: rootGuid
+ , title: "Test Livemark" };
+ function ensureLivemarkAdded() {
+ ensureItemsAdded({ guid: livemark_info.guid
+ , title: livemark_info.title
+ , parentGuid: livemark_info.parentGuid
+ , itemType: bmsvc.TYPE_FOLDER });
+ let annos = [{ name: PlacesUtils.LMANNO_FEEDURI
+ , value: livemark_info.feedUrl.spec }];
+ if ("siteUrl" in livemark_info) {
+ annos.push({ name: PlacesUtils.LMANNO_SITEURI
+ , value: livemark_info.siteUrl.spec });
+ }
+ ensureAnnotationsSet(livemark_info.guid, annos);
+ }
+ function ensureLivemarkRemoved() {
+ ensureItemsRemoved({ guid: livemark_info.guid
+ , parentGuid: livemark_info.parentGuid });
+ }
+
+ function* _testDoUndoRedoUndo() {
+ observer.reset();
+ livemark_info.guid = yield PT.NewLivemark(livemark_info).transact();
+ ensureLivemarkAdded();
+
+ observer.reset();
+ yield PT.undo();
+ ensureLivemarkRemoved();
+
+ observer.reset();
+ yield PT.redo();
+ ensureLivemarkAdded();
+
+ yield PT.undo();
+ ensureLivemarkRemoved();
+ }
+
+ yield* _testDoUndoRedoUndo()
+ livemark_info.siteUrl = NetUtil.newURI("http://feed.site.uri");
+ yield* _testDoUndoRedoUndo();
+
+ // Cleanup
+ observer.reset();
+ yield PT.clearTransactionsHistory();
+});
+
+add_task(function* test_copy() {
+ function* duplicate_and_test(aOriginalGuid) {
+ let txn = PT.Copy({ guid: aOriginalGuid, newParentGuid: rootGuid });
+ yield duplicateGuid = yield txn.transact();
+ let originalInfo = yield PlacesUtils.promiseBookmarksTree(aOriginalGuid);
+ let duplicateInfo = yield PlacesUtils.promiseBookmarksTree(duplicateGuid);
+ yield ensureEqualBookmarksTrees(originalInfo, duplicateInfo, false);
+
+ function* redo() {
+ yield PT.redo();
+ yield ensureBookmarksTreeRestoredCorrectly(originalInfo);
+ yield PT.redo();
+ yield ensureBookmarksTreeRestoredCorrectly(duplicateInfo);
+ }
+ function* undo() {
+ yield PT.undo();
+ // also undo the original item addition.
+ yield PT.undo();
+ yield ensureNonExistent(aOriginalGuid, duplicateGuid);
+ }
+
+ yield undo();
+ yield redo();
+ yield undo();
+ yield redo();
+
+ // Cleanup. This also remove the original item.
+ yield PT.undo();
+ observer.reset();
+ yield PT.clearTransactionsHistory();
+ }
+
+ // Test duplicating leafs (bookmark, separator, empty folder)
+ PT.NewBookmark({ url: new URL("http://test.item.duplicate")
+ , parentGuid: rootGuid
+ , annos: [{ name: "Anno", value: "AnnoValue"}] });
+ let sepTxn = PT.NewSeparator({ parentGuid: rootGuid, index: 1 });
+ let livemarkTxn = PT.NewLivemark(
+ { feedUrl: new URL("http://test.feed.uri")
+ , parentGuid: rootGuid
+ , title: "Test Livemark", index: 1 });
+ let emptyFolderTxn = PT.NewFolder(createTestFolderInfo());
+ for (let txn of [livemarkTxn, sepTxn, emptyFolderTxn]) {
+ let guid = yield txn.transact();
+ yield duplicate_and_test(guid);
+ }
+
+ // Test duplicating a folder having some contents.
+ let filledFolderGuid = yield PT.batch(function *() {
+ let folderGuid = yield PT.NewFolder(createTestFolderInfo()).transact();
+ let nestedFolderGuid =
+ yield PT.NewFolder({ parentGuid: folderGuid
+ , title: "Nested Folder" }).transact();
+ // Insert a bookmark under the nested folder.
+ yield PT.NewBookmark({ url: new URL("http://nested.nested.bookmark")
+ , parentGuid: nestedFolderGuid }).transact();
+ // Insert a separator below the nested folder
+ yield PT.NewSeparator({ parentGuid: folderGuid }).transact();
+ // And another bookmark.
+ yield PT.NewBookmark({ url: new URL("http://nested.bookmark")
+ , parentGuid: folderGuid }).transact();
+ return folderGuid;
+ });
+
+ yield duplicate_and_test(filledFolderGuid);
+
+ // Cleanup
+ yield PT.clearTransactionsHistory();
+});
+
+add_task(function* test_array_input_for_batch() {
+ let folderTxn = PT.NewFolder(createTestFolderInfo());
+ let folderGuid = yield folderTxn.transact();
+
+ let sep1_txn = PT.NewSeparator({ parentGuid: folderGuid });
+ let sep2_txn = PT.NewSeparator({ parentGuid: folderGuid });
+ yield PT.batch([sep1_txn, sep2_txn]);
+ ensureUndoState([[sep2_txn, sep1_txn], [folderTxn]], 0);
+
+ let ensureChildCount = function* (count) {
+ let tree = yield PlacesUtils.promiseBookmarksTree(folderGuid);
+ if (count == 0)
+ Assert.ok(!("children" in tree));
+ else
+ Assert.equal(tree.children.length, count);
+ };
+
+ yield ensureChildCount(2);
+ yield PT.undo();
+ yield ensureChildCount(0);
+ yield PT.redo()
+ yield ensureChildCount(2);
+ yield PT.undo();
+ yield ensureChildCount(0);
+
+ yield PT.undo();
+ Assert.equal((yield PlacesUtils.promiseBookmarksTree(folderGuid)), null);
+
+ // Cleanup
+ yield PT.clearTransactionsHistory();
+});
+
+add_task(function* test_copy_excluding_annotations() {
+ let folderInfo = createTestFolderInfo();
+ let anno = n => { return { name: n, value: 1 } };
+ folderInfo.annotations = [anno("a"), anno("b"), anno("c")];
+ let folderGuid = yield PT.NewFolder(folderInfo).transact();
+
+ let ensureAnnosSet = function* (guid, ...expectedAnnoNames) {
+ let tree = yield PlacesUtils.promiseBookmarksTree(guid);
+ let annoNames = "annos" in tree ?
+ tree.annos.map(a => a.name).sort() : [];
+ Assert.deepEqual(annoNames, expectedAnnoNames);
+ };
+
+ yield ensureAnnosSet(folderGuid, "a", "b", "c");
+
+ let excluding_a_dupeGuid =
+ yield PT.Copy({ guid: folderGuid
+ , newParentGuid: rootGuid
+ , excludingAnnotation: "a" }).transact();
+ yield ensureAnnosSet(excluding_a_dupeGuid, "b", "c");
+
+ let excluding_ac_dupeGuid =
+ yield PT.Copy({ guid: folderGuid
+ , newParentGuid: rootGuid
+ , excludingAnnotations: ["a", "c"] }).transact();
+ yield ensureAnnosSet(excluding_ac_dupeGuid, "b");
+
+ // Cleanup
+ yield PT.undo();
+ yield PT.undo();
+ yield PT.undo();
+ yield PT.clearTransactionsHistory();
+});
+
+add_task(function* test_invalid_uri_spec_throws() {
+ Assert.throws(() =>
+ PT.NewBookmark({ parentGuid: rootGuid
+ , url: "invalid uri spec"
+ , title: "test bookmark" }));
+ Assert.throws(() =>
+ PT.Tag({ tag: "TheTag"
+ , urls: ["invalid uri spec"] }));
+ Assert.throws(() =>
+ PT.Tag({ tag: "TheTag"
+ , urls: ["about:blank", "invalid uri spec"] }));
+});
+
+add_task(function* test_annotate_multiple_items() {
+ let parentGuid = rootGuid;
+ let guids = [
+ yield PT.NewBookmark({ url: "about:blank", parentGuid }).transact(),
+ yield PT.NewFolder({ title: "Test Folder", parentGuid }).transact()];
+
+ let annotation = { name: "TestAnno", value: "TestValue" };
+ yield PT.Annotate({ guids, annotation }).transact();
+
+ function *ensureAnnoSet() {
+ for (let guid of guids) {
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ Assert.equal(annosvc.getItemAnnotation(itemId, annotation.name),
+ annotation.value);
+ }
+ }
+ function *ensureAnnoUnset() {
+ for (let guid of guids) {
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ Assert.ok(!annosvc.itemHasAnnotation(itemId, annotation.name));
+ }
+ }
+
+ yield ensureAnnoSet();
+ yield PT.undo();
+ yield ensureAnnoUnset();
+ yield PT.redo();
+ yield ensureAnnoSet();
+ yield PT.undo();
+ yield ensureAnnoUnset();
+
+ // Cleanup
+ yield PT.undo();
+ yield PT.undo();
+ yield ensureNonExistent(...guids);
+ yield PT.clearTransactionsHistory();
+ observer.reset();
+});
+
+add_task(function* test_remove_multiple() {
+ let guids = [];
+ yield PT.batch(function* () {
+ let folderGuid = yield PT.NewFolder({ title: "Test Folder"
+ , parentGuid: rootGuid }).transact();
+ let nestedFolderGuid =
+ yield PT.NewFolder({ title: "Nested Test Folder"
+ , parentGuid: folderGuid }).transact();
+ yield PT.NewSeparator(nestedFolderGuid).transact();
+
+ guids.push(folderGuid);
+
+ let bmGuid =
+ yield PT.NewBookmark({ url: new URL("http://test.bookmark.removed")
+ , parentGuid: rootGuid }).transact();
+ guids.push(bmGuid);
+ });
+
+ let originalInfos = [];
+ for (let guid of guids) {
+ originalInfos.push(yield PlacesUtils.promiseBookmarksTree(guid));
+ }
+
+ yield PT.Remove(guids).transact();
+ yield ensureNonExistent(...guids);
+ yield PT.undo();
+ yield ensureBookmarksTreeRestoredCorrectly(...originalInfos);
+ yield PT.redo();
+ yield ensureNonExistent(...guids);
+ yield PT.undo();
+ yield ensureBookmarksTreeRestoredCorrectly(...originalInfos);
+
+ // Undo the New* transactions batch.
+ yield PT.undo();
+ yield ensureNonExistent(...guids);
+
+ // Redo it.
+ yield PT.redo();
+ yield ensureBookmarksTreeRestoredCorrectly(...originalInfos);
+
+ // Redo remove.
+ yield PT.redo();
+ yield ensureNonExistent(...guids);
+
+ // Cleanup
+ yield PT.clearTransactionsHistory();
+ observer.reset();
+});
+
+add_task(function* test_remove_bookmarks_for_urls() {
+ let urls = [new URL("http://test.url.1"), new URL("http://test.url.2")];
+ let guids = [];
+ yield PT.batch(function* () {
+ for (let url of urls) {
+ for (let title of ["test title a", "test title b"]) {
+ let txn = PT.NewBookmark({ url, title, parentGuid: rootGuid });
+ guids.push(yield txn.transact());
+ }
+ }
+ });
+
+ let originalInfos = [];
+ for (let guid of guids) {
+ originalInfos.push(yield PlacesUtils.promiseBookmarksTree(guid));
+ }
+
+ yield PT.RemoveBookmarksForUrls(urls).transact();
+ yield ensureNonExistent(...guids);
+ yield PT.undo();
+ yield ensureBookmarksTreeRestoredCorrectly(...originalInfos);
+ yield PT.redo();
+ yield ensureNonExistent(...guids);
+ yield PT.undo();
+ yield ensureBookmarksTreeRestoredCorrectly(...originalInfos);
+
+ // Cleanup.
+ yield PT.redo();
+ yield ensureNonExistent(...guids);
+ yield PT.clearTransactionsHistory();
+ observer.reset();
+});
diff --git a/toolkit/components/places/tests/unit/test_autocomplete_stopSearch_no_throw.js b/toolkit/components/places/tests/unit/test_autocomplete_stopSearch_no_throw.js
new file mode 100644
index 000000000..7d5df565f
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_autocomplete_stopSearch_no_throw.js
@@ -0,0 +1,39 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Added with bug 508102 to make sure that calling stopSearch on our
+ * AutoComplete implementation does not throw.
+ */
+
+// Globals and Constants
+
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var ac = Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"].
+ getService(Ci.nsIAutoCompleteSearch);
+
+// Test Functions
+
+function test_stopSearch()
+{
+ try {
+ ac.stopSearch();
+ }
+ catch (e) {
+ do_throw("we should not have caught anything!");
+ }
+}
+
+// Test Runner
+
+var tests = [
+ test_stopSearch,
+];
+function run_test()
+{
+ tests.forEach(test => test());
+}
diff --git a/toolkit/components/places/tests/unit/test_bookmark_catobs.js b/toolkit/components/places/tests/unit/test_bookmark_catobs.js
new file mode 100644
index 000000000..e2b589090
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmark_catobs.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ run_next_test()
+}
+
+add_task(function* test_observers() {
+ do_load_manifest("nsDummyObserver.manifest");
+
+ let dummyCreated = false;
+ let dummyReceivedOnItemAdded = false;
+
+ Services.obs.addObserver(function created() {
+ Services.obs.removeObserver(created, "dummy-observer-created");
+ dummyCreated = true;
+ }, "dummy-observer-created", false);
+ Services.obs.addObserver(function added() {
+ Services.obs.removeObserver(added, "dummy-observer-item-added");
+ dummyReceivedOnItemAdded = true;
+ }, "dummy-observer-item-added", false);
+
+ let initialObservers = PlacesUtils.bookmarks.getObservers();
+
+ // Add a common observer, it should be invoked after the category observer.
+ let notificationsPromised = new Promise((resolve, reject) => {
+ PlacesUtils.bookmarks.addObserver( {
+ __proto__: NavBookmarkObserver.prototype,
+ onItemAdded() {
+ let observers = PlacesUtils.bookmarks.getObservers();
+ Assert.equal(observers.length, initialObservers.length + 1);
+
+ // Check the common observer is the last one.
+ for (let i = 0; i < initialObservers.length; ++i) {
+ Assert.equal(initialObservers[i], observers[i]);
+ }
+
+ PlacesUtils.bookmarks.removeObserver(this);
+ observers = PlacesUtils.bookmarks.getObservers();
+ Assert.equal(observers.length, initialObservers.length);
+
+ // Check the category observer has been invoked before this one.
+ Assert.ok(dummyCreated);
+ Assert.ok(dummyReceivedOnItemAdded);
+ resolve();
+ }
+ }, false);
+ });
+
+ // Add a bookmark
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("http://typed.mozilla.org"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark");
+
+ yield notificationsPromised;
+});
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html.js b/toolkit/components/places/tests/unit/test_bookmarks_html.js
new file mode 100644
index 000000000..b10dc6185
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_html.js
@@ -0,0 +1,385 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+
+// An object representing the contents of bookmarks.preplaces.html.
+var test_bookmarks = {
+ menu: [
+ { title: "Mozilla Firefox",
+ children: [
+ { title: "Help and Tutorials",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/help/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { title: "Customize Firefox",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/customize/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { title: "Get Involved",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/community/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { title: "About Us",
+ url: "http://en-us.www.mozilla.com/en-US/about/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ }
+ ]
+ },
+ {
+ type: Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR
+ },
+ { title: "test",
+ description: "folder test comment",
+ dateAdded: 1177541020000000,
+ lastModified: 1177541050000000,
+ children: [
+ { title: "test post keyword",
+ description: "item description",
+ dateAdded: 1177375336000000,
+ lastModified: 1177375423000000,
+ keyword: "test",
+ sidebar: true,
+ postData: "hidden1%3Dbar&text1%3D%25s",
+ charset: "ISO-8859-1",
+ url: "http://test/post"
+ }
+ ]
+ }
+ ],
+ toolbar: [
+ { title: "Getting Started",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/central/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { title: "Latest Headlines",
+ url: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/",
+ feedUrl: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml"
+ }
+ ],
+ unfiled: [
+ { title: "Example.tld",
+ url: "http://example.tld/"
+ }
+ ]
+};
+
+// Pre-Places bookmarks.html file pointer.
+var gBookmarksFileOld;
+// Places bookmarks.html file pointer.
+var gBookmarksFileNew;
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* setup() {
+ // Avoid creating smart bookmarks during the test.
+ Services.prefs.setIntPref("browser.places.smartBookmarksVersion", -1);
+
+ // File pointer to legacy bookmarks file.
+ gBookmarksFileOld = do_get_file("bookmarks.preplaces.html");
+
+ // File pointer to a new Places-exported bookmarks file.
+ gBookmarksFileNew = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ gBookmarksFileNew.append("bookmarks.exported.html");
+ if (gBookmarksFileNew.exists()) {
+ gBookmarksFileNew.remove(false);
+ }
+
+ // This test must be the first one, since it setups the new bookmarks.html.
+ // Test importing a pre-Places canonical bookmarks file.
+ // 1. import bookmarks.preplaces.html
+ // 2. run the test-suite
+ // Note: we do not empty the db before this import to catch bugs like 380999
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileOld, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_new()
+{
+ // Test importing a Places bookmarks.html file.
+ // 1. import bookmarks.exported.html
+ // 2. run the test-suite
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ yield testImportedBookmarks();
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_emptytitle_export()
+{
+ // Test exporting and importing with an empty-titled bookmark.
+ // 1. import bookmarks
+ // 2. create an empty-titled bookmark.
+ // 3. export to bookmarks.exported.html
+ // 4. empty bookmarks db
+ // 5. import bookmarks.exported.html
+ // 6. run the test-suite
+ // 7. remove the empty-titled bookmark
+ // 8. export to bookmarks.exported.html
+ // 9. empty bookmarks db and continue
+
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ const NOTITLE_URL = "http://notitle.mozilla.org/";
+ let bookmark = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: NOTITLE_URL
+ });
+ test_bookmarks.unfiled.push({ title: "", url: NOTITLE_URL });
+
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield PlacesUtils.bookmarks.eraseEverything();
+
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+
+ // Cleanup.
+ test_bookmarks.unfiled.pop();
+ // HTML imports don't restore GUIDs yet.
+ let reimportedBookmark = yield PlacesUtils.bookmarks.fetch({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX
+ });
+ Assert.equal(reimportedBookmark.url.href, bookmark.url.href);
+ yield PlacesUtils.bookmarks.remove(reimportedBookmark);
+
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_chromefavicon()
+{
+ // Test exporting and importing with a bookmark pointing to a chrome favicon.
+ // 1. import bookmarks
+ // 2. create a bookmark pointing to a chrome favicon.
+ // 3. export to bookmarks.exported.html
+ // 4. empty bookmarks db
+ // 5. import bookmarks.exported.html
+ // 6. run the test-suite
+ // 7. remove the bookmark pointing to a chrome favicon.
+ // 8. export to bookmarks.exported.html
+ // 9. empty bookmarks db and continue
+
+ const PAGE_URI = NetUtil.newURI("http://example.com/chromefavicon_page");
+ const CHROME_FAVICON_URI = NetUtil.newURI("chrome://global/skin/icons/information-16.png");
+ const CHROME_FAVICON_URI_2 = NetUtil.newURI("chrome://global/skin/icons/error-16.png");
+
+ do_print("Importing from html");
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Insert bookmark");
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: PAGE_URI,
+ title: "Test"
+ });
+
+ do_print("Set favicon");
+ yield new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ PAGE_URI, CHROME_FAVICON_URI, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+
+ let data = yield new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(
+ PAGE_URI, (uri, dataLen, faviconData, mimeType) => resolve(faviconData));
+ });
+
+ let base64Icon = "data:image/png;base64," +
+ base64EncodeString(String.fromCharCode.apply(String, data));
+
+ test_bookmarks.unfiled.push(
+ { title: "Test", url: PAGE_URI.spec, icon: base64Icon });
+
+ do_print("Export to html");
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Set favicon");
+ // Change the favicon to check it's really imported again later.
+ yield new Promise(resolve => {
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ PAGE_URI, CHROME_FAVICON_URI_2, true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ resolve, Services.scriptSecurityManager.getSystemPrincipal());
+ });
+
+ do_print("import from html");
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ do_print("Test imported bookmarks");
+ yield testImportedBookmarks();
+
+ // Cleanup.
+ test_bookmarks.unfiled.pop();
+ // HTML imports don't restore GUIDs yet.
+ let reimportedBookmark = yield PlacesUtils.bookmarks.fetch({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX
+ });
+ yield PlacesUtils.bookmarks.remove(reimportedBookmark);
+
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_ontop()
+{
+ // Test importing the exported bookmarks.html file *on top of* the existing
+ // bookmarks.
+ // 1. empty bookmarks db
+ // 2. import the exported bookmarks file
+ // 3. export to file
+ // 3. import the exported bookmarks file
+ // 4. run the test-suite
+
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield BookmarkHTMLUtils.exportToFile(gBookmarksFileNew);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ yield BookmarkHTMLUtils.importFromFile(gBookmarksFileNew, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+function* testImportedBookmarks()
+{
+ for (let group in test_bookmarks) {
+ do_print("[testImportedBookmarks()] Checking group '" + group + "'");
+
+ let root;
+ switch (group) {
+ case "menu":
+ root = PlacesUtils.getFolderContents(PlacesUtils.bookmarksMenuFolderId).root;
+ break;
+ case "toolbar":
+ root = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+ break;
+ case "unfiled":
+ root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ break;
+ }
+
+ let items = test_bookmarks[group];
+ do_check_eq(root.childCount, items.length);
+
+ for (let key in items) {
+ yield checkItem(items[key], root.getChild(key));
+ }
+
+ root.containerOpen = false;
+ }
+}
+
+function* checkItem(aExpected, aNode)
+{
+ let id = aNode.itemId;
+
+ return Task.spawn(function* () {
+ for (prop in aExpected) {
+ switch (prop) {
+ case "type":
+ do_check_eq(aNode.type, aExpected.type);
+ break;
+ case "title":
+ do_check_eq(aNode.title, aExpected.title);
+ break;
+ case "description":
+ do_check_eq(PlacesUtils.annotations
+ .getItemAnnotation(id, DESCRIPTION_ANNO),
+ aExpected.description);
+ break;
+ case "dateAdded":
+ do_check_eq(PlacesUtils.bookmarks.getItemDateAdded(id),
+ aExpected.dateAdded);
+ break;
+ case "lastModified":
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(id),
+ aExpected.lastModified);
+ break;
+ case "url":
+ if (!("feedUrl" in aExpected))
+ do_check_eq(aNode.uri, aExpected.url)
+ break;
+ case "icon":
+ let deferred = Promise.defer();
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(aExpected.url),
+ function (aURI, aDataLen, aData, aMimeType) {
+ deferred.resolve(aData);
+ });
+ let data = yield deferred.promise;
+ let base64Icon = "data:image/png;base64," +
+ base64EncodeString(String.fromCharCode.apply(String, data));
+ do_check_true(base64Icon == aExpected.icon);
+ break;
+ case "keyword": {
+ let entry = yield PlacesUtils.keywords.fetch({ url: aNode.uri });
+ Assert.equal(entry.keyword, aExpected.keyword);
+ break;
+ }
+ case "sidebar":
+ do_check_eq(PlacesUtils.annotations
+ .itemHasAnnotation(id, LOAD_IN_SIDEBAR_ANNO),
+ aExpected.sidebar);
+ break;
+ case "postData": {
+ let entry = yield PlacesUtils.keywords.fetch({ url: aNode.uri });
+ Assert.equal(entry.postData, aExpected.postData);
+ break;
+ }
+ case "charset":
+ let testURI = NetUtil.newURI(aNode.uri);
+ do_check_eq((yield PlacesUtils.getCharsetForURI(testURI)), aExpected.charset);
+ break;
+ case "feedUrl":
+ let livemark = yield PlacesUtils.livemarks.getLivemark({ id: id });
+ do_check_eq(livemark.siteURI.spec, aExpected.url);
+ do_check_eq(livemark.feedURI.spec, aExpected.feedUrl);
+ break;
+ case "children":
+ let folder = aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(folder.hasChildren, aExpected.children.length > 0);
+ folder.containerOpen = true;
+ do_check_eq(folder.childCount, aExpected.children.length);
+
+ for (let index = 0; index < aExpected.children.length; index++) {
+ yield checkItem(aExpected.children[index], folder.getChild(index));
+ }
+
+ folder.containerOpen = false;
+ break;
+ default:
+ throw new Error("Unknown property");
+ }
+ }
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js b/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js
new file mode 100644
index 000000000..845b2227b
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_html_corrupt.js
@@ -0,0 +1,143 @@
+/*
+ * This test ensures that importing/exporting to HTML does not stop
+ * if a malformed uri is found.
+ */
+
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+
+const TEST_FAVICON_PAGE_URL = "http://en-US.www.mozilla.com/en-US/firefox/central/";
+const TEST_FAVICON_DATA_SIZE = 580;
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_corrupt_file() {
+ // avoid creating the places smart folder during tests
+ Services.prefs.setIntPref("browser.places.smartBookmarksVersion", -1);
+
+ // Import bookmarks from the corrupt file.
+ let corruptHtml = OS.Path.join(do_get_cwd().path, "bookmarks.corrupt.html");
+ yield BookmarkHTMLUtils.importFromFile(corruptHtml, true);
+
+ // Check that bookmarks that are not corrupt have been imported.
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield database_check();
+});
+
+add_task(function* test_corrupt_database() {
+ // Create corruption in the database, then export.
+ let corruptBookmark = yield PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: "http://test.mozilla.org",
+ title: "We love belugas" });
+ yield PlacesUtils.withConnectionWrapper("test", Task.async(function*(db) {
+ yield db.execute("UPDATE moz_bookmarks SET fk = NULL WHERE guid = :guid",
+ { guid: corruptBookmark.guid });
+ }));
+
+ let bookmarksFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.exported.html");
+ if ((yield OS.File.exists(bookmarksFile)))
+ yield OS.File.remove(bookmarksFile);
+ yield BookmarkHTMLUtils.exportToFile(bookmarksFile);
+
+ // Import again and check for correctness.
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield BookmarkHTMLUtils.importFromFile(bookmarksFile, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield database_check();
+});
+
+/*
+ * Check for imported bookmarks correctness
+ *
+ * @return {Promise}
+ * @resolves When the checks are finished.
+ * @rejects Never.
+ */
+var database_check = Task.async(function* () {
+ // BOOKMARKS MENU
+ let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarksMenuFolderId).root;
+ Assert.equal(root.childCount, 2);
+
+ let folderNode = root.getChild(1);
+ Assert.equal(folderNode.type, folderNode.RESULT_TYPE_FOLDER);
+ Assert.equal(folderNode.title, "test");
+ Assert.equal(PlacesUtils.bookmarks.getItemDateAdded(folderNode.itemId), 1177541020000000);
+ Assert.equal(PlacesUtils.bookmarks.getItemLastModified(folderNode.itemId), 1177541050000000);
+ Assert.equal("folder test comment",
+ PlacesUtils.annotations.getItemAnnotation(folderNode.itemId,
+ DESCRIPTION_ANNO));
+ // open test folder, and test the children
+ PlacesUtils.asQuery(folderNode);
+ Assert.equal(folderNode.hasChildren, true);
+ folderNode.containerOpen = true;
+ Assert.equal(folderNode.childCount, 1);
+
+ let bookmarkNode = folderNode.getChild(0);
+ Assert.equal("http://test/post", bookmarkNode.uri);
+ Assert.equal("test post keyword", bookmarkNode.title);
+
+ let entry = yield PlacesUtils.keywords.fetch({ url: bookmarkNode.uri });
+ Assert.equal("test", entry.keyword);
+ Assert.equal("hidden1%3Dbar&text1%3D%25s", entry.postData);
+
+ Assert.ok(PlacesUtils.annotations.itemHasAnnotation(bookmarkNode.itemId,
+ LOAD_IN_SIDEBAR_ANNO));
+ Assert.equal(bookmarkNode.dateAdded, 1177375336000000);
+ Assert.equal(bookmarkNode.lastModified, 1177375423000000);
+
+ Assert.equal((yield PlacesUtils.getCharsetForURI(NetUtil.newURI(bookmarkNode.uri))),
+ "ISO-8859-1");
+
+ Assert.equal("item description",
+ PlacesUtils.annotations.getItemAnnotation(bookmarkNode.itemId,
+ DESCRIPTION_ANNO));
+
+ // clean up
+ folderNode.containerOpen = false;
+ root.containerOpen = false;
+
+ // BOOKMARKS TOOLBAR
+ root = PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+ Assert.equal(root.childCount, 3);
+
+ // For now some promises are resolved later, so we can't guarantee an order.
+ let foundLivemark = false;
+ for (let i = 0; i < root.childCount; ++i) {
+ let node = root.getChild(i);
+ if (node.title == "Latest Headlines") {
+ foundLivemark = true;
+ Assert.equal("Latest Headlines", node.title);
+
+ let livemark = yield PlacesUtils.livemarks.getLivemark({ guid: node.bookmarkGuid });
+ Assert.equal("http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/",
+ livemark.siteURI.spec);
+ Assert.equal("http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml",
+ livemark.feedURI.spec);
+ }
+ }
+ Assert.ok(foundLivemark);
+
+ // cleanup
+ root.containerOpen = false;
+
+ // UNFILED BOOKMARKS
+ root = PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ Assert.equal(root.childCount, 1);
+ root.containerOpen = false;
+
+ // favicons
+ yield new Promise(resolve => {
+ PlacesUtils.favicons.getFaviconDataForPage(uri(TEST_FAVICON_PAGE_URL),
+ (aURI, aDataLen, aData, aMimeType) => {
+ // aURI should never be null when aDataLen > 0.
+ Assert.notEqual(aURI, null);
+ // Favicon data is stored in the bookmarks file as a "data:" URI. For
+ // simplicity, instead of converting the data we receive to a "data:" URI
+ // and comparing it, we just check the data size.
+ Assert.equal(TEST_FAVICON_DATA_SIZE, aDataLen);
+ resolve();
+ });
+ });
+});
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js b/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js
new file mode 100644
index 000000000..e4ba433a3
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_html_import_tags.js
@@ -0,0 +1,57 @@
+var bookmarkData = [
+ { uri: uri("http://www.toastytech.com"),
+ title: "Nathan's Toasty Technology Page",
+ tags: ["technology", "personal", "retro"] },
+ { uri: uri("http://www.reddit.com"),
+ title: "reddit: the front page of the internet",
+ tags: ["social media", "news", "humour"] },
+ { uri: uri("http://www.4chan.org"),
+ title: "4chan",
+ tags: ["discussion", "imageboard", "anime"] }
+];
+
+/*
+ TEST SUMMARY
+ - Add bookmarks with tags
+ - Export tagged bookmarks as HTML file
+ - Delete bookmarks
+ - Import bookmarks from HTML file
+ - Check that all bookmarks are successfully imported with tags
+*/
+
+add_task(function* test_import_tags() {
+ // Removes bookmarks.html if the file already exists.
+ let HTMLFile = OS.Path.join(OS.Constants.Path.profileDir, "bookmarks.html");
+ if ((yield OS.File.exists(HTMLFile)))
+ yield OS.File.remove(HTMLFile);
+
+ // Adds bookmarks and tags to the database.
+ let bookmarkList = new Set();
+ for (let { uri, title, tags } of bookmarkData) {
+ bookmarkList.add(yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: uri,
+ title }));
+ PlacesUtils.tagging.tagURI(uri, tags);
+ }
+
+ // Exports the bookmarks as a HTML file.
+ yield BookmarkHTMLUtils.exportToFile(HTMLFile);
+
+ // Deletes bookmarks and tags from the database.
+ for (let bookmark of bookmarkList) {
+ yield PlacesUtils.bookmarks.remove(bookmark.guid);
+ }
+
+ // Re-imports the bookmarks from the HTML file.
+ yield BookmarkHTMLUtils.importFromFile(HTMLFile, true);
+
+ // Tests to ensure that the tags are still present for each bookmark URI.
+ for (let { uri, tags } of bookmarkData) {
+ do_print("Test tags for " + uri.spec + ": " + tags + "\n");
+ let foundTags = PlacesUtils.tagging.getTagsForURI(uri);
+ Assert.equal(foundTags.length, tags.length);
+ Assert.ok(tags.every(tag => foundTags.includes(tag)));
+ }
+});
+
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js b/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js
new file mode 100644
index 000000000..02b430ff2
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_html_singleframe.js
@@ -0,0 +1,32 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test for bug #801450
+
+// Get Services
+Cu.import("resource://gre/modules/BookmarkHTMLUtils.jsm");
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_bookmarks_html_singleframe()
+{
+ let bookmarksFile = OS.Path.join(do_get_cwd().path, "bookmarks_html_singleframe.html");
+ yield BookmarkHTMLUtils.importFromFile(bookmarksFile, true);
+
+ let root = PlacesUtils.getFolderContents(PlacesUtils.bookmarksMenuFolderId).root;
+ do_check_eq(root.childCount, 1);
+ let folder = root.getChild(0);
+ PlacesUtils.asContainer(folder).containerOpen = true;
+ do_check_eq(folder.title, "Subtitle");
+ do_check_eq(folder.childCount, 1);
+ let bookmark = folder.getChild(0);
+ do_check_eq(bookmark.uri, "http://www.mozilla.org/");
+ do_check_eq(bookmark.title, "Mozilla");
+ folder.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_json.js b/toolkit/components/places/tests/unit/test_bookmarks_json.js
new file mode 100644
index 000000000..a6801540a
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_json.js
@@ -0,0 +1,241 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/BookmarkJSONUtils.jsm");
+
+function run_test() {
+ run_next_test();
+}
+
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+
+// An object representing the contents of bookmarks.json.
+var test_bookmarks = {
+ menu: [
+ { guid: "OCyeUO5uu9FF",
+ title: "Mozilla Firefox",
+ children: [
+ { guid:"OCyeUO5uu9FG",
+ title: "Help and Tutorials",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/help/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { guid:"OCyeUO5uu9FH",
+ title: "Customize Firefox",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/customize/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { guid:"OCyeUO5uu9FI",
+ title: "Get Involved",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/community/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { guid:"OCyeUO5uu9FJ",
+ title: "About Us",
+ url: "http://en-us.www.mozilla.com/en-US/about/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ }
+ ]
+ },
+ {
+ guid: "OCyeUO5uu9FK",
+ type: Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR
+ },
+ {
+ guid:"OCyeUO5uu9FL",
+ title: "test",
+ description: "folder test comment",
+ dateAdded: 1177541020000000,
+ // lastModified: 1177541050000000,
+ children: [
+ { guid:"OCyeUO5uu9GX",
+ title: "test post keyword",
+ description: "item description",
+ dateAdded: 1177375336000000,
+ // lastModified: 1177375423000000,
+ keyword: "test",
+ sidebar: true,
+ postData: "hidden1%3Dbar&text1%3D%25s",
+ charset: "ISO-8859-1"
+ }
+ ]
+ }
+ ],
+ toolbar: [
+ { guid: "OCyeUO5uu9FB",
+ title: "Getting Started",
+ url: "http://en-us.www.mozilla.com/en-US/firefox/central/",
+ icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="
+ },
+ { guid:"OCyeUO5uu9FR",
+ title: "Latest Headlines",
+ url: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/livebookmarks/",
+ feedUrl: "http://en-us.fxfeeds.mozilla.com/en-US/firefox/headlines.xml"
+ }
+ ],
+ unfiled: [
+ { guid: "OCyeUO5uu9FW",
+ title: "Example.tld",
+ url: "http://example.tld/"
+ }
+ ]
+};
+
+// Exported bookmarks file pointer.
+var bookmarksExportedFile;
+
+add_task(function* test_import_bookmarks() {
+ let bookmarksFile = OS.Path.join(do_get_cwd().path, "bookmarks.json");
+
+ yield BookmarkJSONUtils.importFromFile(bookmarksFile, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+});
+
+add_task(function* test_export_bookmarks() {
+ bookmarksExportedFile = OS.Path.join(OS.Constants.Path.profileDir,
+ "bookmarks.exported.json");
+ yield BookmarkJSONUtils.exportToFile(bookmarksExportedFile);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+});
+
+add_task(function* test_import_exported_bookmarks() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield BookmarkJSONUtils.importFromFile(bookmarksExportedFile, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+});
+
+add_task(function* test_import_ontop() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield BookmarkJSONUtils.importFromFile(bookmarksExportedFile, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield BookmarkJSONUtils.exportToFile(bookmarksExportedFile);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield BookmarkJSONUtils.importFromFile(bookmarksExportedFile, true);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ yield testImportedBookmarks();
+});
+
+add_task(function* test_clean() {
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+function* testImportedBookmarks() {
+ for (let group in test_bookmarks) {
+ do_print("[testImportedBookmarks()] Checking group '" + group + "'");
+
+ let root;
+ switch (group) {
+ case "menu":
+ root =
+ PlacesUtils.getFolderContents(PlacesUtils.bookmarksMenuFolderId).root;
+ break;
+ case "toolbar":
+ root =
+ PlacesUtils.getFolderContents(PlacesUtils.toolbarFolderId).root;
+ break;
+ case "unfiled":
+ root =
+ PlacesUtils.getFolderContents(PlacesUtils.unfiledBookmarksFolderId).root;
+ break;
+ }
+
+ let items = test_bookmarks[group];
+ do_check_eq(root.childCount, items.length);
+
+ for (let key in items) {
+ yield checkItem(items[key], root.getChild(key));
+ }
+
+ root.containerOpen = false;
+ }
+}
+
+function* checkItem(aExpected, aNode) {
+ let id = aNode.itemId;
+
+ return Task.spawn(function* () {
+ for (prop in aExpected) {
+ switch (prop) {
+ case "type":
+ do_check_eq(aNode.type, aExpected.type);
+ break;
+ case "title":
+ do_check_eq(aNode.title, aExpected.title);
+ break;
+ case "description":
+ do_check_eq(PlacesUtils.annotations.getItemAnnotation(
+ id, DESCRIPTION_ANNO), aExpected.description);
+ break;
+ case "dateAdded":
+ do_check_eq(PlacesUtils.bookmarks.getItemDateAdded(id),
+ aExpected.dateAdded);
+ break;
+ case "lastModified":
+ do_check_eq(PlacesUtils.bookmarks.getItemLastModified(id),
+ aExpected.lastModified);
+ break;
+ case "url":
+ if (!("feedUrl" in aExpected))
+ do_check_eq(aNode.uri, aExpected.url);
+ break;
+ case "icon":
+ let deferred = Promise.defer();
+ PlacesUtils.favicons.getFaviconDataForPage(
+ NetUtil.newURI(aExpected.url),
+ function (aURI, aDataLen, aData, aMimeType) {
+ deferred.resolve(aData);
+ });
+ let data = yield deferred.promise;
+ let base64Icon = "data:image/png;base64," +
+ base64EncodeString(String.fromCharCode.apply(String, data));
+ do_check_true(base64Icon == aExpected.icon);
+ break;
+ case "keyword": {
+ let entry = yield PlacesUtils.keywords.fetch({ url: aNode.uri });
+ Assert.equal(entry.keyword, aExpected.keyword);
+ break;
+ }
+ case "guid":
+ let guid = yield PlacesUtils.promiseItemGuid(id);
+ do_check_eq(guid, aExpected.guid);
+ break;
+ case "sidebar":
+ do_check_eq(PlacesUtils.annotations.itemHasAnnotation(
+ id, LOAD_IN_SIDEBAR_ANNO), aExpected.sidebar);
+ break;
+ case "postData": {
+ let entry = yield PlacesUtils.keywords.fetch({ url: aNode.uri });
+ Assert.equal(entry.postData, aExpected.postData);
+ break;
+ }
+ case "charset":
+ let testURI = NetUtil.newURI(aNode.uri);
+ do_check_eq((yield PlacesUtils.getCharsetForURI(testURI)), aExpected.charset);
+ break;
+ case "feedUrl":
+ let livemark = yield PlacesUtils.livemarks.getLivemark({ id: id });
+ do_check_eq(livemark.siteURI.spec, aExpected.url);
+ do_check_eq(livemark.feedURI.spec, aExpected.feedUrl);
+ break;
+ case "children":
+ let folder = aNode.QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(folder.hasChildren, aExpected.children.length > 0);
+ folder.containerOpen = true;
+ do_check_eq(folder.childCount, aExpected.children.length);
+
+ for (let index = 0; index < aExpected.children.length; index++) {
+ yield checkItem(aExpected.children[index], folder.getChild(index));
+ }
+
+ folder.containerOpen = false;
+ break;
+ default:
+ throw new Error("Unknown property");
+ }
+ }
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js b/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js
new file mode 100644
index 000000000..2f8022c6b
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_restore_notification.js
@@ -0,0 +1,325 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Cu.import("resource://gre/modules/BookmarkHTMLUtils.jsm");
+
+/**
+ * Tests the bookmarks-restore-* nsIObserver notifications after restoring
+ * bookmarks from JSON and HTML. See bug 470314.
+ */
+
+// The topics and data passed to nsIObserver.observe() on bookmarks restore
+const NSIOBSERVER_TOPIC_BEGIN = "bookmarks-restore-begin";
+const NSIOBSERVER_TOPIC_SUCCESS = "bookmarks-restore-success";
+const NSIOBSERVER_TOPIC_FAILED = "bookmarks-restore-failed";
+const NSIOBSERVER_DATA_JSON = "json";
+const NSIOBSERVER_DATA_HTML = "html";
+const NSIOBSERVER_DATA_HTML_INIT = "html-initial";
+
+// Bookmarks are added for these URIs
+var uris = [
+ "http://example.com/1",
+ "http://example.com/2",
+ "http://example.com/3",
+ "http://example.com/4",
+ "http://example.com/5",
+];
+
+/**
+ * Adds some bookmarks for the URIs in |uris|.
+ */
+function* addBookmarks() {
+ for (let url of uris) {
+ yield PlacesUtils.bookmarks.insert({
+ url: url, parentGuid: PlacesUtils.bookmarks.menuGuid
+ })
+ }
+ checkBookmarksExist();
+}
+
+/**
+ * Checks that all of the bookmarks created for |uris| exist. It works by
+ * creating one query per URI and then ORing all the queries. The number of
+ * results returned should be uris.length.
+ */
+function checkBookmarksExist() {
+ let hs = PlacesUtils.history;
+ let queries = uris.map(function (u) {
+ let q = hs.getNewQuery();
+ q.uri = uri(u);
+ return q;
+ });
+ let options = hs.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = hs.executeQueries(queries, uris.length, options).root;
+ root.containerOpen = true;
+ Assert.equal(root.childCount, uris.length);
+ root.containerOpen = false;
+}
+
+/**
+ * Creates an file in the profile directory.
+ *
+ * @param aBasename
+ * e.g., "foo.txt" in the path /some/long/path/foo.txt
+ * @return {Promise}
+ * @resolves to an OS.File path
+ */
+function promiseFile(aBasename) {
+ let path = OS.Path.join(OS.Constants.Path.profileDir, aBasename);
+ do_print("opening " + path);
+ return OS.File.open(path, { truncate: true })
+ .then(aFile => {
+ aFile.close();
+ return path;
+ });
+}
+
+/**
+ * Register observers via promiseTopicObserved helper.
+ *
+ * @param {boolean} expectSuccess pass true when expect a success notification
+ * @return {Promise[]}
+ */
+function registerObservers(expectSuccess) {
+ let promiseBegin = promiseTopicObserved(NSIOBSERVER_TOPIC_BEGIN);
+ let promiseResult;
+ if (expectSuccess) {
+ promiseResult = promiseTopicObserved(NSIOBSERVER_TOPIC_SUCCESS);
+ } else {
+ promiseResult = promiseTopicObserved(NSIOBSERVER_TOPIC_FAILED);
+ }
+
+ return [promiseBegin, promiseResult];
+}
+
+/**
+ * Check notification results.
+ *
+ * @param {Promise[]} expectPromises array contain promiseBegin and promiseResult
+ * @param {object} expectedData contain data and folderId
+ */
+function* checkObservers(expectPromises, expectedData) {
+ let [promiseBegin, promiseResult] = expectPromises;
+
+ let beginData = (yield promiseBegin)[1];
+ Assert.equal(beginData, expectedData.data,
+ "Data for current test should be what is expected");
+
+ let [resultSubject, resultData] = yield promiseResult;
+ Assert.equal(resultData, expectedData.data,
+ "Data for current test should be what is expected");
+
+ // Make sure folder ID is what is expected. For importing HTML into a
+ // folder, this will be an integer, otherwise null.
+ if (resultSubject) {
+ Assert.equal(aSubject.QueryInterface(Ci.nsISupportsPRInt64).data,
+ expectedData.folderId);
+ } else {
+ Assert.equal(expectedData.folderId, null);
+ }
+}
+
+/**
+ * Run after every test cases.
+ */
+function* teardown(file, begin, success, fail) {
+ // On restore failed, file may not exist, so wrap in try-catch.
+ try {
+ yield OS.File.remove(file, {ignoreAbsent: true});
+ } catch (e) {}
+
+ // clean up bookmarks
+ yield PlacesUtils.bookmarks.eraseEverything();
+}
+
+add_task(function* test_json_restore_normal() {
+ // data: the data passed to nsIObserver.observe() corresponding to the test
+ // folderId: for HTML restore into a folder, the folder ID to restore into;
+ // otherwise, set it to null
+ let expectedData = {
+ data: NSIOBSERVER_DATA_JSON,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("JSON restore: normal restore should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.json");
+ yield addBookmarks();
+
+ yield BookmarkJSONUtils.exportToFile(file);
+ yield PlacesUtils.bookmarks.eraseEverything();
+ try {
+ yield BookmarkJSONUtils.importFromFile(file, true);
+ } catch (e) {
+ do_throw(" Restore should not have failed" + e);
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_json_restore_empty() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_JSON,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("JSON restore: empty file should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.json");
+ try {
+ yield BookmarkJSONUtils.importFromFile(file, true);
+ } catch (e) {
+ do_throw(" Restore should not have failed" + e);
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_json_restore_nonexist() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_JSON,
+ folderId: null
+ }
+ let expectPromises = registerObservers(false);
+
+ do_print("JSON restore: nonexistent file should fail");
+ let file = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ file.append("this file doesn't exist because nobody created it 1");
+ try {
+ yield BookmarkJSONUtils.importFromFile(file, true);
+ do_throw(" Restore should have failed");
+ } catch (e) {}
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_restore_normal() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("HTML restore: normal restore should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.html");
+ yield addBookmarks();
+ yield BookmarkHTMLUtils.exportToFile(file);
+ yield PlacesUtils.bookmarks.eraseEverything();
+ try {
+ BookmarkHTMLUtils.importFromFile(file, false)
+ .then(null, do_report_unexpected_exception);
+ } catch (e) {
+ do_throw(" Restore should not have failed");
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_restore_empty() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("HTML restore: empty file should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.init.html");
+ try {
+ BookmarkHTMLUtils.importFromFile(file, false)
+ .then(null, do_report_unexpected_exception);
+ } catch (e) {
+ do_throw(" Restore should not have failed");
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_restore_nonexist() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML,
+ folderId: null
+ }
+ let expectPromises = registerObservers(false);
+
+ do_print("HTML restore: nonexistent file should fail");
+ let file = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ file.append("this file doesn't exist because nobody created it 2");
+ try {
+ yield BookmarkHTMLUtils.importFromFile(file, false);
+ do_throw("Should fail!");
+ } catch (e) {}
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_init_restore_normal() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML_INIT,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("HTML initial restore: normal restore should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.init.html");
+ yield addBookmarks();
+ yield BookmarkHTMLUtils.exportToFile(file);
+ yield PlacesUtils.bookmarks.eraseEverything();
+ try {
+ BookmarkHTMLUtils.importFromFile(file, true)
+ .then(null, do_report_unexpected_exception);
+ } catch (e) {
+ do_throw(" Restore should not have failed");
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_init_restore_empty() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML_INIT,
+ folderId: null
+ }
+ let expectPromises = registerObservers(true);
+
+ do_print("HTML initial restore: empty file should succeed");
+ let file = yield promiseFile("bookmarks-test_restoreNotification.init.html");
+ try {
+ BookmarkHTMLUtils.importFromFile(file, true)
+ .then(null, do_report_unexpected_exception);
+ } catch (e) {
+ do_throw(" Restore should not have failed");
+ }
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
+
+add_task(function* test_html_init_restore_nonexist() {
+ let expectedData = {
+ data: NSIOBSERVER_DATA_HTML_INIT,
+ folderId: null
+ }
+ let expectPromises = registerObservers(false);
+
+ do_print("HTML initial restore: nonexistent file should fail");
+ let file = Services.dirsvc.get("ProfD", Ci.nsILocalFile);
+ file.append("this file doesn't exist because nobody created it 3");
+ try {
+ yield BookmarkHTMLUtils.importFromFile(file, true);
+ do_throw("Should fail!");
+ } catch (e) {}
+
+ yield checkObservers(expectPromises, expectedData);
+ yield teardown(file);
+});
diff --git a/toolkit/components/places/tests/unit/test_bookmarks_setNullTitle.js b/toolkit/components/places/tests/unit/test_bookmarks_setNullTitle.js
new file mode 100644
index 000000000..959dfe85f
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bookmarks_setNullTitle.js
@@ -0,0 +1,44 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Both SetItemtitle and insertBookmark should allow for null titles.
+ */
+
+const bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+const TEST_URL = "http://www.mozilla.org";
+
+function run_test() {
+ // Insert a bookmark with an empty title.
+ var itemId = bs.insertBookmark(bs.toolbarFolder,
+ uri(TEST_URL),
+ bs.DEFAULT_INDEX,
+ "");
+ // Check returned title is an empty string.
+ do_check_eq(bs.getItemTitle(itemId), "");
+ // Set title to null.
+ bs.setItemTitle(itemId, null);
+ // Check returned title is null.
+ do_check_eq(bs.getItemTitle(itemId), null);
+ // Cleanup.
+ bs.removeItem(itemId);
+
+ // Insert a bookmark with a null title.
+ itemId = bs.insertBookmark(bs.toolbarFolder,
+ uri(TEST_URL),
+ bs.DEFAULT_INDEX,
+ null);
+ // Check returned title is null.
+ do_check_eq(bs.getItemTitle(itemId), null);
+ // Set title to an empty string.
+ bs.setItemTitle(itemId, "");
+ // Check returned title is an empty string.
+ do_check_eq(bs.getItemTitle(itemId), "");
+ // Cleanup.
+ bs.removeItem(itemId);
+}
diff --git a/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js b/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js
new file mode 100644
index 000000000..b67e141e6
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_broken_folderShortcut_result.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, NetUtil.newURI("http://1.moz.org/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Bookmark 1"
+ );
+ let id1 = PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, NetUtil.newURI("place:folder=1234"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Shortcut 1"
+ );
+ let id2 = PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, NetUtil.newURI("place:folder=-1"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Shortcut 2"
+ );
+ PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, NetUtil.newURI("http://2.moz.org/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Bookmark 2"
+ );
+
+ // Add also a simple visit.
+ yield PlacesTestUtils.addVisits(uri(("http://3.moz.org/")));
+
+ // Query containing a broken folder shortcuts among results.
+ let query = PlacesUtils.history.getNewQuery();
+ query.setFolders([PlacesUtils.unfiledBookmarksFolderId], 1);
+ let options = PlacesUtils.history.getNewQueryOptions();
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ do_check_eq(root.childCount, 4);
+
+ let shortcut = root.getChild(1);
+ do_check_eq(shortcut.uri, "place:folder=1234");
+ PlacesUtils.asContainer(shortcut);
+ shortcut.containerOpen = true;
+ do_check_eq(shortcut.childCount, 0);
+ shortcut.containerOpen = false;
+ // Remove the broken shortcut while the containing result is open.
+ PlacesUtils.bookmarks.removeItem(id1);
+ do_check_eq(root.childCount, 3);
+
+ shortcut = root.getChild(1);
+ do_check_eq(shortcut.uri, "place:folder=-1");
+ PlacesUtils.asContainer(shortcut);
+ shortcut.containerOpen = true;
+ do_check_eq(shortcut.childCount, 0);
+ shortcut.containerOpen = false;
+ // Remove the broken shortcut while the containing result is open.
+ PlacesUtils.bookmarks.removeItem(id2);
+ do_check_eq(root.childCount, 2);
+
+ root.containerOpen = false;
+
+ // Broken folder shortcut as root node.
+ query = PlacesUtils.history.getNewQuery();
+ query.setFolders([1234], 1);
+ options = PlacesUtils.history.getNewQueryOptions();
+ root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 0);
+ root.containerOpen = false;
+
+ // Broken folder shortcut as root node with folder=-1.
+ query = PlacesUtils.history.getNewQuery();
+ query.setFolders([-1], 1);
+ options = PlacesUtils.history.getNewQueryOptions();
+ root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 0);
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_browserhistory.js b/toolkit/components/places/tests/unit/test_browserhistory.js
new file mode 100644
index 000000000..5f88c26e3
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_browserhistory.js
@@ -0,0 +1,129 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_URI = NetUtil.newURI("http://mozilla.com/");
+const TEST_SUBDOMAIN_URI = NetUtil.newURI("http://foobar.mozilla.com/");
+
+add_task(function* test_addPage() {
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ do_check_eq(1, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_removePage() {
+ PlacesUtils.bhistory.removePage(TEST_URI);
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_removePages() {
+ let pages = [];
+ for (let i = 0; i < 8; i++) {
+ pages.push(NetUtil.newURI(TEST_URI.spec + i));
+ }
+
+ yield PlacesTestUtils.addVisits(pages.map(uri => ({ uri: uri })));
+ // Bookmarked item should not be removed from moz_places.
+ const ANNO_INDEX = 1;
+ const ANNO_NAME = "testAnno";
+ const ANNO_VALUE = "foo";
+ const BOOKMARK_INDEX = 2;
+ PlacesUtils.annotations.setPageAnnotation(pages[ANNO_INDEX],
+ ANNO_NAME, ANNO_VALUE, 0,
+ Ci.nsIAnnotationService.EXPIRE_NEVER);
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ pages[BOOKMARK_INDEX],
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test bookmark");
+ PlacesUtils.annotations.setPageAnnotation(pages[BOOKMARK_INDEX],
+ ANNO_NAME, ANNO_VALUE, 0,
+ Ci.nsIAnnotationService.EXPIRE_NEVER);
+
+ PlacesUtils.bhistory.removePages(pages, pages.length);
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+
+ // Check that the bookmark and its annotation still exist.
+ do_check_true(PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId, 0) > 0);
+ do_check_eq(PlacesUtils.annotations.getPageAnnotation(pages[BOOKMARK_INDEX], ANNO_NAME),
+ ANNO_VALUE);
+
+ // Check the annotation on the non-bookmarked page does not exist anymore.
+ try {
+ PlacesUtils.annotations.getPageAnnotation(pages[ANNO_INDEX], ANNO_NAME);
+ do_throw("did not expire expire_never anno on a not bookmarked item");
+ } catch (ex) {}
+
+ // Cleanup.
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId);
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_removePagesByTimeframe() {
+ let visits = [];
+ let startDate = (Date.now() - 10000) * 1000;
+ for (let i = 0; i < 10; i++) {
+ visits.push({
+ uri: NetUtil.newURI(TEST_URI.spec + i),
+ visitDate: startDate + i * 1000
+ });
+ }
+
+ yield PlacesTestUtils.addVisits(visits);
+
+ // Delete all pages except the first and the last.
+ PlacesUtils.bhistory.removePagesByTimeframe(startDate + 1000, startDate + 8000);
+
+ // Check that we have removed the correct pages.
+ for (let i = 0; i < 10; i++) {
+ do_check_eq(page_in_database(NetUtil.newURI(TEST_URI.spec + i)) == 0,
+ i > 0 && i < 9);
+ }
+
+ // Clear remaining items and check that all pages have been removed.
+ PlacesUtils.bhistory.removePagesByTimeframe(startDate, startDate + 9000);
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_removePagesFromHost() {
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ PlacesUtils.bhistory.removePagesFromHost("mozilla.com", true);
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_removePagesFromHost_keepSubdomains() {
+ yield PlacesTestUtils.addVisits([{ uri: TEST_URI }, { uri: TEST_SUBDOMAIN_URI }]);
+ PlacesUtils.bhistory.removePagesFromHost("mozilla.com", false);
+ do_check_eq(1, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_history_clear() {
+ yield PlacesTestUtils.clearHistory();
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+});
+
+add_task(function* test_getObservers() {
+ // Ensure that getObservers() invalidates the hasHistoryEntries cache.
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ do_check_eq(1, PlacesUtils.history.hasHistoryEntries);
+ // This is just for testing purposes, never do it.
+ return new Promise((resolve, reject) => {
+ DBConn().executeSimpleSQLAsync("DELETE FROM moz_historyvisits", {
+ handleError: function(error) {
+ reject(error);
+ },
+ handleResult: function(result) {
+ },
+ handleCompletion: function(result) {
+ // Just invoking getObservers should be enough to invalidate the cache.
+ PlacesUtils.history.getObservers();
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+ resolve();
+ }
+ });
+ });
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_bug636917_isLivemark.js b/toolkit/components/places/tests/unit/test_bug636917_isLivemark.js
new file mode 100644
index 000000000..a7ad1257a
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_bug636917_isLivemark.js
@@ -0,0 +1,35 @@
+// Test that asking for a livemark in a annotationChanged notification works.
+add_task(function* () {
+ let annoPromise = new Promise(resolve => {
+ let annoObserver = {
+ onItemAnnotationSet(id, name) {
+ if (name == PlacesUtils.LMANNO_FEEDURI) {
+ PlacesUtils.annotations.removeObserver(this);
+ resolve();
+ }
+ },
+ onItemAnnotationRemoved() {},
+ onPageAnnotationSet() {},
+ onPageAnnotationRemoved() {},
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIAnnotationObserver
+ ]),
+ };
+ PlacesUtils.annotations.addObserver(annoObserver, false);
+ });
+
+
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "livemark title"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , index: PlacesUtils.bookmarks.DEFAULT_INDEX
+ , siteURI: uri("http://example.com/")
+ , feedURI: uri("http://example.com/rdf")
+ });
+
+ yield annoPromise;
+
+ livemark = yield PlacesUtils.livemarks.getLivemark({ guid: livemark.guid });
+ Assert.ok(livemark);
+ yield PlacesUtils.livemarks.removeLivemark({ guid: livemark.guid });
+});
diff --git a/toolkit/components/places/tests/unit/test_childlessTags.js b/toolkit/components/places/tests/unit/test_childlessTags.js
new file mode 100644
index 000000000..4c3e38fa4
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_childlessTags.js
@@ -0,0 +1,117 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Ensures that removal of a bookmark untags the bookmark if it's no longer
+ * contained in any regular, non-tag folders. See bug 444849.
+ */
+
+// Add your tests here. Each is an object with a summary string |desc| and a
+// method run() that's called to run the test.
+var tests = [
+ {
+ desc: "Removing a tagged bookmark should cause the tag to be removed.",
+ run: function () {
+ print(" Make a bookmark.");
+ var bmId = bmsvc.insertBookmark(bmsvc.unfiledBookmarksFolder,
+ BOOKMARK_URI,
+ bmsvc.DEFAULT_INDEX,
+ "test bookmark");
+ do_check_true(bmId > 0);
+
+ print(" Tag it up.");
+ var tags = ["foo", "bar"];
+ tagssvc.tagURI(BOOKMARK_URI, tags);
+ ensureTagsExist(tags);
+
+ print(" Remove the bookmark. The tags should no longer exist.");
+ bmsvc.removeItem(bmId);
+ ensureTagsExist([]);
+ }
+ },
+
+ {
+ desc: "Removing a folder containing a tagged bookmark should cause the " +
+ "tag to be removed.",
+ run: function () {
+ print(" Make a folder.");
+ var folderId = bmsvc.createFolder(bmsvc.unfiledBookmarksFolder,
+ "test folder",
+ bmsvc.DEFAULT_INDEX);
+ do_check_true(folderId > 0);
+
+ print(" Stick a bookmark in the folder.");
+ var bmId = bmsvc.insertBookmark(folderId,
+ BOOKMARK_URI,
+ bmsvc.DEFAULT_INDEX,
+ "test bookmark");
+ do_check_true(bmId > 0);
+
+ print(" Tag the bookmark.");
+ var tags = ["foo", "bar"];
+ tagssvc.tagURI(BOOKMARK_URI, tags);
+ ensureTagsExist(tags);
+
+ print(" Remove the folder. The tags should no longer exist.");
+ bmsvc.removeItem(folderId);
+ ensureTagsExist([]);
+ }
+ }
+];
+
+var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+
+var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+
+const BOOKMARK_URI = uri("http://example.com/");
+
+/**
+ * Runs a tag query and ensures that the tags returned are those and only those
+ * in aTags. aTags may be empty, in which case this function ensures that no
+ * tags exist.
+ *
+ * @param aTags
+ * An array of tags (strings)
+ */
+function ensureTagsExist(aTags) {
+ var query = histsvc.getNewQuery();
+ var opts = histsvc.getNewQueryOptions();
+ opts.resultType = opts.RESULTS_AS_TAG_QUERY;
+ var resultRoot = histsvc.executeQuery(query, opts).root;
+
+ // Dupe aTags.
+ var tags = aTags.slice(0);
+
+ resultRoot.containerOpen = true;
+
+ // Ensure that the number of tags returned from the query is the same as the
+ // number in |tags|.
+ do_check_eq(resultRoot.childCount, tags.length);
+
+ // For each tag result from the query, ensure that it's contained in |tags|.
+ // Remove the tag from |tags| so that we ensure the sets are equal.
+ for (let i = 0; i < resultRoot.childCount; i++) {
+ var tag = resultRoot.getChild(i).title;
+ var indexOfTag = tags.indexOf(tag);
+ do_check_true(indexOfTag >= 0);
+ tags.splice(indexOfTag, 1);
+ }
+
+ resultRoot.containerOpen = false;
+}
+
+function run_test()
+{
+ tests.forEach(function (test) {
+ print("Running test: " + test.desc);
+ test.run();
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_corrupt_telemetry.js b/toolkit/components/places/tests/unit/test_corrupt_telemetry.js
new file mode 100644
index 000000000..cd9e9ec0c
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_corrupt_telemetry.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+add_task(function* () {
+ let profileDBPath = yield OS.Path.join(OS.Constants.Path.profileDir, "places.sqlite");
+ yield OS.File.remove(profileDBPath, {ignoreAbsent: true});
+ // Ensure that our database doesn't already exist.
+ Assert.ok(!(yield OS.File.exists(profileDBPath)), "places.sqlite shouldn't exist");
+ let dir = yield OS.File.getCurrentDirectory();
+ let src = OS.Path.join(dir, "corruptDB.sqlite");
+ yield OS.File.copy(src, profileDBPath);
+ Assert.ok(yield OS.File.exists(profileDBPath), "places.sqlite should exist");
+
+ let count = Services.telemetry
+ .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE")
+ .snapshot()
+ .counts[3];
+ Assert.equal(count, 0, "There should be no telemetry");
+
+ do_check_eq(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT);
+
+ count = Services.telemetry
+ .getHistogramById("PLACES_DATABASE_CORRUPTION_HANDLING_STAGE")
+ .snapshot()
+ .counts[3];
+ Assert.equal(count, 1, "Telemetry should have been added");
+});
diff --git a/toolkit/components/places/tests/unit/test_crash_476292.js b/toolkit/components/places/tests/unit/test_crash_476292.js
new file mode 100644
index 000000000..8f0862022
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_crash_476292.js
@@ -0,0 +1,28 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This tests a crash during startup found in bug 476292 that was caused by
+ * getting the bookmarks service during nsNavHistory::Init when the bookmarks
+ * service was created before the history service was.
+ */
+
+function run_test()
+{
+ // First, we need to move our old database file into our test profile
+ // directory. This will trigger DATABASE_STATUS_UPGRADED (CREATE is not
+ // sufficient since there will be no entries to update frecencies for, which
+ // causes us to get the bookmarks service in the first place).
+ let dbFile = do_get_file("bug476292.sqlite");
+ let profD = Cc["@mozilla.org/file/directory_service;1"].
+ getService(Ci.nsIProperties).
+ get(NS_APP_USER_PROFILE_50_DIR, Ci.nsIFile);
+ dbFile.copyTo(profD, "places.sqlite");
+
+ // Now get the bookmarks service. This will crash when the bug exists.
+ Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+}
diff --git a/toolkit/components/places/tests/unit/test_database_replaceOnStartup.js b/toolkit/components/places/tests/unit/test_database_replaceOnStartup.js
new file mode 100644
index 000000000..e83d0fdae
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_database_replaceOnStartup.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that history initialization correctly handles a request to forcibly
+// replace the current database.
+
+function run_test() {
+ // Ensure that our database doesn't already exist.
+ let dbFile = gProfD.clone();
+ dbFile.append("places.sqlite");
+ do_check_false(dbFile.exists());
+
+ dbFile = gProfD.clone();
+ dbFile.append("places.sqlite.corrupt");
+ do_check_false(dbFile.exists());
+
+ let file = do_get_file("default.sqlite");
+ file.copyToFollowingLinks(gProfD, "places.sqlite");
+ file = gProfD.clone();
+ file.append("places.sqlite");
+
+ // Create some unique stuff to check later.
+ let db = Services.storage.openUnsharedDatabase(file);
+ db.executeSimpleSQL("CREATE TABLE test (id INTEGER PRIMARY KEY)");
+ db.close();
+
+ Services.prefs.setBoolPref("places.database.replaceOnStartup", true);
+ do_check_eq(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_CORRUPT);
+
+ dbFile = gProfD.clone();
+ dbFile.append("places.sqlite");
+ do_check_true(dbFile.exists());
+
+ // Check the new database is really a new one.
+ db = Services.storage.openUnsharedDatabase(file);
+ try {
+ db.executeSimpleSQL("DELETE * FROM test");
+ do_throw("The new database should not have our unique content");
+ } catch (ex) {}
+ db.close();
+
+ dbFile = gProfD.clone();
+ dbFile.append("places.sqlite.corrupt");
+ do_check_true(dbFile.exists());
+}
diff --git a/toolkit/components/places/tests/unit/test_download_history.js b/toolkit/components/places/tests/unit/test_download_history.js
new file mode 100644
index 000000000..643360b20
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_download_history.js
@@ -0,0 +1,283 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests the nsIDownloadHistory Places implementation.
+ */
+
+XPCOMUtils.defineLazyServiceGetter(this, "gDownloadHistory",
+ "@mozilla.org/browser/download-history;1",
+ "nsIDownloadHistory");
+
+const DOWNLOAD_URI = NetUtil.newURI("http://www.example.com/");
+const REFERRER_URI = NetUtil.newURI("http://www.example.org/");
+const PRIVATE_URI = NetUtil.newURI("http://www.example.net/");
+
+/**
+ * Waits for the first visit notification to be received.
+ *
+ * @param aCallback
+ * Callback function to be called with the same arguments of onVisit.
+ */
+function waitForOnVisit(aCallback) {
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onVisit: function HO_onVisit() {
+ PlacesUtils.history.removeObserver(this);
+ aCallback.apply(null, arguments);
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+}
+
+/**
+ * Waits for the first onDeleteURI notification to be received.
+ *
+ * @param aCallback
+ * Callback function to be called with the same arguments of onDeleteURI.
+ */
+function waitForOnDeleteURI(aCallback) {
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onDeleteURI: function HO_onDeleteURI() {
+ PlacesUtils.history.removeObserver(this);
+ aCallback.apply(null, arguments);
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+}
+
+/**
+ * Waits for the first onDeleteVisits notification to be received.
+ *
+ * @param aCallback
+ * Callback function to be called with the same arguments of onDeleteVisits.
+ */
+function waitForOnDeleteVisits(aCallback) {
+ let historyObserver = {
+ __proto__: NavHistoryObserver.prototype,
+ onDeleteVisits: function HO_onDeleteVisits() {
+ PlacesUtils.history.removeObserver(this);
+ aCallback.apply(null, arguments);
+ }
+ };
+ PlacesUtils.history.addObserver(historyObserver, false);
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_test(function test_dh_is_from_places()
+{
+ // Test that this nsIDownloadHistory is the one places implements.
+ do_check_true(gDownloadHistory instanceof Ci.mozIAsyncHistory);
+
+ run_next_test();
+});
+
+add_test(function test_dh_addRemoveDownload()
+{
+ waitForOnVisit(function DHAD_onVisit(aURI) {
+ do_check_true(aURI.equals(DOWNLOAD_URI));
+
+ // Verify that the URI is already available in results at this time.
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ waitForOnDeleteURI(function DHRAD_onDeleteURI(aDeletedURI) {
+ do_check_true(aDeletedURI.equals(DOWNLOAD_URI));
+
+ // Verify that the URI is already available in results at this time.
+ do_check_false(!!page_in_database(DOWNLOAD_URI));
+
+ run_next_test();
+ });
+ gDownloadHistory.removeAllDownloads();
+ });
+
+ gDownloadHistory.addDownload(DOWNLOAD_URI, null, Date.now() * 1000);
+});
+
+add_test(function test_dh_addMultiRemoveDownload()
+{
+ PlacesTestUtils.addVisits({
+ uri: DOWNLOAD_URI,
+ transition: TRANSITION_TYPED
+ }).then(function () {
+ waitForOnVisit(function DHAD_onVisit(aURI) {
+ do_check_true(aURI.equals(DOWNLOAD_URI));
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ waitForOnDeleteVisits(function DHRAD_onDeleteVisits(aDeletedURI) {
+ do_check_true(aDeletedURI.equals(DOWNLOAD_URI));
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ PlacesTestUtils.clearHistory().then(run_next_test);
+ });
+ gDownloadHistory.removeAllDownloads();
+ });
+
+ gDownloadHistory.addDownload(DOWNLOAD_URI, null, Date.now() * 1000);
+ });
+});
+
+add_test(function test_dh_addBookmarkRemoveDownload()
+{
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ DOWNLOAD_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "A bookmark");
+ waitForOnVisit(function DHAD_onVisit(aURI) {
+ do_check_true(aURI.equals(DOWNLOAD_URI));
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ waitForOnDeleteVisits(function DHRAD_onDeleteVisits(aDeletedURI) {
+ do_check_true(aDeletedURI.equals(DOWNLOAD_URI));
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ PlacesTestUtils.clearHistory().then(run_next_test);
+ });
+ gDownloadHistory.removeAllDownloads();
+ });
+
+ gDownloadHistory.addDownload(DOWNLOAD_URI, null, Date.now() * 1000);
+});
+
+add_test(function test_dh_addDownload_referrer()
+{
+ waitForOnVisit(function DHAD_prepareReferrer(aURI, aVisitID) {
+ do_check_true(aURI.equals(REFERRER_URI));
+ let referrerVisitId = aVisitID;
+
+ waitForOnVisit(function DHAD_onVisit(aVisitedURI, unused, unused2, unused3,
+ aReferringID) {
+ do_check_true(aVisitedURI.equals(DOWNLOAD_URI));
+ do_check_eq(aReferringID, referrerVisitId);
+
+ // Verify that the URI is already available in results at this time.
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+
+ PlacesTestUtils.clearHistory().then(run_next_test);
+ });
+
+ gDownloadHistory.addDownload(DOWNLOAD_URI, REFERRER_URI, Date.now() * 1000);
+ });
+
+ // Note that we don't pass the optional callback argument here because we must
+ // ensure that we receive the onVisit notification before we call addDownload.
+ PlacesUtils.asyncHistory.updatePlaces({
+ uri: REFERRER_URI,
+ visits: [{
+ transitionType: Ci.nsINavHistoryService.TRANSITION_TYPED,
+ visitDate: Date.now() * 1000
+ }]
+ });
+});
+
+add_test(function test_dh_addDownload_disabledHistory()
+{
+ waitForOnVisit(function DHAD_onVisit(aURI) {
+ // We should only receive the notification for the non-private URI. This
+ // test is based on the assumption that visit notifications are received in
+ // the same order of the addDownload calls, which is currently true because
+ // database access is serialized on the same worker thread.
+ do_check_true(aURI.equals(DOWNLOAD_URI));
+
+ do_check_true(!!page_in_database(DOWNLOAD_URI));
+ do_check_false(!!page_in_database(PRIVATE_URI));
+
+ PlacesTestUtils.clearHistory().then(run_next_test);
+ });
+
+ Services.prefs.setBoolPref("places.history.enabled", false);
+ gDownloadHistory.addDownload(PRIVATE_URI, REFERRER_URI, Date.now() * 1000);
+
+ // The addDownload functions calls CanAddURI synchronously, thus we can set
+ // the preference back to true immediately (not all apps enable places by
+ // default).
+ Services.prefs.setBoolPref("places.history.enabled", true);
+ gDownloadHistory.addDownload(DOWNLOAD_URI, REFERRER_URI, Date.now() * 1000);
+});
+
+/**
+ * Tests that nsIDownloadHistory::AddDownload saves the additional download
+ * details if the optional destination URL is specified.
+ */
+add_test(function test_dh_details()
+{
+ const REMOTE_URI = NetUtil.newURI("http://localhost/");
+ const SOURCE_URI = NetUtil.newURI("http://example.com/test_dh_details");
+ const DEST_FILE_NAME = "dest.txt";
+
+ // We must build a real, valid file URI for the destination.
+ let destFileUri = NetUtil.newURI(FileUtils.getFile("TmpD", [DEST_FILE_NAME]));
+
+ let titleSet = false;
+ let destinationFileUriSet = false;
+ let destinationFileNameSet = false;
+
+ function checkFinished()
+ {
+ if (titleSet && destinationFileUriSet && destinationFileNameSet) {
+ PlacesUtils.annotations.removeObserver(annoObserver);
+ PlacesUtils.history.removeObserver(historyObserver);
+
+ PlacesTestUtils.clearHistory().then(run_next_test);
+ }
+ }
+
+ let annoObserver = {
+ onPageAnnotationSet: function AO_onPageAnnotationSet(aPage, aName)
+ {
+ if (aPage.equals(SOURCE_URI)) {
+ let value = PlacesUtils.annotations.getPageAnnotation(aPage, aName);
+ switch (aName)
+ {
+ case "downloads/destinationFileURI":
+ destinationFileUriSet = true;
+ do_check_eq(value, destFileUri.spec);
+ break;
+ case "downloads/destinationFileName":
+ destinationFileNameSet = true;
+ do_check_eq(value, DEST_FILE_NAME);
+ break;
+ }
+ checkFinished();
+ }
+ },
+ onItemAnnotationSet: function() {},
+ onPageAnnotationRemoved: function() {},
+ onItemAnnotationRemoved: function() {}
+ }
+
+ let historyObserver = {
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function() {},
+ onTitleChanged: function HO_onTitleChanged(aURI, aPageTitle)
+ {
+ if (aURI.equals(SOURCE_URI)) {
+ titleSet = true;
+ do_check_eq(aPageTitle, DEST_FILE_NAME);
+ checkFinished();
+ }
+ },
+ onDeleteURI: function() {},
+ onClearHistory: function() {},
+ onPageChanged: function() {},
+ onDeleteVisits: function() {}
+ };
+
+ PlacesUtils.annotations.addObserver(annoObserver, false);
+ PlacesUtils.history.addObserver(historyObserver, false);
+
+ // Both null values and remote URIs should not cause errors.
+ gDownloadHistory.addDownload(SOURCE_URI, null, Date.now() * 1000);
+ gDownloadHistory.addDownload(SOURCE_URI, null, Date.now() * 1000, null);
+ gDownloadHistory.addDownload(SOURCE_URI, null, Date.now() * 1000, REMOTE_URI);
+
+ // Valid local file URIs should cause the download details to be saved.
+ gDownloadHistory.addDownload(SOURCE_URI, null, Date.now() * 1000,
+ destFileUri);
+});
diff --git a/toolkit/components/places/tests/unit/test_frecency.js b/toolkit/components/places/tests/unit/test_frecency.js
new file mode 100644
index 000000000..a04befe00
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_frecency.js
@@ -0,0 +1,294 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test for bug 406358 to make sure frecency works for empty input/search, but
+ * this also tests for non-empty inputs as well. Because the interactions among
+ * *DIFFERENT* visit counts and visit dates is not well defined, this test
+ * holds one of the two values constant when modifying the other.
+ *
+ * Also test bug 419068 to make sure tagged pages don't necessarily have to be
+ * first in the results.
+ *
+ * Also test bug 426166 to make sure that the results of autocomplete searches
+ * are stable. Note that failures of this test will be intermittent by nature
+ * since we are testing to make sure that the unstable sort algorithm used
+ * by SQLite is not changing the order of the results on us.
+ */
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+function ensure_results(uris, searchTerm)
+{
+ PlacesTestUtils.promiseAsyncUpdates()
+ .then(() => ensure_results_internal(uris, searchTerm));
+}
+
+function ensure_results_internal(uris, searchTerm)
+{
+ var controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
+ getService(Components.interfaces.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ input.onSearchComplete = function() {
+ do_check_eq(numSearchesStarted, 1);
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+ do_check_eq(controller.matchCount, uris.length);
+ for (var i=0; i<controller.matchCount; i++) {
+ do_check_eq(controller.getValueAt(i), uris[i].spec);
+ }
+
+ deferEnsureResults.resolve();
+ };
+
+ controller.startSearch(searchTerm);
+}
+
+// Get history service
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+ var bhist = histsvc.QueryInterface(Ci.nsIBrowserHistory);
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+ var bmksvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+} catch (ex) {
+ do_throw("Could not get history service\n");
+}
+
+function* task_setCountDate(aURI, aCount, aDate)
+{
+ // We need visits so that frecency can be computed over multiple visits
+ let visits = [];
+ for (let i = 0; i < aCount; i++) {
+ visits.push({ uri: aURI, visitDate: aDate, transition: TRANSITION_TYPED });
+ }
+ yield PlacesTestUtils.addVisits(visits);
+}
+
+function setBookmark(aURI)
+{
+ bmksvc.insertBookmark(bmksvc.bookmarksMenuFolder, aURI, -1, "bleh");
+}
+
+function tagURI(aURI, aTags) {
+ bmksvc.insertBookmark(bmksvc.unfiledBookmarksFolder, aURI,
+ bmksvc.DEFAULT_INDEX, "bleh");
+ tagssvc.tagURI(aURI, aTags);
+}
+
+var uri1 = uri("http://site.tld/1");
+var uri2 = uri("http://site.tld/2");
+var uri3 = uri("http://aaaaaaaaaa/1");
+var uri4 = uri("http://aaaaaaaaaa/2");
+
+// d1 is younger (should show up higher) than d2 (PRTime is in usecs not msec)
+// Make sure the dates fall into different frecency buckets
+var d1 = new Date(Date.now() - 1000 * 60 * 60) * 1000;
+var d2 = new Date(Date.now() - 1000 * 60 * 60 * 24 * 10) * 1000;
+// c1 is larger (should show up higher) than c2
+var c1 = 10;
+var c2 = 1;
+
+var tests = [
+// test things without a search term
+function*() {
+ print("TEST-INFO | Test 0: same count, different date");
+ yield task_setCountDate(uri1, c1, d1);
+ yield task_setCountDate(uri2, c1, d2);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri1, uri2], "");
+},
+function*() {
+ print("TEST-INFO | Test 1: same count, different date");
+ yield task_setCountDate(uri1, c1, d2);
+ yield task_setCountDate(uri2, c1, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri2, uri1], "");
+},
+function*() {
+ print("TEST-INFO | Test 2: different count, same date");
+ yield task_setCountDate(uri1, c1, d1);
+ yield task_setCountDate(uri2, c2, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri1, uri2], "");
+},
+function*() {
+ print("TEST-INFO | Test 3: different count, same date");
+ yield task_setCountDate(uri1, c2, d1);
+ yield task_setCountDate(uri2, c1, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri2, uri1], "");
+},
+
+// test things with a search term
+function*() {
+ print("TEST-INFO | Test 4: same count, different date");
+ yield task_setCountDate(uri1, c1, d1);
+ yield task_setCountDate(uri2, c1, d2);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri1, uri2], "site");
+},
+function*() {
+ print("TEST-INFO | Test 5: same count, different date");
+ yield task_setCountDate(uri1, c1, d2);
+ yield task_setCountDate(uri2, c1, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri2, uri1], "site");
+},
+function*() {
+ print("TEST-INFO | Test 6: different count, same date");
+ yield task_setCountDate(uri1, c1, d1);
+ yield task_setCountDate(uri2, c2, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri1, uri2], "site");
+},
+function*() {
+ print("TEST-INFO | Test 7: different count, same date");
+ yield task_setCountDate(uri1, c2, d1);
+ yield task_setCountDate(uri2, c1, d1);
+ tagURI(uri1, ["site"]);
+ ensure_results([uri2, uri1], "site");
+},
+// There are multiple tests for 8, hence the multiple functions
+// Bug 426166 section
+function*() {
+ print("TEST-INFO | Test 8.1a: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "a");
+},
+function*() {
+ print("TEST-INFO | Test 8.1b: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "aa");
+},
+function*() {
+ print("TEST-INFO | Test 8.2: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "aaa");
+},
+function*() {
+ print("TEST-INFO | Test 8.3: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "aaaa");
+},
+function*() {
+ print("TEST-INFO | Test 8.4: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "aaa");
+},
+function*() {
+ print("TEST-INFO | Test 8.5: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "aa");
+},
+function*() {
+ print("TEST-INFO | Test 8.6: same count, same date");
+ setBookmark(uri3);
+ setBookmark(uri4);
+ ensure_results([uri4, uri3], "a");
+}
+];
+
+/**
+ * This deferred object contains a promise that is resolved when the
+ * ensure_results_internal function has finished its execution.
+ */
+var deferEnsureResults;
+
+add_task(function* test_frecency()
+{
+ // Disable autoFill for this test.
+ Services.prefs.setBoolPref("browser.urlbar.autoFill", false);
+ do_register_cleanup(() => Services.prefs.clearUserPref("browser.urlbar.autoFill"));
+ // always search in history + bookmarks, no matter what the default is
+ var prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+
+ prefs.setBoolPref("browser.urlbar.suggest.history", true);
+ prefs.setBoolPref("browser.urlbar.suggest.bookmark", true);
+ prefs.setBoolPref("browser.urlbar.suggest.openpage", false);
+ for (let test of tests) {
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+
+ deferEnsureResults = Promise.defer();
+ yield test();
+ yield deferEnsureResults.promise;
+ }
+ for (let type of ["history", "bookmark", "openpage"]) {
+ prefs.clearUserPref("browser.urlbar.suggest." + type);
+ }
+});
diff --git a/toolkit/components/places/tests/unit/test_frecency_observers.js b/toolkit/components/places/tests/unit/test_frecency_observers.js
new file mode 100644
index 000000000..7fadd4ae9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_frecency_observers.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ run_next_test();
+}
+
+// Each of these tests a path that triggers a frecency update. Together they
+// hit all sites that update a frecency.
+
+// InsertVisitedURIs::UpdateFrecency and History::InsertPlace
+add_task(function* test_InsertVisitedURIs_UpdateFrecency_and_History_InsertPlace() {
+ // InsertPlace is at the end of a path that UpdateFrecency is also on, so kill
+ // two birds with one stone and expect two notifications. Trigger the path by
+ // adding a download.
+ let uri = NetUtil.newURI("http://example.com/a");
+ Cc["@mozilla.org/browser/download-history;1"].
+ getService(Ci.nsIDownloadHistory).
+ addDownload(uri);
+ yield Promise.all([onFrecencyChanged(uri), onFrecencyChanged(uri)]);
+});
+
+// nsNavHistory::UpdateFrecency
+add_task(function* test_nsNavHistory_UpdateFrecency() {
+ let bm = PlacesUtils.bookmarks;
+ let uri = NetUtil.newURI("http://example.com/b");
+ bm.insertBookmark(bm.unfiledBookmarksFolder, uri,
+ Ci.nsINavBookmarksService.DEFAULT_INDEX, "test");
+ yield onFrecencyChanged(uri);
+});
+
+// nsNavHistory::invalidateFrecencies for particular pages
+add_task(function* test_nsNavHistory_invalidateFrecencies_somePages() {
+ let uri = NetUtil.newURI("http://test-nsNavHistory-invalidateFrecencies-somePages.com/");
+ // Bookmarking the URI is enough to add it to moz_places, and importantly, it
+ // means that removePagesFromHost doesn't remove it from moz_places, so its
+ // frecency is able to be changed.
+ let bm = PlacesUtils.bookmarks;
+ bm.insertBookmark(bm.unfiledBookmarksFolder, uri,
+ Ci.nsINavBookmarksService.DEFAULT_INDEX, "test");
+ PlacesUtils.history.removePagesFromHost(uri.host, false);
+ yield onFrecencyChanged(uri);
+});
+
+// nsNavHistory::invalidateFrecencies for all pages
+add_task(function* test_nsNavHistory_invalidateFrecencies_allPages() {
+ yield Promise.all([onManyFrecenciesChanged(), PlacesTestUtils.clearHistory()]);
+});
+
+// nsNavHistory::DecayFrecency and nsNavHistory::FixInvalidFrecencies
+add_task(function* test_nsNavHistory_DecayFrecency_and_nsNavHistory_FixInvalidFrecencies() {
+ // FixInvalidFrecencies is at the end of a path that DecayFrecency is also on,
+ // so expect two notifications. Trigger the path by making nsNavHistory
+ // observe the idle-daily notification.
+ PlacesUtils.history.QueryInterface(Ci.nsIObserver).
+ observe(null, "idle-daily", "");
+ yield Promise.all([onManyFrecenciesChanged(), onManyFrecenciesChanged()]);
+});
+
+function onFrecencyChanged(expectedURI) {
+ let deferred = Promise.defer();
+ let obs = new NavHistoryObserver();
+ obs.onFrecencyChanged =
+ (uri, newFrecency, guid, hidden, visitDate) => {
+ PlacesUtils.history.removeObserver(obs);
+ do_check_true(!!uri);
+ do_check_true(uri.equals(expectedURI));
+ deferred.resolve();
+ };
+ PlacesUtils.history.addObserver(obs, false);
+ return deferred.promise;
+}
+
+function onManyFrecenciesChanged() {
+ let deferred = Promise.defer();
+ let obs = new NavHistoryObserver();
+ obs.onManyFrecenciesChanged = () => {
+ PlacesUtils.history.removeObserver(obs);
+ do_check_true(true);
+ deferred.resolve();
+ };
+ PlacesUtils.history.addObserver(obs, false);
+ return deferred.promise;
+}
diff --git a/toolkit/components/places/tests/unit/test_frecency_zero_updated.js b/toolkit/components/places/tests/unit/test_frecency_zero_updated.js
new file mode 100644
index 000000000..e60030ca5
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_frecency_zero_updated.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests a zero frecency is correctly updated when inserting new valid visits.
+
+function run_test()
+{
+ run_next_test()
+}
+
+add_task(function* ()
+{
+ const TEST_URI = NetUtil.newURI("http://example.com/");
+ let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ TEST_URI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "A title");
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+
+ // Removing the bookmark should leave an orphan page with zero frecency.
+ // Note this would usually be expired later by expiration.
+ PlacesUtils.bookmarks.removeItem(id);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_check_eq(frecencyForUrl(TEST_URI), 0);
+
+ // Now add a valid visit to the page, frecency should increase.
+ yield PlacesTestUtils.addVisits({ uri: TEST_URI });
+ do_check_true(frecencyForUrl(TEST_URI) > 0);
+});
diff --git a/toolkit/components/places/tests/unit/test_getChildIndex.js b/toolkit/components/places/tests/unit/test_getChildIndex.js
new file mode 100644
index 000000000..4cf164d45
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_getChildIndex.js
@@ -0,0 +1,69 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim:set ts=2 sw=2 sts=2 et:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * Tests nsNavHistoryContainerResultNode::GetChildIndex(aNode) functionality.
+ */
+
+function run_test() {
+ // Add a bookmark to the menu.
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarksMenuFolderId,
+ uri("http://test.mozilla.org/bookmark/"),
+ Ci.nsINavBookmarksService.DEFAULT_INDEX,
+ "Test bookmark");
+
+ // Add a bookmark to unfiled folder.
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("http://test.mozilla.org/unfiled/"),
+ Ci.nsINavBookmarksService.DEFAULT_INDEX,
+ "Unfiled bookmark");
+
+ // Get the unfiled bookmark node.
+ let unfiledNode = getNodeAt(PlacesUtils.unfiledBookmarksFolderId, 0);
+ if (!unfiledNode)
+ do_throw("Unable to find bookmark in hierarchy!");
+ do_check_eq(unfiledNode.title, "Unfiled bookmark");
+
+ let hs = PlacesUtils.history;
+ let query = hs.getNewQuery();
+ query.setFolders([PlacesUtils.bookmarksMenuFolderId], 1);
+ let options = hs.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = hs.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ // Check functionality for proper nodes.
+ for (let i = 0; i < root.childCount; i++) {
+ let node = root.getChild(i);
+ print("Now testing: " + node.title);
+ do_check_eq(root.getChildIndex(node), i);
+ }
+
+ // Now search for an invalid node and expect an exception.
+ try {
+ root.getChildIndex(unfiledNode);
+ do_throw("Searching for an invalid node should have thrown.");
+ } catch (ex) {
+ print("We correctly got an exception.");
+ }
+
+ root.containerOpen = false;
+}
+
+function getNodeAt(aFolderId, aIndex) {
+ let hs = PlacesUtils.history;
+ let query = hs.getNewQuery();
+ query.setFolders([aFolderId], 1);
+ let options = hs.getNewQueryOptions();
+ options.queryType = options.QUERY_TYPE_BOOKMARKS;
+ let root = hs.executeQuery(query, options).root;
+ root.containerOpen = true;
+ if (root.childCount < aIndex)
+ do_throw("Not enough children to find bookmark!");
+ let node = root.getChild(aIndex);
+ root.containerOpen = false;
+ return node;
+}
diff --git a/toolkit/components/places/tests/unit/test_getPlacesInfo.js b/toolkit/components/places/tests/unit/test_getPlacesInfo.js
new file mode 100644
index 000000000..3dfecb934
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_getPlacesInfo.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function promiseGetPlacesInfo(aPlacesIdentifiers) {
+ let deferred = Promise.defer();
+ PlacesUtils.asyncHistory.getPlacesInfo(aPlacesIdentifiers, {
+ _results: [],
+ _errors: [],
+
+ handleResult: function handleResult(aPlaceInfo) {
+ this._results.push(aPlaceInfo);
+ },
+ handleError: function handleError(aResultCode, aPlaceInfo) {
+ this._errors.push({ resultCode: aResultCode, info: aPlaceInfo });
+ },
+ handleCompletion: function handleCompletion() {
+ deferred.resolve({ errors: this._errors, results: this._results });
+ }
+ });
+
+ return deferred.promise;
+}
+
+function ensurePlacesInfoObjectsAreEqual(a, b) {
+ do_check_true(a.uri.equals(b.uri));
+ do_check_eq(a.title, b.title);
+ do_check_eq(a.guid, b.guid);
+ do_check_eq(a.placeId, b.placeId);
+}
+
+function* test_getPlacesInfoExistentPlace() {
+ let testURI = NetUtil.newURI("http://www.example.tld");
+ yield PlacesTestUtils.addVisits(testURI);
+
+ let getPlacesInfoResult = yield promiseGetPlacesInfo([testURI]);
+ do_check_eq(getPlacesInfoResult.results.length, 1);
+ do_check_eq(getPlacesInfoResult.errors.length, 0);
+
+ let placeInfo = getPlacesInfoResult.results[0];
+ do_check_true(placeInfo instanceof Ci.mozIPlaceInfo);
+
+ do_check_true(placeInfo.uri.equals(testURI));
+ do_check_eq(placeInfo.title, "test visit for " + testURI.spec);
+ do_check_true(placeInfo.guid.length > 0);
+ do_check_eq(placeInfo.visits, null);
+}
+add_task(test_getPlacesInfoExistentPlace);
+
+function* test_getPlacesInfoNonExistentPlace() {
+ let testURI = NetUtil.newURI("http://www.example_non_existent.tld");
+ let getPlacesInfoResult = yield promiseGetPlacesInfo(testURI);
+ do_check_eq(getPlacesInfoResult.results.length, 0);
+ do_check_eq(getPlacesInfoResult.errors.length, 1);
+}
+add_task(test_getPlacesInfoNonExistentPlace);
+
+function* test_promisedHelper() {
+ let uri = NetUtil.newURI("http://www.helper_existent_example.tld");
+ yield PlacesTestUtils.addVisits(uri);
+ let placeInfo = yield PlacesUtils.promisePlaceInfo(uri);
+ do_check_true(placeInfo instanceof Ci.mozIPlaceInfo);
+
+ uri = NetUtil.newURI("http://www.helper_non_existent_example.tld");
+ try {
+ yield PlacesUtils.promisePlaceInfo(uri);
+ do_throw("PlacesUtils.promisePlaceInfo should have rejected the promise");
+ }
+ catch (ex) { }
+}
+add_task(test_promisedHelper);
+
+function* test_infoByGUID() {
+ let testURI = NetUtil.newURI("http://www.guid_example.tld");
+ yield PlacesTestUtils.addVisits(testURI);
+
+ let placeInfoByURI = yield PlacesUtils.promisePlaceInfo(testURI);
+ let placeInfoByGUID = yield PlacesUtils.promisePlaceInfo(placeInfoByURI.guid);
+ ensurePlacesInfoObjectsAreEqual(placeInfoByURI, placeInfoByGUID);
+}
+add_task(test_infoByGUID);
+
+function* test_invalid_guid() {
+ try {
+ yield PlacesUtils.promisePlaceInfo("###");
+ do_throw("getPlacesInfo should fail for invalid guids")
+ }
+ catch (ex) { }
+}
+add_task(test_invalid_guid);
+
+function* test_mixed_selection() {
+ let placeInfo1, placeInfo2;
+ let uri = NetUtil.newURI("http://www.mixed_selection_test_1.tld");
+ yield PlacesTestUtils.addVisits(uri);
+ placeInfo1 = yield PlacesUtils.promisePlaceInfo(uri);
+
+ uri = NetUtil.newURI("http://www.mixed_selection_test_2.tld");
+ yield PlacesTestUtils.addVisits(uri);
+ placeInfo2 = yield PlacesUtils.promisePlaceInfo(uri);
+
+ let getPlacesInfoResult = yield promiseGetPlacesInfo([placeInfo1.uri, placeInfo2.guid]);
+ do_check_eq(getPlacesInfoResult.results.length, 2);
+ do_check_eq(getPlacesInfoResult.errors.length, 0);
+
+ do_check_eq(getPlacesInfoResult.results[0].uri.spec, placeInfo1.uri.spec);
+ do_check_eq(getPlacesInfoResult.results[1].guid, placeInfo2.guid);
+}
+add_task(test_mixed_selection);
+
+function run_test() {
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_history.js b/toolkit/components/places/tests/unit/test_history.js
new file mode 100644
index 000000000..8d194cde1
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history.js
@@ -0,0 +1,184 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history services
+var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+
+/**
+ * Checks to see that a URI is in the database.
+ *
+ * @param aURI
+ * The URI to check.
+ * @returns true if the URI is in the DB, false otherwise.
+ */
+function uri_in_db(aURI) {
+ var options = histsvc.getNewQueryOptions();
+ options.maxResults = 1;
+ options.resultType = options.RESULTS_AS_URI
+ var query = histsvc.getNewQuery();
+ query.uri = aURI;
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+ root.containerOpen = false;
+ return (cc == 1);
+}
+
+// main
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ // we have a new profile, so we should have imported bookmarks
+ do_check_eq(histsvc.databaseStatus, histsvc.DATABASE_STATUS_CREATE);
+
+ // add a visit
+ var testURI = uri("http://mozilla.com");
+ yield PlacesTestUtils.addVisits(testURI);
+
+ // now query for the visit, setting sorting and limit such that
+ // we should retrieve only the visit we just added
+ var options = histsvc.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.maxResults = 1;
+ // TODO: using full visit crashes in xpcshell test
+ // options.resultType = options.RESULTS_AS_FULL_VISIT;
+ options.resultType = options.RESULTS_AS_VISIT;
+ var query = histsvc.getNewQuery();
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+ for (var i=0; i < cc; ++i) {
+ var node = root.getChild(i);
+ // test node properties in RESULTS_AS_VISIT
+ do_check_eq(node.uri, testURI.spec);
+ do_check_eq(node.type, Ci.nsINavHistoryResultNode.RESULT_TYPE_URI);
+ // TODO: change query type to RESULTS_AS_FULL_VISIT and test this
+ // do_check_eq(node.transitionType, histsvc.TRANSITION_TYPED);
+ }
+ root.containerOpen = false;
+
+ // add another visit for the same URI, and a third visit for a different URI
+ var testURI2 = uri("http://google.com/");
+ yield PlacesTestUtils.addVisits(testURI);
+ yield PlacesTestUtils.addVisits(testURI2);
+
+ options.maxResults = 5;
+ options.resultType = options.RESULTS_AS_URI;
+
+ // test minVisits
+ query.minVisits = 0;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 2);
+ result.root.containerOpen = false;
+ query.minVisits = 1;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 2);
+ result.root.containerOpen = false;
+ query.minVisits = 2;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 1);
+ query.minVisits = 3;
+ result.root.containerOpen = false;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 0);
+ result.root.containerOpen = false;
+
+ // test maxVisits
+ query.minVisits = -1;
+ query.maxVisits = -1;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 2);
+ result.root.containerOpen = false;
+ query.maxVisits = 0;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 0);
+ result.root.containerOpen = false;
+ query.maxVisits = 1;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 1);
+ result.root.containerOpen = false;
+ query.maxVisits = 2;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 2);
+ result.root.containerOpen = false;
+ query.maxVisits = 3;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 2);
+ result.root.containerOpen = false;
+
+ // test annotation-based queries
+ var annos = Cc["@mozilla.org/browser/annotation-service;1"].
+ getService(Ci.nsIAnnotationService);
+ annos.setPageAnnotation(uri("http://mozilla.com/"), "testAnno", 0, 0,
+ Ci.nsIAnnotationService.EXPIRE_NEVER);
+ query.annotation = "testAnno";
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 1);
+ do_check_eq(result.root.getChild(0).uri, "http://mozilla.com/");
+ result.root.containerOpen = false;
+
+ // test annotationIsNot
+ query.annotationIsNot = true;
+ result = histsvc.executeQuery(query, options);
+ result.root.containerOpen = true;
+ do_check_eq(result.root.childCount, 1);
+ do_check_eq(result.root.getChild(0).uri, "http://google.com/");
+ result.root.containerOpen = false;
+
+ // By default history is enabled.
+ do_check_true(!histsvc.historyDisabled);
+
+ // test getPageTitle
+ yield PlacesTestUtils.addVisits({ uri: uri("http://example.com"), title: "title" });
+ let placeInfo = yield PlacesUtils.promisePlaceInfo(uri("http://example.com"));
+ do_check_eq(placeInfo.title, "title");
+
+ // query for the visit
+ do_check_true(uri_in_db(testURI));
+
+ // test for schema changes in bug 373239
+ // get direct db connection
+ var db = histsvc.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+ var q = "SELECT id FROM moz_bookmarks";
+ var statement;
+ try {
+ statement = db.createStatement(q);
+ } catch (ex) {
+ do_throw("bookmarks table does not have id field, schema is too old!");
+ }
+ finally {
+ statement.finalize();
+ }
+
+ // bug 394741 - regressed history text searches
+ yield PlacesTestUtils.addVisits(uri("http://mozilla.com"));
+ options = histsvc.getNewQueryOptions();
+ // options.resultType = options.RESULTS_AS_VISIT;
+ query = histsvc.getNewQuery();
+ query.searchTerms = "moz";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_true(root.childCount > 0);
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_history_autocomplete_tags.js b/toolkit/components/places/tests/unit/test_history_autocomplete_tags.js
new file mode 100644
index 000000000..a5e0e1cb1
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_autocomplete_tags.js
@@ -0,0 +1,185 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var current_test = 0;
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+function ensure_tag_results(uris, searchTerm)
+{
+ print("Searching for '" + searchTerm + "'");
+ var controller = Components.classes["@mozilla.org/autocomplete/controller;1"].
+ getService(Components.interfaces.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["unifiedcomplete"]);
+
+ controller.input = input;
+
+ // Search is asynchronous, so don't let the test finish immediately
+ do_test_pending();
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ input.onSearchComplete = function() {
+ do_check_eq(numSearchesStarted, 1);
+ do_check_eq(controller.searchStatus,
+ uris.length ?
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH :
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+ do_check_eq(controller.matchCount, uris.length);
+ let vals = [];
+ for (let i=0; i<controller.matchCount; i++) {
+ // Keep the URL for later because order of tag results is undefined
+ vals.push(controller.getValueAt(i));
+ do_check_eq(controller.getStyleAt(i), "bookmark-tag");
+ }
+ // Sort the results then check if we have the right items
+ vals.sort().forEach((val, i) => do_check_eq(val, uris[i].spec))
+
+ if (current_test < (tests.length - 1)) {
+ current_test++;
+ tests[current_test]();
+ }
+
+ do_test_finished();
+ };
+
+ controller.startSearch(searchTerm);
+}
+
+var uri1 = uri("http://site.tld/1/aaa");
+var uri2 = uri("http://site.tld/2/bbb");
+var uri3 = uri("http://site.tld/3/aaa");
+var uri4 = uri("http://site.tld/4/bbb");
+var uri5 = uri("http://site.tld/5/aaa");
+var uri6 = uri("http://site.tld/6/bbb");
+
+var tests = [
+ () => ensure_tag_results([uri1, uri4, uri6], "foo"),
+ () => ensure_tag_results([uri1], "foo aaa"),
+ () => ensure_tag_results([uri4, uri6], "foo bbb"),
+ () => ensure_tag_results([uri2, uri4, uri5, uri6], "bar"),
+ () => ensure_tag_results([uri5], "bar aaa"),
+ () => ensure_tag_results([uri2, uri4, uri6], "bar bbb"),
+ () => ensure_tag_results([uri3, uri5, uri6], "cheese"),
+ () => ensure_tag_results([uri3, uri5], "chees aaa"),
+ () => ensure_tag_results([uri6], "chees bbb"),
+ () => ensure_tag_results([uri4, uri6], "fo bar"),
+ () => ensure_tag_results([], "fo bar aaa"),
+ () => ensure_tag_results([uri4, uri6], "fo bar bbb"),
+ () => ensure_tag_results([uri4, uri6], "ba foo"),
+ () => ensure_tag_results([], "ba foo aaa"),
+ () => ensure_tag_results([uri4, uri6], "ba foo bbb"),
+ () => ensure_tag_results([uri5, uri6], "ba chee"),
+ () => ensure_tag_results([uri5], "ba chee aaa"),
+ () => ensure_tag_results([uri6], "ba chee bbb"),
+ () => ensure_tag_results([uri5, uri6], "cheese bar"),
+ () => ensure_tag_results([uri5], "cheese bar aaa"),
+ () => ensure_tag_results([uri6], "chees bar bbb"),
+ () => ensure_tag_results([uri6], "cheese bar foo"),
+ () => ensure_tag_results([], "foo bar cheese aaa"),
+ () => ensure_tag_results([uri6], "foo bar cheese bbb"),
+];
+
+/**
+ * Properly tags a uri adding it to bookmarks.
+ *
+ * @param aURI
+ * The nsIURI to tag.
+ * @param aTags
+ * The tags to add.
+ */
+function tagURI(aURI, aTags) {
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ aURI,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "A title");
+ tagssvc.tagURI(aURI, aTags);
+}
+
+/**
+ * Test history autocomplete
+ */
+function run_test() {
+ // always search in history + bookmarks, no matter what the default is
+ var prefs = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+ prefs.setIntPref("browser.urlbar.search.sources", 3);
+ prefs.setIntPref("browser.urlbar.default.behavior", 0);
+
+ tagURI(uri1, ["foo"]);
+ tagURI(uri2, ["bar"]);
+ tagURI(uri3, ["cheese"]);
+ tagURI(uri4, ["foo bar"]);
+ tagURI(uri5, ["bar cheese"]);
+ tagURI(uri6, ["foo bar cheese"]);
+
+ tests[0]();
+}
diff --git a/toolkit/components/places/tests/unit/test_history_catobs.js b/toolkit/components/places/tests/unit/test_history_catobs.js
new file mode 100644
index 000000000..e0a81d67b
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_catobs.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ do_load_manifest("nsDummyObserver.manifest");
+
+ let dummyCreated = false;
+ let dummyReceivedOnVisit = false;
+
+ Services.obs.addObserver(function created() {
+ Services.obs.removeObserver(created, "dummy-observer-created");
+ dummyCreated = true;
+ }, "dummy-observer-created", false);
+ Services.obs.addObserver(function visited() {
+ Services.obs.removeObserver(visited, "dummy-observer-visited");
+ dummyReceivedOnVisit = true;
+ }, "dummy-observer-visited", false);
+
+ let initialObservers = PlacesUtils.history.getObservers();
+
+ // Add a common observer, it should be invoked after the category observer.
+ let notificationsPromised = new Promise((resolve, reject) => {
+ PlacesUtils.history.addObserver({
+ __proto__: NavHistoryObserver.prototype,
+ onVisit() {
+ let observers = PlacesUtils.history.getObservers();
+ Assert.equal(observers.length, initialObservers.length + 1);
+
+ // Check the common observer is the last one.
+ for (let i = 0; i < initialObservers.length; ++i) {
+ Assert.equal(initialObservers[i], observers[i]);
+ }
+
+ PlacesUtils.history.removeObserver(this);
+ observers = PlacesUtils.history.getObservers();
+ Assert.equal(observers.length, initialObservers.length);
+
+ // Check the category observer has been invoked before this one.
+ Assert.ok(dummyCreated);
+ Assert.ok(dummyReceivedOnVisit);
+ resolve();
+ }
+ }, false);
+ });
+
+ // Add a visit.
+ yield PlacesTestUtils.addVisits(uri("http://typed.mozilla.org"));
+
+ yield notificationsPromised;
+});
diff --git a/toolkit/components/places/tests/unit/test_history_clear.js b/toolkit/components/places/tests/unit/test_history_clear.js
new file mode 100644
index 000000000..56d34994f
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_clear.js
@@ -0,0 +1,169 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var mDBConn = DBConn();
+
+function promiseOnClearHistoryObserved() {
+ let deferred = Promise.defer();
+
+ let historyObserver = {
+ onBeginUpdateBatch: function() {},
+ onEndUpdateBatch: function() {},
+ onVisit: function() {},
+ onTitleChanged: function() {},
+ onDeleteURI: function(aURI) {},
+ onPageChanged: function() {},
+ onDeleteVisits: function() {},
+
+ onClearHistory: function() {
+ PlacesUtils.history.removeObserver(this, false);
+ deferred.resolve();
+ },
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavHistoryObserver,
+ ])
+ }
+ PlacesUtils.history.addObserver(historyObserver, false);
+ return deferred.promise;
+}
+
+// This global variable is a promise object, initialized in run_test and waited
+// upon in the first asynchronous test. It is resolved when the
+// "places-init-complete" notification is received. We cannot initialize it in
+// the asynchronous test, because then it's too late to register the observer.
+var promiseInit;
+
+function run_test() {
+ // places-init-complete is notified after run_test, and it will
+ // run a first frecency fix through async statements.
+ // To avoid random failures we have to run after all of this.
+ promiseInit = promiseTopicObserved(PlacesUtils.TOPIC_INIT_COMPLETE);
+
+ run_next_test();
+}
+
+add_task(function* test_history_clear()
+{
+ yield promiseInit;
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri("http://typed.mozilla.org/"),
+ transition: TRANSITION_TYPED },
+ { uri: uri("http://link.mozilla.org/"),
+ transition: TRANSITION_LINK },
+ { uri: uri("http://download.mozilla.org/"),
+ transition: TRANSITION_DOWNLOAD },
+ { uri: uri("http://redir_temp.mozilla.org/"),
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ referrer: "http://link.mozilla.org/"},
+ { uri: uri("http://redir_perm.mozilla.org/"),
+ transition: TRANSITION_REDIRECT_PERMANENT,
+ referrer: "http://link.mozilla.org/"},
+ ]);
+
+ // add a place: bookmark
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("place:folder=4"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "shortcut");
+
+ // Add an expire never annotation
+ // Actually expire never annotations are removed as soon as a page is removed
+ // from the database, so this should act as a normal visit.
+ PlacesUtils.annotations.setPageAnnotation(uri("http://download.mozilla.org/"),
+ "never", "never", 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+
+ // Add a bookmark
+ // Bookmarked page should have history cleared and frecency = -1
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ uri("http://typed.mozilla.org/"),
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: uri("http://typed.mozilla.org/"),
+ transition: TRANSITION_BOOKMARK },
+ { uri: uri("http://frecency.mozilla.org/"),
+ transition: TRANSITION_LINK },
+ ]);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // Clear history and wait for the onClearHistory notification.
+ let promiseWaitClearHistory = promiseOnClearHistoryObserved();
+ PlacesUtils.history.clear();
+ yield promiseWaitClearHistory;
+
+ // check browserHistory returns no entries
+ do_check_eq(0, PlacesUtils.history.hasHistoryEntries);
+
+ yield promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // Check that frecency for not cleared items (bookmarks) has been converted
+ // to -1.
+ stmt = mDBConn.createStatement(
+ "SELECT h.id FROM moz_places h WHERE h.frecency > 0 ");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ stmt = mDBConn.createStatement(
+ `SELECT h.id FROM moz_places h WHERE h.frecency < 0
+ AND EXISTS (SELECT id FROM moz_bookmarks WHERE fk = h.id) LIMIT 1`);
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that all visit_counts have been brought to 0
+ stmt = mDBConn.createStatement(
+ "SELECT id FROM moz_places WHERE visit_count <> 0 LIMIT 1");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that history tables are empty
+ stmt = mDBConn.createStatement(
+ "SELECT * FROM (SELECT id FROM moz_historyvisits LIMIT 1)");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that all moz_places entries except bookmarks and place: have been removed
+ stmt = mDBConn.createStatement(
+ `SELECT h.id FROM moz_places h WHERE
+ url_hash NOT BETWEEN hash('place', 'prefix_lo') AND hash('place', 'prefix_hi')
+ AND NOT EXISTS (SELECT id FROM moz_bookmarks WHERE fk = h.id) LIMIT 1`);
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that we only have favicons for retained places
+ stmt = mDBConn.createStatement(
+ `SELECT f.id FROM moz_favicons f WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE favicon_id = f.id) LIMIT 1`);
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that we only have annotations for retained places
+ stmt = mDBConn.createStatement(
+ `SELECT a.id FROM moz_annos a WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE id = a.place_id) LIMIT 1`);
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that we only have inputhistory for retained places
+ stmt = mDBConn.createStatement(
+ `SELECT i.place_id FROM moz_inputhistory i WHERE NOT EXISTS
+ (SELECT id FROM moz_places WHERE id = i.place_id) LIMIT 1`);
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+
+ // Check that place:uris have frecency 0
+ stmt = mDBConn.createStatement(
+ `SELECT h.id FROM moz_places h
+ WHERE url_hash BETWEEN hash('place', 'prefix_lo')
+ AND hash('place', 'prefix_hi')
+ AND h.frecency <> 0 LIMIT 1`);
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+});
diff --git a/toolkit/components/places/tests/unit/test_history_notifications.js b/toolkit/components/places/tests/unit/test_history_notifications.js
new file mode 100644
index 000000000..4e1e635a0
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_notifications.js
@@ -0,0 +1,38 @@
+const NS_PLACES_INIT_COMPLETE_TOPIC = "places-init-complete";
+const NS_PLACES_DATABASE_LOCKED_TOPIC = "places-database-locked";
+
+add_task(function* () {
+ // Create a dummy places.sqlite and open an unshared connection on it
+ let db = Services.dirsvc.get('ProfD', Ci.nsIFile);
+ db.append("places.sqlite");
+ let dbConn = Services.storage.openUnsharedDatabase(db);
+ Assert.ok(db.exists(), "The database should have been created");
+
+ // We need an exclusive lock on the db
+ dbConn.executeSimpleSQL("PRAGMA locking_mode = EXCLUSIVE");
+ // Exclusive locking is lazy applied, we need to make a write to activate it
+ dbConn.executeSimpleSQL("PRAGMA USER_VERSION = 1");
+
+ // Try to create history service while the db is locked
+ let promiseLocked = promiseTopicObserved(NS_PLACES_DATABASE_LOCKED_TOPIC);
+ Assert.throws(() => Cc["@mozilla.org/browser/nav-history-service;1"]
+ .getService(Ci.nsINavHistoryService),
+ /NS_ERROR_XPC_GS_RETURNED_FAILURE/);
+ yield promiseLocked;
+
+ // Close our connection and try to cleanup the file (could fail on Windows)
+ dbConn.close();
+ if (db.exists()) {
+ try {
+ db.remove(false);
+ } catch (e) {
+ do_print("Unable to remove dummy places.sqlite");
+ }
+ }
+
+ // Create history service correctly
+ let promiseComplete = promiseTopicObserved(NS_PLACES_INIT_COMPLETE_TOPIC);
+ Cc["@mozilla.org/browser/nav-history-service;1"]
+ .getService(Ci.nsINavHistoryService);
+ yield promiseComplete;
+});
diff --git a/toolkit/components/places/tests/unit/test_history_observer.js b/toolkit/components/places/tests/unit/test_history_observer.js
new file mode 100644
index 000000000..c101cfb61
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_observer.js
@@ -0,0 +1,215 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Generic nsINavHistoryObserver that doesn't implement anything, but provides
+ * dummy methods to prevent errors about an object not having a certain method.
+ */
+function NavHistoryObserver() {
+}
+NavHistoryObserver.prototype = {
+ onBeginUpdateBatch: function() { },
+ onEndUpdateBatch: function() { },
+ onVisit: function() { },
+ onTitleChanged: function() { },
+ onDeleteURI: function() { },
+ onClearHistory: function() { },
+ onPageChanged: function() { },
+ onDeleteVisits: function() { },
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsINavHistoryObserver])
+};
+
+/**
+ * Registers a one-time history observer for and calls the callback
+ * when the specified nsINavHistoryObserver method is called.
+ * Returns a promise that is resolved when the callback returns.
+ */
+function onNotify(callback) {
+ return new Promise(resolve => {
+ let obs = new NavHistoryObserver();
+ obs[callback.name] = function () {
+ PlacesUtils.history.removeObserver(this);
+ callback.apply(this, arguments);
+ resolve();
+ };
+ PlacesUtils.history.addObserver(obs, false);
+ });
+}
+
+/**
+ * Asynchronous task that adds a visit to the history database.
+ */
+function* task_add_visit(uri, timestamp, transition) {
+ uri = uri || NetUtil.newURI("http://firefox.com/");
+ timestamp = timestamp || Date.now() * 1000;
+ yield PlacesTestUtils.addVisits({
+ uri: uri,
+ transition: transition || TRANSITION_TYPED,
+ visitDate: timestamp
+ });
+ return [uri, timestamp];
+}
+
+add_task(function* test_onVisit() {
+ let promiseNotify = onNotify(function onVisit(aURI, aVisitID, aTime,
+ aSessionID, aReferringID,
+ aTransitionType, aGUID,
+ aHidden, aVisitCount, aTyped) {
+ Assert.ok(aURI.equals(testuri));
+ Assert.ok(aVisitID > 0);
+ Assert.equal(aTime, testtime);
+ Assert.equal(aSessionID, 0);
+ Assert.equal(aReferringID, 0);
+ Assert.equal(aTransitionType, TRANSITION_TYPED);
+ do_check_guid_for_uri(aURI, aGUID);
+ Assert.ok(!aHidden);
+ Assert.equal(aVisitCount, 1);
+ Assert.equal(aTyped, 1);
+ });
+ let testuri = NetUtil.newURI("http://firefox.com/");
+ let testtime = Date.now() * 1000;
+ yield task_add_visit(testuri, testtime);
+ yield promiseNotify;
+});
+
+add_task(function* test_onVisit() {
+ let promiseNotify = onNotify(function onVisit(aURI, aVisitID, aTime,
+ aSessionID, aReferringID,
+ aTransitionType, aGUID,
+ aHidden, aVisitCount, aTyped) {
+ Assert.ok(aURI.equals(testuri));
+ Assert.ok(aVisitID > 0);
+ Assert.equal(aTime, testtime);
+ Assert.equal(aSessionID, 0);
+ Assert.equal(aReferringID, 0);
+ Assert.equal(aTransitionType, TRANSITION_FRAMED_LINK);
+ do_check_guid_for_uri(aURI, aGUID);
+ Assert.ok(aHidden);
+ Assert.equal(aVisitCount, 1);
+ Assert.equal(aTyped, 0);
+ });
+ let testuri = NetUtil.newURI("http://hidden.firefox.com/");
+ let testtime = Date.now() * 1000;
+ yield task_add_visit(testuri, testtime, TRANSITION_FRAMED_LINK);
+ yield promiseNotify;
+});
+
+add_task(function* test_multiple_onVisit() {
+ let testuri = NetUtil.newURI("http://self.firefox.com/");
+ let promiseNotifications = new Promise(resolve => {
+ let observer = {
+ _c: 0,
+ __proto__: NavHistoryObserver.prototype,
+ onVisit(uri, id, time, unused, referrerId, transition, guid,
+ hidden, visitCount, typed) {
+ Assert.ok(testuri.equals(uri));
+ Assert.ok(id > 0);
+ Assert.ok(time > 0);
+ Assert.ok(!hidden);
+ do_check_guid_for_uri(uri, guid);
+ switch (++this._c) {
+ case 1:
+ Assert.equal(referrerId, 0);
+ Assert.equal(transition, TRANSITION_LINK);
+ Assert.equal(visitCount, 1);
+ Assert.equal(typed, 0);
+ break;
+ case 2:
+ Assert.ok(referrerId > 0);
+ Assert.equal(transition, TRANSITION_LINK);
+ Assert.equal(visitCount, 2);
+ Assert.equal(typed, 0);
+ break;
+ case 3:
+ Assert.equal(referrerId, 0);
+ Assert.equal(transition, TRANSITION_TYPED);
+ Assert.equal(visitCount, 3);
+ Assert.equal(typed, 1);
+
+ PlacesUtils.history.removeObserver(observer, false);
+ resolve();
+ break;
+ }
+ }
+ };
+ PlacesUtils.history.addObserver(observer, false);
+ });
+ yield PlacesTestUtils.addVisits([
+ { uri: testuri, transition: TRANSITION_LINK },
+ { uri: testuri, referrer: testuri, transition: TRANSITION_LINK },
+ { uri: testuri, transition: TRANSITION_TYPED },
+ ]);
+ yield promiseNotifications;
+});
+
+add_task(function* test_onDeleteURI() {
+ let promiseNotify = onNotify(function onDeleteURI(aURI, aGUID, aReason) {
+ Assert.ok(aURI.equals(testuri));
+ // Can't use do_check_guid_for_uri() here because the visit is already gone.
+ Assert.equal(aGUID, testguid);
+ Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_DELETED);
+ });
+ let [testuri] = yield task_add_visit();
+ let testguid = do_get_guid_for_uri(testuri);
+ PlacesUtils.bhistory.removePage(testuri);
+ yield promiseNotify;
+});
+
+add_task(function* test_onDeleteVisits() {
+ let promiseNotify = onNotify(function onDeleteVisits(aURI, aVisitTime, aGUID,
+ aReason) {
+ Assert.ok(aURI.equals(testuri));
+ // Can't use do_check_guid_for_uri() here because the visit is already gone.
+ Assert.equal(aGUID, testguid);
+ Assert.equal(aReason, Ci.nsINavHistoryObserver.REASON_DELETED);
+ Assert.equal(aVisitTime, 0); // All visits have been removed.
+ });
+ let msecs24hrsAgo = Date.now() - (86400 * 1000);
+ let [testuri] = yield task_add_visit(undefined, msecs24hrsAgo * 1000);
+ // Add a bookmark so the page is not removed.
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ testuri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "test");
+ let testguid = do_get_guid_for_uri(testuri);
+ PlacesUtils.bhistory.removePage(testuri);
+ yield promiseNotify;
+});
+
+add_task(function* test_onTitleChanged() {
+ let promiseNotify = onNotify(function onTitleChanged(aURI, aTitle, aGUID) {
+ Assert.ok(aURI.equals(testuri));
+ Assert.equal(aTitle, title);
+ do_check_guid_for_uri(aURI, aGUID);
+ });
+
+ let [testuri] = yield task_add_visit();
+ let title = "test-title";
+ yield PlacesTestUtils.addVisits({
+ uri: testuri,
+ title: title
+ });
+ yield promiseNotify;
+});
+
+add_task(function* test_onPageChanged() {
+ let promiseNotify = onNotify(function onPageChanged(aURI, aChangedAttribute,
+ aNewValue, aGUID) {
+ Assert.equal(aChangedAttribute, Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON);
+ Assert.ok(aURI.equals(testuri));
+ Assert.equal(aNewValue, SMALLPNG_DATA_URI.spec);
+ do_check_guid_for_uri(aURI, aGUID);
+ });
+
+ let [testuri] = yield task_add_visit();
+
+ // The new favicon for the page must have data associated with it in order to
+ // receive the onPageChanged notification. To keep this test self-contained,
+ // we use an URI representing the smallest possible PNG file.
+ PlacesUtils.favicons.setAndFetchFaviconForPage(testuri, SMALLPNG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ yield promiseNotify;
+});
diff --git a/toolkit/components/places/tests/unit/test_history_sidebar.js b/toolkit/components/places/tests/unit/test_history_sidebar.js
new file mode 100644
index 000000000..1c03547d7
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_history_sidebar.js
@@ -0,0 +1,447 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history service
+var hs = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+var bh = hs.QueryInterface(Ci.nsIBrowserHistory);
+var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+var ps = Cc["@mozilla.org/preferences-service;1"].
+ getService(Ci.nsIPrefBranch);
+
+/**
+ * Adds a test URI visit to the database.
+ *
+ * @param aURI
+ * The URI to add a visit for.
+ * @param aTime
+ * Reference "now" time.
+ * @param aDayOffset
+ * number of days to add, pass a negative value to subtract them.
+ */
+function* task_add_normalized_visit(aURI, aTime, aDayOffset) {
+ var dateObj = new Date(aTime);
+ // Normalize to midnight
+ dateObj.setHours(0);
+ dateObj.setMinutes(0);
+ dateObj.setSeconds(0);
+ dateObj.setMilliseconds(0);
+ // Days where DST changes should be taken in count.
+ var previousDateObj = new Date(dateObj.getTime() + aDayOffset * 86400000);
+ var DSTCorrection = (dateObj.getTimezoneOffset() -
+ previousDateObj.getTimezoneOffset()) * 60 * 1000;
+ // Substract aDayOffset
+ var PRTimeWithOffset = (previousDateObj.getTime() - DSTCorrection) * 1000;
+ var timeInMs = new Date(PRTimeWithOffset/1000);
+ print("Adding visit to " + aURI.spec + " at " + timeInMs);
+ yield PlacesTestUtils.addVisits({
+ uri: aURI,
+ visitDate: PRTimeWithOffset
+ });
+}
+
+function days_for_x_months_ago(aNowObj, aMonths) {
+ var oldTime = new Date();
+ // Set day before month, otherwise we could try to calculate 30 February, or
+ // other nonexistent days.
+ oldTime.setDate(1);
+ oldTime.setMonth(aNowObj.getMonth() - aMonths);
+ oldTime.setHours(0);
+ oldTime.setMinutes(0);
+ oldTime.setSeconds(0);
+ // Stay larger for eventual timezone issues, add 2 days.
+ return parseInt((aNowObj - oldTime) / (1000*60*60*24)) + 2;
+}
+
+var nowObj = new Date();
+// This test relies on en-US locale
+// Offset is number of days
+/* eslint-disable comma-spacing */
+var containers = [
+ { label: "Today" , offset: 0 , visible: true },
+ { label: "Yesterday" , offset: -1 , visible: true },
+ { label: "Last 7 days" , offset: -3 , visible: true },
+ { label: "This month" , offset: -8 , visible: nowObj.getDate() > 8 },
+ { label: "" , offset: -days_for_x_months_ago(nowObj, 0) , visible: true },
+ { label: "" , offset: -days_for_x_months_ago(nowObj, 1) , visible: true },
+ { label: "" , offset: -days_for_x_months_ago(nowObj, 2) , visible: true },
+ { label: "" , offset: -days_for_x_months_ago(nowObj, 3) , visible: true },
+ { label: "" , offset: -days_for_x_months_ago(nowObj, 4) , visible: true },
+ { label: "Older than 6 months" , offset: -days_for_x_months_ago(nowObj, 5) , visible: true },
+];
+/* eslint-enable comma-spacing */
+
+var visibleContainers = containers.filter(
+ function(aContainer) { return aContainer.visible });
+
+/**
+ * Asynchronous task that fills history and checks containers' labels.
+ */
+function* task_fill_history() {
+ print("\n\n*** TEST Fill History\n");
+ // We can't use "now" because our hardcoded offsets would be invalid for some
+ // date. So we hardcode a date.
+ for (let i = 0; i < containers.length; i++) {
+ let container = containers[i];
+ var testURI = uri("http://mirror"+i+".mozilla.com/b");
+ yield task_add_normalized_visit(testURI, nowObj.getTime(), container.offset);
+ testURI = uri("http://mirror"+i+".mozilla.com/a");
+ yield task_add_normalized_visit(testURI, nowObj.getTime(), container.offset);
+ testURI = uri("http://mirror"+i+".google.com/b");
+ yield task_add_normalized_visit(testURI, nowObj.getTime(), container.offset);
+ testURI = uri("http://mirror"+i+".google.com/a");
+ yield task_add_normalized_visit(testURI, nowObj.getTime(), container.offset);
+ // Bug 485703 - Hide date containers not containing additional entries
+ // compared to previous ones.
+ // Check after every new container is added.
+ check_visit(container.offset);
+ }
+
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_SITE_QUERY;
+ var query = hs.getNewQuery();
+
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ var cc = root.childCount;
+ print("Found containers:");
+ var previousLabels = [];
+ for (let i = 0; i < cc; i++) {
+ let container = visibleContainers[i];
+ var node = root.getChild(i);
+ print(node.title);
+ if (container.label)
+ do_check_eq(node.title, container.label);
+ // Check labels are not repeated.
+ do_check_eq(previousLabels.indexOf(node.title), -1);
+ previousLabels.push(node.title);
+ }
+ do_check_eq(cc, visibleContainers.length);
+ root.containerOpen = false;
+}
+
+/**
+ * Bug 485703 - Hide date containers not containing additional entries compared
+ * to previous ones.
+ */
+function check_visit(aOffset) {
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_SITE_QUERY;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ var cc = root.childCount;
+
+ var unexpected = [];
+ switch (aOffset) {
+ case 0:
+ unexpected = ["Yesterday", "Last 7 days", "This month"];
+ break;
+ case -1:
+ unexpected = ["Last 7 days", "This month"];
+ break;
+ case -3:
+ unexpected = ["This month"];
+ break;
+ default:
+ // Other containers are tested later.
+ }
+
+ print("Found containers:");
+ for (var i = 0; i < cc; i++) {
+ var node = root.getChild(i);
+ print(node.title);
+ do_check_eq(unexpected.indexOf(node.title), -1);
+ }
+
+ root.containerOpen = false;
+}
+
+/**
+ * Queries history grouped by date and site, checking containers' labels and
+ * children.
+ */
+function test_RESULTS_AS_DATE_SITE_QUERY() {
+ print("\n\n*** TEST RESULTS_AS_DATE_SITE_QUERY\n");
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_SITE_QUERY;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ // Check one of the days
+ var dayNode = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayNode.containerOpen = true;
+ do_check_eq(dayNode.childCount, 2);
+
+ // Items should be sorted by host
+ var site1 = dayNode.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(site1.title, "mirror0.google.com");
+
+ var site2 = dayNode.getChild(1)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(site2.title, "mirror0.mozilla.com");
+
+ site1.containerOpen = true;
+ do_check_eq(site1.childCount, 2);
+
+ // Inside of host sites are sorted by title
+ var site1visit = site1.getChild(0);
+ do_check_eq(site1visit.uri, "http://mirror0.google.com/a");
+
+ // Bug 473157: changing sorting mode should not affect the containers
+ result.sortingMode = options.SORT_BY_TITLE_DESCENDING;
+
+ // Check one of the days
+ dayNode = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayNode.containerOpen = true;
+ do_check_eq(dayNode.childCount, 2);
+
+ // Hosts are still sorted by title
+ site1 = dayNode.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(site1.title, "mirror0.google.com");
+
+ site2 = dayNode.getChild(1)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(site2.title, "mirror0.mozilla.com");
+
+ site1.containerOpen = true;
+ do_check_eq(site1.childCount, 2);
+
+ // But URLs are now sorted by title descending
+ site1visit = site1.getChild(0);
+ do_check_eq(site1visit.uri, "http://mirror0.google.com/b");
+
+ site1.containerOpen = false;
+ dayNode.containerOpen = false;
+ root.containerOpen = false;
+}
+
+/**
+ * Queries history grouped by date, checking containers' labels and children.
+ */
+function test_RESULTS_AS_DATE_QUERY() {
+ print("\n\n*** TEST RESULTS_AS_DATE_QUERY\n");
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_DATE_QUERY;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ var cc = root.childCount;
+ do_check_eq(cc, visibleContainers.length);
+ print("Found containers:");
+ for (var i = 0; i < cc; i++) {
+ var container = visibleContainers[i];
+ var node = root.getChild(i);
+ print(node.title);
+ if (container.label)
+ do_check_eq(node.title, container.label);
+ }
+
+ // Check one of the days
+ var dayNode = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayNode.containerOpen = true;
+ do_check_eq(dayNode.childCount, 4);
+
+ // Items should be sorted by title
+ var visit1 = dayNode.getChild(0);
+ do_check_eq(visit1.uri, "http://mirror0.google.com/a");
+
+ var visit2 = dayNode.getChild(3);
+ do_check_eq(visit2.uri, "http://mirror0.mozilla.com/b");
+
+ // Bug 473157: changing sorting mode should not affect the containers
+ result.sortingMode = options.SORT_BY_TITLE_DESCENDING;
+
+ // Check one of the days
+ dayNode = root.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dayNode.containerOpen = true;
+ do_check_eq(dayNode.childCount, 4);
+
+ // But URLs are now sorted by title descending
+ visit1 = dayNode.getChild(0);
+ do_check_eq(visit1.uri, "http://mirror0.mozilla.com/b");
+
+ visit2 = dayNode.getChild(3);
+ do_check_eq(visit2.uri, "http://mirror0.google.com/a");
+
+ dayNode.containerOpen = false;
+ root.containerOpen = false;
+}
+
+/**
+ * Queries history grouped by site, checking containers' labels and children.
+ */
+function test_RESULTS_AS_SITE_QUERY() {
+ print("\n\n*** TEST RESULTS_AS_SITE_QUERY\n");
+ // add a bookmark with a domain not in the set of visits in the db
+ var itemId = bs.insertBookmark(bs.toolbarFolder, uri("http://foobar"),
+ bs.DEFAULT_INDEX, "");
+
+ var options = hs.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_SITE_QUERY;
+ options.sortingMode = options.SORT_BY_TITLE_ASCENDING;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, containers.length * 2);
+
+/* Expected results:
+ "mirror0.google.com",
+ "mirror0.mozilla.com",
+ "mirror1.google.com",
+ "mirror1.mozilla.com",
+ "mirror2.google.com",
+ "mirror2.mozilla.com",
+ "mirror3.google.com", <== We check for this site (index 6)
+ "mirror3.mozilla.com",
+ "mirror4.google.com",
+ "mirror4.mozilla.com",
+ "mirror5.google.com",
+ "mirror5.mozilla.com",
+ ...
+*/
+
+ // Items should be sorted by host
+ var siteNode = root.getChild(6)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(siteNode.title, "mirror3.google.com");
+
+ siteNode.containerOpen = true;
+ do_check_eq(siteNode.childCount, 2);
+
+ // Inside of host sites are sorted by title
+ var visitNode = siteNode.getChild(0);
+ do_check_eq(visitNode.uri, "http://mirror3.google.com/a");
+
+ // Bug 473157: changing sorting mode should not affect the containers
+ result.sortingMode = options.SORT_BY_TITLE_DESCENDING;
+ siteNode = root.getChild(6)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ do_check_eq(siteNode.title, "mirror3.google.com");
+
+ siteNode.containerOpen = true;
+ do_check_eq(siteNode.childCount, 2);
+
+ // But URLs are now sorted by title descending
+ var visit = siteNode.getChild(0);
+ do_check_eq(visit.uri, "http://mirror3.google.com/b");
+
+ siteNode.containerOpen = false;
+ root.containerOpen = false;
+
+ // Cleanup.
+ bs.removeItem(itemId);
+}
+
+/**
+ * Checks that queries grouped by date do liveupdate correctly.
+ */
+function* task_test_date_liveupdate(aResultType) {
+ var midnight = nowObj;
+ midnight.setHours(0);
+ midnight.setMinutes(0);
+ midnight.setSeconds(0);
+ midnight.setMilliseconds(0);
+
+ // TEST 1. Test that the query correctly updates when it is root.
+ var options = hs.getNewQueryOptions();
+ options.resultType = aResultType;
+ var query = hs.getNewQuery();
+ var result = hs.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_check_eq(root.childCount, visibleContainers.length);
+ // Remove "Today".
+ hs.removePagesByTimeframe(midnight.getTime() * 1000, Date.now() * 1000);
+ do_check_eq(root.childCount, visibleContainers.length - 1);
+
+ // Open "Last 7 days" container, this way we will have a container accepting
+ // the new visit, but we should still add back "Today" container.
+ var last7Days = root.getChild(1)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ last7Days.containerOpen = true;
+
+ // Add a visit for "Today". This should add back the missing "Today"
+ // container.
+ yield task_add_normalized_visit(uri("http://www.mozilla.org/"), nowObj.getTime(), 0);
+ do_check_eq(root.childCount, visibleContainers.length);
+
+ last7Days.containerOpen = false;
+ root.containerOpen = false;
+
+ // TEST 2. Test that the query correctly updates even if it is not root.
+ var itemId = bs.insertBookmark(bs.toolbarFolder,
+ uri("place:type=" + aResultType),
+ bs.DEFAULT_INDEX, "");
+
+ // Query toolbar and open our query container, then check again liveupdate.
+ options = hs.getNewQueryOptions();
+ query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ result = hs.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+ var dateContainer = root.getChild(0).QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ dateContainer.containerOpen = true;
+
+ do_check_eq(dateContainer.childCount, visibleContainers.length);
+ // Remove "Today".
+ hs.removePagesByTimeframe(midnight.getTime() * 1000, Date.now() * 1000);
+ do_check_eq(dateContainer.childCount, visibleContainers.length - 1);
+ // Add a visit for "Today".
+ yield task_add_normalized_visit(uri("http://www.mozilla.org/"), nowObj.getTime(), 0);
+ do_check_eq(dateContainer.childCount, visibleContainers.length);
+
+ dateContainer.containerOpen = false;
+ root.containerOpen = false;
+
+ // Cleanup.
+ bs.removeItem(itemId);
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_history_sidebar()
+{
+ // If we're dangerously close to a date change, just bail out.
+ if (nowObj.getHours() == 23 && nowObj.getMinutes() >= 50) {
+ return;
+ }
+
+ yield task_fill_history();
+ test_RESULTS_AS_DATE_SITE_QUERY();
+ test_RESULTS_AS_DATE_QUERY();
+ test_RESULTS_AS_SITE_QUERY();
+
+ yield task_test_date_liveupdate(Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY);
+ yield task_test_date_liveupdate(Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY);
+
+ // The remaining views are
+ // RESULTS_AS_URI + SORT_BY_VISITCOUNT_DESCENDING
+ // -> test_399266.js
+ // RESULTS_AS_URI + SORT_BY_DATE_DESCENDING
+ // -> test_385397.js
+});
diff --git a/toolkit/components/places/tests/unit/test_hosts_triggers.js b/toolkit/components/places/tests/unit/test_hosts_triggers.js
new file mode 100644
index 000000000..9c3359e76
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_hosts_triggers.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests the validity of various triggers that add remove hosts from moz_hosts
+ */
+
+XPCOMUtils.defineLazyServiceGetter(this, "gHistory",
+ "@mozilla.org/browser/history;1",
+ "mozIAsyncHistory");
+
+// add some visits and remove them, add a bookmark,
+// change its uri, then remove it, and
+// for each change check that moz_hosts has correctly been updated.
+
+function isHostInMozPlaces(aURI)
+{
+ let stmt = DBConn().createStatement(
+ `SELECT url
+ FROM moz_places
+ WHERE url_hash = hash(:host) AND url = :host`
+ );
+ let result = false;
+ stmt.params.host = aURI.spec;
+ while (stmt.executeStep()) {
+ if (stmt.row.url == aURI.spec) {
+ result = true;
+ break;
+ }
+ }
+ stmt.finalize();
+ return result;
+}
+
+function isHostInMozHosts(aURI, aTyped, aPrefix)
+{
+ let stmt = DBConn().createStatement(
+ `SELECT host, typed, prefix
+ FROM moz_hosts
+ WHERE host = fixup_url(:host)
+ AND frecency NOTNULL`
+ );
+ let result = false;
+ stmt.params.host = aURI.host;
+ if (stmt.executeStep()) {
+ result = aTyped == stmt.row.typed && aPrefix == stmt.row.prefix;
+ }
+ stmt.finalize();
+ return result;
+}
+
+var urls = [{uri: NetUtil.newURI("http://visit1.mozilla.org"),
+ expected: "visit1.mozilla.org",
+ typed: 0,
+ prefix: null
+ },
+ {uri: NetUtil.newURI("http://visit2.mozilla.org"),
+ expected: "visit2.mozilla.org",
+ typed: 0,
+ prefix: null
+ },
+ {uri: NetUtil.newURI("http://www.foo.mozilla.org"),
+ expected: "foo.mozilla.org",
+ typed: 1,
+ prefix: "www."
+ },
+ ];
+
+const NEW_URL = "http://different.mozilla.org/";
+
+add_task(function* test_moz_hosts_update()
+{
+ let places = [];
+ urls.forEach(function(url) {
+ let place = { uri: url.uri,
+ title: "test for " + url.url,
+ transition: url.typed ? TRANSITION_TYPED : undefined };
+ places.push(place);
+ });
+
+ yield PlacesTestUtils.addVisits(places);
+
+ do_check_true(isHostInMozHosts(urls[0].uri, urls[0].typed, urls[0].prefix));
+ do_check_true(isHostInMozHosts(urls[1].uri, urls[1].typed, urls[1].prefix));
+ do_check_true(isHostInMozHosts(urls[2].uri, urls[2].typed, urls[2].prefix));
+});
+
+add_task(function* test_remove_places()
+{
+ for (let idx in urls) {
+ PlacesUtils.history.removePage(urls[idx].uri);
+ }
+
+ yield PlacesTestUtils.clearHistory();
+
+ for (let idx in urls) {
+ do_check_false(isHostInMozHosts(urls[idx].uri, urls[idx].typed, urls[idx].prefix));
+ }
+});
+
+add_task(function* test_bookmark_changes()
+{
+ let testUri = NetUtil.newURI("http://test.mozilla.org");
+
+ let itemId = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ testUri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "bookmark title");
+
+ do_check_true(isHostInMozPlaces(testUri));
+
+ // Change the hostname
+ PlacesUtils.bookmarks.changeBookmarkURI(itemId, NetUtil.newURI(NEW_URL));
+
+ yield PlacesTestUtils.clearHistory();
+
+ let newUri = NetUtil.newURI(NEW_URL);
+ do_check_true(isHostInMozPlaces(newUri));
+ do_check_true(isHostInMozHosts(newUri, false, null));
+ do_check_false(isHostInMozHosts(NetUtil.newURI("http://test.mozilla.org"), false, null));
+});
+
+add_task(function* test_bookmark_removal()
+{
+ let itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.unfiledBookmarksFolderId,
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let newUri = NetUtil.newURI(NEW_URL);
+ PlacesUtils.bookmarks.removeItem(itemId);
+ yield PlacesTestUtils.clearHistory();
+
+ do_check_false(isHostInMozHosts(newUri, false, null));
+});
+
+add_task(function* test_moz_hosts_typed_update()
+{
+ const TEST_URI = NetUtil.newURI("http://typed.mozilla.com");
+ let places = [{ uri: TEST_URI
+ , title: "test for " + TEST_URI.spec
+ },
+ { uri: TEST_URI
+ , title: "test for " + TEST_URI.spec
+ , transition: TRANSITION_TYPED
+ }];
+
+ yield PlacesTestUtils.addVisits(places);
+
+ do_check_true(isHostInMozHosts(TEST_URI, true, null));
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_moz_hosts_www_remove()
+{
+ function* test_removal(aURIToRemove, aURIToKeep, aCallback) {
+ let places = [{ uri: aURIToRemove
+ , title: "test for " + aURIToRemove.spec
+ , transition: TRANSITION_TYPED
+ },
+ { uri: aURIToKeep
+ , title: "test for " + aURIToKeep.spec
+ , transition: TRANSITION_TYPED
+ }];
+
+ yield PlacesTestUtils.addVisits(places);
+ print("removing " + aURIToRemove.spec + " keeping " + aURIToKeep);
+ dump_table("moz_hosts");
+ dump_table("moz_places");
+ PlacesUtils.history.removePage(aURIToRemove);
+ let prefix = /www/.test(aURIToKeep.spec) ? "www." : null;
+ dump_table("moz_hosts");
+ dump_table("moz_places");
+ do_check_true(isHostInMozHosts(aURIToKeep, true, prefix));
+ }
+
+ const TEST_URI = NetUtil.newURI("http://rem.mozilla.com");
+ const TEST_WWW_URI = NetUtil.newURI("http://www.rem.mozilla.com");
+ yield test_removal(TEST_URI, TEST_WWW_URI);
+ yield test_removal(TEST_WWW_URI, TEST_URI);
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_moz_hosts_ftp_matchall()
+{
+ const TEST_URI_1 = NetUtil.newURI("ftp://www.mozilla.com/");
+ const TEST_URI_2 = NetUtil.newURI("ftp://mozilla.com/");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: TEST_URI_1, transition: TRANSITION_TYPED },
+ { uri: TEST_URI_2, transition: TRANSITION_TYPED }
+ ]);
+
+ do_check_true(isHostInMozHosts(TEST_URI_1, true, "ftp://"));
+});
+
+add_task(function* test_moz_hosts_ftp_not_matchall()
+{
+ const TEST_URI_1 = NetUtil.newURI("http://mozilla.com/");
+ const TEST_URI_2 = NetUtil.newURI("ftp://mozilla.com/");
+
+ yield PlacesTestUtils.addVisits([
+ { uri: TEST_URI_1, transition: TRANSITION_TYPED },
+ { uri: TEST_URI_2, transition: TRANSITION_TYPED }
+ ]);
+
+ do_check_true(isHostInMozHosts(TEST_URI_1, true, null));
+});
+
+add_task(function* test_moz_hosts_update_2()
+{
+ // Check that updating trigger takes into account prefixes for different
+ // rev_hosts.
+ const TEST_URI_1 = NetUtil.newURI("https://www.google.it/");
+ const TEST_URI_2 = NetUtil.newURI("https://google.it/");
+ let places = [{ uri: TEST_URI_1
+ , transition: TRANSITION_TYPED
+ },
+ { uri: TEST_URI_2
+ }];
+ yield PlacesTestUtils.addVisits(places);
+
+ do_check_true(isHostInMozHosts(TEST_URI_1, true, "https://www."));
+});
+
+function run_test()
+{
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js b/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js
new file mode 100644
index 000000000..771a6ac17
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_import_mobile_bookmarks.js
@@ -0,0 +1,292 @@
+function* importFromFixture(fixture, replace) {
+ let cwd = yield OS.File.getCurrentDirectory();
+ let path = OS.Path.join(cwd, fixture);
+
+ do_print(`Importing from ${path}`);
+ yield BookmarkJSONUtils.importFromFile(path, replace);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+}
+
+function* treeEquals(guid, expected, message) {
+ let root = yield PlacesUtils.promiseBookmarksTree(guid);
+ let bookmarks = (function nodeToEntry(node) {
+ let entry = { guid: node.guid, index: node.index }
+ if (node.children) {
+ entry.children = node.children.map(nodeToEntry);
+ }
+ if (node.annos) {
+ entry.annos = node.annos;
+ }
+ return entry;
+ }(root));
+
+ do_print(`Checking if ${guid} tree matches ${JSON.stringify(expected)}`);
+ do_print(`Got bookmarks tree for ${guid}: ${JSON.stringify(bookmarks)}`);
+
+ deepEqual(bookmarks, expected, message);
+}
+
+add_task(function* test_restore_mobile_bookmarks_root() {
+ yield* importFromFixture("mobile_bookmarks_root_import.json",
+ /* replace */ true);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "X6lUyOspVYwi", index: 0 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "_o8e1_zxTJFg", index: 0 },
+ { guid: "QCtSqkVYUbXB", index: 1 },
+ ],
+ }],
+ }, "Should restore mobile bookmarks from root");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_mobile_bookmarks_root() {
+ yield* importFromFixture("mobile_bookmarks_root_import.json",
+ /* replace */ false);
+ yield* importFromFixture("mobile_bookmarks_root_merge.json",
+ /* replace */ false);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "Utodo9b0oVws", index: 0 },
+ { guid: "X6lUyOspVYwi", index: 1 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "a17yW6-nTxEJ", index: 0 },
+ { guid: "xV10h9Wi3FBM", index: 1 },
+ { guid: "_o8e1_zxTJFg", index: 2 },
+ { guid: "QCtSqkVYUbXB", index: 3 },
+ ],
+ }],
+ }, "Should merge bookmarks root contents");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_restore_mobile_bookmarks_folder() {
+ yield* importFromFixture("mobile_bookmarks_folder_import.json",
+ /* replace */ true);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "X6lUyOspVYwi", index: 0 },
+ { guid: "XF4yRP6bTuil", index: 1 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ children: [{ guid: "buy7711R3ZgE", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ children: [{ guid: "KIa9iKZab2Z5", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "_o8e1_zxTJFg", index: 0 },
+ { guid: "QCtSqkVYUbXB", index: 1 },
+ ],
+ }],
+ }, "Should restore mobile bookmark folder contents into mobile root");
+
+ // We rewrite queries to point to the root ID instead of the name
+ // ("MOBILE_BOOKMARKS") so that we don't break them if the user downgrades
+ // to an earlier release channel. This can be removed along with the anno in
+ // bug 1306445.
+ let queryById = yield PlacesUtils.bookmarks.fetch("XF4yRP6bTuil");
+ equal(queryById.url.href, "place:folder=" + PlacesUtils.mobileFolderId,
+ "Should rewrite mobile query to point to root ID");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_mobile_bookmarks_folder() {
+ yield* importFromFixture("mobile_bookmarks_folder_import.json",
+ /* replace */ false);
+ yield* importFromFixture("mobile_bookmarks_folder_merge.json",
+ /* replace */ false);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "Utodo9b0oVws", index: 0 },
+ { guid: "X6lUyOspVYwi", index: 1 },
+ { guid: "XF4yRP6bTuil", index: 2 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ children: [{ guid: "buy7711R3ZgE", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ children: [{ guid: "KIa9iKZab2Z5", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "a17yW6-nTxEJ", index: 0 },
+ { guid: "xV10h9Wi3FBM", index: 1 },
+ { guid: "_o8e1_zxTJFg", index: 2 },
+ { guid: "QCtSqkVYUbXB", index: 3 },
+ ],
+ }],
+ }, "Should merge bookmarks folder contents into mobile root");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_restore_multiple_bookmarks_folders() {
+ yield* importFromFixture("mobile_bookmarks_multiple_folders.json",
+ /* replace */ true);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "buy7711R3ZgE", index: 0 },
+ { guid: "F_LBgd1fS_uQ", index: 1 },
+ { guid: "oIpmQXMWsXvY", index: 2 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ children: [{ guid: "Utodo9b0oVws", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ children: [{ guid: "xV10h9Wi3FBM", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "sSZ86WT9WbN3", index: 0 },
+ { guid: "a17yW6-nTxEJ", index: 1 },
+ ],
+ }],
+ }, "Should restore multiple bookmarks folder contents into root");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_import_multiple_bookmarks_folders() {
+ yield* importFromFixture("mobile_bookmarks_root_import.json",
+ /* replace */ false);
+ yield* importFromFixture("mobile_bookmarks_multiple_folders.json",
+ /* replace */ false);
+
+ yield* treeEquals(PlacesUtils.bookmarks.rootGuid, {
+ guid: PlacesUtils.bookmarks.rootGuid,
+ index: 0,
+ children: [{
+ guid: PlacesUtils.bookmarks.menuGuid,
+ index: 0,
+ children: [
+ { guid: "buy7711R3ZgE", index: 0 },
+ { guid: "F_LBgd1fS_uQ", index: 1 },
+ { guid: "oIpmQXMWsXvY", index: 2 },
+ { guid: "X6lUyOspVYwi", index: 3 },
+ ],
+ }, {
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ index: 1,
+ children: [{ guid: "Utodo9b0oVws", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ index: 3,
+ children: [{ guid: "xV10h9Wi3FBM", index: 0 }],
+ }, {
+ guid: PlacesUtils.bookmarks.mobileGuid,
+ index: 4,
+ annos: [{
+ name: "mobile/bookmarksRoot",
+ flags: 0,
+ expires: 4,
+ value: 1,
+ }],
+ children: [
+ { guid: "sSZ86WT9WbN3", index: 0 },
+ { guid: "a17yW6-nTxEJ", index: 1 },
+ { guid: "_o8e1_zxTJFg", index: 2 },
+ { guid: "QCtSqkVYUbXB", index: 3 },
+ ],
+ }],
+ }, "Should merge multiple mobile folders into root");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/unit/test_isPageInDB.js b/toolkit/components/places/tests/unit/test_isPageInDB.js
new file mode 100644
index 000000000..249853fa9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_isPageInDB.js
@@ -0,0 +1,10 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+add_task(function* test_execute() {
+ var good_uri = uri("http://mozilla.com");
+ var bad_uri = uri("http://google.com");
+ yield PlacesTestUtils.addVisits({uri: good_uri});
+ do_check_true(yield PlacesTestUtils.isPageInDB(good_uri));
+ do_check_false(yield PlacesTestUtils.isPageInDB(bad_uri));
+});
diff --git a/toolkit/components/places/tests/unit/test_isURIVisited.js b/toolkit/components/places/tests/unit/test_isURIVisited.js
new file mode 100644
index 000000000..93c010e83
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_isURIVisited.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests functionality of the isURIVisited API.
+
+const SCHEMES = {
+ "http://": true,
+ "https://": true,
+ "ftp://": true,
+ "file:///": true,
+ "about:": false,
+// nsIIOService.newURI() can throw if e.g. the app knows about imap://
+// but the account is not set up and so the URL is invalid for it.
+// "imap://": false,
+ "news://": false,
+ "mailbox:": false,
+ "moz-anno:favicon:http://": false,
+ "view-source:http://": false,
+ "chrome://browser/content/browser.xul?": false,
+ "resource://": false,
+ "data:,": false,
+ "wyciwyg:/0/http://": false,
+ "javascript:": false,
+};
+
+var gRunner;
+function run_test()
+{
+ do_test_pending();
+ gRunner = step();
+ gRunner.next();
+}
+
+function* step()
+{
+ let history = Cc["@mozilla.org/browser/history;1"]
+ .getService(Ci.mozIAsyncHistory);
+
+ for (let scheme in SCHEMES) {
+ do_print("Testing scheme " + scheme);
+ for (let t in PlacesUtils.history.TRANSITIONS) {
+ do_print("With transition " + t);
+ let transition = PlacesUtils.history.TRANSITIONS[t];
+
+ let uri = NetUtil.newURI(scheme + "mozilla.org/");
+
+ history.isURIVisited(uri, function(aURI, aIsVisited) {
+ do_check_true(uri.equals(aURI));
+ do_check_false(aIsVisited);
+
+ let callback = {
+ handleError: function () {},
+ handleResult: function () {},
+ handleCompletion: function () {
+ do_print("Added visit to " + uri.spec);
+
+ history.isURIVisited(uri, function (aURI2, aIsVisited2) {
+ do_check_true(uri.equals(aURI2));
+ let checker = SCHEMES[scheme] ? do_check_true : do_check_false;
+ checker(aIsVisited2);
+
+ PlacesTestUtils.clearHistory().then(function () {
+ history.isURIVisited(uri, function(aURI3, aIsVisited3) {
+ do_check_true(uri.equals(aURI3));
+ do_check_false(aIsVisited3);
+ gRunner.next();
+ });
+ });
+ });
+ },
+ };
+
+ history.updatePlaces({ uri: uri
+ , visits: [ { transitionType: transition
+ , visitDate: Date.now() * 1000
+ } ]
+ }, callback);
+ });
+ yield undefined;
+ }
+ }
+
+ do_test_finished();
+}
diff --git a/toolkit/components/places/tests/unit/test_isvisited.js b/toolkit/components/places/tests/unit/test_isvisited.js
new file mode 100644
index 000000000..d7bcc2851
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_isvisited.js
@@ -0,0 +1,75 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ var referrer = uri("about:blank");
+
+ // add a http:// uri
+ var uri1 = uri("http://mozilla.com");
+ yield PlacesTestUtils.addVisits({uri: uri1, referrer: referrer});
+ do_check_guid_for_uri(uri1);
+ do_check_true(yield promiseIsURIVisited(uri1));
+
+ // add a https:// uri
+ var uri2 = uri("https://etrade.com");
+ yield PlacesTestUtils.addVisits({uri: uri2, referrer: referrer});
+ do_check_guid_for_uri(uri2);
+ do_check_true(yield promiseIsURIVisited(uri2));
+
+ // add a ftp:// uri
+ var uri3 = uri("ftp://ftp.mozilla.org");
+ yield PlacesTestUtils.addVisits({uri: uri3, referrer: referrer});
+ do_check_guid_for_uri(uri3);
+ do_check_true(yield promiseIsURIVisited(uri3));
+
+ // check if a nonexistent uri is visited
+ var uri4 = uri("http://foobarcheese.com");
+ do_check_false(yield promiseIsURIVisited(uri4));
+
+ // check that certain schemes never show up as visited
+ // even if we attempt to add them to history
+ // see CanAddURI() in nsNavHistory.cpp
+ const URLS = [
+ "about:config",
+ "imap://cyrus.andrew.cmu.edu/archive.imap",
+ "news://new.mozilla.org/mozilla.dev.apps.firefox",
+ "mailbox:Inbox",
+ "moz-anno:favicon:http://mozilla.org/made-up-favicon",
+ "view-source:http://mozilla.org",
+ "chrome://browser/content/browser.xul",
+ "resource://gre-resources/hiddenWindow.html",
+ "data:,Hello%2C%20World!",
+ "wyciwyg:/0/http://mozilla.org",
+ "javascript:alert('hello wolrd!');",
+ "http://localhost/" + "a".repeat(1984),
+ ];
+ for (let currentURL of URLS) {
+ try {
+ var cantAddUri = uri(currentURL);
+ }
+ catch (e) {
+ // nsIIOService.newURI() can throw if e.g. our app knows about imap://
+ // but the account is not set up and so the URL is invalid for us.
+ // Note this in the log but ignore as it's not the subject of this test.
+ do_print("Could not construct URI for '" + currentURL + "'; ignoring");
+ }
+ if (cantAddUri) {
+ PlacesTestUtils.addVisits({uri: cantAddUri, referrer: referrer}).then(() => {
+ do_throw("Should not have added history for invalid URI.");
+ }, error => {
+ do_check_true(error.message.includes("No items were added to history"));
+ });
+ do_check_false(yield promiseIsURIVisited(cantAddUri));
+ }
+ }
+});
+
diff --git a/toolkit/components/places/tests/unit/test_keywords.js b/toolkit/components/places/tests/unit/test_keywords.js
new file mode 100644
index 000000000..57b734c5d
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_keywords.js
@@ -0,0 +1,548 @@
+"use strict"
+
+function* check_keyword(aExpectExists, aHref, aKeyword, aPostData = null) {
+ // Check case-insensitivity.
+ aKeyword = aKeyword.toUpperCase();
+
+ let entry = yield PlacesUtils.keywords.fetch(aKeyword);
+
+ Assert.deepEqual(entry, yield PlacesUtils.keywords.fetch({ keyword: aKeyword }));
+
+ if (aExpectExists) {
+ Assert.ok(!!entry, "A keyword should exist");
+ Assert.equal(entry.url.href, aHref);
+ Assert.equal(entry.postData, aPostData);
+ Assert.deepEqual(entry, yield PlacesUtils.keywords.fetch({ keyword: aKeyword, url: aHref }));
+ let entries = [];
+ yield PlacesUtils.keywords.fetch({ url: aHref }, e => entries.push(e));
+ Assert.ok(entries.some(e => e.url.href == aHref && e.keyword == aKeyword.toLowerCase()));
+ } else {
+ Assert.ok(!entry || entry.url.href != aHref,
+ "The given keyword entry should not exist");
+ Assert.equal(null, yield PlacesUtils.keywords.fetch({ keyword: aKeyword, url: aHref }));
+ }
+}
+
+/**
+ * Polls the keywords cache waiting for the given keyword entry.
+ */
+function* promiseKeyword(keyword, expectedHref) {
+ let href = null;
+ do {
+ yield new Promise(resolve => do_timeout(100, resolve));
+ let entry = yield PlacesUtils.keywords.fetch(keyword);
+ if (entry)
+ href = entry.url.href;
+ } while (href != expectedHref);
+}
+
+function* check_no_orphans() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(
+ `SELECT id FROM moz_keywords k
+ WHERE NOT EXISTS (SELECT 1 FROM moz_places WHERE id = k.place_id)
+ `);
+ Assert.equal(rows.length, 0);
+}
+
+function expectBookmarkNotifications() {
+ let notifications = [];
+ let observer = new Proxy(NavBookmarkObserver, {
+ get(target, name) {
+ if (name == "check") {
+ PlacesUtils.bookmarks.removeObserver(observer);
+ return expectedNotifications =>
+ Assert.deepEqual(notifications, expectedNotifications);
+ }
+
+ if (name.startsWith("onItemChanged")) {
+ return function(itemId, property) {
+ if (property != "keyword")
+ return;
+ let args = Array.from(arguments, arg => {
+ if (arg && arg instanceof Ci.nsIURI)
+ return new URL(arg.spec);
+ if (arg && typeof(arg) == "number" && arg >= Date.now() * 1000)
+ return new Date(parseInt(arg/1000));
+ return arg;
+ });
+ notifications.push({ name: name, arguments: args });
+ }
+ }
+
+ if (name in target)
+ return target[name];
+ return undefined;
+ }
+ });
+ PlacesUtils.bookmarks.addObserver(observer, false);
+ return observer;
+}
+
+add_task(function* test_invalid_input() {
+ Assert.throws(() => PlacesUtils.keywords.fetch(null),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch(5),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch(undefined),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ keyword: null }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ keyword: {} }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ keyword: 5 }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({}),
+ /At least keyword or url must be provided/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ keyword: "test" }, "test"),
+ /onResult callback must be a valid function/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ url: "test" }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ url: {} }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ url: null }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.fetch({ url: "" }),
+ /is not a valid URL/);
+
+ Assert.throws(() => PlacesUtils.keywords.insert(null),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.keywords.insert("test"),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.keywords.insert(undefined),
+ /Input should be a valid object/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: null }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: 5 }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "" }),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", postData: 5 }),
+ /Invalid POST data/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", postData: {} }),
+ /Invalid POST data/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test" }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: 5 }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: "" }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: null }),
+ /is not a valid URL/);
+ Assert.throws(() => PlacesUtils.keywords.insert({ keyword: "test", url: "mozilla" }),
+ /is not a valid URL/);
+
+ Assert.throws(() => PlacesUtils.keywords.remove(null),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.remove(""),
+ /Invalid keyword/);
+ Assert.throws(() => PlacesUtils.keywords.remove(5),
+ /Invalid keyword/);
+});
+
+add_task(function* test_addKeyword() {
+ yield check_keyword(false, "http://example.com/", "keyword");
+ let fc = yield foreign_count("http://example.com/");
+ let observer = expectBookmarkNotifications();
+
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+ observer.check([]);
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+ // Now remove the keyword.
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+ observer.check([]);
+
+ yield check_keyword(false, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc); // -1 keyword
+
+ // Check using URL.
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: new URL("http://example.com/") });
+ yield check_keyword(true, "http://example.com/", "keyword");
+ yield PlacesUtils.keywords.remove("keyword");
+ yield check_keyword(false, "http://example.com/", "keyword");
+
+ yield check_no_orphans();
+});
+
+add_task(function* test_addBookmarkAndKeyword() {
+ yield check_keyword(false, "http://example.com/", "keyword");
+ let fc = yield foreign_count("http://example.com/");
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+
+ let observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+ "keyword", false, "keyword",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark +1 keyword
+
+ // Now remove the keyword.
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+ "keyword", false, "",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ yield check_keyword(false, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // -1 keyword
+
+ // Add again the keyword, then remove the bookmark.
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.bookmarks.remove(bookmark.guid);
+ // the notification is synchronous but the removal process is async.
+ // Unfortunately there's nothing explicit we can wait for.
+ while ((yield foreign_count("http://example.com/")));
+ // We don't get any itemChanged notification since the bookmark has been
+ // removed already.
+ observer.check([]);
+
+ yield check_keyword(false, "http://example.com/", "keyword");
+
+ yield check_no_orphans();
+});
+
+add_task(function* test_addKeywordToURIHavingKeyword() {
+ yield check_keyword(false, "http://example.com/", "keyword");
+ let fc = yield foreign_count("http://example.com/");
+
+ let observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+ observer.check([]);
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+ yield PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/" });
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ yield check_keyword(true, "http://example.com/", "keyword2");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 keyword
+ let entries = [];
+ let entry = yield PlacesUtils.keywords.fetch({ url: "http://example.com/" }, e => entries.push(e));
+ Assert.equal(entries.length, 2);
+ Assert.deepEqual(entries[0], entry);
+
+ // Now remove the keywords.
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+ yield PlacesUtils.keywords.remove("keyword2");
+ observer.check([]);
+
+ yield check_keyword(false, "http://example.com/", "keyword");
+ yield check_keyword(false, "http://example.com/", "keyword2");
+ Assert.equal((yield foreign_count("http://example.com/")), fc); // -1 keyword
+
+ yield check_no_orphans();
+});
+
+add_task(function* test_addBookmarkToURIHavingKeyword() {
+ yield check_keyword(false, "http://example.com/", "keyword");
+ let fc = yield foreign_count("http://example.com/");
+ let observer = expectBookmarkNotifications();
+
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+ observer.check([]);
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 1); // +1 keyword
+
+ observer = expectBookmarkNotifications();
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark
+ observer.check([]);
+
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.bookmarks.remove(bookmark.guid);
+ // the notification is synchronous but the removal process is async.
+ // Unfortunately there's nothing explicit we can wait for.
+ while ((yield foreign_count("http://example.com/")));
+ // We don't get any itemChanged notification since the bookmark has been
+ // removed already.
+ observer.check([]);
+
+ yield check_keyword(false, "http://example.com/", "keyword");
+
+ yield check_no_orphans();
+});
+
+add_task(function* test_sameKeywordDifferentURL() {
+ let fc1 = yield foreign_count("http://example1.com/");
+ let bookmark1 = yield PlacesUtils.bookmarks.insert({ url: "http://example1.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let fc2 = yield foreign_count("http://example2.com/");
+ let bookmark2 = yield PlacesUtils.bookmarks.insert({ url: "http://example2.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example1.com/" });
+
+ yield check_keyword(true, "http://example1.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 2); // +1 bookmark +1 keyword
+ yield check_keyword(false, "http://example2.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 1); // +1 bookmark
+
+ // Assign the same keyword to another url.
+ let observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example2.com/" });
+
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+ "keyword", false, "",
+ bookmark1.lastModified, bookmark1.type,
+ (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+ bookmark1.guid, bookmark1.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+ "keyword", false, "keyword",
+ bookmark2.lastModified, bookmark2.type,
+ (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+ bookmark2.guid, bookmark2.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ yield check_keyword(false, "http://example1.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 1); // -1 keyword
+ yield check_keyword(true, "http://example2.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 2); // +1 keyword
+
+ // Now remove the keyword.
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+ "keyword", false, "",
+ bookmark2.lastModified, bookmark2.type,
+ (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+ bookmark2.guid, bookmark2.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ yield check_keyword(false, "http://example1.com/", "keyword");
+ yield check_keyword(false, "http://example2.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 1);
+ Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 1); // -1 keyword
+
+ yield PlacesUtils.bookmarks.remove(bookmark1);
+ yield PlacesUtils.bookmarks.remove(bookmark2);
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1); // -1 bookmark
+ while ((yield foreign_count("http://example2.com/"))); // -1 keyword
+
+ yield check_no_orphans();
+});
+
+add_task(function* test_sameURIDifferentKeyword() {
+ let fc = yield foreign_count("http://example.com/");
+
+ let observer = expectBookmarkNotifications();
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield PlacesUtils.keywords.insert({keyword: "keyword", url: "http://example.com/" });
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // +1 bookmark +1 keyword
+
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+ "keyword", false, "keyword",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/" });
+ yield check_keyword(true, "http://example.com/", "keyword");
+ yield check_keyword(true, "http://example.com/", "keyword2");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // +1 keyword
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+ "keyword", false, "keyword2",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ // Add a third keyword.
+ yield PlacesUtils.keywords.insert({ keyword: "keyword3", url: "http://example.com/" });
+ yield check_keyword(true, "http://example.com/", "keyword");
+ yield check_keyword(true, "http://example.com/", "keyword2");
+ yield check_keyword(true, "http://example.com/", "keyword3");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 4); // +1 keyword
+
+ // Remove one of the keywords.
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+ yield check_keyword(false, "http://example.com/", "keyword");
+ yield check_keyword(true, "http://example.com/", "keyword2");
+ yield check_keyword(true, "http://example.com/", "keyword3");
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark.guid)),
+ "keyword", false, "",
+ bookmark.lastModified, bookmark.type,
+ (yield PlacesUtils.promiseItemId(bookmark.parentGuid)),
+ bookmark.guid, bookmark.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // -1 keyword
+
+ // Now remove the bookmark.
+ yield PlacesUtils.bookmarks.remove(bookmark);
+ while ((yield foreign_count("http://example.com/")));
+ yield check_keyword(false, "http://example.com/", "keyword");
+ yield check_keyword(false, "http://example.com/", "keyword2");
+ yield check_keyword(false, "http://example.com/", "keyword3");
+
+ check_no_orphans();
+});
+
+add_task(function* test_deleteKeywordMultipleBookmarks() {
+ let fc = yield foreign_count("http://example.com/");
+
+ let observer = expectBookmarkNotifications();
+ let bookmark1 = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ let bookmark2 = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+
+ yield check_keyword(true, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 3); // +2 bookmark +1 keyword
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+ "keyword", false, "keyword",
+ bookmark2.lastModified, bookmark2.type,
+ (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+ bookmark2.guid, bookmark2.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+ "keyword", false, "keyword",
+ bookmark1.lastModified, bookmark1.type,
+ (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+ bookmark1.guid, bookmark1.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ observer = expectBookmarkNotifications();
+ yield PlacesUtils.keywords.remove("keyword");
+ yield check_keyword(false, "http://example.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example.com/")), fc + 2); // -1 keyword
+ observer.check([{ name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark2.guid)),
+ "keyword", false, "",
+ bookmark2.lastModified, bookmark2.type,
+ (yield PlacesUtils.promiseItemId(bookmark2.parentGuid)),
+ bookmark2.guid, bookmark2.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] },
+ { name: "onItemChanged",
+ arguments: [ (yield PlacesUtils.promiseItemId(bookmark1.guid)),
+ "keyword", false, "",
+ bookmark1.lastModified, bookmark1.type,
+ (yield PlacesUtils.promiseItemId(bookmark1.parentGuid)),
+ bookmark1.guid, bookmark1.parentGuid, "",
+ Ci.nsINavBookmarksService.SOURCE_DEFAULT ] } ]);
+
+ // Now remove the bookmarks.
+ yield PlacesUtils.bookmarks.remove(bookmark1);
+ yield PlacesUtils.bookmarks.remove(bookmark2);
+ Assert.equal((yield foreign_count("http://example.com/")), fc); // -2 bookmarks
+
+ check_no_orphans();
+});
+
+add_task(function* test_multipleKeywordsSamePostData() {
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/", postData: "postData1" });
+ yield check_keyword(true, "http://example.com/", "keyword", "postData1");
+ // Add another keyword with same postData, should fail.
+ yield Assert.rejects(PlacesUtils.keywords.insert({ keyword: "keyword2", url: "http://example.com/", postData: "postData1" }),
+ /constraint failed/);
+ yield check_keyword(false, "http://example.com/", "keyword2", "postData1");
+
+ yield PlacesUtils.keywords.remove("keyword");
+
+ check_no_orphans();
+});
+
+add_task(function* test_oldPostDataAPI() {
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com/" });
+ let itemId = yield PlacesUtils.promiseItemId(bookmark.guid);
+ yield PlacesUtils.setPostDataForBookmark(itemId, "postData");
+ yield check_keyword(true, "http://example.com/", "keyword", "postData");
+ Assert.equal(PlacesUtils.getPostDataForBookmark(itemId), "postData");
+
+ yield PlacesUtils.keywords.remove("keyword");
+ yield PlacesUtils.bookmarks.remove(bookmark);
+
+ check_no_orphans();
+});
+
+add_task(function* test_oldKeywordsAPI() {
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield check_keyword(false, "http://example.com/", "keyword");
+ let itemId = yield PlacesUtils.promiseItemId(bookmark.guid);
+
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "keyword");
+ yield promiseKeyword("keyword", "http://example.com/");
+
+ // Remove the keyword.
+ PlacesUtils.bookmarks.setKeywordForBookmark(itemId, "");
+ yield promiseKeyword("keyword", null);
+
+ yield PlacesUtils.keywords.insert({ keyword: "keyword", url: "http://example.com" });
+ Assert.equal(PlacesUtils.bookmarks.getKeywordForBookmark(itemId), "keyword");
+ Assert.equal(PlacesUtils.bookmarks.getURIForKeyword("keyword").spec, "http://example.com/");
+ yield PlacesUtils.bookmarks.remove(bookmark);
+
+ check_no_orphans();
+});
+
+add_task(function* test_bookmarkURLChange() {
+ let fc1 = yield foreign_count("http://example1.com/");
+ let fc2 = yield foreign_count("http://example2.com/");
+ let bookmark = yield PlacesUtils.bookmarks.insert({ url: "http://example1.com/",
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ yield PlacesUtils.keywords.insert({ keyword: "keyword",
+ url: "http://example1.com/" });
+
+ yield check_keyword(true, "http://example1.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1 + 2); // +1 bookmark +1 keyword
+
+ yield PlacesUtils.bookmarks.update({ guid: bookmark.guid,
+ url: "http://example2.com/"});
+ yield promiseKeyword("keyword", "http://example2.com/");
+
+ yield check_keyword(false, "http://example1.com/", "keyword");
+ yield check_keyword(true, "http://example2.com/", "keyword");
+ Assert.equal((yield foreign_count("http://example1.com/")), fc1); // -1 bookmark -1 keyword
+ Assert.equal((yield foreign_count("http://example2.com/")), fc2 + 2); // +1 bookmark +1 keyword
+});
diff --git a/toolkit/components/places/tests/unit/test_lastModified.js b/toolkit/components/places/tests/unit/test_lastModified.js
new file mode 100644
index 000000000..c75494932
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_lastModified.js
@@ -0,0 +1,34 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Test that inserting a new bookmark will set lastModified to the same
+ * values as dateAdded.
+ */
+// main
+function run_test() {
+ var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ var itemId = bs.insertBookmark(bs.bookmarksMenuFolder,
+ uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX,
+ "itemTitle");
+ var dateAdded = bs.getItemDateAdded(itemId);
+ do_check_eq(dateAdded, bs.getItemLastModified(itemId));
+
+ // Change lastModified, then change dateAdded. LastModified should be set
+ // to the new dateAdded.
+ // This could randomly fail on virtual machines due to timing issues, so
+ // we manually increase the time value. See bug 500640 for details.
+ bs.setItemLastModified(itemId, dateAdded + 1000);
+ do_check_true(bs.getItemLastModified(itemId) === dateAdded + 1000);
+ do_check_true(bs.getItemDateAdded(itemId) < bs.getItemLastModified(itemId));
+ bs.setItemDateAdded(itemId, dateAdded + 2000);
+ do_check_true(bs.getItemDateAdded(itemId) === dateAdded + 2000);
+ do_check_eq(bs.getItemDateAdded(itemId), bs.getItemLastModified(itemId));
+
+ bs.removeItem(itemId);
+}
diff --git a/toolkit/components/places/tests/unit/test_markpageas.js b/toolkit/components/places/tests/unit/test_markpageas.js
new file mode 100644
index 000000000..ba4f740c6
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_markpageas.js
@@ -0,0 +1,61 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var gVisits = [{url: "http://www.mozilla.com/",
+ transition: TRANSITION_TYPED},
+ {url: "http://www.google.com/",
+ transition: TRANSITION_BOOKMARK},
+ {url: "http://www.espn.com/",
+ transition: TRANSITION_LINK}];
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ let observer;
+ let completionPromise = new Promise(resolveCompletionPromise => {
+ observer = {
+ __proto__: NavHistoryObserver.prototype,
+ _visitCount: 0,
+ onVisit: function (aURI, aVisitID, aTime, aSessionID, aReferringID,
+ aTransitionType, aAdded)
+ {
+ do_check_eq(aURI.spec, gVisits[this._visitCount].url);
+ do_check_eq(aTransitionType, gVisits[this._visitCount].transition);
+ this._visitCount++;
+
+ if (this._visitCount == gVisits.length) {
+ resolveCompletionPromise();
+ }
+ },
+ };
+ });
+
+ PlacesUtils.history.addObserver(observer, false);
+
+ for (var visit of gVisits) {
+ if (visit.transition == TRANSITION_TYPED)
+ PlacesUtils.history.markPageAsTyped(uri(visit.url));
+ else if (visit.transition == TRANSITION_BOOKMARK)
+ PlacesUtils.history.markPageAsFollowedBookmark(uri(visit.url))
+ else {
+ // because it is a top level visit with no referrer,
+ // it will result in TRANSITION_LINK
+ }
+ yield PlacesTestUtils.addVisits({
+ uri: uri(visit.url),
+ transition: visit.transition
+ });
+ }
+
+ yield completionPromise;
+
+ PlacesUtils.history.removeObserver(observer);
+});
+
diff --git a/toolkit/components/places/tests/unit/test_mozIAsyncLivemarks.js b/toolkit/components/places/tests/unit/test_mozIAsyncLivemarks.js
new file mode 100644
index 000000000..5136591ba
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_mozIAsyncLivemarks.js
@@ -0,0 +1,514 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests functionality of the mozIAsyncLivemarks interface.
+
+const FEED_URI = NetUtil.newURI("http://feed.rss/");
+const SITE_URI = NetUtil.newURI("http://site.org/");
+
+// This test must be the first one, since it's testing the cache.
+add_task(function* test_livemark_cache() {
+ // Add a livemark through other APIs.
+ let folder = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "test",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ });
+ let id = yield PlacesUtils.promiseItemId(folder.guid);
+ PlacesUtils.annotations
+ .setItemAnnotation(id, PlacesUtils.LMANNO_FEEDURI,
+ "http://example.com/feed",
+ 0, PlacesUtils.annotations.EXPIRE_NEVER);
+ PlacesUtils.annotations
+ .setItemAnnotation(id, PlacesUtils.LMANNO_SITEURI,
+ "http://example.com/site",
+ 0, PlacesUtils.annotations.EXPIRE_NEVER);
+
+ let livemark = yield PlacesUtils.livemarks.getLivemark({ guid: folder.guid });
+ Assert.equal(folder.guid, livemark.guid);
+ Assert.equal(folder.dateAdded * 1000, livemark.dateAdded);
+ Assert.equal(folder.parentGuid, livemark.parentGuid);
+ Assert.equal(folder.index, livemark.index);
+ Assert.equal(folder.title, livemark.title);
+ Assert.equal(id, livemark.id);
+ Assert.equal(PlacesUtils.unfiledBookmarksFolderId, livemark.parentId);
+ Assert.equal("http://example.com/feed", livemark.feedURI.spec);
+ Assert.equal("http://example.com/site", livemark.siteURI.spec);
+
+ yield PlacesUtils.livemarks.removeLivemark(livemark);
+});
+
+add_task(function* test_addLivemark_noArguments_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark();
+ do_throw("Invoking addLivemark with no arguments should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_XPC_NOT_ENOUGH_ARGS);
+ }
+});
+
+add_task(function* test_addLivemark_emptyObject_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark({});
+ do_throw("Invoking addLivemark with empty object should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_badParentId_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark({ parentId: "test" });
+ do_throw("Invoking addLivemark with a bad parent id should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_invalidParentId_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark({ parentId: -2 });
+ do_throw("Invoking addLivemark with an invalid parent id should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_noIndex_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark({
+ parentId: PlacesUtils.unfiledBookmarksFolderId });
+ do_throw("Invoking addLivemark with no index should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_badIndex_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentId: PlacesUtils.unfiledBookmarksFolderId
+ , index: "test" });
+ do_throw("Invoking addLivemark with a bad index should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_invalidIndex_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentId: PlacesUtils.unfiledBookmarksFolderId
+ , index: -2
+ });
+ do_throw("Invoking addLivemark with an invalid index should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_noFeedURI_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentGuid: PlacesUtils.bookmarks.unfiledGuid });
+ do_throw("Invoking addLivemark with no feedURI should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_badFeedURI_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: "test" });
+ do_throw("Invoking addLivemark with a bad feedURI should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_badSiteURI_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , siteURI: "test" });
+ do_throw("Invoking addLivemark with a bad siteURI should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_badGuid_throws() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { parentGuid: PlacesUtils.bookmarks.unfileGuid
+ , feedURI: FEED_URI
+ , guid: "123456" });
+ do_throw("Invoking addLivemark with a bad guid should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_parentId_succeeds() {
+ let onItemAddedCalled = false;
+ PlacesUtils.bookmarks.addObserver({
+ __proto__: NavBookmarkObserver.prototype,
+ onItemAdded: function onItemAdded(aItemId, aParentId, aIndex, aItemType,
+ aURI, aTitle)
+ {
+ onItemAddedCalled = true;
+ PlacesUtils.bookmarks.removeObserver(this);
+ do_check_eq(aParentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_eq(aIndex, 0);
+ do_check_eq(aItemType, Ci.nsINavBookmarksService.TYPE_FOLDER);
+ do_check_eq(aTitle, "test");
+ }
+ }, false);
+
+ yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentId: PlacesUtils.unfiledBookmarksFolderId
+ , feedURI: FEED_URI });
+ do_check_true(onItemAddedCalled);
+});
+
+
+add_task(function* test_addLivemark_noSiteURI_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+ do_check_true(livemark.id > 0);
+ do_check_valid_places_guid(livemark.guid);
+ do_check_eq(livemark.title, "test");
+ do_check_eq(livemark.parentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_eq(livemark.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ do_check_true(livemark.feedURI.equals(FEED_URI));
+ do_check_eq(livemark.siteURI, null);
+ do_check_true(livemark.lastModified > 0);
+ do_check_true(is_time_ordered(livemark.dateAdded, livemark.lastModified));
+
+ let bookmark = yield PlacesUtils.bookmarks.fetch(livemark.guid);
+ do_check_eq(livemark.index, bookmark.index);
+ do_check_eq(livemark.dateAdded, bookmark.dateAdded * 1000);
+});
+
+add_task(function* test_addLivemark_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , siteURI: SITE_URI
+ });
+
+ do_check_true(livemark.id > 0);
+ do_check_valid_places_guid(livemark.guid);
+ do_check_eq(livemark.title, "test");
+ do_check_eq(livemark.parentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_eq(livemark.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ do_check_true(livemark.feedURI.equals(FEED_URI));
+ do_check_true(livemark.siteURI.equals(SITE_URI));
+ do_check_true(PlacesUtils.annotations
+ .itemHasAnnotation(livemark.id,
+ PlacesUtils.LMANNO_FEEDURI));
+ do_check_true(PlacesUtils.annotations
+ .itemHasAnnotation(livemark.id,
+ PlacesUtils.LMANNO_SITEURI));
+});
+
+add_task(function* test_addLivemark_bogusid_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { id: 100 // Should be ignored.
+ , title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , siteURI: SITE_URI
+ });
+ do_check_true(livemark.id > 0);
+ do_check_neq(livemark.id, 100);
+});
+
+add_task(function* test_addLivemark_bogusParentId_fails() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentId: 187
+ , feedURI: FEED_URI
+ });
+ do_throw("Adding a livemark with a bogus parent should fail");
+ } catch (ex) {}
+});
+
+add_task(function* test_addLivemark_bogusParentGuid_fails() {
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: "123456789012"
+ , feedURI: FEED_URI
+ });
+ do_throw("Adding a livemark with a bogus parent should fail");
+ } catch (ex) {}
+})
+
+add_task(function* test_addLivemark_intoLivemark_fails() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+
+ try {
+ yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: livemark.guid
+ , feedURI: FEED_URI
+ });
+ do_throw("Adding a livemark into a livemark should fail");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_addLivemark_forceGuid_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , guid: "1234567890AB"
+ });
+ do_check_eq(livemark.guid, "1234567890AB");
+ do_check_guid_for_bookmark(livemark.id, "1234567890AB");
+});
+
+add_task(function* test_addLivemark_dateAdded_succeeds() {
+ let dateAdded = new Date("2013-03-01T01:10:00") * 1000;
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , dateAdded
+ });
+ do_check_eq(livemark.dateAdded, dateAdded);
+});
+
+add_task(function* test_addLivemark_lastModified_succeeds() {
+ let now = Date.now() * 1000;
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , lastModified: now
+ });
+ do_check_eq(livemark.dateAdded, now);
+ do_check_eq(livemark.lastModified, now);
+});
+
+add_task(function* test_removeLivemark_emptyObject_throws() {
+ try {
+ yield PlacesUtils.livemarks.removeLivemark({});
+ do_throw("Invoking removeLivemark with empty object should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_removeLivemark_noValidId_throws() {
+ try {
+ yield PlacesUtils.livemarks.removeLivemark({ id: -10, guid: "test"});
+ do_throw("Invoking removeLivemark with no valid id should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_removeLivemark_nonExistent_fails() {
+ try {
+ yield PlacesUtils.livemarks.removeLivemark({ id: 1337 });
+ do_throw("Removing a non-existent livemark should fail");
+ }
+ catch (ex) {
+ }
+});
+
+add_task(function* test_removeLivemark_guid_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , guid: "234567890ABC"
+ });
+
+ do_check_eq(livemark.guid, "234567890ABC");
+
+ yield PlacesUtils.livemarks.removeLivemark({
+ id: 789, guid: "234567890ABC"
+ });
+
+ do_check_eq((yield PlacesUtils.bookmarks.fetch("234567890ABC")), null);
+});
+
+add_task(function* test_removeLivemark_id_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+
+ yield PlacesUtils.livemarks.removeLivemark({ id: livemark.id });
+
+ do_check_eq((yield PlacesUtils.bookmarks.fetch("234567890ABC")), null);
+});
+
+add_task(function* test_getLivemark_emptyObject_throws() {
+ try {
+ yield PlacesUtils.livemarks.getLivemark({});
+ do_throw("Invoking getLivemark with empty object should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_getLivemark_noValidId_throws() {
+ try {
+ yield PlacesUtils.livemarks.getLivemark({ id: -10, guid: "test"});
+ do_throw("Invoking getLivemark with no valid id should throw");
+ } catch (ex) {
+ do_check_eq(ex.result, Cr.NS_ERROR_INVALID_ARG);
+ }
+});
+
+add_task(function* test_getLivemark_nonExistentId_fails() {
+ try {
+ yield PlacesUtils.livemarks.getLivemark({ id: 1234 });
+ do_throw("getLivemark for a non existent id should fail");
+ } catch (ex) {}
+});
+
+add_task(function* test_getLivemark_nonExistentGUID_fails() {
+ try {
+ yield PlacesUtils.livemarks.getLivemark({ guid: "34567890ABCD" });
+ do_throw("getLivemark for a non-existent guid should fail");
+ } catch (ex) {}
+});
+
+add_task(function* test_getLivemark_guid_succeeds() {
+ yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ , guid: "34567890ABCD" });
+
+ // invalid id to check the guid wins.
+ let livemark =
+ yield PlacesUtils.livemarks.getLivemark({ id: 789, guid: "34567890ABCD" });
+
+ do_check_eq(livemark.title, "test");
+ do_check_eq(livemark.parentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_eq(livemark.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ do_check_true(livemark.feedURI.equals(FEED_URI));
+ do_check_eq(livemark.siteURI, null);
+ do_check_eq(livemark.guid, "34567890ABCD");
+
+ let bookmark = yield PlacesUtils.bookmarks.fetch("34567890ABCD");
+ do_check_eq(livemark.index, bookmark.index);
+});
+
+add_task(function* test_getLivemark_id_succeeds() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+
+ livemark = yield PlacesUtils.livemarks.getLivemark({ id: livemark.id });
+
+ do_check_eq(livemark.title, "test");
+ do_check_eq(livemark.parentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_eq(livemark.parentGuid, PlacesUtils.bookmarks.unfiledGuid);
+ do_check_true(livemark.feedURI.equals(FEED_URI));
+ do_check_eq(livemark.siteURI, null);
+ do_check_guid_for_bookmark(livemark.id, livemark.guid);
+
+ let bookmark = yield PlacesUtils.bookmarks.fetch(livemark.guid);
+ do_check_eq(livemark.index, bookmark.index);
+});
+
+add_task(function* test_getLivemark_removeItem_contention() {
+ // do not yield.
+ PlacesUtils.livemarks.addLivemark({ title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ }).catch(() => { /* swallow errors*/ });
+ yield PlacesUtils.bookmarks.eraseEverything();
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+
+ livemark = yield PlacesUtils.livemarks.getLivemark({ guid: livemark.guid });
+
+ do_check_eq(livemark.title, "test");
+ do_check_eq(livemark.parentId, PlacesUtils.unfiledBookmarksFolderId);
+ do_check_true(livemark.feedURI.equals(FEED_URI));
+ do_check_eq(livemark.siteURI, null);
+ do_check_guid_for_bookmark(livemark.id, livemark.guid);
+});
+
+add_task(function* test_title_change() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI
+ });
+
+ yield PlacesUtils.bookmarks.update({ guid: livemark.guid,
+ title: "test2" });
+ // Poll for the title change.
+ while (true) {
+ let lm = yield PlacesUtils.livemarks.getLivemark({ guid: livemark.guid });
+ if (lm.title == "test2")
+ break;
+ yield new Promise(resolve => do_timeout(resolve, 100));
+ }
+});
+
+add_task(function* test_livemark_move() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI } );
+
+ yield PlacesUtils.bookmarks.update({ guid: livemark.guid,
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX });
+ // Poll for the parent change.
+ while (true) {
+ let lm = yield PlacesUtils.livemarks.getLivemark({ guid: livemark.guid });
+ if (lm.parentGuid == PlacesUtils.bookmarks.toolbarGuid)
+ break;
+ yield new Promise(resolve => do_timeout(resolve, 100));
+ }
+});
+
+add_task(function* test_livemark_removed() {
+ let livemark = yield PlacesUtils.livemarks.addLivemark(
+ { title: "test"
+ , parentGuid: PlacesUtils.bookmarks.unfiledGuid
+ , feedURI: FEED_URI } );
+
+ yield PlacesUtils.bookmarks.remove(livemark.guid);
+ // Poll for the livemark removal.
+ while (true) {
+ try {
+ yield PlacesUtils.livemarks.getLivemark({ guid: livemark.guid });
+ } catch (ex) {
+ break;
+ }
+ yield new Promise(resolve => do_timeout(resolve, 100));
+ }
+});
diff --git a/toolkit/components/places/tests/unit/test_multi_queries.js b/toolkit/components/places/tests/unit/test_multi_queries.js
new file mode 100644
index 000000000..d485355a5
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_multi_queries.js
@@ -0,0 +1,53 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Adds a test URI visit to history.
+ *
+ * @param aURI
+ * The URI to add a visit for.
+ * @param aReferrer
+ * The referring URI for the given URI. This can be null.
+ */
+function* add_visit(aURI, aDayOffset, aTransition) {
+ yield PlacesTestUtils.addVisits({
+ uri: aURI,
+ transition: aTransition,
+ visitDate: (Date.now() + aDayOffset*86400000) * 1000
+ });
+}
+
+function run_test()
+{
+ run_next_test();
+}
+
+add_task(function* test_execute()
+{
+ yield add_visit(uri("http://mirror1.mozilla.com/a"), -1, TRANSITION_LINK);
+ yield add_visit(uri("http://mirror2.mozilla.com/b"), -2, TRANSITION_LINK);
+ yield add_visit(uri("http://mirror3.mozilla.com/c"), -4, TRANSITION_FRAMED_LINK);
+ yield add_visit(uri("http://mirror1.google.com/b"), -1, TRANSITION_EMBED);
+ yield add_visit(uri("http://mirror2.google.com/a"), -2, TRANSITION_LINK);
+ yield add_visit(uri("http://mirror1.apache.org/b"), -3, TRANSITION_LINK);
+ yield add_visit(uri("http://mirror2.apache.org/a"), -4, TRANSITION_FRAMED_LINK);
+
+ let queries = [
+ PlacesUtils.history.getNewQuery(),
+ PlacesUtils.history.getNewQuery()
+ ];
+ queries[0].domain = "mozilla.com";
+ queries[1].domain = "google.com";
+
+ let root = PlacesUtils.history.executeQueries(
+ queries, queries.length, PlacesUtils.history.getNewQueryOptions()
+ ).root;
+ root.containerOpen = true;
+ let childCount = root.childCount;
+ root.containerOpen = false;
+
+ do_check_eq(childCount, 3);
+});
diff --git a/toolkit/components/places/tests/unit/test_multi_word_tags.js b/toolkit/components/places/tests/unit/test_multi_word_tags.js
new file mode 100644
index 000000000..6a0e5f130
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_multi_word_tags.js
@@ -0,0 +1,150 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history service
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
+ getService(Ci.nsINavHistoryService);
+} catch (ex) {
+ do_throw("Could not get history service\n");
+}
+
+// Get bookmark service
+try {
+ var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+}
+catch (ex) {
+ do_throw("Could not get the nav-bookmarks-service\n");
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+// main
+function run_test() {
+ var uri1 = uri("http://site.tld/1");
+ var uri2 = uri("http://site.tld/2");
+ var uri3 = uri("http://site.tld/3");
+ var uri4 = uri("http://site.tld/4");
+ var uri5 = uri("http://site.tld/5");
+ var uri6 = uri("http://site.tld/6");
+
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri1, bmsvc.DEFAULT_INDEX, null);
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri2, bmsvc.DEFAULT_INDEX, null);
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri3, bmsvc.DEFAULT_INDEX, null);
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri4, bmsvc.DEFAULT_INDEX, null);
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri5, bmsvc.DEFAULT_INDEX, null);
+ bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri6, bmsvc.DEFAULT_INDEX, null);
+
+ tagssvc.tagURI(uri1, ["foo"]);
+ tagssvc.tagURI(uri2, ["bar"]);
+ tagssvc.tagURI(uri3, ["cheese"]);
+ tagssvc.tagURI(uri4, ["foo bar"]);
+ tagssvc.tagURI(uri5, ["bar cheese"]);
+ tagssvc.tagURI(uri6, ["foo bar cheese"]);
+
+ // exclude livemark items, search for "item", should get one result
+ var options = histsvc.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+
+ var query = histsvc.getNewQuery();
+ query.searchTerms = "foo";
+ var result = histsvc.executeQuery(query, options);
+ var root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 3);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/1");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/4");
+ do_check_eq(root.getChild(2).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "bar";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 4);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/2");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/4");
+ do_check_eq(root.getChild(2).uri, "http://site.tld/5");
+ do_check_eq(root.getChild(3).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "cheese";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 3);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/3");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/5");
+ do_check_eq(root.getChild(2).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "foo bar";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/4");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "bar foo";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/4");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "bar cheese";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/5");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "cheese bar";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/5");
+ do_check_eq(root.getChild(1).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "foo bar cheese";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "cheese foo bar";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/6");
+ root.containerOpen = false;
+
+ query.searchTerms = "cheese bar foo";
+ result = histsvc.executeQuery(query, options);
+ root = result.root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+ do_check_eq(root.getChild(0).uri, "http://site.tld/6");
+ root.containerOpen = false;
+}
diff --git a/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js b/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js
new file mode 100644
index 000000000..037ab7d08
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_nsINavHistoryViewer.js
@@ -0,0 +1,256 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Get history service
+var histsvc = PlacesUtils.history;
+var bhist = PlacesUtils.bhistory;
+var bmsvc = PlacesUtils.bookmarks;
+
+var resultObserver = {
+ insertedNode: null,
+ nodeInserted: function(parent, node, newIndex) {
+ this.insertedNode = node;
+ },
+ removedNode: null,
+ nodeRemoved: function(parent, node, oldIndex) {
+ this.removedNode = node;
+ },
+
+ nodeAnnotationChanged: function() {},
+
+ newTitle: "",
+ nodeChangedByTitle: null,
+ nodeTitleChanged: function(node, newTitle) {
+ this.nodeChangedByTitle = node;
+ this.newTitle = newTitle;
+ },
+
+ newAccessCount: 0,
+ newTime: 0,
+ nodeChangedByHistoryDetails: null,
+ nodeHistoryDetailsChanged: function(node,
+ updatedVisitDate,
+ updatedVisitCount) {
+ this.nodeChangedByHistoryDetails = node
+ this.newTime = updatedVisitDate;
+ this.newAccessCount = updatedVisitCount;
+ },
+
+ movedNode: null,
+ nodeMoved: function(node, oldParent, oldIndex, newParent, newIndex) {
+ this.movedNode = node;
+ },
+ openedContainer: null,
+ closedContainer: null,
+ containerStateChanged: function (aNode, aOldState, aNewState) {
+ if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) {
+ this.openedContainer = aNode;
+ }
+ else if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED) {
+ this.closedContainer = aNode;
+ }
+ },
+ invalidatedContainer: null,
+ invalidateContainer: function(node) {
+ this.invalidatedContainer = node;
+ },
+ sortingMode: null,
+ sortingChanged: function(sortingMode) {
+ this.sortingMode = sortingMode;
+ },
+ inBatchMode: false,
+ batching: function(aToggleMode) {
+ do_check_neq(this.inBatchMode, aToggleMode);
+ this.inBatchMode = aToggleMode;
+ },
+ result: null,
+ reset: function() {
+ this.insertedNode = null;
+ this.removedNode = null;
+ this.nodeChangedByTitle = null;
+ this.nodeChangedByHistoryDetails = null;
+ this.replacedNode = null;
+ this.movedNode = null;
+ this.openedContainer = null;
+ this.closedContainer = null;
+ this.invalidatedContainer = null;
+ this.sortingMode = null;
+ }
+};
+
+var testURI = uri("http://mozilla.com");
+
+function run_test() {
+ run_next_test();
+}
+
+add_test(function check_history_query() {
+ var options = histsvc.getNewQueryOptions();
+ options.sortingMode = options.SORT_BY_DATE_DESCENDING;
+ options.resultType = options.RESULTS_AS_VISIT;
+ var query = histsvc.getNewQuery();
+ var result = histsvc.executeQuery(query, options);
+ result.addObserver(resultObserver, false);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_check_neq(resultObserver.openedContainer, null);
+
+ // nsINavHistoryResultObserver.nodeInserted
+ // add a visit
+ PlacesTestUtils.addVisits(testURI).then(function() {
+ do_check_eq(testURI.spec, resultObserver.insertedNode.uri);
+
+ // nsINavHistoryResultObserver.nodeHistoryDetailsChanged
+ // adding a visit causes nodeHistoryDetailsChanged for the folder
+ do_check_eq(root.uri, resultObserver.nodeChangedByHistoryDetails.uri);
+
+ // nsINavHistoryResultObserver.itemTitleChanged for a leaf node
+ PlacesTestUtils.addVisits({ uri: testURI, title: "baz" }).then(function () {
+ do_check_eq(resultObserver.nodeChangedByTitle.title, "baz");
+
+ // nsINavHistoryResultObserver.nodeRemoved
+ var removedURI = uri("http://google.com");
+ PlacesTestUtils.addVisits(removedURI).then(function() {
+ bhist.removePage(removedURI);
+ do_check_eq(removedURI.spec, resultObserver.removedNode.uri);
+
+ // nsINavHistoryResultObserver.invalidateContainer
+ bhist.removePagesFromHost("mozilla.com", false);
+ do_check_eq(root.uri, resultObserver.invalidatedContainer.uri);
+
+ // nsINavHistoryResultObserver.sortingChanged
+ resultObserver.invalidatedContainer = null;
+ result.sortingMode = options.SORT_BY_TITLE_ASCENDING;
+ do_check_eq(resultObserver.sortingMode, options.SORT_BY_TITLE_ASCENDING);
+ do_check_eq(resultObserver.invalidatedContainer, result.root);
+
+ // nsINavHistoryResultObserver.invalidateContainer
+ PlacesTestUtils.clearHistoryEnabled().then(() => {
+ do_check_eq(root.uri, resultObserver.invalidatedContainer.uri);
+
+ // nsINavHistoryResultObserver.batching
+ do_check_false(resultObserver.inBatchMode);
+ histsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+ bmsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+
+ root.containerOpen = false;
+ do_check_eq(resultObserver.closedContainer, resultObserver.openedContainer);
+ result.removeObserver(resultObserver);
+ resultObserver.reset();
+ PlacesTestUtils.promiseAsyncUpdates().then(run_next_test);
+ });
+ });
+ });
+ });
+});
+
+add_test(function check_bookmarks_query() {
+ var options = histsvc.getNewQueryOptions();
+ var query = histsvc.getNewQuery();
+ query.setFolders([bmsvc.bookmarksMenuFolder], 1);
+ var result = histsvc.executeQuery(query, options);
+ result.addObserver(resultObserver, false);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_check_neq(resultObserver.openedContainer, null);
+
+ // nsINavHistoryResultObserver.nodeInserted
+ // add a bookmark
+ var testBookmark = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, testURI, bmsvc.DEFAULT_INDEX, "foo");
+ do_check_eq("foo", resultObserver.insertedNode.title);
+ do_check_eq(testURI.spec, resultObserver.insertedNode.uri);
+
+ // nsINavHistoryResultObserver.nodeHistoryDetailsChanged
+ // adding a visit causes nodeHistoryDetailsChanged for the folder
+ do_check_eq(root.uri, resultObserver.nodeChangedByHistoryDetails.uri);
+
+ // nsINavHistoryResultObserver.nodeTitleChanged for a leaf node
+ bmsvc.setItemTitle(testBookmark, "baz");
+ do_check_eq(resultObserver.nodeChangedByTitle.title, "baz");
+ do_check_eq(resultObserver.newTitle, "baz");
+
+ var testBookmark2 = bmsvc.insertBookmark(bmsvc.bookmarksMenuFolder, uri("http://google.com"), bmsvc.DEFAULT_INDEX, "foo");
+ bmsvc.moveItem(testBookmark2, bmsvc.bookmarksMenuFolder, 0);
+ do_check_eq(resultObserver.movedNode.itemId, testBookmark2);
+
+ // nsINavHistoryResultObserver.nodeRemoved
+ bmsvc.removeItem(testBookmark2);
+ do_check_eq(testBookmark2, resultObserver.removedNode.itemId);
+
+ // XXX nsINavHistoryResultObserver.invalidateContainer
+
+ // nsINavHistoryResultObserver.sortingChanged
+ resultObserver.invalidatedContainer = null;
+ result.sortingMode = options.SORT_BY_TITLE_ASCENDING;
+ do_check_eq(resultObserver.sortingMode, options.SORT_BY_TITLE_ASCENDING);
+ do_check_eq(resultObserver.invalidatedContainer, result.root);
+
+ // nsINavHistoryResultObserver.batching
+ do_check_false(resultObserver.inBatchMode);
+ histsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+ bmsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+
+ root.containerOpen = false;
+ do_check_eq(resultObserver.closedContainer, resultObserver.openedContainer);
+ result.removeObserver(resultObserver);
+ resultObserver.reset();
+ PlacesTestUtils.promiseAsyncUpdates().then(run_next_test);
+});
+
+add_test(function check_mixed_query() {
+ var options = histsvc.getNewQueryOptions();
+ var query = histsvc.getNewQuery();
+ query.onlyBookmarked = true;
+ var result = histsvc.executeQuery(query, options);
+ result.addObserver(resultObserver, false);
+ var root = result.root;
+ root.containerOpen = true;
+
+ do_check_neq(resultObserver.openedContainer, null);
+
+ // nsINavHistoryResultObserver.batching
+ do_check_false(resultObserver.inBatchMode);
+ histsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+ bmsvc.runInBatchMode({
+ runBatched: function (aUserData) {
+ do_check_true(resultObserver.inBatchMode);
+ }
+ }, null);
+ do_check_false(resultObserver.inBatchMode);
+
+ root.containerOpen = false;
+ do_check_eq(resultObserver.closedContainer, resultObserver.openedContainer);
+ result.removeObserver(resultObserver);
+ resultObserver.reset();
+ PlacesTestUtils.promiseAsyncUpdates().then(run_next_test);
+});
diff --git a/toolkit/components/places/tests/unit/test_null_interfaces.js b/toolkit/components/places/tests/unit/test_null_interfaces.js
new file mode 100644
index 000000000..524837ca3
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_null_interfaces.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 489872 to make sure passing nulls to nsNavHistory doesn't crash.
+ */
+
+// Make an array of services to test, each specifying a class id, interface
+// and an array of function names that don't throw when passed nulls
+var testServices = [
+ ["browser/nav-history-service;1",
+ ["nsINavHistoryService"],
+ ["queryStringToQueries", "removePagesByTimeframe", "removePagesFromHost", "getObservers"]
+ ],
+ ["browser/nav-bookmarks-service;1",
+ ["nsINavBookmarksService", "nsINavHistoryObserver", "nsIAnnotationObserver"],
+ ["createFolder", "getObservers", "onFrecencyChanged", "onTitleChanged",
+ "onPageAnnotationSet", "onPageAnnotationRemoved", "onDeleteURI"]
+ ],
+ ["browser/livemark-service;2", ["mozIAsyncLivemarks"], ["reloadLivemarks"]],
+ ["browser/annotation-service;1", ["nsIAnnotationService"], []],
+ ["browser/favicon-service;1", ["nsIFaviconService"], []],
+ ["browser/tagging-service;1", ["nsITaggingService"], []],
+];
+do_print(testServices.join("\n"));
+
+function run_test()
+{
+ for (let [cid, ifaces, nothrow] of testServices) {
+ do_print(`Running test with ${cid} ${ifaces.join(", ")} ${nothrow}`);
+ let s = Cc["@mozilla.org/" + cid].getService(Ci.nsISupports);
+ for (let iface of ifaces) {
+ s.QueryInterface(Ci[iface]);
+ }
+
+ let okName = function(name) {
+ do_print(`Checking if function is okay to test: ${name}`);
+ let func = s[name];
+
+ let mesg = "";
+ if (typeof func != "function")
+ mesg = "Not a function!";
+ else if (func.length == 0)
+ mesg = "No args needed!";
+ else if (name == "QueryInterface")
+ mesg = "Ignore QI!";
+
+ if (mesg) {
+ do_print(`${mesg} Skipping: ${name}`);
+ return false;
+ }
+
+ return true;
+ }
+
+ do_print(`Generating an array of functions to test service: ${s}`);
+ for (let n of Object.keys(s).filter(i => okName(i)).sort()) {
+ do_print(`\nTesting ${ifaces.join(", ")} function with null args: ${n}`);
+
+ let func = s[n];
+ let num = func.length;
+ do_print(`Generating array of nulls for #args: ${num}`);
+ let args = Array(num).fill(null);
+
+ let tryAgain = true;
+ while (tryAgain == true) {
+ try {
+ do_print(`Calling with args: ${JSON.stringify(args)}`);
+ func.apply(s, args);
+
+ do_print(`The function did not throw! Is it one of the nothrow? ${nothrow}`);
+ Assert.notEqual(nothrow.indexOf(n), -1);
+
+ do_print("Must have been an expected nothrow, so no need to try again");
+ tryAgain = false;
+ }
+ catch (ex) {
+ if (ex.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ do_print(`Caught an expected exception: ${ex.name}`);
+ do_print("Moving on to the next test..");
+ tryAgain = false;
+ } else if (ex.result == Cr.NS_ERROR_XPC_NEED_OUT_OBJECT) {
+ let pos = Number(ex.message.match(/object arg (\d+)/)[1]);
+ do_print(`Function call expects an out object at ${pos}`);
+ args[pos] = {};
+ } else if (ex.result == Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ do_print(`Method not implemented exception: ${ex.name}`);
+ do_print("Moving on to the next test..");
+ tryAgain = false;
+ } else {
+ throw ex;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/toolkit/components/places/tests/unit/test_onItemChanged_tags.js b/toolkit/components/places/tests/unit/test_onItemChanged_tags.js
new file mode 100644
index 000000000..7a0eb354d
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_onItemChanged_tags.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test checks that changing a tag for a bookmark with multiple tags
+// notifies OnItemChanged("tags") only once, and not once per tag.
+
+function run_test() {
+ do_test_pending();
+
+ let tags = ["a", "b", "c"];
+ let uri = NetUtil.newURI("http://1.moz.org/");
+
+ let id = PlacesUtils.bookmarks.insertBookmark(
+ PlacesUtils.unfiledBookmarksFolderId, uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX, "Bookmark 1"
+ );
+ PlacesUtils.tagging.tagURI(uri, tags);
+
+ let bookmarksObserver = {
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsINavBookmarkObserver
+ ]),
+
+ _changedCount: 0,
+ onItemChanged: function (aItemId, aProperty, aIsAnnotationProperty, aValue,
+ aLastModified, aItemType) {
+ if (aProperty == "tags") {
+ do_check_eq(aItemId, id);
+ this._changedCount++;
+ }
+ },
+
+ onItemRemoved: function (aItemId, aParentId, aIndex, aItemType) {
+ if (aItemId == id) {
+ PlacesUtils.bookmarks.removeObserver(this);
+ do_check_eq(this._changedCount, 2);
+ do_test_finished();
+ }
+ },
+
+ onItemAdded: function () {},
+ onBeginUpdateBatch: function () {},
+ onEndUpdateBatch: function () {},
+ onItemVisited: function () {},
+ onItemMoved: function () {},
+ };
+ PlacesUtils.bookmarks.addObserver(bookmarksObserver, false);
+
+ PlacesUtils.tagging.tagURI(uri, ["d"]);
+ PlacesUtils.tagging.tagURI(uri, ["e"]);
+ PlacesUtils.bookmarks.removeItem(id);
+}
diff --git a/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js b/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js
new file mode 100644
index 000000000..f6131b211
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_pageGuid_bookmarkGuid.js
@@ -0,0 +1,179 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const bmsvc = PlacesUtils.bookmarks;
+const histsvc = PlacesUtils.history;
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_addBookmarksAndCheckGuids() {
+ let folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder", bmsvc.DEFAULT_INDEX);
+ bmsvc.insertBookmark(folder, uri("http://test1.com/"),
+ bmsvc.DEFAULT_INDEX, "1 title");
+ bmsvc.insertBookmark(folder, uri("http://test2.com/"),
+ bmsvc.DEFAULT_INDEX, "2 title");
+ bmsvc.insertBookmark(folder, uri("http://test3.com/"),
+ bmsvc.DEFAULT_INDEX, "3 title");
+ bmsvc.insertSeparator(folder, bmsvc.DEFAULT_INDEX);
+ bmsvc.createFolder(folder, "test folder 2", bmsvc.DEFAULT_INDEX);
+
+ let root = PlacesUtils.getFolderContents(folder).root;
+ do_check_eq(root.childCount, 5);
+
+ // check bookmark guids
+ let bookmarkGuidZero = root.getChild(0).bookmarkGuid;
+ do_check_eq(bookmarkGuidZero.length, 12);
+ // bookmarks have bookmark guids
+ do_check_eq(root.getChild(1).bookmarkGuid.length, 12);
+ do_check_eq(root.getChild(2).bookmarkGuid.length, 12);
+ // separator has bookmark guid
+ do_check_eq(root.getChild(3).bookmarkGuid.length, 12);
+ // folder has bookmark guid
+ do_check_eq(root.getChild(4).bookmarkGuid.length, 12);
+ // all bookmark guids are different.
+ do_check_neq(bookmarkGuidZero, root.getChild(1).bookmarkGuid);
+ do_check_neq(root.getChild(1).bookmarkGuid, root.getChild(2).bookmarkGuid);
+ do_check_neq(root.getChild(2).bookmarkGuid, root.getChild(3).bookmarkGuid);
+ do_check_neq(root.getChild(3).bookmarkGuid, root.getChild(4).bookmarkGuid);
+
+ // check page guids
+ let pageGuidZero = root.getChild(0).pageGuid;
+ do_check_eq(pageGuidZero.length, 12);
+ // bookmarks have page guids
+ do_check_eq(root.getChild(1).pageGuid.length, 12);
+ do_check_eq(root.getChild(2).pageGuid.length, 12);
+ // folder and separator don't have page guids
+ do_check_eq(root.getChild(3).pageGuid, "");
+ do_check_eq(root.getChild(4).pageGuid, "");
+
+ do_check_neq(pageGuidZero, root.getChild(1).pageGuid);
+ do_check_neq(root.getChild(1).pageGuid, root.getChild(2).pageGuid);
+
+ root.containerOpen = false;
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_updateBookmarksAndCheckGuids() {
+ let folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder", bmsvc.DEFAULT_INDEX);
+ let b1 = bmsvc.insertBookmark(folder, uri("http://test1.com/"),
+ bmsvc.DEFAULT_INDEX, "1 title");
+ let f1 = bmsvc.createFolder(folder, "test folder 2", bmsvc.DEFAULT_INDEX);
+
+ let root = PlacesUtils.getFolderContents(folder).root;
+ do_check_eq(root.childCount, 2);
+
+ // ensure the bookmark and page guids remain the same after modifing other property.
+ let bookmarkGuidZero = root.getChild(0).bookmarkGuid;
+ let pageGuidZero = root.getChild(0).pageGuid;
+ bmsvc.setItemTitle(b1, "1 title mod");
+ do_check_eq(root.getChild(0).title, "1 title mod");
+ do_check_eq(root.getChild(0).bookmarkGuid, bookmarkGuidZero);
+ do_check_eq(root.getChild(0).pageGuid, pageGuidZero);
+
+ let bookmarkGuidOne = root.getChild(1).bookmarkGuid;
+ let pageGuidOne = root.getChild(1).pageGuid;
+ bmsvc.setItemTitle(f1, "test foolder 234");
+ do_check_eq(root.getChild(1).title, "test foolder 234");
+ do_check_eq(root.getChild(1).bookmarkGuid, bookmarkGuidOne);
+ do_check_eq(root.getChild(1).pageGuid, pageGuidOne);
+
+ root.containerOpen = false;
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_addVisitAndCheckGuid() {
+ // add a visit and test page guid and non-existing bookmark guids.
+ let sourceURI = uri("http://test4.com/");
+ yield PlacesTestUtils.addVisits({ uri: sourceURI });
+ do_check_eq(bmsvc.getBookmarkedURIFor(sourceURI), null);
+
+ let options = histsvc.getNewQueryOptions();
+ let query = histsvc.getNewQuery();
+ query.uri = sourceURI;
+ let root = histsvc.executeQuery(query, options).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 1);
+
+ do_check_valid_places_guid(root.getChild(0).pageGuid);
+ do_check_eq(root.getChild(0).bookmarkGuid, "");
+ root.containerOpen = false;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_addItemsWithInvalidGUIDsFails() {
+ const INVALID_GUID = "XYZ";
+ try {
+ bmsvc.createFolder(bmsvc.placesRoot, "XYZ folder",
+ bmsvc.DEFAULT_INDEX, INVALID_GUID);
+ do_throw("Adding a folder with an invalid guid should fail");
+ }
+ catch (ex) { }
+
+ let folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder",
+ bmsvc.DEFAULT_INDEX);
+ try {
+ bmsvc.insertBookmark(folder, uri("http://test.tld"), bmsvc.DEFAULT_INDEX,
+ "title", INVALID_GUID);
+ do_throw("Adding a bookmark with an invalid guid should fail");
+ }
+ catch (ex) { }
+
+ try {
+ bmsvc.insertSeparator(folder, bmsvc.DEFAULT_INDEX, INVALID_GUID);
+ do_throw("Adding a separator with an invalid guid should fail");
+ }
+ catch (ex) { }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_addItemsWithGUIDs() {
+ const FOLDER_GUID = "FOLDER--GUID";
+ const BOOKMARK_GUID = "BM------GUID";
+ const SEPARATOR_GUID = "SEP-----GUID";
+
+ let folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder",
+ bmsvc.DEFAULT_INDEX, FOLDER_GUID);
+ bmsvc.insertBookmark(folder, uri("http://test1.com/"), bmsvc.DEFAULT_INDEX,
+ "1 title", BOOKMARK_GUID);
+ bmsvc.insertSeparator(folder, bmsvc.DEFAULT_INDEX, SEPARATOR_GUID);
+
+ let root = PlacesUtils.getFolderContents(folder).root;
+ do_check_eq(root.childCount, 2);
+ do_check_eq(root.bookmarkGuid, FOLDER_GUID);
+ do_check_eq(root.getChild(0).bookmarkGuid, BOOKMARK_GUID);
+ do_check_eq(root.getChild(1).bookmarkGuid, SEPARATOR_GUID);
+
+ root.containerOpen = false;
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_emptyGUIDIgnored() {
+ let folder = bmsvc.createFolder(bmsvc.placesRoot, "test folder",
+ bmsvc.DEFAULT_INDEX, "");
+ do_check_valid_places_guid(PlacesUtils.getFolderContents(folder)
+ .root.bookmarkGuid);
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_usingSameGUIDFails() {
+ const GUID = "XYZXYZXYZXYZ";
+ bmsvc.createFolder(bmsvc.placesRoot, "test folder",
+ bmsvc.DEFAULT_INDEX, GUID);
+ try {
+ bmsvc.createFolder(bmsvc.placesRoot, "test folder 2",
+ bmsvc.DEFAULT_INDEX, GUID);
+ do_throw("Using the same guid twice should fail");
+ }
+ catch (ex) { }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/unit/test_placeURIs.js b/toolkit/components/places/tests/unit/test_placeURIs.js
new file mode 100644
index 000000000..0f585ca51
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_placeURIs.js
@@ -0,0 +1,42 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+// Get history service
+try {
+ var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService(Ci.nsINavHistoryService);
+} catch (ex) {
+ do_throw("Could not get history service\n");
+}
+
+// main
+function run_test() {
+ // XXX Full testing coverage for QueriesToQueryString and
+ // QueryStringToQueries
+
+ var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
+ getService(Ci.nsINavBookmarksService);
+ const NHQO = Ci.nsINavHistoryQueryOptions;
+ // Bug 376798
+ var query = histsvc.getNewQuery();
+ query.setFolders([bs.placesRoot], 1);
+ do_check_eq(histsvc.queriesToQueryString([query], 1, histsvc.getNewQueryOptions()),
+ "place:folder=PLACES_ROOT");
+
+ // Bug 378828
+ var options = histsvc.getNewQueryOptions();
+ options.sortingAnnotation = "test anno";
+ options.sortingMode = NHQO.SORT_BY_ANNOTATION_DESCENDING;
+ var placeURI =
+ "place:folder=PLACES_ROOT&sort=" + NHQO.SORT_BY_ANNOTATION_DESCENDING +
+ "&sortingAnnotation=test%20anno";
+ do_check_eq(histsvc.queriesToQueryString([query], 1, options),
+ placeURI);
+ options = {};
+ histsvc.queryStringToQueries(placeURI, { }, {}, options);
+ do_check_eq(options.value.sortingAnnotation, "test anno");
+ do_check_eq(options.value.sortingMode, NHQO.SORT_BY_ANNOTATION_DESCENDING);
+}
diff --git a/toolkit/components/places/tests/unit/test_placesTxn.js b/toolkit/components/places/tests/unit/test_placesTxn.js
new file mode 100644
index 000000000..3cc9809bb
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_placesTxn.js
@@ -0,0 +1,937 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var bmsvc = PlacesUtils.bookmarks;
+var tagssvc = PlacesUtils.tagging;
+var annosvc = PlacesUtils.annotations;
+var txnManager = PlacesUtils.transactionManager;
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+
+function* promiseKeyword(keyword, href, postData) {
+ while (true) {
+ let entry = yield PlacesUtils.keywords.fetch(keyword);
+ if (href == null && !entry)
+ break;
+ if (entry && entry.url.href == href && entry.postData == postData) {
+ break;
+ }
+
+ yield new Promise(resolve => do_timeout(100, resolve));
+ }
+}
+
+// create and add bookmarks observer
+var observer = {
+
+ onBeginUpdateBatch: function() {
+ this._beginUpdateBatch = true;
+ },
+ _beginUpdateBatch: false,
+
+ onEndUpdateBatch: function() {
+ this._endUpdateBatch = true;
+ },
+ _endUpdateBatch: false,
+
+ onItemAdded: function(id, folder, index, itemType, uri) {
+ this._itemAddedId = id;
+ this._itemAddedParent = folder;
+ this._itemAddedIndex = index;
+ this._itemAddedType = itemType;
+ },
+ _itemAddedId: null,
+ _itemAddedParent: null,
+ _itemAddedIndex: null,
+ _itemAddedType: null,
+
+ onItemRemoved: function(id, folder, index, itemType) {
+ this._itemRemovedId = id;
+ this._itemRemovedFolder = folder;
+ this._itemRemovedIndex = index;
+ },
+ _itemRemovedId: null,
+ _itemRemovedFolder: null,
+ _itemRemovedIndex: null,
+
+ onItemChanged: function(id, property, isAnnotationProperty, newValue,
+ lastModified, itemType) {
+ // The transaction manager is being rewritten in bug 891303, so just
+ // skip checking this for now.
+ if (property == "tags")
+ return;
+ this._itemChangedId = id;
+ this._itemChangedProperty = property;
+ this._itemChanged_isAnnotationProperty = isAnnotationProperty;
+ this._itemChangedValue = newValue;
+ },
+ _itemChangedId: null,
+ _itemChangedProperty: null,
+ _itemChanged_isAnnotationProperty: null,
+ _itemChangedValue: null,
+
+ onItemVisited: function(id, visitID, time) {
+ this._itemVisitedId = id;
+ this._itemVisitedVistId = visitID;
+ this._itemVisitedTime = time;
+ },
+ _itemVisitedId: null,
+ _itemVisitedVistId: null,
+ _itemVisitedTime: null,
+
+ onItemMoved: function(id, oldParent, oldIndex, newParent, newIndex,
+ itemType) {
+ this._itemMovedId = id;
+ this._itemMovedOldParent = oldParent;
+ this._itemMovedOldIndex = oldIndex;
+ this._itemMovedNewParent = newParent;
+ this._itemMovedNewIndex = newIndex;
+ },
+ _itemMovedId: null,
+ _itemMovedOldParent: null,
+ _itemMovedOldIndex: null,
+ _itemMovedNewParent: null,
+ _itemMovedNewIndex: null,
+
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsINavBookmarkObserver) ||
+ iid.equals(Ci.nsISupports)) {
+ return this;
+ }
+ throw Cr.NS_ERROR_NO_INTERFACE;
+ }
+};
+
+// index at which items should begin
+var bmStartIndex = 0;
+
+// get bookmarks root id
+var root = PlacesUtils.bookmarksMenuFolderId;
+
+add_task(function* init() {
+ bmsvc.addObserver(observer, false);
+ do_register_cleanup(function () {
+ bmsvc.removeObserver(observer);
+ });
+});
+
+add_task(function* test_create_folder_with_description() {
+ const TEST_FOLDERNAME = "Test creating a folder with a description";
+ const TEST_DESCRIPTION = "this is my test description";
+
+ let annos = [{ name: DESCRIPTION_ANNO,
+ type: annosvc.TYPE_STRING,
+ flags: 0,
+ value: TEST_DESCRIPTION,
+ expires: annosvc.EXPIRE_NEVER }];
+ let txn = new PlacesCreateFolderTransaction(TEST_FOLDERNAME, root, bmStartIndex, annos);
+ txnManager.doTransaction(txn);
+
+ // This checks that calling undoTransaction on an "empty batch" doesn't
+ // undo the previous transaction (getItemTitle will fail)
+ txnManager.beginBatch(null);
+ txnManager.endBatch(false);
+ txnManager.undoTransaction();
+
+ let folderId = observer._itemAddedId;
+ do_check_eq(bmsvc.getItemTitle(folderId), TEST_FOLDERNAME);
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_eq(observer._itemAddedParent, root);
+ do_check_eq(observer._itemAddedId, folderId);
+ do_check_eq(TEST_DESCRIPTION, annosvc.getItemAnnotation(folderId, DESCRIPTION_ANNO));
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, folderId);
+ do_check_eq(observer._itemRemovedFolder, root);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_eq(observer._itemAddedParent, root);
+ do_check_eq(observer._itemAddedId, folderId);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, folderId);
+ do_check_eq(observer._itemRemovedFolder, root);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+});
+
+add_task(function* test_create_item() {
+ let testURI = NetUtil.newURI("http://test_create_item.com");
+
+ let txn = new PlacesCreateBookmarkTransaction(testURI, root, bmStartIndex,
+ "Test creating an item");
+
+ txnManager.doTransaction(txn);
+ let id = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(observer._itemAddedId, id);
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_true(bmsvc.isBookmarked(testURI));
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, id);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+ do_check_false(bmsvc.isBookmarked(testURI));
+
+ txn.redoTransaction();
+ do_check_true(bmsvc.isBookmarked(testURI));
+ let newId = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_eq(observer._itemAddedParent, root);
+ do_check_eq(observer._itemAddedId, newId);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, newId);
+ do_check_eq(observer._itemRemovedFolder, root);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+});
+
+add_task(function* test_create_item_to_folder() {
+ const TEST_FOLDERNAME = "Test creating item to a folder";
+ let testURI = NetUtil.newURI("http://test_create_item_to_folder.com");
+ let folderId = bmsvc.createFolder(root, TEST_FOLDERNAME, bmsvc.DEFAULT_INDEX);
+
+ let txn = new PlacesCreateBookmarkTransaction(testURI, folderId, bmStartIndex,
+ "Test creating item");
+ txnManager.doTransaction(txn);
+ let bkmId = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(observer._itemAddedId, bkmId);
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_true(bmsvc.isBookmarked(testURI));
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, bkmId);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+
+ txn.redoTransaction();
+ let newBkmId = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(observer._itemAddedIndex, bmStartIndex);
+ do_check_eq(observer._itemAddedParent, folderId);
+ do_check_eq(observer._itemAddedId, newBkmId);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, newBkmId);
+ do_check_eq(observer._itemRemovedFolder, folderId);
+ do_check_eq(observer._itemRemovedIndex, bmStartIndex);
+});
+
+add_task(function* test_move_items_to_folder() {
+ let testFolderId = bmsvc.createFolder(root, "Test move items", bmsvc.DEFAULT_INDEX);
+ let testURI = NetUtil.newURI("http://test_move_items.com");
+ let testBkmId = bmsvc.insertBookmark(testFolderId, testURI, bmsvc.DEFAULT_INDEX, "1: Test move items");
+ bmsvc.insertBookmark(testFolderId, testURI, bmsvc.DEFAULT_INDEX, "2: Test move items");
+
+ // Moving items between the same folder
+ let sameTxn = new PlacesMoveItemTransaction(testBkmId, testFolderId, bmsvc.DEFAULT_INDEX);
+
+ sameTxn.doTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 1);
+
+ sameTxn.undoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 1);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+
+ sameTxn.redoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 1);
+
+ sameTxn.undoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 1);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+
+ // Moving items between different folders
+ let folderId = bmsvc.createFolder(testFolderId,
+ "Test move items between different folders",
+ bmsvc.DEFAULT_INDEX);
+ let diffTxn = new PlacesMoveItemTransaction(testBkmId, folderId, bmsvc.DEFAULT_INDEX);
+
+ diffTxn.doTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, folderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+
+ sameTxn.undoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, folderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+
+ diffTxn.redoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, testFolderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, folderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+
+ sameTxn.undoTransaction();
+ do_check_eq(observer._itemMovedId, testBkmId);
+ do_check_eq(observer._itemMovedOldParent, folderId);
+ do_check_eq(observer._itemMovedOldIndex, 0);
+ do_check_eq(observer._itemMovedNewParent, testFolderId);
+ do_check_eq(observer._itemMovedNewIndex, 0);
+});
+
+add_task(function* test_remove_folder() {
+ let testFolder = bmsvc.createFolder(root, "Test Removing a Folder", bmsvc.DEFAULT_INDEX);
+ let folderId = bmsvc.createFolder(testFolder, "Removed Folder", bmsvc.DEFAULT_INDEX);
+
+ let txn = new PlacesRemoveItemTransaction(folderId);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemRemovedId, folderId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedId, folderId);
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemRemovedId, folderId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedId, folderId);
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+});
+
+add_task(function* test_remove_item_with_tag() {
+ // Notice in this case the tag persists since other bookmarks have same uri.
+ let testFolder = bmsvc.createFolder(root, "Test removing an item with a tag",
+ bmsvc.DEFAULT_INDEX);
+
+ const TAG_NAME = "tag-test_remove_item_with_tag";
+ let testURI = NetUtil.newURI("http://test_remove_item_with_tag.com");
+ let testBkmId = bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "test-item1");
+
+ // create bookmark for not removing tag.
+ bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "test-item2");
+
+ // set tag
+ tagssvc.tagURI(testURI, [TAG_NAME]);
+
+ let txn = new PlacesRemoveItemTransaction(testBkmId);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemRemovedId, testBkmId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+ do_check_eq(tagssvc.getTagsForURI(testURI), TAG_NAME);
+
+ txn.undoTransaction();
+ let newbkmk2Id = observer._itemAddedId;
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_eq(tagssvc.getTagsForURI(testURI)[0], TAG_NAME);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemRemovedId, newbkmk2Id);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+ do_check_eq(tagssvc.getTagsForURI(testURI)[0], TAG_NAME);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_eq(tagssvc.getTagsForURI(testURI)[0], TAG_NAME);
+});
+
+add_task(function* test_remove_item_with_keyword() {
+ // Notice in this case the tag persists since other bookmarks have same uri.
+ let testFolder = bmsvc.createFolder(root, "Test removing an item with a keyword",
+ bmsvc.DEFAULT_INDEX);
+
+ const KEYWORD = "test: test removing an item with a keyword";
+ let testURI = NetUtil.newURI("http://test_remove_item_with_keyword.com");
+ let testBkmId = bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "test-item1");
+
+ // set keyword
+ yield PlacesUtils.keywords.insert({ url: testURI.spec, keyword: KEYWORD});
+
+ let txn = new PlacesRemoveItemTransaction(testBkmId);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemRemovedId, testBkmId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+ yield promiseKeyword(KEYWORD, null);
+
+ txn.undoTransaction();
+ let newbkmk2Id = observer._itemAddedId;
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+ yield promiseKeyword(KEYWORD, testURI.spec);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemRemovedId, newbkmk2Id);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+ yield promiseKeyword(KEYWORD, null);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+});
+
+add_task(function* test_creating_separator() {
+ let testFolder = bmsvc.createFolder(root, "Test creating a separator", bmsvc.DEFAULT_INDEX);
+
+ let txn = new PlacesCreateSeparatorTransaction(testFolder, 0);
+ txn.doTransaction();
+
+ let sepId = observer._itemAddedId;
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_eq(observer._itemAddedParent, testFolder);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, sepId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+
+ txn.redoTransaction();
+ let newSepId = observer._itemAddedId;
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_eq(observer._itemAddedParent, testFolder);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, newSepId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+});
+
+add_task(function* test_removing_separator() {
+ let testFolder = bmsvc.createFolder(root, "Test removing a separator", bmsvc.DEFAULT_INDEX);
+
+ let sepId = bmsvc.insertSeparator(testFolder, 0);
+ let txn = new PlacesRemoveItemTransaction(sepId);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemRemovedId, sepId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedId, sepId); // New separator created
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemRemovedId, sepId);
+ do_check_eq(observer._itemRemovedFolder, testFolder);
+ do_check_eq(observer._itemRemovedIndex, 0);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemAddedId, sepId); // New separator created
+ do_check_eq(observer._itemAddedParent, testFolder);
+ do_check_eq(observer._itemAddedIndex, 0);
+});
+
+add_task(function* test_editing_item_title() {
+ const TITLE = "Test editing item title";
+ const MOD_TITLE = "Mod: Test editing item title";
+ let testURI = NetUtil.newURI("http://www.test_editing_item_title.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, TITLE);
+
+ let txn = new PlacesEditItemTitleTransaction(testBkmId, MOD_TITLE);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "title");
+ do_check_eq(observer._itemChangedValue, MOD_TITLE);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "title");
+ do_check_eq(observer._itemChangedValue, TITLE);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "title");
+ do_check_eq(observer._itemChangedValue, MOD_TITLE);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "title");
+ do_check_eq(observer._itemChangedValue, TITLE);
+});
+
+add_task(function* test_editing_item_uri() {
+ const OLD_TEST_URI = NetUtil.newURI("http://old.test_editing_item_uri.com/");
+ const NEW_TEST_URI = NetUtil.newURI("http://new.test_editing_item_uri.com/");
+ let testBkmId = bmsvc.insertBookmark(root, OLD_TEST_URI, bmsvc.DEFAULT_INDEX,
+ "Test editing item title");
+ tagssvc.tagURI(OLD_TEST_URI, ["tag"]);
+
+ let txn = new PlacesEditBookmarkURITransaction(testBkmId, NEW_TEST_URI);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "uri");
+ do_check_eq(observer._itemChangedValue, NEW_TEST_URI.spec);
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(NEW_TEST_URI)), JSON.stringify(["tag"]));
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(OLD_TEST_URI)), JSON.stringify([]));
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "uri");
+ do_check_eq(observer._itemChangedValue, OLD_TEST_URI.spec);
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(OLD_TEST_URI)), JSON.stringify(["tag"]));
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(NEW_TEST_URI)), JSON.stringify([]));
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "uri");
+ do_check_eq(observer._itemChangedValue, NEW_TEST_URI.spec);
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(NEW_TEST_URI)), JSON.stringify(["tag"]));
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(OLD_TEST_URI)), JSON.stringify([]));
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "uri");
+ do_check_eq(observer._itemChangedValue, OLD_TEST_URI.spec);
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(OLD_TEST_URI)), JSON.stringify(["tag"]));
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(NEW_TEST_URI)), JSON.stringify([]));
+});
+
+add_task(function* test_edit_description_transaction() {
+ let testURI = NetUtil.newURI("http://test_edit_description_transaction.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, "Test edit description transaction");
+
+ let anno = {
+ name: DESCRIPTION_ANNO,
+ type: Ci.nsIAnnotationService.TYPE_STRING,
+ flags: 0,
+ value: "Test edit Description",
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER,
+ };
+ let txn = new PlacesSetItemAnnotationTransaction(testBkmId, anno);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, DESCRIPTION_ANNO);
+});
+
+add_task(function* test_edit_keyword() {
+ const KEYWORD = "keyword-test_edit_keyword";
+
+ let testURI = NetUtil.newURI("http://test_edit_keyword.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, "Test edit keyword");
+
+ let txn = new PlacesEditBookmarkKeywordTransaction(testBkmId, KEYWORD, "postData");
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "keyword");
+ do_check_eq(observer._itemChangedValue, KEYWORD);
+ do_check_eq(PlacesUtils.getPostDataForBookmark(testBkmId), "postData");
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "keyword");
+ do_check_eq(observer._itemChangedValue, "");
+ do_check_eq(PlacesUtils.getPostDataForBookmark(testBkmId), null);
+});
+
+add_task(function* test_edit_specific_keyword() {
+ const KEYWORD = "keyword-test_edit_keyword2";
+
+ let testURI = NetUtil.newURI("http://test_edit_keyword2.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, "Test edit keyword");
+ // Add multiple keyword to this uri.
+ yield PlacesUtils.keywords.insert({ keyword: "kw1", url: testURI.spec, postData: "postData1" });
+ yield PlacesUtils.keywords.insert({keyword: "kw2", url: testURI.spec, postData: "postData2" });
+
+ // Try to change only kw2.
+ let txn = new PlacesEditBookmarkKeywordTransaction(testBkmId, KEYWORD, "postData2", "kw2");
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "keyword");
+ do_check_eq(observer._itemChangedValue, KEYWORD);
+ let entry = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry.url.href, testURI.spec);
+ Assert.equal(entry.postData, "postData1");
+ yield promiseKeyword(KEYWORD, testURI.spec, "postData2");
+ yield promiseKeyword("kw2", null);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "keyword");
+ do_check_eq(observer._itemChangedValue, "kw2");
+ do_check_eq(PlacesUtils.getPostDataForBookmark(testBkmId), "postData1");
+ entry = yield PlacesUtils.keywords.fetch("kw1");
+ Assert.equal(entry.url.href, testURI.spec);
+ Assert.equal(entry.postData, "postData1");
+ yield promiseKeyword("kw2", testURI.spec, "postData2");
+ yield promiseKeyword("keyword", null);
+});
+
+add_task(function* test_LoadInSidebar_transaction() {
+ let testURI = NetUtil.newURI("http://test_LoadInSidebar_transaction.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, "Test LoadInSidebar transaction");
+
+ const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+ let anno = { name: LOAD_IN_SIDEBAR_ANNO,
+ type: Ci.nsIAnnotationService.TYPE_INT32,
+ flags: 0,
+ value: true,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER };
+ let txn = new PlacesSetItemAnnotationTransaction(testBkmId, anno);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, LOAD_IN_SIDEBAR_ANNO);
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, LOAD_IN_SIDEBAR_ANNO);
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+});
+
+add_task(function* test_generic_item_annotation() {
+ let testURI = NetUtil.newURI("http://test_generic_item_annotation.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX, "Test generic item annotation");
+
+ let itemAnnoObj = { name: "testAnno/testInt",
+ type: Ci.nsIAnnotationService.TYPE_INT32,
+ flags: 0,
+ value: 123,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER };
+ let txn = new PlacesSetItemAnnotationTransaction(testBkmId, itemAnnoObj);
+
+ txn.doTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "testAnno/testInt");
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+
+ txn.undoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "testAnno/testInt");
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+
+ txn.redoTransaction();
+ do_check_eq(observer._itemChangedId, testBkmId);
+ do_check_eq(observer._itemChangedProperty, "testAnno/testInt");
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+});
+
+add_task(function* test_editing_item_date_added() {
+ let testURI = NetUtil.newURI("http://test_editing_item_date_added.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX,
+ "Test editing item date added");
+
+ let oldAdded = bmsvc.getItemDateAdded(testBkmId);
+ let newAdded = Date.now() * 1000 + 1000;
+ let txn = new PlacesEditItemDateAddedTransaction(testBkmId, newAdded);
+
+ txn.doTransaction();
+ do_check_eq(newAdded, bmsvc.getItemDateAdded(testBkmId));
+
+ txn.undoTransaction();
+ do_check_eq(oldAdded, bmsvc.getItemDateAdded(testBkmId));
+});
+
+add_task(function* test_edit_item_last_modified() {
+ let testURI = NetUtil.newURI("http://test_edit_item_last_modified.com");
+ let testBkmId = bmsvc.insertBookmark(root, testURI, bmsvc.DEFAULT_INDEX,
+ "Test editing item last modified");
+
+ let oldModified = bmsvc.getItemLastModified(testBkmId);
+ let newModified = Date.now() * 1000 + 1000;
+ let txn = new PlacesEditItemLastModifiedTransaction(testBkmId, newModified);
+
+ txn.doTransaction();
+ do_check_eq(newModified, bmsvc.getItemLastModified(testBkmId));
+
+ txn.undoTransaction();
+ do_check_eq(oldModified, bmsvc.getItemLastModified(testBkmId));
+});
+
+add_task(function* test_generic_page_annotation() {
+ const TEST_ANNO = "testAnno/testInt";
+ let testURI = NetUtil.newURI("http://www.mozilla.org/");
+ PlacesTestUtils.addVisits(testURI).then(function () {
+ let pageAnnoObj = { name: TEST_ANNO,
+ type: Ci.nsIAnnotationService.TYPE_INT32,
+ flags: 0,
+ value: 123,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER };
+ let txn = new PlacesSetPageAnnotationTransaction(testURI, pageAnnoObj);
+
+ txn.doTransaction();
+ do_check_true(annosvc.pageHasAnnotation(testURI, TEST_ANNO));
+
+ txn.undoTransaction();
+ do_check_false(annosvc.pageHasAnnotation(testURI, TEST_ANNO));
+
+ txn.redoTransaction();
+ do_check_true(annosvc.pageHasAnnotation(testURI, TEST_ANNO));
+ });
+});
+
+add_task(function* test_sort_folder_by_name() {
+ let testFolder = bmsvc.createFolder(root, "Test PlacesSortFolderByNameTransaction",
+ bmsvc.DEFAULT_INDEX);
+ let testURI = NetUtil.newURI("http://test_sort_folder_by_name.com");
+
+ bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "bookmark3");
+ bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "bookmark2");
+ bmsvc.insertBookmark(testFolder, testURI, bmsvc.DEFAULT_INDEX, "bookmark1");
+
+ let bkmIds = bmsvc.getBookmarkIdsForURI(testURI);
+ bkmIds.sort();
+
+ let b1 = bkmIds[0];
+ let b2 = bkmIds[1];
+ let b3 = bkmIds[2];
+
+ do_check_eq(0, bmsvc.getItemIndex(b1));
+ do_check_eq(1, bmsvc.getItemIndex(b2));
+ do_check_eq(2, bmsvc.getItemIndex(b3));
+
+ let txn = new PlacesSortFolderByNameTransaction(testFolder);
+
+ txn.doTransaction();
+ do_check_eq(2, bmsvc.getItemIndex(b1));
+ do_check_eq(1, bmsvc.getItemIndex(b2));
+ do_check_eq(0, bmsvc.getItemIndex(b3));
+
+ txn.undoTransaction();
+ do_check_eq(0, bmsvc.getItemIndex(b1));
+ do_check_eq(1, bmsvc.getItemIndex(b2));
+ do_check_eq(2, bmsvc.getItemIndex(b3));
+
+ txn.redoTransaction();
+ do_check_eq(2, bmsvc.getItemIndex(b1));
+ do_check_eq(1, bmsvc.getItemIndex(b2));
+ do_check_eq(0, bmsvc.getItemIndex(b3));
+
+ txn.undoTransaction();
+ do_check_eq(0, bmsvc.getItemIndex(b1));
+ do_check_eq(1, bmsvc.getItemIndex(b2));
+ do_check_eq(2, bmsvc.getItemIndex(b3));
+});
+
+add_task(function* test_tagURI_untagURI() {
+ const TAG_1 = "tag-test_tagURI_untagURI-bar";
+ const TAG_2 = "tag-test_tagURI_untagURI-foo";
+ let tagURI = NetUtil.newURI("http://test_tagURI_untagURI.com");
+
+ // Test tagURI
+ let tagTxn = new PlacesTagURITransaction(tagURI, [TAG_1, TAG_2]);
+
+ tagTxn.doTransaction();
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(tagURI)), JSON.stringify([TAG_1, TAG_2]));
+
+ tagTxn.undoTransaction();
+ do_check_eq(tagssvc.getTagsForURI(tagURI).length, 0);
+
+ tagTxn.redoTransaction();
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(tagURI)), JSON.stringify([TAG_1, TAG_2]));
+
+ // Test untagURI
+ let untagTxn = new PlacesUntagURITransaction(tagURI, [TAG_1]);
+
+ untagTxn.doTransaction();
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(tagURI)), JSON.stringify([TAG_2]));
+
+ untagTxn.undoTransaction();
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(tagURI)), JSON.stringify([TAG_1, TAG_2]));
+
+ untagTxn.redoTransaction();
+ do_check_eq(JSON.stringify(tagssvc.getTagsForURI(tagURI)), JSON.stringify([TAG_2]));
+});
+
+add_task(function* test_aggregate_removeItem_Txn() {
+ let testFolder = bmsvc.createFolder(root, "Test aggregate removeItem transaction", bmsvc.DEFAULT_INDEX);
+
+ const TEST_URL = "http://test_aggregate_removeitem_txn.com/";
+ const FOLDERNAME = "Folder";
+ let testURI = NetUtil.newURI(TEST_URL);
+
+ let bkmk1Id = bmsvc.insertBookmark(testFolder, testURI, 0, "Mozilla");
+ let bkmk2Id = bmsvc.insertSeparator(testFolder, 1);
+ let bkmk3Id = bmsvc.createFolder(testFolder, FOLDERNAME, 2);
+
+ let bkmk3_1Id = bmsvc.insertBookmark(bkmk3Id, testURI, 0, "Mozilla");
+ let bkmk3_2Id = bmsvc.insertSeparator(bkmk3Id, 1);
+ let bkmk3_3Id = bmsvc.createFolder(bkmk3Id, FOLDERNAME, 2);
+
+ let childTxn1 = new PlacesRemoveItemTransaction(bkmk1Id);
+ let childTxn2 = new PlacesRemoveItemTransaction(bkmk2Id);
+ let childTxn3 = new PlacesRemoveItemTransaction(bkmk3Id);
+ let transactions = [childTxn1, childTxn2, childTxn3];
+ let txn = new PlacesAggregatedTransaction("RemoveItems", transactions);
+
+ txn.doTransaction();
+ do_check_eq(bmsvc.getItemIndex(bkmk1Id), -1);
+ do_check_eq(bmsvc.getItemIndex(bkmk2Id), -1);
+ do_check_eq(bmsvc.getItemIndex(bkmk3Id), -1);
+ do_check_eq(bmsvc.getItemIndex(bkmk3_1Id), -1);
+ do_check_eq(bmsvc.getItemIndex(bkmk3_2Id), -1);
+ do_check_eq(bmsvc.getItemIndex(bkmk3_3Id), -1);
+ // Check last removed item id.
+ do_check_eq(observer._itemRemovedId, bkmk3Id);
+
+ txn.undoTransaction();
+ let newBkmk1Id = bmsvc.getIdForItemAt(testFolder, 0);
+ let newBkmk2Id = bmsvc.getIdForItemAt(testFolder, 1);
+ let newBkmk3Id = bmsvc.getIdForItemAt(testFolder, 2);
+ let newBkmk3_1Id = bmsvc.getIdForItemAt(newBkmk3Id, 0);
+ let newBkmk3_2Id = bmsvc.getIdForItemAt(newBkmk3Id, 1);
+ let newBkmk3_3Id = bmsvc.getIdForItemAt(newBkmk3Id, 2);
+ do_check_eq(bmsvc.getItemType(newBkmk1Id), bmsvc.TYPE_BOOKMARK);
+ do_check_eq(bmsvc.getBookmarkURI(newBkmk1Id).spec, TEST_URL);
+ do_check_eq(bmsvc.getItemType(newBkmk2Id), bmsvc.TYPE_SEPARATOR);
+ do_check_eq(bmsvc.getItemType(newBkmk3Id), bmsvc.TYPE_FOLDER);
+ do_check_eq(bmsvc.getItemTitle(newBkmk3Id), FOLDERNAME);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_1Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_1Id), bmsvc.TYPE_BOOKMARK);
+ do_check_eq(bmsvc.getBookmarkURI(newBkmk3_1Id).spec, TEST_URL);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_2Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_2Id), bmsvc.TYPE_SEPARATOR);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_3Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_3Id), bmsvc.TYPE_FOLDER);
+ do_check_eq(bmsvc.getItemTitle(newBkmk3_3Id), FOLDERNAME);
+ // Check last added back item id.
+ // Notice items are restored in reverse order.
+ do_check_eq(observer._itemAddedId, newBkmk1Id);
+
+ txn.redoTransaction();
+ do_check_eq(bmsvc.getItemIndex(newBkmk1Id), -1);
+ do_check_eq(bmsvc.getItemIndex(newBkmk2Id), -1);
+ do_check_eq(bmsvc.getItemIndex(newBkmk3Id), -1);
+ do_check_eq(bmsvc.getItemIndex(newBkmk3_1Id), -1);
+ do_check_eq(bmsvc.getItemIndex(newBkmk3_2Id), -1);
+ do_check_eq(bmsvc.getItemIndex(newBkmk3_3Id), -1);
+ // Check last removed item id.
+ do_check_eq(observer._itemRemovedId, newBkmk3Id);
+
+ txn.undoTransaction();
+ newBkmk1Id = bmsvc.getIdForItemAt(testFolder, 0);
+ newBkmk2Id = bmsvc.getIdForItemAt(testFolder, 1);
+ newBkmk3Id = bmsvc.getIdForItemAt(testFolder, 2);
+ newBkmk3_1Id = bmsvc.getIdForItemAt(newBkmk3Id, 0);
+ newBkmk3_2Id = bmsvc.getIdForItemAt(newBkmk3Id, 1);
+ newBkmk3_3Id = bmsvc.getIdForItemAt(newBkmk3Id, 2);
+ do_check_eq(bmsvc.getItemType(newBkmk1Id), bmsvc.TYPE_BOOKMARK);
+ do_check_eq(bmsvc.getBookmarkURI(newBkmk1Id).spec, TEST_URL);
+ do_check_eq(bmsvc.getItemType(newBkmk2Id), bmsvc.TYPE_SEPARATOR);
+ do_check_eq(bmsvc.getItemType(newBkmk3Id), bmsvc.TYPE_FOLDER);
+ do_check_eq(bmsvc.getItemTitle(newBkmk3Id), FOLDERNAME);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_1Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_1Id), bmsvc.TYPE_BOOKMARK);
+ do_check_eq(bmsvc.getBookmarkURI(newBkmk3_1Id).spec, TEST_URL);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_2Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_2Id), bmsvc.TYPE_SEPARATOR);
+ do_check_eq(bmsvc.getFolderIdForItem(newBkmk3_3Id), newBkmk3Id);
+ do_check_eq(bmsvc.getItemType(newBkmk3_3Id), bmsvc.TYPE_FOLDER);
+ do_check_eq(bmsvc.getItemTitle(newBkmk3_3Id), FOLDERNAME);
+ // Check last added back item id.
+ // Notice items are restored in reverse order.
+ do_check_eq(observer._itemAddedId, newBkmk1Id);
+});
+
+add_task(function* test_create_item_with_childTxn() {
+ let testFolder = bmsvc.createFolder(root, "Test creating an item with childTxns", bmsvc.DEFAULT_INDEX);
+
+ const BOOKMARK_TITLE = "parent item";
+ let testURI = NetUtil.newURI("http://test_create_item_with_childTxn.com");
+ let childTxns = [];
+ let newDateAdded = Date.now() * 1000 - 20000;
+ let editDateAdddedTxn = new PlacesEditItemDateAddedTransaction(null, newDateAdded);
+ childTxns.push(editDateAdddedTxn);
+
+ let itemChildAnnoObj = { name: "testAnno/testInt",
+ type: Ci.nsIAnnotationService.TYPE_INT32,
+ flags: 0,
+ value: 123,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER };
+ let annoTxn = new PlacesSetItemAnnotationTransaction(null, itemChildAnnoObj);
+ childTxns.push(annoTxn);
+
+ let itemWChildTxn = new PlacesCreateBookmarkTransaction(testURI, testFolder, bmStartIndex,
+ BOOKMARK_TITLE, null, null,
+ childTxns);
+ try {
+ txnManager.doTransaction(itemWChildTxn);
+ let itemId = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(observer._itemAddedId, itemId);
+ do_check_eq(newDateAdded, bmsvc.getItemDateAdded(itemId));
+ do_check_eq(observer._itemChangedProperty, "testAnno/testInt");
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+ do_check_true(annosvc.itemHasAnnotation(itemId, itemChildAnnoObj.name))
+ do_check_eq(annosvc.getItemAnnotation(itemId, itemChildAnnoObj.name), itemChildAnnoObj.value);
+
+ itemWChildTxn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, itemId);
+
+ itemWChildTxn.redoTransaction();
+ do_check_true(bmsvc.isBookmarked(testURI));
+ let newId = bmsvc.getBookmarkIdsForURI(testURI)[0];
+ do_check_eq(newDateAdded, bmsvc.getItemDateAdded(newId));
+ do_check_eq(observer._itemAddedId, newId);
+ do_check_eq(observer._itemChangedProperty, "testAnno/testInt");
+ do_check_eq(observer._itemChanged_isAnnotationProperty, true);
+ do_check_true(annosvc.itemHasAnnotation(newId, itemChildAnnoObj.name))
+ do_check_eq(annosvc.getItemAnnotation(newId, itemChildAnnoObj.name), itemChildAnnoObj.value);
+
+ itemWChildTxn.undoTransaction();
+ do_check_eq(observer._itemRemovedId, newId);
+ }
+ catch (ex) {
+ do_throw("Setting a child transaction in a createItem transaction did throw: " + ex);
+ }
+});
+
+add_task(function* test_create_folder_with_child_itemTxn() {
+ let childURI = NetUtil.newURI("http://test_create_folder_with_child_itemTxn.com");
+ let childItemTxn = new PlacesCreateBookmarkTransaction(childURI, root,
+ bmStartIndex, "childItem");
+ let txn = new PlacesCreateFolderTransaction("Test creating a folder with child itemTxns",
+ root, bmStartIndex, null, [childItemTxn]);
+ try {
+ txnManager.doTransaction(txn);
+ let childItemId = bmsvc.getBookmarkIdsForURI(childURI)[0];
+ do_check_eq(observer._itemAddedId, childItemId);
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_true(bmsvc.isBookmarked(childURI));
+
+ txn.undoTransaction();
+ do_check_false(bmsvc.isBookmarked(childURI));
+
+ txn.redoTransaction();
+ let newchildItemId = bmsvc.getBookmarkIdsForURI(childURI)[0];
+ do_check_eq(observer._itemAddedIndex, 0);
+ do_check_eq(observer._itemAddedId, newchildItemId);
+ do_check_true(bmsvc.isBookmarked(childURI));
+
+ txn.undoTransaction();
+ do_check_false(bmsvc.isBookmarked(childURI));
+ }
+ catch (ex) {
+ do_throw("Setting a child item transaction in a createFolder transaction did throw: " + ex);
+ }
+});
diff --git a/toolkit/components/places/tests/unit/test_preventive_maintenance.js b/toolkit/components/places/tests/unit/test_preventive_maintenance.js
new file mode 100644
index 000000000..a533c8295
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_preventive_maintenance.js
@@ -0,0 +1,1356 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Test preventive maintenance
+ * For every maintenance query create an uncoherent db and check that we take
+ * correct fix steps, without polluting valid data.
+ */
+
+// Include PlacesDBUtils module
+Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
+
+const FINISHED_MAINTENANCE_NOTIFICATION_TOPIC = "places-maintenance-finished";
+
+// Get services and database connection
+var hs = PlacesUtils.history;
+var bs = PlacesUtils.bookmarks;
+var ts = PlacesUtils.tagging;
+var as = PlacesUtils.annotations;
+var fs = PlacesUtils.favicons;
+
+var mDBConn = hs.QueryInterface(Ci.nsPIPlacesDatabase).DBConnection;
+
+// ------------------------------------------------------------------------------
+// Helpers
+
+var defaultBookmarksMaxId = 0;
+function cleanDatabase() {
+ mDBConn.executeSimpleSQL("DELETE FROM moz_places");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_historyvisits");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_anno_attributes");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_annos");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_items_annos");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_inputhistory");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_keywords");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_favicons");
+ mDBConn.executeSimpleSQL("DELETE FROM moz_bookmarks WHERE id > " + defaultBookmarksMaxId);
+}
+
+function addPlace(aUrl, aFavicon) {
+ let stmt = mDBConn.createStatement(
+ "INSERT INTO moz_places (url, url_hash, favicon_id) VALUES (:url, hash(:url), :favicon)");
+ stmt.params["url"] = aUrl || "http://www.mozilla.org";
+ stmt.params["favicon"] = aFavicon || null;
+ stmt.execute();
+ stmt.finalize();
+ return mDBConn.lastInsertRowID;
+}
+
+function addBookmark(aPlaceId, aType, aParent, aKeywordId, aFolderType, aTitle) {
+ let stmt = mDBConn.createStatement(
+ `INSERT INTO moz_bookmarks (fk, type, parent, keyword_id, folder_type,
+ title, guid)
+ VALUES (:place_id, :type, :parent, :keyword_id, :folder_type, :title,
+ GENERATE_GUID())`);
+ stmt.params["place_id"] = aPlaceId || null;
+ stmt.params["type"] = aType || bs.TYPE_BOOKMARK;
+ stmt.params["parent"] = aParent || bs.unfiledBookmarksFolder;
+ stmt.params["keyword_id"] = aKeywordId || null;
+ stmt.params["folder_type"] = aFolderType || null;
+ stmt.params["title"] = typeof(aTitle) == "string" ? aTitle : null;
+ stmt.execute();
+ stmt.finalize();
+ return mDBConn.lastInsertRowID;
+}
+
+// ------------------------------------------------------------------------------
+// Tests
+
+var tests = [];
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "A.1",
+ desc: "Remove obsolete annotations from moz_annos",
+
+ _obsoleteWeaveAttribute: "weave/test",
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid.
+ this._placeId = addPlace();
+ // Add an obsolete attribute.
+ let stmt = mDBConn.createStatement(
+ "INSERT INTO moz_anno_attributes (name) VALUES (:anno)"
+ );
+ stmt.params['anno'] = this._obsoleteWeaveAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement(
+ `INSERT INTO moz_annos (place_id, anno_attribute_id)
+ VALUES (:place_id,
+ (SELECT id FROM moz_anno_attributes WHERE name = :anno)
+ )`
+ );
+ stmt.params['place_id'] = this._placeId;
+ stmt.params['anno'] = this._obsoleteWeaveAttribute;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that the obsolete annotation has been removed.
+ let stmt = mDBConn.createStatement(
+ "SELECT id FROM moz_anno_attributes WHERE name = :anno"
+ );
+ stmt.params['anno'] = this._obsoleteWeaveAttribute;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+tests.push({
+ name: "A.2",
+ desc: "Remove obsolete annotations from moz_items_annos",
+
+ _obsoleteSyncAttribute: "sync/children",
+ _obsoleteGuidAttribute: "placesInternal/GUID",
+ _obsoleteWeaveAttribute: "weave/test",
+ _placeId: null,
+ _bookmarkId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid.
+ this._placeId = addPlace();
+ // Add a bookmark.
+ this._bookmarkId = addBookmark(this._placeId);
+ // Add an obsolete attribute.
+ let stmt = mDBConn.createStatement(
+ `INSERT INTO moz_anno_attributes (name)
+ VALUES (:anno1), (:anno2), (:anno3)`
+ );
+ stmt.params['anno1'] = this._obsoleteSyncAttribute;
+ stmt.params['anno2'] = this._obsoleteGuidAttribute;
+ stmt.params['anno3'] = this._obsoleteWeaveAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement(
+ `INSERT INTO moz_items_annos (item_id, anno_attribute_id)
+ SELECT :item_id, id
+ FROM moz_anno_attributes
+ WHERE name IN (:anno1, :anno2, :anno3)`
+ );
+ stmt.params['item_id'] = this._bookmarkId;
+ stmt.params['anno1'] = this._obsoleteSyncAttribute;
+ stmt.params['anno2'] = this._obsoleteGuidAttribute;
+ stmt.params['anno3'] = this._obsoleteWeaveAttribute;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that the obsolete annotations have been removed.
+ let stmt = mDBConn.createStatement(
+ `SELECT id FROM moz_anno_attributes
+ WHERE name IN (:anno1, :anno2, :anno3)`
+ );
+ stmt.params['anno1'] = this._obsoleteSyncAttribute;
+ stmt.params['anno2'] = this._obsoleteGuidAttribute;
+ stmt.params['anno3'] = this._obsoleteWeaveAttribute;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+tests.push({
+ name: "A.3",
+ desc: "Remove unused attributes",
+
+ _usedPageAttribute: "usedPage",
+ _usedItemAttribute: "usedItem",
+ _unusedAttribute: "unused",
+ _placeId: null,
+ _bookmarkId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // add a bookmark
+ this._bookmarkId = addBookmark(this._placeId);
+ // Add a used attribute and an unused one.
+ let stmt = mDBConn.createStatement("INSERT INTO moz_anno_attributes (name) VALUES (:anno)");
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.reset();
+ stmt.params['anno'] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.reset();
+ stmt.params['anno'] = this._unusedAttribute;
+ stmt.execute();
+ stmt.finalize();
+
+ stmt = mDBConn.createStatement("INSERT INTO moz_annos (place_id, anno_attribute_id) VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params['place_id'] = this._placeId;
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement("INSERT INTO moz_items_annos (item_id, anno_attribute_id) VALUES(:item_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params['item_id'] = this._bookmarkId;
+ stmt.params['anno'] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that used attributes are still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_anno_attributes WHERE name = :anno");
+ stmt.params['anno'] = this._usedPageAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params['anno'] = this._usedItemAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that unused attribute has been removed
+ stmt.params['anno'] = this._unusedAttribute;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "B.1",
+ desc: "Remove annotations with an invalid attribute",
+
+ _usedPageAttribute: "usedPage",
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add a used attribute.
+ let stmt = mDBConn.createStatement("INSERT INTO moz_anno_attributes (name) VALUES (:anno)");
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement("INSERT INTO moz_annos (place_id, anno_attribute_id) VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params['place_id'] = this._placeId;
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.finalize();
+ // Add an annotation with a nonexistent attribute
+ stmt = mDBConn.createStatement("INSERT INTO moz_annos (place_id, anno_attribute_id) VALUES(:place_id, 1337)");
+ stmt.params['place_id'] = this._placeId;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that used attribute is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_anno_attributes WHERE name = :anno");
+ stmt.params['anno'] = this._usedPageAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // check that annotation with valid attribute is still there
+ stmt = mDBConn.createStatement("SELECT id FROM moz_annos WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)");
+ stmt.params['anno'] = this._usedPageAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // Check that annotation with bogus attribute has been removed
+ stmt = mDBConn.createStatement("SELECT id FROM moz_annos WHERE anno_attribute_id = 1337");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "B.2",
+ desc: "Remove orphan page annotations",
+
+ _usedPageAttribute: "usedPage",
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add a used attribute.
+ let stmt = mDBConn.createStatement("INSERT INTO moz_anno_attributes (name) VALUES (:anno)");
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement("INSERT INTO moz_annos (place_id, anno_attribute_id) VALUES(:place_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params['place_id'] = this._placeId;
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.reset();
+ // Add an annotation to a nonexistent page
+ stmt.params['place_id'] = 1337;
+ stmt.params['anno'] = this._usedPageAttribute;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that used attribute is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_anno_attributes WHERE name = :anno");
+ stmt.params['anno'] = this._usedPageAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // check that annotation with valid attribute is still there
+ stmt = mDBConn.createStatement("SELECT id FROM moz_annos WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)");
+ stmt.params['anno'] = this._usedPageAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // Check that an annotation to a nonexistent page has been removed
+ stmt = mDBConn.createStatement("SELECT id FROM moz_annos WHERE place_id = 1337");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+tests.push({
+ name: "C.1",
+ desc: "fix missing Places root",
+
+ setup: function() {
+ // Sanity check: ensure that roots are intact.
+ do_check_eq(bs.getFolderIdForItem(bs.placesRoot), 0);
+ do_check_eq(bs.getFolderIdForItem(bs.bookmarksMenuFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.tagsFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.unfiledBookmarksFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.toolbarFolder), bs.placesRoot);
+
+ // Remove the root.
+ mDBConn.executeSimpleSQL("DELETE FROM moz_bookmarks WHERE parent = 0");
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE parent = 0");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Ensure the roots have been correctly restored.
+ do_check_eq(bs.getFolderIdForItem(bs.placesRoot), 0);
+ do_check_eq(bs.getFolderIdForItem(bs.bookmarksMenuFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.tagsFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.unfiledBookmarksFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.toolbarFolder), bs.placesRoot);
+ }
+});
+
+// ------------------------------------------------------------------------------
+tests.push({
+ name: "C.2",
+ desc: "Fix roots titles",
+
+ setup: function() {
+ // Sanity check: ensure that roots titles are correct. We can use our check.
+ this.check();
+ // Change some roots' titles.
+ bs.setItemTitle(bs.placesRoot, "bad title");
+ do_check_eq(bs.getItemTitle(bs.placesRoot), "bad title");
+ bs.setItemTitle(bs.unfiledBookmarksFolder, "bad title");
+ do_check_eq(bs.getItemTitle(bs.unfiledBookmarksFolder), "bad title");
+ },
+
+ check: function() {
+ // Ensure all roots titles are correct.
+ do_check_eq(bs.getItemTitle(bs.placesRoot), "");
+ do_check_eq(bs.getItemTitle(bs.bookmarksMenuFolder),
+ PlacesUtils.getString("BookmarksMenuFolderTitle"));
+ do_check_eq(bs.getItemTitle(bs.tagsFolder),
+ PlacesUtils.getString("TagsFolderTitle"));
+ do_check_eq(bs.getItemTitle(bs.unfiledBookmarksFolder),
+ PlacesUtils.getString("OtherBookmarksFolderTitle"));
+ do_check_eq(bs.getItemTitle(bs.toolbarFolder),
+ PlacesUtils.getString("BookmarksToolbarFolderTitle"));
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.1",
+ desc: "Remove items without a valid place",
+
+ _validItemId: null,
+ _invalidItemId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this.placeId = addPlace();
+ // Insert a valid bookmark
+ this._validItemId = addBookmark(this.placeId);
+ // Insert a bookmark with an invalid place
+ this._invalidItemId = addBookmark(1337);
+ },
+
+ check: function() {
+ // Check that valid bookmark is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :item_id");
+ stmt.params["item_id"] = this._validItemId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that invalid bookmark has been removed
+ stmt.params["item_id"] = this._invalidItemId;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.2",
+ desc: "Remove items that are not uri bookmarks from tag containers",
+
+ _tagId: null,
+ _bookmarkId: null,
+ _separatorId: null,
+ _folderId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Create a tag
+ this._tagId = addBookmark(null, bs.TYPE_FOLDER, bs.tagsFolder);
+ // Insert a bookmark in the tag
+ this._bookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK, this._tagId);
+ // Insert a separator in the tag
+ this._separatorId = addBookmark(null, bs.TYPE_SEPARATOR, this._tagId);
+ // Insert a folder in the tag
+ this._folderId = addBookmark(null, bs.TYPE_FOLDER, this._tagId);
+ },
+
+ check: function() {
+ // Check that valid bookmark is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE type = :type AND parent = :parent");
+ stmt.params["type"] = bs.TYPE_BOOKMARK;
+ stmt.params["parent"] = this._tagId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that separator is no more there
+ stmt.params["type"] = bs.TYPE_SEPARATOR;
+ stmt.params["parent"] = this._tagId;
+ do_check_false(stmt.executeStep());
+ stmt.reset();
+ // Check that folder is no more there
+ stmt.params["type"] = bs.TYPE_FOLDER;
+ stmt.params["parent"] = this._tagId;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.3",
+ desc: "Remove empty tags",
+
+ _tagId: null,
+ _bookmarkId: null,
+ _emptyTagId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Create a tag
+ this._tagId = addBookmark(null, bs.TYPE_FOLDER, bs.tagsFolder);
+ // Insert a bookmark in the tag
+ this._bookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK, this._tagId);
+ // Create another tag (empty)
+ this._emptyTagId = addBookmark(null, bs.TYPE_FOLDER, bs.tagsFolder);
+ },
+
+ check: function() {
+ // Check that valid bookmark is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :id AND type = :type AND parent = :parent");
+ stmt.params["id"] = this._bookmarkId;
+ stmt.params["type"] = bs.TYPE_BOOKMARK;
+ stmt.params["parent"] = this._tagId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["id"] = this._tagId;
+ stmt.params["type"] = bs.TYPE_FOLDER;
+ stmt.params["parent"] = bs.tagsFolder;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["id"] = this._emptyTagId;
+ stmt.params["type"] = bs.TYPE_FOLDER;
+ stmt.params["parent"] = bs.tagsFolder;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.4",
+ desc: "Move orphan items to unsorted folder",
+
+ _orphanBookmarkId: null,
+ _orphanSeparatorId: null,
+ _orphanFolderId: null,
+ _bookmarkId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Insert an orphan bookmark
+ this._orphanBookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK, 8888);
+ // Insert an orphan separator
+ this._orphanSeparatorId = addBookmark(null, bs.TYPE_SEPARATOR, 8888);
+ // Insert a orphan folder
+ this._orphanFolderId = addBookmark(null, bs.TYPE_FOLDER, 8888);
+ // Create a child of the last created folder
+ this._bookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK, this._orphanFolderId);
+ },
+
+ check: function() {
+ // Check that bookmarks are now children of a real folder (unsorted)
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :item_id AND parent = :parent");
+ stmt.params["item_id"] = this._orphanBookmarkId;
+ stmt.params["parent"] = bs.unfiledBookmarksFolder;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["item_id"] = this._orphanSeparatorId;
+ stmt.params["parent"] = bs.unfiledBookmarksFolder;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["item_id"] = this._orphanFolderId;
+ stmt.params["parent"] = bs.unfiledBookmarksFolder;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["item_id"] = this._bookmarkId;
+ stmt.params["parent"] = this._orphanFolderId;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.6",
+ desc: "Fix wrong item types | bookmarks",
+
+ _separatorId: null,
+ _folderId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add a separator with a fk
+ this._separatorId = addBookmark(this._placeId, bs.TYPE_SEPARATOR);
+ // Add a folder with a fk
+ this._folderId = addBookmark(this._placeId, bs.TYPE_FOLDER);
+ },
+
+ check: function() {
+ // Check that items with an fk have been converted to bookmarks
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :item_id AND type = :type");
+ stmt.params["item_id"] = this._separatorId;
+ stmt.params["type"] = bs.TYPE_BOOKMARK;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["item_id"] = this._folderId;
+ stmt.params["type"] = bs.TYPE_BOOKMARK;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.7",
+ desc: "Fix wrong item types | bookmarks",
+
+ _validBookmarkId: null,
+ _invalidBookmarkId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add a bookmark with a valid place id
+ this._validBookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK);
+ // Add a bookmark with a null place id
+ this._invalidBookmarkId = addBookmark(null, bs.TYPE_BOOKMARK);
+ },
+
+ check: function() {
+ // Check valid bookmark
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :item_id AND type = :type");
+ stmt.params["item_id"] = this._validBookmarkId;
+ stmt.params["type"] = bs.TYPE_BOOKMARK;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check invalid bookmark has been converted to a folder
+ stmt.params["item_id"] = this._invalidBookmarkId;
+ stmt.params["type"] = bs.TYPE_FOLDER;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.9",
+ desc: "Fix wrong parents",
+
+ _bookmarkId: null,
+ _separatorId: null,
+ _bookmarkId1: null,
+ _bookmarkId2: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Insert a bookmark
+ this._bookmarkId = addBookmark(this._placeId, bs.TYPE_BOOKMARK);
+ // Insert a separator
+ this._separatorId = addBookmark(null, bs.TYPE_SEPARATOR);
+ // Create 3 children of these items
+ this._bookmarkId1 = addBookmark(this._placeId, bs.TYPE_BOOKMARK, this._bookmarkId);
+ this._bookmarkId2 = addBookmark(this._placeId, bs.TYPE_BOOKMARK, this._separatorId);
+ },
+
+ check: function() {
+ // Check that bookmarks are now children of a real folder (unsorted)
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_bookmarks WHERE id = :item_id AND parent = :parent");
+ stmt.params["item_id"] = this._bookmarkId1;
+ stmt.params["parent"] = bs.unfiledBookmarksFolder;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["item_id"] = this._bookmarkId2;
+ stmt.params["parent"] = bs.unfiledBookmarksFolder;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.10",
+ desc: "Recalculate positions",
+
+ _unfiledBookmarks: [],
+ _toolbarBookmarks: [],
+
+ setup: function() {
+ const NUM_BOOKMARKS = 20;
+ bs.runInBatchMode({
+ runBatched: function (aUserData) {
+ // Add bookmarks to two folders to better perturbate the table.
+ for (let i = 0; i < NUM_BOOKMARKS; i++) {
+ bs.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
+ NetUtil.newURI("http://example.com/"),
+ bs.DEFAULT_INDEX, "testbookmark");
+ }
+ for (let i = 0; i < NUM_BOOKMARKS; i++) {
+ bs.insertBookmark(PlacesUtils.toolbarFolderId,
+ NetUtil.newURI("http://example.com/"),
+ bs.DEFAULT_INDEX, "testbookmark");
+ }
+ }
+ }, null);
+
+ function randomize_positions(aParent, aResultArray) {
+ let stmt = mDBConn.createStatement(
+ `UPDATE moz_bookmarks SET position = :rand
+ WHERE id IN (
+ SELECT id FROM moz_bookmarks WHERE parent = :parent
+ ORDER BY RANDOM() LIMIT 1
+ )`
+ );
+ for (let i = 0; i < (NUM_BOOKMARKS / 2); i++) {
+ stmt.params["parent"] = aParent;
+ stmt.params["rand"] = Math.round(Math.random() * (NUM_BOOKMARKS - 1));
+ stmt.execute();
+ stmt.reset();
+ }
+ stmt.finalize();
+
+ // Build the expected ordered list of bookmarks.
+ stmt = mDBConn.createStatement(
+ `SELECT id, position
+ FROM moz_bookmarks WHERE parent = :parent
+ ORDER BY position ASC, ROWID ASC`
+ );
+ stmt.params["parent"] = aParent;
+ while (stmt.executeStep()) {
+ aResultArray.push(stmt.row.id);
+ print(stmt.row.id + "\t" + stmt.row.position + "\t" +
+ (aResultArray.length - 1));
+ }
+ stmt.finalize();
+ }
+
+ // Set random positions for the added bookmarks.
+ randomize_positions(PlacesUtils.unfiledBookmarksFolderId,
+ this._unfiledBookmarks);
+ randomize_positions(PlacesUtils.toolbarFolderId, this._toolbarBookmarks);
+ },
+
+ check: function() {
+ function check_order(aParent, aResultArray) {
+ // Build the expected ordered list of bookmarks.
+ let stmt = mDBConn.createStatement(
+ `SELECT id, position FROM moz_bookmarks WHERE parent = :parent
+ ORDER BY position ASC`
+ );
+ stmt.params["parent"] = aParent;
+ let pass = true;
+ while (stmt.executeStep()) {
+ print(stmt.row.id + "\t" + stmt.row.position);
+ if (aResultArray.indexOf(stmt.row.id) != stmt.row.position) {
+ pass = false;
+ }
+ }
+ stmt.finalize();
+ if (!pass) {
+ dump_table("moz_bookmarks");
+ do_throw("Unexpected unfiled bookmarks order.");
+ }
+ }
+
+ check_order(PlacesUtils.unfiledBookmarksFolderId, this._unfiledBookmarks);
+ check_order(PlacesUtils.toolbarFolderId, this._toolbarBookmarks);
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "D.12",
+ desc: "Fix empty-named tags",
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ let placeId = addPlace();
+ // Create a empty-named tag.
+ this._untitledTagId = addBookmark(null, bs.TYPE_FOLDER, bs.tagsFolder, null, null, "");
+ // Insert a bookmark in the tag, otherwise it will be removed.
+ addBookmark(placeId, bs.TYPE_BOOKMARK, this._untitledTagId);
+ // Create a empty-named folder.
+ this._untitledFolderId = addBookmark(null, bs.TYPE_FOLDER, bs.toolbarFolder, null, null, "");
+ // Create a titled tag.
+ this._titledTagId = addBookmark(null, bs.TYPE_FOLDER, bs.tagsFolder, null, null, "titledTag");
+ // Insert a bookmark in the tag, otherwise it will be removed.
+ addBookmark(placeId, bs.TYPE_BOOKMARK, this._titledTagId);
+ // Create a titled folder.
+ this._titledFolderId = addBookmark(null, bs.TYPE_FOLDER, bs.toolbarFolder, null, null, "titledFolder");
+ },
+
+ check: function() {
+ // Check that valid bookmark is still there
+ let stmt = mDBConn.createStatement(
+ "SELECT title FROM moz_bookmarks WHERE id = :id"
+ );
+ stmt.params["id"] = this._untitledTagId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.title, "(notitle)");
+ stmt.reset();
+ stmt.params["id"] = this._untitledFolderId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.title, "");
+ stmt.reset();
+ stmt.params["id"] = this._titledTagId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.title, "titledTag");
+ stmt.reset();
+ stmt.params["id"] = this._titledFolderId;
+ do_check_true(stmt.executeStep());
+ do_check_eq(stmt.row.title, "titledFolder");
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "E.1",
+ desc: "Remove orphan icons",
+
+ _placeId: null,
+
+ setup: function() {
+ // Insert favicon entries
+ let stmt = mDBConn.createStatement("INSERT INTO moz_favicons (id, url) VALUES(:favicon_id, :url)");
+ stmt.params["favicon_id"] = 1;
+ stmt.params["url"] = "http://www1.mozilla.org/favicon.ico";
+ stmt.execute();
+ stmt.reset();
+ stmt.params["favicon_id"] = 2;
+ stmt.params["url"] = "http://www2.mozilla.org/favicon.ico";
+ stmt.execute();
+ stmt.finalize();
+ // Insert a place using the existing favicon entry
+ this._placeId = addPlace("http://www.mozilla.org", 1);
+ },
+
+ check: function() {
+ // Check that used icon is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_favicons WHERE id = :favicon_id");
+ stmt.params["favicon_id"] = 1;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that unused icon has been removed
+ stmt.params["favicon_id"] = 2;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "F.1",
+ desc: "Remove orphan visits",
+
+ _placeId: null,
+ _invalidPlaceId: 1337,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add a valid visit and an invalid one
+ stmt = mDBConn.createStatement("INSERT INTO moz_historyvisits(place_id) VALUES (:place_id)");
+ stmt.params["place_id"] = this._placeId;
+ stmt.execute();
+ stmt.reset();
+ stmt.params["place_id"] = this._invalidPlaceId;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that valid visit is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_historyvisits WHERE place_id = :place_id");
+ stmt.params["place_id"] = this._placeId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that invalid visit has been removed
+ stmt.params["place_id"] = this._invalidPlaceId;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "G.1",
+ desc: "Remove orphan input history",
+
+ _placeId: null,
+ _invalidPlaceId: 1337,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Add input history entries
+ let stmt = mDBConn.createStatement("INSERT INTO moz_inputhistory (place_id, input) VALUES (:place_id, :input)");
+ stmt.params["place_id"] = this._placeId;
+ stmt.params["input"] = "moz";
+ stmt.execute();
+ stmt.reset();
+ stmt.params["place_id"] = this._invalidPlaceId;
+ stmt.params["input"] = "moz";
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that inputhistory on valid place is still there
+ let stmt = mDBConn.createStatement("SELECT place_id FROM moz_inputhistory WHERE place_id = :place_id");
+ stmt.params["place_id"] = this._placeId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ // Check that inputhistory on invalid place has gone
+ stmt.params["place_id"] = this._invalidPlaceId;
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "H.1",
+ desc: "Remove item annos with an invalid attribute",
+
+ _usedItemAttribute: "usedItem",
+ _bookmarkId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Insert a bookmark
+ this._bookmarkId = addBookmark(this._placeId);
+ // Add a used attribute.
+ let stmt = mDBConn.createStatement("INSERT INTO moz_anno_attributes (name) VALUES (:anno)");
+ stmt.params['anno'] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement("INSERT INTO moz_items_annos (item_id, anno_attribute_id) VALUES(:item_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params['item_id'] = this._bookmarkId;
+ stmt.params['anno'] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.finalize();
+ // Add an annotation with a nonexistent attribute
+ stmt = mDBConn.createStatement("INSERT INTO moz_items_annos (item_id, anno_attribute_id) VALUES(:item_id, 1337)");
+ stmt.params['item_id'] = this._bookmarkId;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that used attribute is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_anno_attributes WHERE name = :anno");
+ stmt.params['anno'] = this._usedItemAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // check that annotation with valid attribute is still there
+ stmt = mDBConn.createStatement("SELECT id FROM moz_items_annos WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)");
+ stmt.params['anno'] = this._usedItemAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // Check that annotation with bogus attribute has been removed
+ stmt = mDBConn.createStatement("SELECT id FROM moz_items_annos WHERE anno_attribute_id = 1337");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "H.2",
+ desc: "Remove orphan item annotations",
+
+ _usedItemAttribute: "usedItem",
+ _bookmarkId: null,
+ _invalidBookmarkId: 8888,
+ _placeId: null,
+
+ setup: function() {
+ // Add a place to ensure place_id = 1 is valid
+ this._placeId = addPlace();
+ // Insert a bookmark
+ this._bookmarkId = addBookmark(this._placeId);
+ // Add a used attribute.
+ stmt = mDBConn.createStatement("INSERT INTO moz_anno_attributes (name) VALUES (:anno)");
+ stmt.params['anno'] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.finalize();
+ stmt = mDBConn.createStatement("INSERT INTO moz_items_annos (item_id, anno_attribute_id) VALUES (:item_id, (SELECT id FROM moz_anno_attributes WHERE name = :anno))");
+ stmt.params["item_id"] = this._bookmarkId;
+ stmt.params["anno"] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.reset();
+ // Add an annotation to a nonexistent item
+ stmt.params["item_id"] = this._invalidBookmarkId;
+ stmt.params["anno"] = this._usedItemAttribute;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that used attribute is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_anno_attributes WHERE name = :anno");
+ stmt.params['anno'] = this._usedItemAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // check that annotation with valid attribute is still there
+ stmt = mDBConn.createStatement("SELECT id FROM moz_items_annos WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes WHERE name = :anno)");
+ stmt.params['anno'] = this._usedItemAttribute;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // Check that an annotation to a nonexistent page has been removed
+ stmt = mDBConn.createStatement("SELECT id FROM moz_items_annos WHERE item_id = 8888");
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "I.1",
+ desc: "Remove unused keywords",
+
+ _bookmarkId: null,
+ _placeId: null,
+
+ setup: function() {
+ // Insert 2 keywords
+ let stmt = mDBConn.createStatement("INSERT INTO moz_keywords (id, keyword, place_id) VALUES(:id, :keyword, :place_id)");
+ stmt.params["id"] = 1;
+ stmt.params["keyword"] = "unused";
+ stmt.params["place_id"] = 100;
+ stmt.execute();
+ stmt.finalize();
+ },
+
+ check: function() {
+ // Check that "used" keyword is still there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_keywords WHERE keyword = :keyword");
+ // Check that "unused" keyword has gone
+ stmt.params["keyword"] = "unused";
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.1",
+ desc: "Fix wrong favicon ids",
+
+ _validIconPlaceId: null,
+ _invalidIconPlaceId: null,
+
+ setup: function() {
+ // Insert a favicon entry
+ let stmt = mDBConn.createStatement("INSERT INTO moz_favicons (id, url) VALUES(1, :url)");
+ stmt.params["url"] = "http://www.mozilla.org/favicon.ico";
+ stmt.execute();
+ stmt.finalize();
+ // Insert a place using the existing favicon entry
+ this._validIconPlaceId = addPlace("http://www1.mozilla.org", 1);
+
+ // Insert a place using a nonexistent favicon entry
+ this._invalidIconPlaceId = addPlace("http://www2.mozilla.org", 1337);
+ },
+
+ check: function() {
+ // Check that bogus favicon is not there
+ let stmt = mDBConn.createStatement("SELECT id FROM moz_places WHERE favicon_id = :favicon_id");
+ stmt.params["favicon_id"] = 1337;
+ do_check_false(stmt.executeStep());
+ stmt.reset();
+ // Check that valid favicon is still there
+ stmt.params["favicon_id"] = 1;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ // Check that place entries are there
+ stmt = mDBConn.createStatement("SELECT id FROM moz_places WHERE id = :place_id");
+ stmt.params["place_id"] = this._validIconPlaceId;
+ do_check_true(stmt.executeStep());
+ stmt.reset();
+ stmt.params["place_id"] = this._invalidIconPlaceId;
+ do_check_true(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.2",
+ desc: "Recalculate visit_count and last_visit_date",
+
+ setup: function* () {
+ function setVisitCount(aURL, aValue) {
+ let stmt = mDBConn.createStatement(
+ `UPDATE moz_places SET visit_count = :count WHERE url_hash = hash(:url)
+ AND url = :url`
+ );
+ stmt.params.count = aValue;
+ stmt.params.url = aURL;
+ stmt.execute();
+ stmt.finalize();
+ }
+ function setLastVisitDate(aURL, aValue) {
+ let stmt = mDBConn.createStatement(
+ `UPDATE moz_places SET last_visit_date = :date WHERE url_hash = hash(:url)
+ AND url = :url`
+ );
+ stmt.params.date = aValue;
+ stmt.params.url = aURL;
+ stmt.execute();
+ stmt.finalize();
+ }
+
+ let now = Date.now() * 1000;
+ // Add a page with 1 visit.
+ let url = "http://1.moz.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ // Add a page with 1 visit and set wrong visit_count.
+ url = "http://2.moz.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ setVisitCount(url, 10);
+ // Add a page with 1 visit and set wrong last_visit_date.
+ url = "http://3.moz.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ setLastVisitDate(url, now++);
+ // Add a page with 1 visit and set wrong stats.
+ url = "http://4.moz.org/";
+ yield PlacesTestUtils.addVisits({ uri: uri(url), visitDate: now++ });
+ setVisitCount(url, 10);
+ setLastVisitDate(url, now++);
+
+ // Add a page without visits.
+ url = "http://5.moz.org/";
+ addPlace(url);
+ // Add a page without visits and set wrong visit_count.
+ url = "http://6.moz.org/";
+ addPlace(url);
+ setVisitCount(url, 10);
+ // Add a page without visits and set wrong last_visit_date.
+ url = "http://7.moz.org/";
+ addPlace(url);
+ setLastVisitDate(url, now++);
+ // Add a page without visits and set wrong stats.
+ url = "http://8.moz.org/";
+ addPlace(url);
+ setVisitCount(url, 10);
+ setLastVisitDate(url, now++);
+ },
+
+ check: function() {
+ let stmt = mDBConn.createStatement(
+ `SELECT h.id FROM moz_places h
+ JOIN moz_historyvisits v ON v.place_id = h.id AND visit_type NOT IN (0,4,7,8,9)
+ GROUP BY h.id HAVING h.visit_count <> count(*)
+ UNION ALL
+ SELECT h.id FROM moz_places h
+ JOIN moz_historyvisits v ON v.place_id = h.id
+ GROUP BY h.id HAVING h.last_visit_date <> MAX(v.visit_date)`
+ );
+ do_check_false(stmt.executeStep());
+ stmt.finalize();
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.3",
+ desc: "recalculate hidden for redirects.",
+
+ *setup() {
+ yield PlacesTestUtils.addVisits([
+ { uri: NetUtil.newURI("http://l3.moz.org/"),
+ transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://l3.moz.org/redirecting/"),
+ transition: TRANSITION_TYPED },
+ { uri: NetUtil.newURI("http://l3.moz.org/redirecting2/"),
+ transition: TRANSITION_REDIRECT_TEMPORARY,
+ referrer: NetUtil.newURI("http://l3.moz.org/redirecting/") },
+ { uri: NetUtil.newURI("http://l3.moz.org/target/"),
+ transition: TRANSITION_REDIRECT_PERMANENT,
+ referrer: NetUtil.newURI("http://l3.moz.org/redirecting2/") },
+ ]);
+ },
+
+ check: function () {
+ return new Promise(resolve => {
+ let stmt = mDBConn.createAsyncStatement(
+ "SELECT h.url FROM moz_places h WHERE h.hidden = 1"
+ );
+ stmt.executeAsync({
+ _count: 0,
+ handleResult: function(aResultSet) {
+ for (let row; (row = aResultSet.getNextRow());) {
+ let url = row.getResultByIndex(0);
+ do_check_true(/redirecting/.test(url));
+ this._count++;
+ }
+ },
+ handleError: function(aError) {
+ },
+ handleCompletion: function(aReason) {
+ dump_table("moz_places");
+ dump_table("moz_historyvisits");
+ do_check_eq(aReason, Ci.mozIStorageStatementCallback.REASON_FINISHED);
+ do_check_eq(this._count, 2);
+ resolve();
+ }
+ });
+ stmt.finalize();
+ });
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.4",
+ desc: "recalculate foreign_count.",
+
+ *setup() {
+ this._pageGuid = (yield PlacesUtils.history.insert({ url: "http://l4.moz.org/",
+ visits: [{ date: new Date() }] })).guid;
+ yield PlacesUtils.bookmarks.insert({ url: "http://l4.moz.org/",
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid});
+ yield PlacesUtils.keywords.insert({ url: "http://l4.moz.org/", keyword: "kw" });
+ Assert.equal((yield this._getForeignCount()), 2);
+ },
+
+ *_getForeignCount() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(`SELECT foreign_count FROM moz_places
+ WHERE guid = :guid`, { guid: this._pageGuid });
+ return rows[0].getResultByName("foreign_count");
+ },
+
+ *check() {
+ Assert.equal((yield this._getForeignCount()), 2);
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "L.5",
+ desc: "recalculate hashes when missing.",
+
+ *setup() {
+ this._pageGuid = (yield PlacesUtils.history.insert({ url: "http://l5.moz.org/",
+ visits: [{ date: new Date() }] })).guid;
+ Assert.ok((yield this._getHash()) > 0);
+ yield PlacesUtils.withConnectionWrapper("change url hash", Task.async(function* (db) {
+ yield db.execute(`UPDATE moz_places SET url_hash = 0`);
+ }));
+ Assert.equal((yield this._getHash()), 0);
+ },
+
+ *_getHash() {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.execute(`SELECT url_hash FROM moz_places
+ WHERE guid = :guid`, { guid: this._pageGuid });
+ return rows[0].getResultByName("url_hash");
+ },
+
+ *check() {
+ Assert.ok((yield this._getHash()) > 0);
+ }
+});
+
+// ------------------------------------------------------------------------------
+
+tests.push({
+ name: "Z",
+ desc: "Sanity: Preventive maintenance does not touch valid items",
+
+ _uri1: uri("http://www1.mozilla.org"),
+ _uri2: uri("http://www2.mozilla.org"),
+ _folderId: null,
+ _bookmarkId: null,
+ _separatorId: null,
+
+ setup: function* () {
+ // use valid api calls to create a bunch of items
+ yield PlacesTestUtils.addVisits([
+ { uri: this._uri1 },
+ { uri: this._uri2 },
+ ]);
+
+ this._folderId = bs.createFolder(bs.toolbarFolder, "testfolder",
+ bs.DEFAULT_INDEX);
+ do_check_true(this._folderId > 0);
+ this._bookmarkId = bs.insertBookmark(this._folderId, this._uri1,
+ bs.DEFAULT_INDEX, "testbookmark");
+ do_check_true(this._bookmarkId > 0);
+ this._separatorId = bs.insertSeparator(bs.unfiledBookmarksFolder,
+ bs.DEFAULT_INDEX);
+ do_check_true(this._separatorId > 0);
+ ts.tagURI(this._uri1, ["testtag"]);
+ fs.setAndFetchFaviconForPage(this._uri2, SMALLPNG_DATA_URI, false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ yield PlacesUtils.keywords.insert({ url: this._uri1.spec, keyword: "testkeyword" });
+ as.setPageAnnotation(this._uri2, "anno", "anno", 0, as.EXPIRE_NEVER);
+ as.setItemAnnotation(this._bookmarkId, "anno", "anno", 0, as.EXPIRE_NEVER);
+ },
+
+ check: Task.async(function* () {
+ // Check that all items are correct
+ let isVisited = yield promiseIsURIVisited(this._uri1);
+ do_check_true(isVisited);
+ isVisited = yield promiseIsURIVisited(this._uri2);
+ do_check_true(isVisited);
+
+ do_check_eq(bs.getBookmarkURI(this._bookmarkId).spec, this._uri1.spec);
+ do_check_eq(bs.getItemIndex(this._folderId), 0);
+ do_check_eq(bs.getItemType(this._folderId), bs.TYPE_FOLDER);
+ do_check_eq(bs.getItemType(this._separatorId), bs.TYPE_SEPARATOR);
+
+ do_check_eq(ts.getTagsForURI(this._uri1).length, 1);
+ do_check_eq((yield PlacesUtils.keywords.fetch({ url: this._uri1.spec })).keyword, "testkeyword");
+ do_check_eq(as.getPageAnnotation(this._uri2, "anno"), "anno");
+ do_check_eq(as.getItemAnnotation(this._bookmarkId, "anno"), "anno");
+
+ yield new Promise(resolve => {
+ fs.getFaviconURLForPage(this._uri2, aFaviconURI => {
+ do_check_true(aFaviconURI.equals(SMALLPNG_DATA_URI));
+ resolve();
+ });
+ });
+ })
+});
+
+// ------------------------------------------------------------------------------
+
+add_task(function* test_preventive_maintenance()
+{
+ // Get current bookmarks max ID for cleanup
+ let stmt = mDBConn.createStatement("SELECT MAX(id) FROM moz_bookmarks");
+ stmt.executeStep();
+ defaultBookmarksMaxId = stmt.getInt32(0);
+ stmt.finalize();
+ do_check_true(defaultBookmarksMaxId > 0);
+
+ for (let test of tests) {
+ dump("\nExecuting test: " + test.name + "\n" + "*** " + test.desc + "\n");
+ yield test.setup();
+
+ let promiseMaintenanceFinished =
+ promiseTopicObserved(FINISHED_MAINTENANCE_NOTIFICATION_TOPIC);
+ Services.prefs.clearUserPref("places.database.lastMaintenance");
+ let callbackInvoked = false;
+ PlacesDBUtils.maintenanceOnIdle(() => callbackInvoked = true);
+ yield promiseMaintenanceFinished;
+ do_check_true(callbackInvoked);
+
+ // Check the lastMaintenance time has been saved.
+ do_check_neq(Services.prefs.getIntPref("places.database.lastMaintenance"), null);
+
+ yield test.check();
+
+ cleanDatabase();
+ }
+
+ // Sanity check: all roots should be intact
+ do_check_eq(bs.getFolderIdForItem(bs.placesRoot), 0);
+ do_check_eq(bs.getFolderIdForItem(bs.bookmarksMenuFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.tagsFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.unfiledBookmarksFolder), bs.placesRoot);
+ do_check_eq(bs.getFolderIdForItem(bs.toolbarFolder), bs.placesRoot);
+});
diff --git a/toolkit/components/places/tests/unit/test_preventive_maintenance_checkAndFixDatabase.js b/toolkit/components/places/tests/unit/test_preventive_maintenance_checkAndFixDatabase.js
new file mode 100644
index 000000000..a8acb4be0
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_preventive_maintenance_checkAndFixDatabase.js
@@ -0,0 +1,50 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Test preventive maintenance checkAndFixDatabase.
+ */
+
+// Include PlacesDBUtils module.
+Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
+
+function run_test() {
+ do_test_pending();
+ PlacesDBUtils.checkAndFixDatabase(function(aLog) {
+ let sections = [];
+ let positives = [];
+ let negatives = [];
+ let infos = [];
+
+ aLog.forEach(function (aMsg) {
+ print (aMsg);
+ switch (aMsg.substr(0, 1)) {
+ case "+":
+ positives.push(aMsg);
+ break;
+ case "-":
+ negatives.push(aMsg);
+ break;
+ case ">":
+ sections.push(aMsg);
+ break;
+ default:
+ infos.push(aMsg);
+ }
+ });
+
+ print("Check that we have run all sections.");
+ do_check_eq(sections.length, 5);
+ print("Check that we have no negatives.");
+ do_check_false(!!negatives.length);
+ print("Check that we have positives.");
+ do_check_true(!!positives.length);
+ print("Check that we have info.");
+ do_check_true(!!infos.length);
+
+ do_test_finished();
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_preventive_maintenance_runTasks.js b/toolkit/components/places/tests/unit/test_preventive_maintenance_runTasks.js
new file mode 100644
index 000000000..ebe308f03
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_preventive_maintenance_runTasks.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Test preventive maintenance runTasks.
+ */
+
+// Include PlacesDBUtils module.
+Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
+
+function run_test() {
+ do_test_pending();
+ PlacesDBUtils.runTasks([PlacesDBUtils.reindex], function(aLog) {
+ let sections = [];
+ let positives = [];
+ let negatives = [];
+ let infos = [];
+
+ aLog.forEach(function (aMsg) {
+ print (aMsg);
+ switch (aMsg.substr(0, 1)) {
+ case "+":
+ positives.push(aMsg);
+ break;
+ case "-":
+ negatives.push(aMsg);
+ break;
+ case ">":
+ sections.push(aMsg);
+ break;
+ default:
+ infos.push(aMsg);
+ }
+ });
+
+ print("Check that we have run all sections.");
+ do_check_eq(sections.length, 1);
+ print("Check that we have no negatives.");
+ do_check_false(!!negatives.length);
+ print("Check that we have positives.");
+ do_check_true(!!positives.length);
+
+ do_test_finished();
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js b/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js
new file mode 100644
index 000000000..0719a0cd4
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_promiseBookmarksTree.js
@@ -0,0 +1,256 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function* check_has_child(aParentGuid, aChildGuid) {
+ let parentTree = yield PlacesUtils.promiseBookmarksTree(aParentGuid);
+ do_check_true("children" in parentTree);
+ do_check_true(parentTree.children.find( e => e.guid == aChildGuid ) != null);
+}
+
+function* compareToNode(aItem, aNode, aIsRootItem, aExcludedGuids = []) {
+ // itemId==-1 indicates a non-bookmark node, which is unexpected.
+ do_check_neq(aNode.itemId, -1);
+
+ function check_unset(...aProps) {
+ aProps.forEach( p => { do_check_false(p in aItem); } );
+ }
+ function strict_eq_check(v1, v2) {
+ dump("v1: " + v1 + " v2: " + v2 + "\n");
+ do_check_eq(typeof v1, typeof v2);
+ do_check_eq(v1, v2);
+ }
+ function compare_prop(aItemProp, aNodeProp = aItemProp, aOptional = false) {
+ if (aOptional && aNode[aNodeProp] === null)
+ check_unset(aItemProp);
+ else
+ strict_eq_check(aItem[aItemProp], aNode[aNodeProp]);
+ }
+ function compare_prop_to_value(aItemProp, aValue, aOptional = true) {
+ if (aOptional && aValue === null)
+ check_unset(aItemProp);
+ else
+ strict_eq_check(aItem[aItemProp], aValue);
+ }
+
+ // Bug 1013053 - bookmarkIndex is unavailable for the query's root
+ if (aNode.bookmarkIndex == -1) {
+ compare_prop_to_value("index",
+ PlacesUtils.bookmarks.getItemIndex(aNode.itemId),
+ false);
+ }
+ else {
+ compare_prop("index", "bookmarkIndex");
+ }
+
+ compare_prop("dateAdded");
+ compare_prop("lastModified");
+
+ if (aIsRootItem && aNode.itemId != PlacesUtils.placesRootId) {
+ do_check_true("parentGuid" in aItem);
+ yield check_has_child(aItem.parentGuid, aItem.guid)
+ }
+ else {
+ check_unset("parentGuid");
+ }
+
+ let expectedAnnos = PlacesUtils.getAnnotationsForItem(aItem.id);
+ if (expectedAnnos.length > 0) {
+ let annosToString = annos => {
+ return annos.map(a => a.name + ":" + a.value).sort().join(",");
+ };
+ do_check_true(Array.isArray(aItem.annos))
+ do_check_eq(annosToString(aItem.annos), annosToString(expectedAnnos));
+ }
+ else {
+ check_unset("annos");
+ }
+ const BOOKMARK_ONLY_PROPS = ["uri", "iconuri", "tags", "charset", "keyword"];
+ const FOLDER_ONLY_PROPS = ["children", "root"];
+
+ let nodesCount = 1;
+
+ switch (aNode.type) {
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER:
+ do_check_eq(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER);
+ compare_prop("title", "title", true);
+ check_unset(...BOOKMARK_ONLY_PROPS);
+
+ let expectedChildrenNodes = [];
+
+ PlacesUtils.asContainer(aNode);
+ if (!aNode.containerOpen)
+ aNode.containerOpen = true;
+
+ for (let i = 0; i < aNode.childCount; i++) {
+ let childNode = aNode.getChild(i);
+ if (childNode.itemId == PlacesUtils.tagsFolderId ||
+ aExcludedGuids.includes(childNode.bookmarkGuid)) {
+ continue;
+ }
+ expectedChildrenNodes.push(childNode);
+ }
+
+ if (expectedChildrenNodes.length > 0) {
+ do_check_true(Array.isArray(aItem.children));
+ do_check_eq(aItem.children.length, expectedChildrenNodes.length);
+ for (let i = 0; i < aItem.children.length; i++) {
+ nodesCount +=
+ yield compareToNode(aItem.children[i], expectedChildrenNodes[i],
+ false, aExcludedGuids);
+ }
+ }
+ else {
+ check_unset("children");
+ }
+
+ switch (aItem.id) {
+ case PlacesUtils.placesRootId:
+ compare_prop_to_value("root", "placesRoot");
+ break;
+ case PlacesUtils.bookmarksMenuFolderId:
+ compare_prop_to_value("root", "bookmarksMenuFolder");
+ break;
+ case PlacesUtils.toolbarFolderId:
+ compare_prop_to_value("root", "toolbarFolder");
+ break;
+ case PlacesUtils.unfiledBookmarksFolderId:
+ compare_prop_to_value("root", "unfiledBookmarksFolder");
+ break;
+ case PlacesUtils.mobileFolderId:
+ compare_prop_to_value("root", "mobileFolder");
+ break;
+ default:
+ check_unset("root");
+ }
+ break;
+ case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR:
+ do_check_eq(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR);
+ check_unset(...BOOKMARK_ONLY_PROPS, ...FOLDER_ONLY_PROPS);
+ break;
+ default:
+ do_check_eq(aItem.type, PlacesUtils.TYPE_X_MOZ_PLACE);
+ compare_prop("uri");
+ // node.tags's format is "a, b" whilst promiseBoookmarksTree is "a,b"
+ if (aNode.tags === null)
+ check_unset("tags");
+ else
+ compare_prop_to_value("tags", aNode.tags.replace(/, /g, ","), false);
+
+ if (aNode.icon) {
+ let nodeIconData = aNode.icon.replace("moz-anno:favicon:", "");
+ compare_prop_to_value("iconuri", nodeIconData);
+ }
+ else {
+ check_unset(aItem.iconuri);
+ }
+
+ check_unset(...FOLDER_ONLY_PROPS);
+
+ let itemURI = uri(aNode.uri);
+ compare_prop_to_value("charset",
+ yield PlacesUtils.getCharsetForURI(itemURI));
+
+ let entry = yield PlacesUtils.keywords.fetch({ url: aNode.uri });
+ compare_prop_to_value("keyword", entry ? entry.keyword : null);
+
+ if ("title" in aItem)
+ compare_prop("title");
+ else
+ do_check_null(aNode.title);
+ }
+
+ if (aIsRootItem)
+ do_check_eq(aItem.itemsCount, nodesCount);
+
+ return nodesCount;
+}
+
+var itemsCount = 0;
+function* new_bookmark(aInfo) {
+ ++itemsCount;
+ if (!("url" in aInfo))
+ aInfo.url = uri("http://test.item." + itemsCount);
+
+ if (!("title" in aInfo))
+ aInfo.title = "Test Item (bookmark) " + itemsCount;
+
+ yield PlacesTransactions.NewBookmark(aInfo).transact();
+}
+
+function* new_folder(aInfo) {
+ if (!("title" in aInfo))
+ aInfo.title = "Test Item (folder) " + itemsCount;
+ return yield PlacesTransactions.NewFolder(aInfo).transact();
+}
+
+// Walks a result nodes tree and test promiseBookmarksTree for each node.
+// DO NOT COPY THIS LOGIC: It is done here to accomplish a more comprehensive
+// test of the API (the entire hierarchy data is available in the very test).
+function* test_promiseBookmarksTreeForEachNode(aNode, aOptions, aExcludedGuids) {
+ do_check_true(aNode.bookmarkGuid && aNode.bookmarkGuid.length > 0);
+ let item = yield PlacesUtils.promiseBookmarksTree(aNode.bookmarkGuid, aOptions);
+ yield* compareToNode(item, aNode, true, aExcludedGuids);
+
+ for (let i = 0; i < aNode.childCount; i++) {
+ let child = aNode.getChild(i);
+ if (child.itemId != PlacesUtils.tagsFolderId)
+ yield test_promiseBookmarksTreeForEachNode(child,
+ { includeItemIds: true },
+ aExcludedGuids);
+ }
+ return item;
+}
+
+function* test_promiseBookmarksTreeAgainstResult(aItemGuid = "",
+ aOptions = { includeItemIds: true },
+ aExcludedGuids) {
+ let itemId = aItemGuid ?
+ yield PlacesUtils.promiseItemId(aItemGuid) : PlacesUtils.placesRootId;
+ let node = PlacesUtils.getFolderContents(itemId).root;
+ return yield test_promiseBookmarksTreeForEachNode(node, aOptions, aExcludedGuids);
+}
+
+add_task(function* () {
+ // Add some bookmarks to cover various use cases.
+ yield new_bookmark({ parentGuid: PlacesUtils.bookmarks.toolbarGuid });
+ yield new_folder({ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ annotations: [{ name: "TestAnnoA", value: "TestVal" },
+ { name: "TestAnnoB", value: 0 }]});
+ let sepInfo = { parentGuid: PlacesUtils.bookmarks.menuGuid };
+ yield PlacesTransactions.NewSeparator(sepInfo).transact();
+ let folderGuid = yield new_folder({ parentGuid: PlacesUtils.bookmarks.menuGuid });
+ yield new_bookmark({ title: null,
+ parentGuid: folderGuid,
+ keyword: "test_keyword",
+ tags: ["TestTagA", "TestTagB"],
+ annotations: [{ name: "TestAnnoA", value: "TestVal2"}]});
+ let urlWithCharsetAndFavicon = uri("http://charset.and.favicon");
+ yield new_bookmark({ parentGuid: folderGuid, url: urlWithCharsetAndFavicon });
+ yield PlacesUtils.setCharsetForURI(urlWithCharsetAndFavicon, "UTF-8");
+ yield promiseSetIconForPage(urlWithCharsetAndFavicon, SMALLPNG_DATA_URI);
+ // Test the default places root without specifying it.
+ yield test_promiseBookmarksTreeAgainstResult();
+
+ // Do specify it
+ yield test_promiseBookmarksTreeAgainstResult(PlacesUtils.bookmarks.rootGuid);
+
+ // Exclude the bookmarks menu.
+ // The calllback should be four times - once for the toolbar, once for
+ // the bookmark we inserted under, and once for the menu (and not
+ // at all for any of its descendants) and once for the unsorted bookmarks
+ // folder. However, promiseBookmarksTree is called multiple times, so
+ // rather than counting the calls, we count the number of unique items
+ // passed in.
+ let guidsPassedToExcludeCallback = new Set();
+ let placesRootWithoutTheMenu =
+ yield test_promiseBookmarksTreeAgainstResult(PlacesUtils.bookmarks.rootGuid, {
+ excludeItemsCallback: aItem => {
+ guidsPassedToExcludeCallback.add(aItem.guid);
+ return aItem.root == "bookmarksMenuFolder";
+ },
+ includeItemIds: true
+ }, [PlacesUtils.bookmarks.menuGuid]);
+ do_check_eq(guidsPassedToExcludeCallback.size, 5);
+ do_check_eq(placesRootWithoutTheMenu.children.length, 3);
+});
diff --git a/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js b/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js
new file mode 100644
index 000000000..01fb3eef9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_resolveNullBookmarkTitles.js
@@ -0,0 +1,49 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function run_test() {
+ run_next_test();
+}
+
+add_test(function test_resolveNullBookmarkTitles() {
+ let uri1 = uri("http://foo.tld/");
+ let uri2 = uri("https://bar.tld/");
+
+ PlacesTestUtils.addVisits([
+ { uri: uri1, title: "foo title" },
+ { uri: uri2, title: "bar title" }
+ ]).then(function () {
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarksMenuFolderId,
+ uri1,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ null);
+ PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarksMenuFolderId,
+ uri2,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ null);
+
+ PlacesUtils.tagging.tagURI(uri1, ["tag 1"]);
+ PlacesUtils.tagging.tagURI(uri2, ["tag 2"]);
+
+ let options = PlacesUtils.history.getNewQueryOptions();
+ options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS;
+ options.resultType = options.RESULTS_AS_TAG_CONTENTS;
+
+ let query = PlacesUtils.history.getNewQuery();
+ // if we don't set a tag folder, RESULTS_AS_TAG_CONTENTS will return all
+ // tagged URIs
+ let root = PlacesUtils.history.executeQuery(query, options).root;
+ root.containerOpen = true;
+ do_check_eq(root.childCount, 2);
+ // actually RESULTS_AS_TAG_CONTENTS return results ordered by place_id DESC
+ // so they are reversed
+ do_check_eq(root.getChild(0).title, "bar title");
+ do_check_eq(root.getChild(1).title, "foo title");
+ root.containerOpen = false;
+
+ run_next_test();
+ });
+});
diff --git a/toolkit/components/places/tests/unit/test_result_sort.js b/toolkit/components/places/tests/unit/test_result_sort.js
new file mode 100644
index 000000000..35405ac50
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_result_sort.js
@@ -0,0 +1,139 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const NHQO = Ci.nsINavHistoryQueryOptions;
+
+/**
+ * Waits for onItemVisited notifications to be received.
+ */
+function promiseOnItemVisited() {
+ let defer = Promise.defer();
+ let bookmarksObserver = {
+ __proto__: NavBookmarkObserver.prototype,
+ onItemVisited: function BO_onItemVisited() {
+ PlacesUtils.bookmarks.removeObserver(this);
+ // Enqueue to be sure that all onItemVisited notifications ran.
+ do_execute_soon(defer.resolve);
+ }
+ };
+ PlacesUtils.bookmarks.addObserver(bookmarksObserver, false);
+ return defer.promise;
+}
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test() {
+ let testFolder = PlacesUtils.bookmarks.createFolder(
+ PlacesUtils.bookmarks.placesRoot,
+ "Result-sort functionality tests root",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+
+ let uri1 = NetUtil.newURI("http://foo.tld/a");
+ let uri2 = NetUtil.newURI("http://foo.tld/b");
+
+ let id1 = PlacesUtils.bookmarks.insertBookmark(
+ testFolder, uri1, PlacesUtils.bookmarks.DEFAULT_INDEX, "b");
+ let id2 = PlacesUtils.bookmarks.insertBookmark(
+ testFolder, uri2, PlacesUtils.bookmarks.DEFAULT_INDEX, "a");
+ // url of id1, title of id2
+ let id3 = PlacesUtils.bookmarks.insertBookmark(
+ testFolder, uri1, PlacesUtils.bookmarks.DEFAULT_INDEX, "a");
+
+ // query with natural order
+ let result = PlacesUtils.getFolderContents(testFolder);
+ let root = result.root;
+
+ do_check_eq(root.childCount, 3);
+
+ function checkOrder(a, b, c) {
+ do_check_eq(root.getChild(0).itemId, a);
+ do_check_eq(root.getChild(1).itemId, b);
+ do_check_eq(root.getChild(2).itemId, c);
+ }
+
+ // natural order
+ do_print("Natural order");
+ checkOrder(id1, id2, id3);
+
+ // title: id3 should precede id2 since we fall-back to URI-based sorting
+ do_print("Sort by title asc");
+ result.sortingMode = NHQO.SORT_BY_TITLE_ASCENDING;
+ checkOrder(id3, id2, id1);
+
+ // In reverse
+ do_print("Sort by title desc");
+ result.sortingMode = NHQO.SORT_BY_TITLE_DESCENDING;
+ checkOrder(id1, id2, id3);
+
+ // uri sort: id1 should precede id3 since we fall-back to natural order
+ do_print("Sort by uri asc");
+ result.sortingMode = NHQO.SORT_BY_URI_ASCENDING;
+ checkOrder(id1, id3, id2);
+
+ // test live update
+ do_print("Change bookmark uri liveupdate");
+ PlacesUtils.bookmarks.changeBookmarkURI(id1, uri2);
+ checkOrder(id3, id1, id2);
+ PlacesUtils.bookmarks.changeBookmarkURI(id1, uri1);
+ checkOrder(id1, id3, id2);
+
+ // keyword sort
+ do_print("Sort by keyword asc");
+ result.sortingMode = NHQO.SORT_BY_KEYWORD_ASCENDING;
+ checkOrder(id3, id2, id1); // no keywords set - falling back to title sort
+ yield PlacesUtils.keywords.insert({ url: uri1.spec, keyword: "a" });
+ yield PlacesUtils.keywords.insert({ url: uri2.spec, keyword: "z" });
+ checkOrder(id3, id1, id2);
+
+ // XXXtodo: test history sortings (visit count, visit date)
+ // XXXtodo: test different item types once folderId and bookmarkId are merged.
+ // XXXtodo: test sortingAnnotation functionality with non-bookmark nodes
+
+ do_print("Sort by annotation desc");
+ PlacesUtils.annotations.setItemAnnotation(id1, "testAnno", "a", 0, 0);
+ PlacesUtils.annotations.setItemAnnotation(id3, "testAnno", "b", 0, 0);
+ result.sortingAnnotation = "testAnno";
+ result.sortingMode = NHQO.SORT_BY_ANNOTATION_DESCENDING;
+
+ // id1 precedes id2 per title-descending fallback
+ checkOrder(id3, id1, id2);
+
+ // XXXtodo: test dateAdded sort
+ // XXXtodo: test lastModified sort
+
+ // test live update
+ do_print("Annotation liveupdate");
+ PlacesUtils.annotations.setItemAnnotation(id1, "testAnno", "c", 0, 0);
+ checkOrder(id1, id3, id2);
+
+ // Add a visit, then check frecency ordering.
+
+ // When the bookmarks service gets onVisit, it asynchronously fetches all
+ // items for that visit, and then notifies onItemVisited. Thus we must
+ // explicitly wait for that.
+ let waitForVisited = promiseOnItemVisited();
+ yield PlacesTestUtils.addVisits({ uri: uri2, transition: TRANSITION_TYPED });
+ yield waitForVisited;
+
+ do_print("Sort by frecency desc");
+ result.sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING;
+ for (let i = 0; i < root.childCount; ++i) {
+ print(root.getChild(i).uri + " " + root.getChild(i).title);
+ }
+ // For id1 and id3, since they have same frecency and no visits, fallback
+ // to sort by the newest bookmark.
+ checkOrder(id2, id3, id1);
+ do_print("Sort by frecency asc");
+ result.sortingMode = NHQO.SORT_BY_FRECENCY_ASCENDING;
+ for (let i = 0; i < root.childCount; ++i) {
+ print(root.getChild(i).uri + " " + root.getChild(i).title);
+ }
+ checkOrder(id1, id3, id2);
+
+ root.containerOpen = false;
+});
diff --git a/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js b/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js
new file mode 100644
index 000000000..8e71ffd0d
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_resultsAsVisit_details.js
@@ -0,0 +1,85 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+const {bookmarks, history} = PlacesUtils
+
+add_task(function* test_addVisitCheckFields() {
+ let uri = NetUtil.newURI("http://test4.com/");
+ yield PlacesTestUtils.addVisits([
+ { uri },
+ { uri, referrer: uri },
+ { uri, transition: history.TRANSITION_TYPED },
+ ]);
+
+
+ let options = history.getNewQueryOptions();
+ let query = history.getNewQuery();
+
+ query.uri = uri;
+
+
+ // Check RESULTS_AS_VISIT node.
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ let root = history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ equal(root.childCount, 3);
+
+ let child = root.getChild(0);
+ equal(child.visitType, history.TRANSITION_LINK, "Visit type should be TRANSITION_LINK");
+ equal(child.visitId, 1, "Visit ID should be 1");
+ equal(child.fromVisitId, -1, "Should have no referrer visit ID");
+
+ child = root.getChild(1);
+ equal(child.visitType, history.TRANSITION_LINK, "Visit type should be TRANSITION_LINK");
+ equal(child.visitId, 2, "Visit ID should be 2");
+ equal(child.fromVisitId, 1, "First visit should be the referring visit");
+
+ child = root.getChild(2);
+ equal(child.visitType, history.TRANSITION_TYPED, "Visit type should be TRANSITION_TYPED");
+ equal(child.visitId, 3, "Visit ID should be 3");
+ equal(child.fromVisitId, -1, "Should have no referrer visit ID");
+
+ root.containerOpen = false;
+
+
+ // Check RESULTS_AS_URI node.
+ options.resultType = options.RESULTS_AS_URI;
+
+ root = history.executeQuery(query, options).root;
+ root.containerOpen = true;
+
+ equal(root.childCount, 1);
+
+ child = root.getChild(0);
+ equal(child.visitType, 0, "Visit type should be 0");
+ equal(child.visitId, -1, "Visit ID should be -1");
+ equal(child.fromVisitId, -1, "Referrer visit id should be -1");
+
+ root.containerOpen = false;
+
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* test_bookmarkFields() {
+ let folder = bookmarks.createFolder(bookmarks.placesRoot, "test folder", bookmarks.DEFAULT_INDEX);
+ bookmarks.insertBookmark(folder, uri("http://test4.com/"),
+ bookmarks.DEFAULT_INDEX, "test4 title");
+
+ let root = PlacesUtils.getFolderContents(folder).root;
+ equal(root.childCount, 1);
+
+ equal(root.visitType, 0, "Visit type should be 0");
+ equal(root.visitId, -1, "Visit ID should be -1");
+ equal(root.fromVisitId, -1, "Referrer visit id should be -1");
+
+ let child = root.getChild(0);
+ equal(child.visitType, 0, "Visit type should be 0");
+ equal(child.visitId, -1, "Visit ID should be -1");
+ equal(child.fromVisitId, -1, "Referrer visit id should be -1");
+
+ root.containerOpen = false;
+
+ yield bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/unit/test_sql_guid_functions.js b/toolkit/components/places/tests/unit/test_sql_guid_functions.js
new file mode 100644
index 000000000..41e6bab9e
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_sql_guid_functions.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests that the guid function generates a guid of the proper length,
+ * with no invalid characters.
+ */
+
+/**
+ * Checks all our invariants about our guids for a given result.
+ *
+ * @param aGuid
+ * The guid to check.
+ */
+function check_invariants(aGuid)
+{
+ do_print("Checking guid '" + aGuid + "'");
+
+ do_check_valid_places_guid(aGuid);
+}
+
+// Test Functions
+
+function test_guid_invariants()
+{
+ const kExpectedChars = 64;
+ const kAllowedChars =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
+ do_check_eq(kAllowedChars.length, kExpectedChars);
+ const kGuidLength = 12;
+
+ let checkedChars = [];
+ for (let i = 0; i < kGuidLength; i++) {
+ checkedChars[i] = {};
+ for (let j = 0; j < kAllowedChars; j++) {
+ checkedChars[i][kAllowedChars[j]] = false;
+ }
+ }
+
+ // We run this until we've seen every character that we expect to see in every
+ // position.
+ let seenChars = 0;
+ let stmt = DBConn().createStatement("SELECT GENERATE_GUID()");
+ while (seenChars != (kExpectedChars * kGuidLength)) {
+ do_check_true(stmt.executeStep());
+ let guid = stmt.getString(0);
+ check_invariants(guid);
+
+ for (let i = 0; i < guid.length; i++) {
+ let character = guid[i];
+ if (!checkedChars[i][character]) {
+ checkedChars[i][character] = true;
+ seenChars++;
+ }
+ }
+ stmt.reset();
+ }
+ stmt.finalize();
+
+ // One last reality check - make sure all of our characters were seen.
+ for (let i = 0; i < kGuidLength; i++) {
+ for (let j = 0; j < kAllowedChars; j++) {
+ do_check_true(checkedChars[i][kAllowedChars[j]]);
+ }
+ }
+
+ run_next_test();
+}
+
+function test_guid_on_background()
+{
+ // We should not assert if we execute this asynchronously.
+ let stmt = DBConn().createAsyncStatement("SELECT GENERATE_GUID()");
+ let checked = false;
+ stmt.executeAsync({
+ handleResult: function(aResult) {
+ try {
+ let row = aResult.getNextRow();
+ check_invariants(row.getResultByIndex(0));
+ do_check_eq(aResult.getNextRow(), null);
+ checked = true;
+ }
+ catch (e) {
+ do_throw(e);
+ }
+ },
+ handleCompletion: function(aReason) {
+ do_check_eq(aReason, Ci.mozIStorageStatementCallback.REASON_FINISHED);
+ do_check_true(checked);
+ run_next_test();
+ }
+ });
+ stmt.finalize();
+}
+
+// Test Runner
+
+[
+ test_guid_invariants,
+ test_guid_on_background,
+].forEach(add_test);
+
+function run_test()
+{
+ run_next_test();
+}
diff --git a/toolkit/components/places/tests/unit/test_svg_favicon.js b/toolkit/components/places/tests/unit/test_svg_favicon.js
new file mode 100644
index 000000000..cec40ddef
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_svg_favicon.js
@@ -0,0 +1,31 @@
+const PAGEURI = NetUtil.newURI("http://deliciousbacon.com/");
+
+add_task(function* () {
+ // First, add a history entry or else Places can't save a favicon.
+ yield PlacesTestUtils.addVisits({
+ uri: PAGEURI,
+ transition: TRANSITION_LINK,
+ visitDate: Date.now() * 1000
+ });
+
+ yield new Promise(resolve => {
+ function onSetComplete(aURI, aDataLen, aData, aMimeType) {
+ equal(aURI.spec, SMALLSVG_DATA_URI.spec, "setFavicon aURI check");
+ equal(aDataLen, 263, "setFavicon aDataLen check");
+ equal(aMimeType, "image/svg+xml", "setFavicon aMimeType check");
+ resolve();
+ }
+
+ PlacesUtils.favicons.setAndFetchFaviconForPage(PAGEURI, SMALLSVG_DATA_URI,
+ false,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ onSetComplete,
+ Services.scriptSecurityManager.getSystemPrincipal());
+ });
+
+ let data = yield PlacesUtils.promiseFaviconData(PAGEURI.spec);
+ equal(data.uri.spec, SMALLSVG_DATA_URI.spec, "getFavicon aURI check");
+ equal(data.dataLen, 263, "getFavicon aDataLen check");
+ equal(data.mimeType, "image/svg+xml", "getFavicon aMimeType check");
+});
+
diff --git a/toolkit/components/places/tests/unit/test_sync_utils.js b/toolkit/components/places/tests/unit/test_sync_utils.js
new file mode 100644
index 000000000..f8c7e6b58
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_sync_utils.js
@@ -0,0 +1,1150 @@
+Cu.import("resource://gre/modules/PlacesSyncUtils.jsm");
+Cu.import("resource://testing-common/httpd.js");
+Cu.importGlobalProperties(["crypto", "URLSearchParams"]);
+
+const DESCRIPTION_ANNO = "bookmarkProperties/description";
+const LOAD_IN_SIDEBAR_ANNO = "bookmarkProperties/loadInSidebar";
+const SYNC_PARENT_ANNO = "sync/parent";
+
+function makeGuid() {
+ return ChromeUtils.base64URLEncode(crypto.getRandomValues(new Uint8Array(9)), {
+ pad: false,
+ });
+}
+
+function makeLivemarkServer() {
+ let server = new HttpServer();
+ server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
+ server.start(-1);
+ return {
+ server,
+ get site() {
+ let { identity } = server;
+ let host = identity.primaryHost.includes(":") ?
+ `[${identity.primaryHost}]` : identity.primaryHost;
+ return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
+ },
+ stopServer() {
+ return new Promise(resolve => server.stop(resolve));
+ },
+ };
+}
+
+function shuffle(array) {
+ let results = [];
+ for (let i = 0; i < array.length; ++i) {
+ let randomIndex = Math.floor(Math.random() * (i + 1));
+ results[i] = results[randomIndex];
+ results[randomIndex] = array[i];
+ }
+ return results;
+}
+
+function compareAscending(a, b) {
+ if (a > b) {
+ return 1;
+ }
+ if (a < b) {
+ return -1;
+ }
+ return 0;
+}
+
+function assertTagForURLs(tag, urls, message) {
+ let taggedURLs = PlacesUtils.tagging.getURIsForTag(tag).map(uri => uri.spec);
+ deepEqual(taggedURLs.sort(compareAscending), urls.sort(compareAscending), message);
+}
+
+function assertURLHasTags(url, tags, message) {
+ let actualTags = PlacesUtils.tagging.getTagsForURI(uri(url));
+ deepEqual(actualTags.sort(compareAscending), tags, message);
+}
+
+var populateTree = Task.async(function* populate(parentGuid, ...items) {
+ let guids = {};
+
+ for (let index = 0; index < items.length; index++) {
+ let item = items[index];
+ let guid = makeGuid();
+
+ switch (item.kind) {
+ case "bookmark":
+ case "query":
+ yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: item.url,
+ title: item.title,
+ parentGuid, guid, index,
+ });
+ break;
+
+ case "separator":
+ yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ parentGuid, guid,
+ });
+ break;
+
+ case "folder":
+ yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: item.title,
+ parentGuid, guid,
+ });
+ if (item.children) {
+ Object.assign(guids, yield* populate(guid, ...item.children));
+ }
+ break;
+
+ default:
+ throw new Error(`Unsupported item type: ${item.type}`);
+ }
+
+ if (item.exclude) {
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ PlacesUtils.annotations.setItemAnnotation(
+ itemId, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, "Don't back this up", 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+ }
+
+ guids[item.title] = guid;
+ }
+
+ return guids;
+});
+
+var syncIdToId = Task.async(function* syncIdToId(syncId) {
+ let guid = yield PlacesSyncUtils.bookmarks.syncIdToGuid(syncId);
+ return PlacesUtils.promiseItemId(guid);
+});
+
+add_task(function* test_order() {
+ do_print("Insert some bookmarks");
+ let guids = yield populateTree(PlacesUtils.bookmarks.menuGuid, {
+ kind: "bookmark",
+ title: "childBmk",
+ url: "http://getfirefox.com",
+ }, {
+ kind: "bookmark",
+ title: "siblingBmk",
+ url: "http://getthunderbird.com",
+ }, {
+ kind: "folder",
+ title: "siblingFolder",
+ }, {
+ kind: "separator",
+ title: "siblingSep",
+ });
+
+ do_print("Reorder inserted bookmarks");
+ {
+ let order = [guids.siblingFolder, guids.siblingSep, guids.childBmk,
+ guids.siblingBmk];
+ yield PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, order);
+ let childSyncIds = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(PlacesUtils.bookmarks.menuGuid);
+ deepEqual(childSyncIds, order, "New bookmarks should be reordered according to array");
+ }
+
+ do_print("Reorder with unspecified children");
+ {
+ yield PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [
+ guids.siblingSep, guids.siblingBmk,
+ ]);
+ let childSyncIds = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(
+ PlacesUtils.bookmarks.menuGuid);
+ deepEqual(childSyncIds, [guids.siblingSep, guids.siblingBmk,
+ guids.siblingFolder, guids.childBmk],
+ "Unordered children should be moved to end");
+ }
+
+ do_print("Reorder with nonexistent children");
+ {
+ yield PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.menuGuid, [
+ guids.childBmk, makeGuid(), guids.siblingBmk, guids.siblingSep,
+ makeGuid(), guids.siblingFolder, makeGuid()]);
+ let childSyncIds = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(
+ PlacesUtils.bookmarks.menuGuid);
+ deepEqual(childSyncIds, [guids.childBmk, guids.siblingBmk, guids.siblingSep,
+ guids.siblingFolder], "Nonexistent children should be ignored");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_changeGuid_invalid() {
+ yield rejects(PlacesSyncUtils.bookmarks.changeGuid(makeGuid()),
+ "Should require a new GUID");
+ yield rejects(PlacesSyncUtils.bookmarks.changeGuid(makeGuid(), "!@#$"),
+ "Should reject invalid GUIDs");
+ yield rejects(PlacesSyncUtils.bookmarks.changeGuid(makeGuid(), makeGuid()),
+ "Should reject nonexistent item GUIDs");
+ yield rejects(
+ PlacesSyncUtils.bookmarks.changeGuid(PlacesUtils.bookmarks.menuGuid,
+ makeGuid()),
+ "Should reject roots");
+});
+
+add_task(function* test_changeGuid() {
+ let item = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "https://mozilla.org",
+ });
+ let id = yield PlacesUtils.promiseItemId(item.guid);
+
+ let newGuid = makeGuid();
+ let result = yield PlacesSyncUtils.bookmarks.changeGuid(item.guid, newGuid);
+ equal(result, newGuid, "Should return new GUID");
+
+ equal(yield PlacesUtils.promiseItemId(newGuid), id, "Should map ID to new GUID");
+ yield rejects(PlacesUtils.promiseItemId(item.guid), "Should not map ID to old GUID");
+ equal(yield PlacesUtils.promiseItemGuid(id), newGuid, "Should map new GUID to ID");
+});
+
+add_task(function* test_order_roots() {
+ let oldOrder = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(
+ PlacesUtils.bookmarks.rootGuid);
+ yield PlacesSyncUtils.bookmarks.order(PlacesUtils.bookmarks.rootGuid,
+ shuffle(oldOrder));
+ let newOrder = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(
+ PlacesUtils.bookmarks.rootGuid);
+ deepEqual(oldOrder, newOrder, "Should ignore attempts to reorder roots");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_update_tags() {
+ do_print("Insert item without tags");
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://mozilla.org",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ });
+
+ do_print("Add tags");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ tags: ["foo", "bar"],
+ });
+ deepEqual(updatedItem.tags, ["foo", "bar"], "Should return new tags");
+ assertURLHasTags("https://mozilla.org", ["bar", "foo"],
+ "Should set new tags for URL");
+ }
+
+ do_print("Add new tag, remove existing tag");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ tags: ["foo", "baz"],
+ });
+ deepEqual(updatedItem.tags, ["foo", "baz"], "Should return updated tags");
+ assertURLHasTags("https://mozilla.org", ["baz", "foo"],
+ "Should update tags for URL");
+ assertTagForURLs("bar", [], "Should remove existing tag");
+ }
+
+ do_print("Tags with whitespace");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ tags: [" leading", "trailing ", " baz ", " "],
+ });
+ deepEqual(updatedItem.tags, ["leading", "trailing", "baz"],
+ "Should return filtered tags");
+ assertURLHasTags("https://mozilla.org", ["baz", "leading", "trailing"],
+ "Should trim whitespace and filter blank tags");
+ }
+
+ do_print("Remove all tags");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ tags: null,
+ });
+ deepEqual(updatedItem.tags, [], "Should return empty tag array");
+ assertURLHasTags("https://mozilla.org", [],
+ "Should remove all existing tags");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_update_keyword() {
+ do_print("Insert item without keyword");
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ parentSyncId: "menu",
+ url: "https://mozilla.org",
+ syncId: makeGuid(),
+ });
+
+ do_print("Add item keyword");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ keyword: "moz",
+ });
+ equal(updatedItem.keyword, "moz", "Should return new keyword");
+ let entryByKeyword = yield PlacesUtils.keywords.fetch("moz");
+ equal(entryByKeyword.url.href, "https://mozilla.org/",
+ "Should set new keyword for URL");
+ let entryByURL = yield PlacesUtils.keywords.fetch({
+ url: "https://mozilla.org",
+ });
+ equal(entryByURL.keyword, "moz", "Looking up URL should return new keyword");
+ }
+
+ do_print("Change item keyword");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ keyword: "m",
+ });
+ equal(updatedItem.keyword, "m", "Should return updated keyword");
+ let newEntry = yield PlacesUtils.keywords.fetch("m");
+ equal(newEntry.url.href, "https://mozilla.org/", "Should update keyword for URL");
+ let oldEntry = yield PlacesUtils.keywords.fetch("moz");
+ ok(!oldEntry, "Should remove old keyword");
+ }
+
+ do_print("Remove existing keyword");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ keyword: null,
+ });
+ ok(!updatedItem.keyword,
+ "Should not include removed keyword in properties");
+ let entry = yield PlacesUtils.keywords.fetch({
+ url: "https://mozilla.org",
+ });
+ ok(!entry, "Should remove new keyword from URL");
+ }
+
+ do_print("Remove keyword for item without keyword");
+ {
+ yield PlacesSyncUtils.bookmarks.update({
+ syncId: item.syncId,
+ keyword: null,
+ });
+ let entry = yield PlacesUtils.keywords.fetch({
+ url: "https://mozilla.org",
+ });
+ ok(!entry,
+ "Removing keyword for URL without existing keyword should succeed");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_update_annos() {
+ let guids = yield populateTree(PlacesUtils.bookmarks.menuGuid, {
+ kind: "folder",
+ title: "folder",
+ }, {
+ kind: "bookmark",
+ title: "bmk",
+ url: "https://example.com",
+ });
+
+ do_print("Add folder description");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: guids.folder,
+ description: "Folder description",
+ });
+ equal(updatedItem.description, "Folder description",
+ "Should return new description");
+ let id = yield syncIdToId(updatedItem.syncId);
+ equal(PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO),
+ "Folder description", "Should set description anno");
+ }
+
+ do_print("Clear folder description");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: guids.folder,
+ description: null,
+ });
+ ok(!updatedItem.description, "Should not return cleared description");
+ let id = yield syncIdToId(updatedItem.syncId);
+ ok(!PlacesUtils.annotations.itemHasAnnotation(id, DESCRIPTION_ANNO),
+ "Should remove description anno");
+ }
+
+ do_print("Add bookmark sidebar anno");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: guids.bmk,
+ loadInSidebar: true,
+ });
+ ok(updatedItem.loadInSidebar, "Should return sidebar anno");
+ let id = yield syncIdToId(updatedItem.syncId);
+ ok(PlacesUtils.annotations.itemHasAnnotation(id, LOAD_IN_SIDEBAR_ANNO),
+ "Should set sidebar anno for existing bookmark");
+ }
+
+ do_print("Clear bookmark sidebar anno");
+ {
+ let updatedItem = yield PlacesSyncUtils.bookmarks.update({
+ syncId: guids.bmk,
+ loadInSidebar: false,
+ });
+ ok(!updatedItem.loadInSidebar, "Should not return cleared sidebar anno");
+ let id = yield syncIdToId(updatedItem.syncId);
+ ok(!PlacesUtils.annotations.itemHasAnnotation(id, LOAD_IN_SIDEBAR_ANNO),
+ "Should clear sidebar anno for existing bookmark");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_update_move_root() {
+ do_print("Move root to same parent");
+ {
+ // This should be a no-op.
+ let sameRoot = yield PlacesSyncUtils.bookmarks.update({
+ syncId: "menu",
+ parentSyncId: "places",
+ });
+ equal(sameRoot.syncId, "menu",
+ "Menu root GUID should not change");
+ equal(sameRoot.parentSyncId, "places",
+ "Parent Places root GUID should not change");
+ }
+
+ do_print("Try reparenting root");
+ yield rejects(PlacesSyncUtils.bookmarks.update({
+ syncId: "menu",
+ parentSyncId: "toolbar",
+ }));
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert() {
+ do_print("Insert bookmark");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ url: "https://example.org",
+ });
+ let { type } = yield PlacesUtils.bookmarks.fetch({ guid: item.syncId });
+ equal(type, PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "Bookmark should have correct type");
+ }
+
+ do_print("Insert query");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "query",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ url: "place:terms=term&folder=TOOLBAR&queryType=1",
+ folder: "Saved search",
+ });
+ let { type } = yield PlacesUtils.bookmarks.fetch({ guid: item.syncId });
+ equal(type, PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ "Queries should be stored as bookmarks");
+ }
+
+ do_print("Insert folder");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "folder",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ title: "New folder",
+ });
+ let { type } = yield PlacesUtils.bookmarks.fetch({ guid: item.syncId });
+ equal(type, PlacesUtils.bookmarks.TYPE_FOLDER,
+ "Folder should have correct type");
+ }
+
+ do_print("Insert separator");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "separator",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ });
+ let { type } = yield PlacesUtils.bookmarks.fetch({ guid: item.syncId });
+ equal(type, PlacesUtils.bookmarks.TYPE_SEPARATOR,
+ "Separator should have correct type");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_livemark() {
+ let { site, stopServer } = makeLivemarkServer();
+
+ try {
+ do_print("Insert livemark with feed URL");
+ {
+ let livemark = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "livemark",
+ syncId: makeGuid(),
+ feed: site + "/feed/1",
+ parentSyncId: "menu",
+ });
+ let bmk = yield PlacesUtils.bookmarks.fetch({
+ guid: yield PlacesSyncUtils.bookmarks.syncIdToGuid(livemark.syncId),
+ })
+ equal(bmk.type, PlacesUtils.bookmarks.TYPE_FOLDER,
+ "Livemarks should be stored as folders");
+ }
+
+ let livemarkSyncId;
+ do_print("Insert livemark with site and feed URLs");
+ {
+ let livemark = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "livemark",
+ syncId: makeGuid(),
+ site,
+ feed: site + "/feed/1",
+ parentSyncId: "menu",
+ });
+ livemarkSyncId = livemark.syncId;
+ }
+
+ do_print("Try inserting livemark into livemark");
+ {
+ let livemark = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "livemark",
+ syncId: makeGuid(),
+ site,
+ feed: site + "/feed/1",
+ parentSyncId: livemarkSyncId,
+ });
+ ok(!livemark, "Should not insert livemark as child of livemark");
+ }
+ } finally {
+ yield stopServer();
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_update_livemark() {
+ let { site, stopServer } = makeLivemarkServer();
+ let feedURI = uri(site + "/feed/1");
+
+ try {
+ // We shouldn't reinsert the livemark if the URLs are the same.
+ do_print("Update livemark with same URLs");
+ {
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI,
+ siteURI: uri(site),
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+
+ yield PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ feed: feedURI,
+ });
+ // `nsLivemarkService` returns references to `Livemark` instances, so we
+ // can compare them with `==` to make sure they haven't been replaced.
+ equal(yield PlacesUtils.livemarks.getLivemark({
+ guid: livemark.guid,
+ }), livemark, "Livemark with same feed URL should not be replaced");
+
+ yield PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ site,
+ });
+ equal(yield PlacesUtils.livemarks.getLivemark({
+ guid: livemark.guid,
+ }), livemark, "Livemark with same site URL should not be replaced");
+
+ yield PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ feed: feedURI,
+ site,
+ });
+ equal(yield PlacesUtils.livemarks.getLivemark({
+ guid: livemark.guid,
+ }), livemark, "Livemark with same feed and site URLs should not be replaced");
+ }
+
+ do_print("Change livemark feed URL");
+ {
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+
+ // Since we're reinserting, we need to pass all properties required
+ // for a new livemark. `update` won't merge the old and new ones.
+ yield rejects(PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ feed: site + "/feed/2",
+ }), "Reinserting livemark with changed feed URL requires full record");
+
+ let newLivemark = yield PlacesSyncUtils.bookmarks.update({
+ kind: "livemark",
+ parentSyncId: "menu",
+ syncId: livemark.guid,
+ feed: site + "/feed/2",
+ });
+ equal(newLivemark.syncId, livemark.guid,
+ "GUIDs should match for reinserted livemark with changed feed URL");
+ equal(newLivemark.feed.href, site + "/feed/2",
+ "Reinserted livemark should have changed feed URI");
+ }
+
+ do_print("Add livemark site URL");
+ {
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI,
+ });
+ ok(livemark.feedURI.equals(feedURI), "Livemark feed URI should match");
+ ok(!livemark.siteURI, "Livemark should not have site URI");
+
+ yield rejects(PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ site,
+ }), "Reinserting livemark with new site URL requires full record");
+
+ let newLivemark = yield PlacesSyncUtils.bookmarks.update({
+ kind: "livemark",
+ parentSyncId: "menu",
+ syncId: livemark.guid,
+ feed: feedURI,
+ site,
+ });
+ notEqual(newLivemark, livemark,
+ "Livemark with new site URL should replace old livemark");
+ equal(newLivemark.syncId, livemark.guid,
+ "GUIDs should match for reinserted livemark with new site URL");
+ equal(newLivemark.site.href, site + "/",
+ "Reinserted livemark should have new site URI");
+ equal(newLivemark.feed.href, feedURI.spec,
+ "Reinserted livemark with new site URL should have same feed URI");
+ }
+
+ do_print("Remove livemark site URL");
+ {
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI,
+ siteURI: uri(site),
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+
+ yield rejects(PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ site: null,
+ }), "Reinserting livemark witout site URL requires full record");
+
+ let newLivemark = yield PlacesSyncUtils.bookmarks.update({
+ kind: "livemark",
+ parentSyncId: "menu",
+ syncId: livemark.guid,
+ feed: feedURI,
+ site: null,
+ });
+ notEqual(newLivemark, livemark,
+ "Livemark without site URL should replace old livemark");
+ equal(newLivemark.syncId, livemark.guid,
+ "GUIDs should match for reinserted livemark without site URL");
+ ok(!newLivemark.site, "Reinserted livemark should not have site URI");
+ }
+
+ do_print("Change livemark site URL");
+ {
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI,
+ siteURI: uri(site),
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+
+ yield rejects(PlacesSyncUtils.bookmarks.update({
+ syncId: livemark.guid,
+ site: site + "/new",
+ }), "Reinserting livemark with changed site URL requires full record");
+
+ let newLivemark = yield PlacesSyncUtils.bookmarks.update({
+ kind: "livemark",
+ parentSyncId: "menu",
+ syncId: livemark.guid,
+ feed:feedURI,
+ site: site + "/new",
+ });
+ notEqual(newLivemark, livemark,
+ "Livemark with changed site URL should replace old livemark");
+ equal(newLivemark.syncId, livemark.guid,
+ "GUIDs should match for reinserted livemark with changed site URL");
+ equal(newLivemark.site.href, site + "/new",
+ "Reinserted livemark should have changed site URI");
+ }
+
+ // Livemarks are stored as folders, but have different kinds. We should
+ // remove the folder and insert a livemark with the same GUID instead of
+ // trying to update the folder in-place.
+ do_print("Replace folder with livemark");
+ {
+ let folder = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ title: "Plain folder",
+ });
+ let livemark = yield PlacesSyncUtils.bookmarks.update({
+ kind: "livemark",
+ parentSyncId: "menu",
+ syncId: folder.guid,
+ feed: feedURI,
+ });
+ equal(livemark.guid, folder.syncId,
+ "Livemark should have same GUID as replaced folder");
+ }
+ } finally {
+ yield stopServer();
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_tags() {
+ yield Promise.all([{
+ kind: "bookmark",
+ url: "https://example.com",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ tags: ["foo", "bar"],
+ }, {
+ kind: "bookmark",
+ url: "https://example.org",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ tags: ["foo", "baz"],
+ }, {
+ kind: "query",
+ url: "place:queryType=1&sort=12&maxResults=10",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ folder: "bar",
+ tags: ["baz", "qux"],
+ title: "bar",
+ }].map(info => PlacesSyncUtils.bookmarks.insert(info)));
+
+ assertTagForURLs("foo", ["https://example.com/", "https://example.org/"],
+ "2 URLs with new tag");
+ assertTagForURLs("bar", ["https://example.com/"], "1 URL with existing tag");
+ assertTagForURLs("baz", ["https://example.org/",
+ "place:queryType=1&sort=12&maxResults=10"],
+ "Should support tagging URLs and tag queries");
+ assertTagForURLs("qux", ["place:queryType=1&sort=12&maxResults=10"],
+ "Should support tagging tag queries");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_tags_whitespace() {
+ do_print("Untrimmed and blank tags");
+ let taggedBlanks = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.org",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ tags: [" untrimmed ", " ", "taggy"],
+ });
+ deepEqual(taggedBlanks.tags, ["untrimmed", "taggy"],
+ "Should not return empty tags");
+ assertURLHasTags("https://example.org/", ["taggy", "untrimmed"],
+ "Should set trimmed tags and ignore dupes");
+
+ do_print("Dupe tags");
+ let taggedDupes = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.net",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ tags: [" taggy", "taggy ", " taggy ", "taggy"],
+ });
+ deepEqual(taggedDupes.tags, ["taggy", "taggy", "taggy", "taggy"],
+ "Should return trimmed and dupe tags");
+ assertURLHasTags("https://example.net/", ["taggy"],
+ "Should ignore dupes when setting tags");
+
+ assertTagForURLs("taggy", ["https://example.net/", "https://example.org/"],
+ "Should exclude falsy tags");
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_keyword() {
+ do_print("Insert item with new keyword");
+ {
+ yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ parentSyncId: "menu",
+ url: "https://example.com",
+ keyword: "moz",
+ syncId: makeGuid(),
+ });
+ let entry = yield PlacesUtils.keywords.fetch("moz");
+ equal(entry.url.href, "https://example.com/",
+ "Should add keyword for item");
+ }
+
+ do_print("Insert item with existing keyword");
+ {
+ yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ parentSyncId: "menu",
+ url: "https://mozilla.org",
+ keyword: "moz",
+ syncId: makeGuid(),
+ });
+ let entry = yield PlacesUtils.keywords.fetch("moz");
+ equal(entry.url.href, "https://mozilla.org/",
+ "Should reassign keyword to new item");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_annos() {
+ do_print("Bookmark with description");
+ let descBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.com",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ description: "Bookmark description",
+ });
+ {
+ equal(descBmk.description, "Bookmark description",
+ "Should return new bookmark description");
+ let id = yield syncIdToId(descBmk.syncId);
+ equal(PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO),
+ "Bookmark description", "Should set new bookmark description");
+ }
+
+ do_print("Folder with description");
+ let descFolder = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "folder",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ description: "Folder description",
+ });
+ {
+ equal(descFolder.description, "Folder description",
+ "Should return new folder description");
+ let id = yield syncIdToId(descFolder.syncId);
+ equal(PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO),
+ "Folder description", "Should set new folder description");
+ }
+
+ do_print("Bookmark with sidebar anno");
+ let sidebarBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.com",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ loadInSidebar: true,
+ });
+ {
+ ok(sidebarBmk.loadInSidebar, "Should return sidebar anno for new bookmark");
+ let id = yield syncIdToId(sidebarBmk.syncId);
+ ok(PlacesUtils.annotations.itemHasAnnotation(id, LOAD_IN_SIDEBAR_ANNO),
+ "Should set sidebar anno for new bookmark");
+ }
+
+ do_print("Bookmark without sidebar anno");
+ let noSidebarBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ url: "https://example.org",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ loadInSidebar: false,
+ });
+ {
+ ok(!noSidebarBmk.loadInSidebar,
+ "Should not return sidebar anno for new bookmark");
+ let id = yield syncIdToId(noSidebarBmk.syncId);
+ ok(!PlacesUtils.annotations.itemHasAnnotation(id, LOAD_IN_SIDEBAR_ANNO),
+ "Should not set sidebar anno for new bookmark");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_tag_query() {
+ let tagFolder = -1;
+
+ do_print("Insert tag query for new tag");
+ {
+ deepEqual(PlacesUtils.tagging.allTags, [], "New tag should not exist yet");
+ let query = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "query",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ url: "place:type=7&folder=90",
+ folder: "taggy",
+ title: "Tagged stuff",
+ });
+ notEqual(query.url.href, "place:type=7&folder=90",
+ "Tag query URL for new tag should differ");
+
+ [, tagFolder] = /\bfolder=(\d+)\b/.exec(query.url.pathname);
+ ok(tagFolder > 0, "New tag query URL should contain valid folder");
+ deepEqual(PlacesUtils.tagging.allTags, ["taggy"], "New tag should exist");
+ }
+
+ do_print("Insert tag query for existing tag");
+ {
+ let url = "place:type=7&folder=90&maxResults=15";
+ let query = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "query",
+ url,
+ folder: "taggy",
+ title: "Sorted and tagged",
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ });
+ notEqual(query.url.href, url, "Tag query URL for existing tag should differ");
+ let params = new URLSearchParams(query.url.pathname);
+ equal(params.get("type"), "7", "Should preserve query type");
+ equal(params.get("maxResults"), "15", "Should preserve additional params");
+ equal(params.get("folder"), tagFolder, "Should update tag folder");
+ deepEqual(PlacesUtils.tagging.allTags, ["taggy"], "Should not duplicate existing tags");
+ }
+
+ do_print("Use the public tagging API to ensure we added the tag correctly");
+ {
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_BOOKMARK,
+ url: "https://mozilla.org",
+ title: "Mozilla",
+ });
+ PlacesUtils.tagging.tagURI(uri("https://mozilla.org"), ["taggy"]);
+ assertURLHasTags("https://mozilla.org/", ["taggy"],
+ "Should set tags using the tagging API");
+ }
+
+ do_print("Removing the tag should clean up the tag folder");
+ {
+ PlacesUtils.tagging.untagURI(uri("https://mozilla.org"), null);
+ deepEqual(PlacesUtils.tagging.allTags, [],
+ "Should remove tag folder once last item is untagged");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_insert_orphans() {
+ let grandParentGuid = makeGuid();
+ let parentGuid = makeGuid();
+ let childGuid = makeGuid();
+ let childId;
+
+ do_print("Insert an orphaned child");
+ {
+ let child = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "bookmark",
+ parentSyncId: parentGuid,
+ syncId: childGuid,
+ url: "https://mozilla.org",
+ });
+ equal(child.syncId, childGuid,
+ "Should insert orphan with requested GUID");
+ equal(child.parentSyncId, "unfiled",
+ "Should reparent orphan to unfiled");
+
+ childId = yield PlacesUtils.promiseItemId(childGuid);
+ equal(PlacesUtils.annotations.getItemAnnotation(childId, SYNC_PARENT_ANNO),
+ parentGuid, "Should set anno to missing parent GUID");
+ }
+
+ do_print("Insert the grandparent");
+ {
+ yield PlacesSyncUtils.bookmarks.insert({
+ kind: "folder",
+ parentSyncId: "menu",
+ syncId: grandParentGuid,
+ });
+ equal(PlacesUtils.annotations.getItemAnnotation(childId, SYNC_PARENT_ANNO),
+ parentGuid, "Child should still have orphan anno");
+ }
+
+ // Note that only `PlacesSyncUtils` reparents orphans, though Sync adds an
+ // observer that removes the orphan anno if the orphan is manually moved.
+ do_print("Insert the missing parent");
+ {
+ let parent = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "folder",
+ parentSyncId: grandParentGuid,
+ syncId: parentGuid,
+ });
+ equal(parent.syncId, parentGuid, "Should insert parent with requested GUID");
+ equal(parent.parentSyncId, grandParentGuid,
+ "Parent should be child of grandparent");
+ ok(!PlacesUtils.annotations.itemHasAnnotation(childId, SYNC_PARENT_ANNO),
+ "Orphan anno should be removed after reparenting");
+
+ let child = yield PlacesUtils.bookmarks.fetch({ guid: childGuid });
+ equal(child.parentGuid, parentGuid,
+ "Should reparent child after inserting missing parent");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_fetch() {
+ let folder = yield PlacesSyncUtils.bookmarks.insert({
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ kind: "folder",
+ description: "Folder description",
+ });
+ let bmk = yield PlacesSyncUtils.bookmarks.insert({
+ syncId: makeGuid(),
+ parentSyncId: "menu",
+ kind: "bookmark",
+ url: "https://example.com",
+ description: "Bookmark description",
+ loadInSidebar: true,
+ tags: ["taggy"],
+ });
+ let folderBmk = yield PlacesSyncUtils.bookmarks.insert({
+ syncId: makeGuid(),
+ parentSyncId: folder.syncId,
+ kind: "bookmark",
+ url: "https://example.org",
+ keyword: "kw",
+ });
+ let folderSep = yield PlacesSyncUtils.bookmarks.insert({
+ syncId: makeGuid(),
+ parentSyncId: folder.syncId,
+ kind: "separator",
+ });
+ let tagQuery = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "query",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ url: "place:type=7&folder=90",
+ folder: "taggy",
+ title: "Tagged stuff",
+ });
+ let [, tagFolderId] = /\bfolder=(\d+)\b/.exec(tagQuery.url.pathname);
+ let smartBmk = yield PlacesSyncUtils.bookmarks.insert({
+ kind: "query",
+ syncId: makeGuid(),
+ parentSyncId: "toolbar",
+ url: "place:folder=TOOLBAR",
+ query: "BookmarksToolbar",
+ title: "Bookmarks toolbar query",
+ });
+
+ do_print("Fetch empty folder with description");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(folder.syncId);
+ deepEqual(item, {
+ syncId: folder.syncId,
+ kind: "folder",
+ parentSyncId: "menu",
+ description: "Folder description",
+ childSyncIds: [folderBmk.syncId, folderSep.syncId],
+ parentTitle: "Bookmarks Menu",
+ title: "",
+ }, "Should include description, children, title, and parent title in folder");
+ }
+
+ do_print("Fetch bookmark with description, sidebar anno, and tags");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(bmk.syncId);
+ deepEqual(Object.keys(item).sort(), ["syncId", "kind", "parentSyncId",
+ "url", "tags", "description", "loadInSidebar", "parentTitle", "title"].sort(),
+ "Should include bookmark-specific properties");
+ equal(item.syncId, bmk.syncId, "Sync ID should match");
+ equal(item.url.href, "https://example.com/", "Should return URL");
+ equal(item.parentSyncId, "menu", "Should return parent sync ID");
+ deepEqual(item.tags, ["taggy"], "Should return tags");
+ equal(item.description, "Bookmark description", "Should return bookmark description");
+ strictEqual(item.loadInSidebar, true, "Should return sidebar anno");
+ equal(item.parentTitle, "Bookmarks Menu", "Should return parent title");
+ strictEqual(item.title, "", "Should return empty title");
+ }
+
+ do_print("Fetch bookmark with keyword; without parent title or annos");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(folderBmk.syncId);
+ deepEqual(Object.keys(item).sort(), ["syncId", "kind", "parentSyncId",
+ "url", "keyword", "tags", "loadInSidebar", "parentTitle", "title"].sort(),
+ "Should omit blank bookmark-specific properties");
+ strictEqual(item.loadInSidebar, false, "Should not load bookmark in sidebar");
+ deepEqual(item.tags, [], "Tags should be empty");
+ equal(item.keyword, "kw", "Should return keyword");
+ strictEqual(item.parentTitle, "", "Should include parent title even if empty");
+ strictEqual(item.title, "", "Should include bookmark title even if empty");
+ }
+
+ do_print("Fetch separator");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(folderSep.syncId);
+ strictEqual(item.index, 1, "Should return separator position");
+ }
+
+ do_print("Fetch tag query");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(tagQuery.syncId);
+ deepEqual(Object.keys(item).sort(), ["syncId", "kind", "parentSyncId",
+ "url", "title", "folder", "parentTitle"].sort(),
+ "Should include query-specific properties");
+ equal(item.url.href, `place:type=7&folder=${tagFolderId}`, "Should not rewrite outgoing tag queries");
+ equal(item.folder, "taggy", "Should return tag name for tag queries");
+ }
+
+ do_print("Fetch smart bookmark");
+ {
+ let item = yield PlacesSyncUtils.bookmarks.fetch(smartBmk.syncId);
+ deepEqual(Object.keys(item).sort(), ["syncId", "kind", "parentSyncId",
+ "url", "title", "query", "parentTitle"].sort(),
+ "Should include smart bookmark-specific properties");
+ equal(item.query, "BookmarksToolbar", "Should return query name for smart bookmarks");
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
+
+add_task(function* test_fetch_livemark() {
+ let { site, stopServer } = makeLivemarkServer();
+
+ try {
+ do_print("Create livemark");
+ let livemark = yield PlacesUtils.livemarks.addLivemark({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ feedURI: uri(site + "/feed/1"),
+ siteURI: uri(site),
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ });
+ PlacesUtils.annotations.setItemAnnotation(livemark.id, DESCRIPTION_ANNO,
+ "Livemark description", 0, PlacesUtils.annotations.EXPIRE_NEVER);
+
+ do_print("Fetch livemark");
+ let item = yield PlacesSyncUtils.bookmarks.fetch(livemark.guid);
+ deepEqual(Object.keys(item).sort(), ["syncId", "kind", "parentSyncId",
+ "description", "feed", "site", "parentTitle", "title"].sort(),
+ "Should include livemark-specific properties");
+ equal(item.description, "Livemark description", "Should return description");
+ equal(item.feed.href, site + "/feed/1", "Should return feed URL");
+ equal(item.site.href, site + "/", "Should return site URL");
+ strictEqual(item.title, "", "Should include livemark title even if empty");
+ } finally {
+ yield stopServer();
+ }
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js b/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js
new file mode 100644
index 000000000..92930e329
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_tag_autocomplete_search.js
@@ -0,0 +1,137 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+var current_test = 0;
+
+function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+}
+AutoCompleteInput.prototype = {
+ constructor: AutoCompleteInput,
+
+ searches: null,
+
+ minResultsForPopup: 0,
+ timeout: 10,
+ searchParam: "",
+ textValue: "",
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+
+ get searchCount() {
+ return this.searches.length;
+ },
+
+ getSearchAt: function(aIndex) {
+ return this.searches[aIndex];
+ },
+
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+
+ popupOpen: false,
+
+ popup: {
+ setSelectedIndex: function(aIndex) {},
+ invalidate: function() {},
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompletePopup))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+ },
+
+ // nsISupports implementation
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsISupports) ||
+ iid.equals(Ci.nsIAutoCompleteInput))
+ return this;
+
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ }
+}
+
+// Get tagging service
+try {
+ var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+} catch (ex) {
+ do_throw("Could not get tagging service\n");
+}
+
+function ensure_tag_results(results, searchTerm)
+{
+ var controller = Cc["@mozilla.org/autocomplete/controller;1"].
+ getService(Ci.nsIAutoCompleteController);
+
+ // Make an AutoCompleteInput that uses our searches
+ // and confirms results on search complete
+ var input = new AutoCompleteInput(["places-tag-autocomplete"]);
+
+ controller.input = input;
+
+ var numSearchesStarted = 0;
+ input.onSearchBegin = function input_onSearchBegin() {
+ numSearchesStarted++;
+ do_check_eq(numSearchesStarted, 1);
+ };
+
+ input.onSearchComplete = function input_onSearchComplete() {
+ do_check_eq(numSearchesStarted, 1);
+ if (results.length)
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH);
+ else
+ do_check_eq(controller.searchStatus,
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH);
+
+ do_check_eq(controller.matchCount, results.length);
+ for (var i=0; i<controller.matchCount; i++) {
+ do_check_eq(controller.getValueAt(i), results[i]);
+ }
+
+ if (current_test < (tests.length - 1)) {
+ current_test++;
+ tests[current_test]();
+ }
+ else {
+ // finish once all tests have run
+ do_test_finished();
+ }
+ };
+
+ controller.startSearch(searchTerm);
+}
+
+var uri1 = uri("http://site.tld/1");
+
+var tests = [
+ function test1() { ensure_tag_results(["bar", "Baz", "boo"], "b"); },
+ function test2() { ensure_tag_results(["bar", "Baz"], "ba"); },
+ function test3() { ensure_tag_results(["bar", "Baz"], "Ba"); },
+ function test4() { ensure_tag_results(["bar"], "bar"); },
+ function test5() { ensure_tag_results(["Baz"], "Baz"); },
+ function test6() { ensure_tag_results([], "barb"); },
+ function test7() { ensure_tag_results([], "foo"); },
+ function test8() { ensure_tag_results(["first tag, bar", "first tag, Baz"], "first tag, ba"); },
+ function test9() { ensure_tag_results(["first tag; bar", "first tag; Baz"], "first tag; ba"); }
+];
+
+/**
+ * Test tag autocomplete
+ */
+function run_test() {
+ // Search is asynchronous, so don't let the test finish immediately
+ do_test_pending();
+
+ tagssvc.tagURI(uri1, ["bar", "Baz", "boo", "*nix"]);
+
+ tests[0]();
+}
diff --git a/toolkit/components/places/tests/unit/test_tagging.js b/toolkit/components/places/tests/unit/test_tagging.js
new file mode 100644
index 000000000..ccb287050
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_tagging.js
@@ -0,0 +1,189 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Notice we use createInstance because later we will have to terminate the
+// service and restart it.
+var tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ createInstance().QueryInterface(Ci.nsITaggingService);
+
+function run_test() {
+ var options = PlacesUtils.history.getNewQueryOptions();
+ var query = PlacesUtils.history.getNewQuery();
+
+ query.setFolders([PlacesUtils.tagsFolderId], 1);
+ var result = PlacesUtils.history.executeQuery(query, options);
+ var tagRoot = result.root;
+ tagRoot.containerOpen = true;
+
+ do_check_eq(tagRoot.childCount, 0);
+
+ var uri1 = uri("http://foo.tld/");
+ var uri2 = uri("https://bar.tld/");
+
+ // this also tests that the multiple folders are not created for the same tag
+ tagssvc.tagURI(uri1, ["tag 1"]);
+ tagssvc.tagURI(uri2, ["tag 1"]);
+ do_check_eq(tagRoot.childCount, 1);
+
+ var tag1node = tagRoot.getChild(0)
+ .QueryInterface(Ci.nsINavHistoryContainerResultNode);
+ var tag1itemId = tag1node.itemId;
+
+ do_check_eq(tag1node.title, "tag 1");
+ tag1node.containerOpen = true;
+ do_check_eq(tag1node.childCount, 2);
+
+ // Tagging the same url twice (or even thrice!) with the same tag should be a
+ // no-op
+ tagssvc.tagURI(uri1, ["tag 1"]);
+ do_check_eq(tag1node.childCount, 2);
+ tagssvc.tagURI(uri1, [tag1itemId]);
+ do_check_eq(tag1node.childCount, 2);
+ do_check_eq(tagRoot.childCount, 1);
+
+ // also tests bug 407575
+ tagssvc.tagURI(uri1, [tag1itemId, "tag 1", "tag 2", "Tag 1", "Tag 2"]);
+ do_check_eq(tagRoot.childCount, 2);
+ do_check_eq(tag1node.childCount, 2);
+
+ // test getTagsForURI
+ var uri1tags = tagssvc.getTagsForURI(uri1);
+ do_check_eq(uri1tags.length, 2);
+ do_check_eq(uri1tags[0], "Tag 1");
+ do_check_eq(uri1tags[1], "Tag 2");
+ var uri2tags = tagssvc.getTagsForURI(uri2);
+ do_check_eq(uri2tags.length, 1);
+ do_check_eq(uri2tags[0], "Tag 1");
+
+ // test getURIsForTag
+ var tag1uris = tagssvc.getURIsForTag("tag 1");
+ do_check_eq(tag1uris.length, 2);
+ do_check_true(tag1uris[0].equals(uri1));
+ do_check_true(tag1uris[1].equals(uri2));
+
+ // test allTags attribute
+ var allTags = tagssvc.allTags;
+ do_check_eq(allTags.length, 2);
+ do_check_eq(allTags[0], "Tag 1");
+ do_check_eq(allTags[1], "Tag 2");
+
+ // test untagging
+ tagssvc.untagURI(uri1, ["tag 1"]);
+ do_check_eq(tag1node.childCount, 1);
+
+ // removing the last uri from a tag should remove the tag-container
+ tagssvc.untagURI(uri2, ["tag 1"]);
+ do_check_eq(tagRoot.childCount, 1);
+
+ // cleanup
+ tag1node.containerOpen = false;
+
+ // get array of tag folder ids => title
+ // for testing tagging with mixed folder ids and tags
+ var child = tagRoot.getChild(0);
+ var tagId = child.itemId;
+ var tagTitle = child.title;
+
+ // test mixed id/name tagging
+ // as well as non-id numeric tags
+ var uri3 = uri("http://testuri/3");
+ tagssvc.tagURI(uri3, [tagId, "tag 3", "456"]);
+ var tags = tagssvc.getTagsForURI(uri3);
+ do_check_true(tags.includes(tagTitle));
+ do_check_true(tags.includes("tag 3"));
+ do_check_true(tags.includes("456"));
+
+ // test mixed id/name tagging
+ tagssvc.untagURI(uri3, [tagId, "tag 3", "456"]);
+ tags = tagssvc.getTagsForURI(uri3);
+ do_check_eq(tags.length, 0);
+
+ // Terminate tagging service, fire up a new instance and check that existing
+ // tags are there. This will ensure that any internal caching system is
+ // correctly filled at startup and we are not losing previously existing tags.
+ var uri4 = uri("http://testuri/4");
+ tagssvc.tagURI(uri4, [tagId, "tag 3", "456"]);
+ tagssvc = null;
+ tagssvc = Cc["@mozilla.org/browser/tagging-service;1"].
+ getService(Ci.nsITaggingService);
+ var uri4Tags = tagssvc.getTagsForURI(uri4);
+ do_check_eq(uri4Tags.length, 3);
+ do_check_true(uri4Tags.includes(tagTitle));
+ do_check_true(uri4Tags.includes("tag 3"));
+ do_check_true(uri4Tags.includes("456"));
+
+ // Test sparse arrays.
+ let curChildCount = tagRoot.childCount;
+
+ try {
+ tagssvc.tagURI(uri1, [, "tagSparse"]);
+ do_check_eq(tagRoot.childCount, curChildCount + 1);
+ } catch (ex) {
+ do_throw("Passing a sparse array should not throw");
+ }
+ try {
+ tagssvc.untagURI(uri1, [, "tagSparse"]);
+ do_check_eq(tagRoot.childCount, curChildCount);
+ } catch (ex) {
+ do_throw("Passing a sparse array should not throw");
+ }
+
+ // Test that the API throws for bad arguments.
+ try {
+ tagssvc.tagURI(uri1, ["", "test"]);
+ do_throw("Passing a bad tags array should throw");
+ } catch (ex) {
+ do_check_eq(ex.name, "NS_ERROR_ILLEGAL_VALUE");
+ }
+ try {
+ tagssvc.untagURI(uri1, ["", "test"]);
+ do_throw("Passing a bad tags array should throw");
+ } catch (ex) {
+ do_check_eq(ex.name, "NS_ERROR_ILLEGAL_VALUE");
+ }
+ try {
+ tagssvc.tagURI(uri1, [0, "test"]);
+ do_throw("Passing a bad tags array should throw");
+ } catch (ex) {
+ do_check_eq(ex.name, "NS_ERROR_ILLEGAL_VALUE");
+ }
+ try {
+ tagssvc.tagURI(uri1, [0, "test"]);
+ do_throw("Passing a bad tags array should throw");
+ } catch (ex) {
+ do_check_eq(ex.name, "NS_ERROR_ILLEGAL_VALUE");
+ }
+
+ // Tag name length should be limited to nsITaggingService.MAX_TAG_LENGTH (bug407821)
+ try {
+
+ // generate a long tag name. i.e. looooo...oong_tag
+ var n = Ci.nsITaggingService.MAX_TAG_LENGTH;
+ var someOos = new Array(n).join('o');
+ var longTagName = "l" + someOos + "ng_tag";
+
+ tagssvc.tagURI(uri1, ["short_tag", longTagName]);
+ do_throw("Passing a bad tags array should throw");
+
+ } catch (ex) {
+ do_check_eq(ex.name, "NS_ERROR_ILLEGAL_VALUE");
+ }
+
+ // cleanup
+ tagRoot.containerOpen = false;
+
+ // Tagging service should trim tags (Bug967196)
+ let exampleURI = uri("http://www.example.com/");
+ PlacesUtils.tagging.tagURI(exampleURI, [ " test " ]);
+
+ let exampleTags = PlacesUtils.tagging.getTagsForURI(exampleURI);
+ do_check_eq(exampleTags.length, 1);
+ do_check_eq(exampleTags[0], "test");
+
+ PlacesUtils.tagging.untagURI(exampleURI, [ "test" ]);
+ exampleTags = PlacesUtils.tagging.getTagsForURI(exampleURI);
+ do_check_eq(exampleTags.length, 0);
+}
diff --git a/toolkit/components/places/tests/unit/test_telemetry.js b/toolkit/components/places/tests/unit/test_telemetry.js
new file mode 100644
index 000000000..99f36d78c
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_telemetry.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests common Places telemetry probes by faking the telemetry service.
+
+Components.utils.import("resource://gre/modules/PlacesDBUtils.jsm");
+
+var histograms = {
+ PLACES_PAGES_COUNT: val => do_check_eq(val, 1),
+ PLACES_BOOKMARKS_COUNT: val => do_check_eq(val, 1),
+ PLACES_TAGS_COUNT: val => do_check_eq(val, 1),
+ PLACES_KEYWORDS_COUNT: val => do_check_eq(val, 1),
+ PLACES_SORTED_BOOKMARKS_PERC: val => do_check_eq(val, 100),
+ PLACES_TAGGED_BOOKMARKS_PERC: val => do_check_eq(val, 100),
+ PLACES_DATABASE_FILESIZE_MB: val => do_check_true(val > 0),
+ PLACES_DATABASE_PAGESIZE_B: val => do_check_eq(val, 32768),
+ PLACES_DATABASE_SIZE_PER_PAGE_B: val => do_check_true(val > 0),
+ PLACES_EXPIRATION_STEPS_TO_CLEAN2: val => do_check_true(val > 1),
+ // PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS: val => do_check_true(val > 1),
+ PLACES_IDLE_FRECENCY_DECAY_TIME_MS: val => do_check_true(val >= 0),
+ PLACES_IDLE_MAINTENANCE_TIME_MS: val => do_check_true(val > 0),
+ // One from the `setItemAnnotation` call; the other from the mobile root.
+ // This can be removed along with the anno in bug 1306445.
+ PLACES_ANNOS_BOOKMARKS_COUNT: val => do_check_eq(val, 2),
+ PLACES_ANNOS_PAGES_COUNT: val => do_check_eq(val, 1),
+ PLACES_MAINTENANCE_DAYSFROMLAST: val => do_check_true(val >= 0),
+}
+
+/**
+ * Forces an expiration run.
+ *
+ * @param [optional] aLimit
+ * Limit for the expiration. Pass -1 for unlimited.
+ * Any other non-positive value will just expire orphans.
+ *
+ * @return {Promise}
+ * @resolves When expiration finishes.
+ * @rejects Never.
+ */
+function promiseForceExpirationStep(aLimit) {
+ let promise = promiseTopicObserved(PlacesUtils.TOPIC_EXPIRATION_FINISHED);
+ let expire = Cc["@mozilla.org/places/expiration;1"].getService(Ci.nsIObserver);
+ expire.observe(null, "places-debug-start-expiration", aLimit);
+ return promise;
+}
+
+/**
+ * Returns a PRTime in the past usable to add expirable visits.
+ *
+ * param [optional] daysAgo
+ * Expiration ignores any visit added in the last 7 days, so by default
+ * this will be set to 7.
+ * @note to be safe against DST issues we go back one day more.
+ */
+function getExpirablePRTime(daysAgo = 7) {
+ let dateObj = new Date();
+ // Normalize to midnight
+ dateObj.setHours(0);
+ dateObj.setMinutes(0);
+ dateObj.setSeconds(0);
+ dateObj.setMilliseconds(0);
+ dateObj = new Date(dateObj.getTime() - (daysAgo + 1) * 86400000);
+ return dateObj.getTime() * 1000;
+}
+
+add_task(function* test_execute()
+{
+ // Put some trash in the database.
+ let uri = NetUtil.newURI("http://moz.org/");
+
+ let folderId = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId,
+ "moz test",
+ PlacesUtils.bookmarks.DEFAULT_INDEX);
+ let itemId = PlacesUtils.bookmarks.insertBookmark(folderId,
+ uri,
+ PlacesUtils.bookmarks.DEFAULT_INDEX,
+ "moz test");
+ PlacesUtils.tagging.tagURI(uri, ["tag"]);
+ yield PlacesUtils.keywords.insert({ url: uri.spec, keyword: "keyword"});
+
+ // Set a large annotation.
+ let content = "";
+ while (content.length < 1024) {
+ content += "0";
+ }
+ PlacesUtils.annotations.setItemAnnotation(itemId, "test-anno", content, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+ PlacesUtils.annotations.setPageAnnotation(uri, "test-anno", content, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER);
+
+ // Request to gather telemetry data.
+ Cc["@mozilla.org/places/categoriesStarter;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "gather-telemetry", null);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+
+ // Test expiration probes.
+ let timeInMicroseconds = getExpirablePRTime(8);
+
+ function newTimeInMicroseconds() {
+ timeInMicroseconds = timeInMicroseconds + 1000;
+ return timeInMicroseconds;
+ }
+
+ for (let i = 0; i < 3; i++) {
+ yield PlacesTestUtils.addVisits({
+ uri: NetUtil.newURI("http://" + i + ".moz.org/"),
+ visitDate: newTimeInMicroseconds()
+ });
+ }
+ Services.prefs.setIntPref("places.history.expiration.max_pages", 0);
+ yield promiseForceExpirationStep(2);
+ yield promiseForceExpirationStep(2);
+
+ // Test autocomplete probes.
+ /*
+ // This is useful for manual testing by changing the minimum time for
+ // autocomplete telemetry to 0, but there is no way to artificially delay
+ // autocomplete by more than 50ms in a realiable way.
+ Services.prefs.setIntPref("browser.urlbar.search.sources", 3);
+ Services.prefs.setIntPref("browser.urlbar.default.behavior", 0);
+ function AutoCompleteInput(aSearches) {
+ this.searches = aSearches;
+ }
+ AutoCompleteInput.prototype = {
+ timeout: 10,
+ textValue: "",
+ searchParam: "",
+ popupOpen: false,
+ minResultsForPopup: 0,
+ invalidate: function() {},
+ disableAutoComplete: false,
+ completeDefaultIndex: false,
+ get popup() { return this; },
+ onSearchBegin: function() {},
+ onSearchComplete: function() {},
+ setSelectedIndex: function() {},
+ get searchCount() { return this.searches.length; },
+ getSearchAt: function(aIndex) { return this.searches[aIndex]; },
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIAutoCompleteInput,
+ Ci.nsIAutoCompletePopup,
+ ])
+ };
+ let controller = Cc["@mozilla.org/autocomplete/controller;1"].
+ getService(Ci.nsIAutoCompleteController);
+ controller.input = new AutoCompleteInput(["unifiedcomplete"]);
+ controller.startSearch("moz");
+ */
+
+ // Test idle probes.
+ PlacesUtils.history.QueryInterface(Ci.nsIObserver)
+ .observe(null, "idle-daily", null);
+ PlacesDBUtils.maintenanceOnIdle();
+
+ yield promiseTopicObserved("places-maintenance-finished");
+
+ for (let histogramId in histograms) {
+ do_print("checking histogram " + histogramId);
+ let validate = histograms[histogramId];
+ let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot();
+ validate(snapshot.sum);
+ do_check_true(snapshot.counts.reduce((a, b) => a + b) > 0);
+ }
+});
diff --git a/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js b/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js
new file mode 100644
index 000000000..662ea0841
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_update_frecency_after_delete.js
@@ -0,0 +1,151 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Bug 455315
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=412132
+ *
+ * Ensures that the frecency of a bookmark's URI is what it should be after the
+ * bookmark is deleted.
+ */
+
+add_task(function* removed_bookmark() {
+ do_print("After removing bookmark, frecency of bookmark's URI should be " +
+ "zero if URI is unvisited and no longer bookmarked.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "bookmark title",
+ url: TEST_URI
+ });
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.remove(bm);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Unvisited URI no longer bookmarked => frecency should = 0");
+ do_check_eq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* removed_but_visited_bookmark() {
+ do_print("After removing bookmark, frecency of bookmark's URI should " +
+ "not be zero if URI is visited.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let bm = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "bookmark title",
+ url: TEST_URI
+ });
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ yield PlacesUtils.bookmarks.remove(bm);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("*Visited* URI no longer bookmarked => frecency should != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* remove_bookmark_still_bookmarked() {
+ do_print("After removing bookmark, frecency of bookmark's URI should " +
+ "not be zero if URI is still bookmarked.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ let bm1 = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "bookmark 1 title",
+ url: TEST_URI
+ });
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "bookmark 2 title",
+ url: TEST_URI
+ });
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.remove(bm1);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("URI still bookmarked => frecency should != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* cleared_parent_of_visited_bookmark() {
+ do_print("After removing all children from bookmark's parent, frecency " +
+ "of bookmark's URI should not be zero if URI is visited.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "bookmark title",
+ url: TEST_URI
+ });
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesTestUtils.addVisits(TEST_URI);
+ PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.unfiledBookmarksFolderId);
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("*Visited* URI no longer bookmarked => frecency should != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
+
+add_task(function* cleared_parent_of_bookmark_still_bookmarked() {
+ do_print("After removing all children from bookmark's parent, frecency " +
+ "of bookmark's URI should not be zero if URI is still " +
+ "bookmarked.");
+ const TEST_URI = NetUtil.newURI("http://example.com/1");
+ yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ title: "bookmark 1 title",
+ url: TEST_URI
+ });
+
+ let folder = yield PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: "bookmark 2 folder"
+ });
+ yield PlacesUtils.bookmarks.insert({
+ title: "bookmark 2 title",
+ parentGuid: folder.guid,
+ url: TEST_URI
+ });
+
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ do_print("Bookmarked => frecency of URI should be != 0");
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.remove(folder);
+ yield PlacesTestUtils.promiseAsyncUpdates();
+ // URI still bookmarked => frecency should != 0.
+ do_check_neq(frecencyForUrl(TEST_URI), 0);
+
+ yield PlacesUtils.bookmarks.eraseEverything();
+ yield PlacesTestUtils.clearHistory();
+});
diff --git a/toolkit/components/places/tests/unit/test_utils_backups_create.js b/toolkit/components/places/tests/unit/test_utils_backups_create.js
new file mode 100644
index 000000000..a30589c44
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_utils_backups_create.js
@@ -0,0 +1,90 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Check for correct functionality of bookmarks backups
+ */
+
+const NUMBER_OF_BACKUPS = 10;
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* () {
+ // Generate random dates.
+ let dateObj = new Date();
+ let dates = [];
+ while (dates.length < NUMBER_OF_BACKUPS) {
+ // Use last year to ensure today's backup is the newest.
+ let randomDate = new Date(dateObj.getFullYear() - 1,
+ Math.floor(12 * Math.random()),
+ Math.floor(28 * Math.random()));
+ if (!dates.includes(randomDate.getTime()))
+ dates.push(randomDate.getTime());
+ }
+ // Sort dates from oldest to newest.
+ dates.sort();
+
+ // Get and cleanup the backups folder.
+ let backupFolderPath = yield PlacesBackups.getBackupFolder();
+ let bookmarksBackupDir = new FileUtils.File(backupFolderPath);
+
+ // Fake backups are created backwards to ensure we won't consider file
+ // creation time.
+ // Create fake backups for the newest dates.
+ for (let i = dates.length - 1; i >= 0; i--) {
+ let backupFilename = PlacesBackups.getFilenameForDate(new Date(dates[i]));
+ let backupFile = bookmarksBackupDir.clone();
+ backupFile.append(backupFilename);
+ backupFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0666", 8));
+ do_print("Creating fake backup " + backupFile.leafName);
+ if (!backupFile.exists())
+ do_throw("Unable to create fake backup " + backupFile.leafName);
+ }
+
+ yield PlacesBackups.create(NUMBER_OF_BACKUPS);
+ // Add today's backup.
+ dates.push(dateObj.getTime());
+
+ // Check backups. We have 11 dates but we the max number is 10 so the
+ // oldest backup should have been removed.
+ for (let i = 0; i < dates.length; i++) {
+ let backupFilename;
+ let shouldExist;
+ let backupFile;
+ if (i > 0) {
+ let files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ if (PlacesBackups.filenamesRegex.test(entry.leafName)) {
+ backupFilename = entry.leafName;
+ backupFile = entry;
+ break;
+ }
+ }
+ shouldExist = true;
+ }
+ else {
+ backupFilename = PlacesBackups.getFilenameForDate(new Date(dates[i]));
+ backupFile = bookmarksBackupDir.clone();
+ backupFile.append(backupFilename);
+ shouldExist = false;
+ }
+ if (backupFile.exists() != shouldExist)
+ do_throw("Backup should " + (shouldExist ? "" : "not") + " exist: " + backupFilename);
+ }
+
+ // Cleanup backups folder.
+ // XXX: Can't use bookmarksBackupDir.remove(true) because file lock happens
+ // on WIN XP.
+ let files = bookmarksBackupDir.directoryEntries;
+ while (files.hasMoreElements()) {
+ let entry = files.getNext().QueryInterface(Ci.nsIFile);
+ entry.remove(false);
+ }
+ do_check_false(bookmarksBackupDir.directoryEntries.hasMoreElements());
+});
diff --git a/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js b/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js
new file mode 100644
index 000000000..ecebce94a
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_utils_getURLsForContainerNode.js
@@ -0,0 +1,180 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Check for correct functionality of PlacesUtils.getURLsForContainerNode and
+ * PlacesUtils.hasChildURIs (those helpers share almost all of their code)
+ */
+
+var PU = PlacesUtils;
+var hs = PU.history;
+var bs = PU.bookmarks;
+
+var tests = [
+
+function() {
+ dump("\n\n*** TEST: folder\n");
+ // This is the folder we will check for children.
+ var folderId = bs.createFolder(bs.toolbarFolder, "folder", bs.DEFAULT_INDEX);
+
+ // Create a folder and a query node inside it, these should not be considered
+ // uri nodes.
+ bs.createFolder(folderId, "inside folder", bs.DEFAULT_INDEX);
+ bs.insertBookmark(folderId, uri("place:sort=1"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ var query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ var options = hs.getNewQueryOptions();
+
+ dump("Check folder without uri nodes\n");
+ check_uri_nodes(query, options, 0);
+
+ dump("Check folder with uri nodes\n");
+ // Add an uri node, this should be considered.
+ bs.insertBookmark(folderId, uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX, "bookmark");
+ check_uri_nodes(query, options, 1);
+},
+
+function() {
+ dump("\n\n*** TEST: folder in an excludeItems root\n");
+ // This is the folder we will check for children.
+ var folderId = bs.createFolder(bs.toolbarFolder, "folder", bs.DEFAULT_INDEX);
+
+ // Create a folder and a query node inside it, these should not be considered
+ // uri nodes.
+ bs.createFolder(folderId, "inside folder", bs.DEFAULT_INDEX);
+ bs.insertBookmark(folderId, uri("place:sort=1"), bs.DEFAULT_INDEX, "inside query");
+
+ var query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ var options = hs.getNewQueryOptions();
+ options.excludeItems = true;
+
+ dump("Check folder without uri nodes\n");
+ check_uri_nodes(query, options, 0);
+
+ dump("Check folder with uri nodes\n");
+ // Add an uri node, this should be considered.
+ bs.insertBookmark(folderId, uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX, "bookmark");
+ check_uri_nodes(query, options, 1);
+},
+
+function() {
+ dump("\n\n*** TEST: query\n");
+ // This is the query we will check for children.
+ bs.insertBookmark(bs.toolbarFolder, uri("place:folder=BOOKMARKS_MENU&sort=1"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ // Create a folder and a query node inside it, these should not be considered
+ // uri nodes.
+ bs.createFolder(bs.bookmarksMenuFolder, "inside folder", bs.DEFAULT_INDEX);
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("place:sort=1"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ var query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ var options = hs.getNewQueryOptions();
+
+ dump("Check query without uri nodes\n");
+ check_uri_nodes(query, options, 0);
+
+ dump("Check query with uri nodes\n");
+ // Add an uri node, this should be considered.
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX, "bookmark");
+ check_uri_nodes(query, options, 1);
+},
+
+function() {
+ dump("\n\n*** TEST: excludeItems Query\n");
+ // This is the query we will check for children.
+ bs.insertBookmark(bs.toolbarFolder, uri("place:folder=BOOKMARKS_MENU&sort=8"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ // Create a folder and a query node inside it, these should not be considered
+ // uri nodes.
+ bs.createFolder(bs.bookmarksMenuFolder, "inside folder", bs.DEFAULT_INDEX);
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("place:sort=1"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ var query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ var options = hs.getNewQueryOptions();
+ options.excludeItems = true;
+
+ dump("Check folder without uri nodes\n");
+ check_uri_nodes(query, options, 0);
+
+ dump("Check folder with uri nodes\n");
+ // Add an uri node, this should be considered.
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX, "bookmark");
+ check_uri_nodes(query, options, 1);
+},
+
+function() {
+ dump("\n\n*** TEST: !expandQueries Query\n");
+ // This is the query we will check for children.
+ bs.insertBookmark(bs.toolbarFolder, uri("place:folder=BOOKMARKS_MENU&sort=8"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ // Create a folder and a query node inside it, these should not be considered
+ // uri nodes.
+ bs.createFolder(bs.bookmarksMenuFolder, "inside folder", bs.DEFAULT_INDEX);
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("place:sort=1"),
+ bs.DEFAULT_INDEX, "inside query");
+
+ var query = hs.getNewQuery();
+ query.setFolders([bs.toolbarFolder], 1);
+ var options = hs.getNewQueryOptions();
+ options.expandQueries = false;
+
+ dump("Check folder without uri nodes\n");
+ check_uri_nodes(query, options, 0);
+
+ dump("Check folder with uri nodes\n");
+ // Add an uri node, this should be considered.
+ bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://www.mozilla.org/"),
+ bs.DEFAULT_INDEX, "bookmark");
+ check_uri_nodes(query, options, 1);
+}
+
+];
+
+/**
+ * Executes a query and checks number of uri nodes in the first container in
+ * query's results. To correctly test a container ensure that the query will
+ * return only your container in the first level.
+ *
+ * @param aQuery
+ * nsINavHistoryQuery object defining the query
+ * @param aOptions
+ * nsINavHistoryQueryOptions object defining the query's options
+ * @param aExpectedURINodes
+ * number of expected uri nodes
+ */
+function check_uri_nodes(aQuery, aOptions, aExpectedURINodes) {
+ var result = hs.executeQuery(aQuery, aOptions);
+ var root = result.root;
+ root.containerOpen = true;
+ var node = root.getChild(0);
+ do_check_eq(PU.hasChildURIs(node), aExpectedURINodes > 0);
+ do_check_eq(PU.getURLsForContainerNode(node).length, aExpectedURINodes);
+ root.containerOpen = false;
+}
+
+add_task(function* () {
+ for (let test of tests) {
+ yield PlacesUtils.bookmarks.eraseEverything();
+ test();
+ }
+
+ // Cleanup.
+ yield PlacesUtils.bookmarks.eraseEverything();
+});
diff --git a/toolkit/components/places/tests/unit/test_utils_setAnnotationsFor.js b/toolkit/components/places/tests/unit/test_utils_setAnnotationsFor.js
new file mode 100644
index 000000000..62947620d
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_utils_setAnnotationsFor.js
@@ -0,0 +1,79 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ /**
+ * Check for correct functionality of PlacesUtils.setAnnotationsForItem/URI
+ */
+
+var hs = PlacesUtils.history;
+var bs = PlacesUtils.bookmarks;
+var as = PlacesUtils.annotations;
+
+const TEST_URL = "http://test.mozilla.org/";
+
+function run_test() {
+ var testURI = uri(TEST_URL);
+ // add a bookmark
+ var itemId = bs.insertBookmark(bs.unfiledBookmarksFolder, testURI,
+ bs.DEFAULT_INDEX, "test");
+
+ // create annotations array
+ var testAnnos = [{ name: "testAnno/test0",
+ flags: 0,
+ value: "test0",
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER },
+ { name: "testAnno/test1",
+ flags: 0,
+ value: "test1",
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER },
+ { name: "testAnno/test2",
+ flags: 0,
+ value: "test2",
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER },
+ { name: "testAnno/test3",
+ flags: 0,
+ value: 0,
+ expires: Ci.nsIAnnotationService.EXPIRE_NEVER }];
+
+ // Add item annotations
+ PlacesUtils.setAnnotationsForItem(itemId, testAnnos);
+ // Check for correct addition
+ testAnnos.forEach(function(anno) {
+ do_check_true(as.itemHasAnnotation(itemId, anno.name));
+ do_check_eq(as.getItemAnnotation(itemId, anno.name), anno.value);
+ });
+
+ // Add page annotations
+ PlacesUtils.setAnnotationsForURI(testURI, testAnnos);
+ // Check for correct addition
+ testAnnos.forEach(function(anno) {
+ do_check_true(as.pageHasAnnotation(testURI, anno.name));
+ do_check_eq(as.getPageAnnotation(testURI, anno.name), anno.value);
+ });
+
+ // To unset annotations we unset their values or set them to
+ // null/undefined
+ testAnnos[0].value = null;
+ testAnnos[1].value = undefined;
+ delete testAnnos[2].value;
+ delete testAnnos[3].value;
+
+ // Unset all item annotations
+ PlacesUtils.setAnnotationsForItem(itemId, testAnnos);
+ // Check for correct removal
+ testAnnos.forEach(function(anno) {
+ do_check_false(as.itemHasAnnotation(itemId, anno.name));
+ // sanity: page annotations should not be removed here
+ do_check_true(as.pageHasAnnotation(testURI, anno.name));
+ });
+
+ // Unset all page annotations
+ PlacesUtils.setAnnotationsForURI(testURI, testAnnos);
+ // Check for correct removal
+ testAnnos.forEach(function(anno) {
+ do_check_false(as.pageHasAnnotation(testURI, anno.name));
+ });
+}
diff --git a/toolkit/components/places/tests/unit/test_visitsInDB.js b/toolkit/components/places/tests/unit/test_visitsInDB.js
new file mode 100644
index 000000000..3cab39ed9
--- /dev/null
+++ b/toolkit/components/places/tests/unit/test_visitsInDB.js
@@ -0,0 +1,12 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+add_task(function* test_execute() {
+ const TEST_URI = uri("http://mozilla.com");
+
+ do_check_eq(0, yield PlacesTestUtils.visitsInDB(TEST_URI));
+ yield PlacesTestUtils.addVisits({uri: TEST_URI});
+ do_check_eq(1, yield PlacesTestUtils.visitsInDB(TEST_URI));
+ yield PlacesTestUtils.addVisits({uri: TEST_URI});
+ do_check_eq(2, yield PlacesTestUtils.visitsInDB(TEST_URI));
+});
diff --git a/toolkit/components/places/tests/unit/xpcshell.ini b/toolkit/components/places/tests/unit/xpcshell.ini
new file mode 100644
index 000000000..60bba4758
--- /dev/null
+++ b/toolkit/components/places/tests/unit/xpcshell.ini
@@ -0,0 +1,163 @@
+[DEFAULT]
+head = head_bookmarks.js
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+support-files =
+ bookmarks.corrupt.html
+ bookmarks.json
+ bookmarks.preplaces.html
+ bookmarks_html_singleframe.html
+ bug476292.sqlite
+ corruptDB.sqlite
+ default.sqlite
+ livemark.xml
+ mobile_bookmarks_folder_import.json
+ mobile_bookmarks_folder_merge.json
+ mobile_bookmarks_multiple_folders.json
+ mobile_bookmarks_root_import.json
+ mobile_bookmarks_root_merge.json
+ nsDummyObserver.js
+ nsDummyObserver.manifest
+ places.sparse.sqlite
+
+[test_000_frecency.js]
+[test_317472.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_331487.js]
+[test_384370.js]
+[test_385397.js]
+# Bug 676989: test fails consistently on Android
+fail-if = os == "android"
+[test_399264_query_to_string.js]
+[test_399264_string_to_query.js]
+[test_399266.js]
+# Bug 676989: test fails consistently on Android
+fail-if = os == "android"
+# Bug 821781: test fails intermittently on Linux
+skip-if = os == "linux"
+[test_402799.js]
+[test_405497.js]
+[test_408221.js]
+[test_412132.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_413784.js]
+[test_415460.js]
+[test_415757.js]
+[test_418643_removeFolderChildren.js]
+[test_419731.js]
+[test_419792_node_tags_property.js]
+[test_425563.js]
+[test_429505_remove_shortcuts.js]
+[test_433317_query_title_update.js]
+[test_433525_hasChildren_crash.js]
+[test_452777.js]
+[test_454977.js]
+[test_463863.js]
+[test_485442_crash_bug_nsNavHistoryQuery_GetUri.js]
+[test_486978_sort_by_date_queries.js]
+[test_536081.js]
+[test_1085291.js]
+[test_1105208.js]
+[test_1105866.js]
+[test_adaptive.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_adaptive_bug527311.js]
+[test_analyze.js]
+[test_annotations.js]
+[test_asyncExecuteLegacyQueries.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_async_history_api.js]
+[test_async_in_batchmode.js]
+[test_async_transactions.js]
+skip-if = (os == "win" && os_version == "5.1") # Bug 1158887
+[test_autocomplete_stopSearch_no_throw.js]
+[test_bookmark_catobs.js]
+[test_bookmarks_json.js]
+[test_bookmarks_html.js]
+[test_bookmarks_html_corrupt.js]
+[test_bookmarks_html_import_tags.js]
+[test_bookmarks_html_singleframe.js]
+[test_bookmarks_restore_notification.js]
+[test_bookmarks_setNullTitle.js]
+[test_broken_folderShortcut_result.js]
+[test_browserhistory.js]
+[test_bug636917_isLivemark.js]
+[test_childlessTags.js]
+[test_corrupt_telemetry.js]
+[test_crash_476292.js]
+[test_database_replaceOnStartup.js]
+[test_download_history.js]
+# Bug 676989: test fails consistently on Android
+fail-if = os == "android"
+[test_frecency.js]
+[test_frecency_zero_updated.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_getChildIndex.js]
+[test_getPlacesInfo.js]
+[test_history.js]
+[test_history_autocomplete_tags.js]
+[test_history_catobs.js]
+[test_history_clear.js]
+[test_history_notifications.js]
+[test_history_observer.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_history_sidebar.js]
+[test_hosts_triggers.js]
+[test_import_mobile_bookmarks.js]
+[test_isPageInDB.js]
+[test_isURIVisited.js]
+[test_isvisited.js]
+[test_keywords.js]
+[test_lastModified.js]
+[test_markpageas.js]
+[test_mozIAsyncLivemarks.js]
+[test_multi_queries.js]
+# Bug 676989: test fails consistently on Android
+fail-if = os == "android"
+[test_multi_word_tags.js]
+[test_nsINavHistoryViewer.js]
+# Bug 902248: intermittent timeouts on all platforms
+skip-if = true
+[test_null_interfaces.js]
+[test_onItemChanged_tags.js]
+[test_pageGuid_bookmarkGuid.js]
+[test_frecency_observers.js]
+[test_placeURIs.js]
+[test_PlacesSearchAutocompleteProvider.js]
+[test_PlacesUtils_asyncGetBookmarkIds.js]
+[test_PlacesUtils_invalidateCachedGuidFor.js]
+[test_PlacesUtils_lazyobservers.js]
+[test_placesTxn.js]
+[test_preventive_maintenance.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_preventive_maintenance_checkAndFixDatabase.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_preventive_maintenance_runTasks.js]
+[test_promiseBookmarksTree.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_resolveNullBookmarkTitles.js]
+[test_result_sort.js]
+[test_resultsAsVisit_details.js]
+[test_sql_guid_functions.js]
+[test_svg_favicon.js]
+[test_sync_utils.js]
+[test_tag_autocomplete_search.js]
+[test_tagging.js]
+[test_telemetry.js]
+[test_update_frecency_after_delete.js]
+# Bug 676989: test hangs consistently on Android
+skip-if = os == "android"
+[test_utils_backups_create.js]
+[test_utils_getURLsForContainerNode.js]
+[test_utils_setAnnotationsFor.js]
+[test_visitsInDB.js]
diff --git a/toolkit/components/places/toolkitplaces.manifest b/toolkit/components/places/toolkitplaces.manifest
new file mode 100644
index 000000000..cd9665200
--- /dev/null
+++ b/toolkit/components/places/toolkitplaces.manifest
@@ -0,0 +1,32 @@
+# nsLivemarkService.js
+component {dca61eb5-c7cd-4df1-b0fb-d0722baba251} nsLivemarkService.js
+contract @mozilla.org/browser/livemark-service;2 {dca61eb5-c7cd-4df1-b0fb-d0722baba251}
+
+# nsTaggingService.js
+component {bbc23860-2553-479d-8b78-94d9038334f7} nsTaggingService.js
+contract @mozilla.org/browser/tagging-service;1 {bbc23860-2553-479d-8b78-94d9038334f7}
+component {1dcc23b0-d4cb-11dc-9ad6-479d56d89593} nsTaggingService.js
+contract @mozilla.org/autocomplete/search;1?name=places-tag-autocomplete {1dcc23b0-d4cb-11dc-9ad6-479d56d89593}
+
+# nsPlacesExpiration.js
+component {705a423f-2f69-42f3-b9fe-1517e0dee56f} nsPlacesExpiration.js
+contract @mozilla.org/places/expiration;1 {705a423f-2f69-42f3-b9fe-1517e0dee56f}
+category history-observers nsPlacesExpiration @mozilla.org/places/expiration;1
+
+# PlacesCategoriesStarter.js
+component {803938d5-e26d-4453-bf46-ad4b26e41114} PlacesCategoriesStarter.js
+contract @mozilla.org/places/categoriesStarter;1 {803938d5-e26d-4453-bf46-ad4b26e41114}
+category idle-daily PlacesCategoriesStarter @mozilla.org/places/categoriesStarter;1
+category bookmark-observers PlacesCategoriesStarter @mozilla.org/places/categoriesStarter;1
+
+# ColorAnalyzer.js
+component {d056186c-28a0-494e-aacc-9e433772b143} ColorAnalyzer.js
+contract @mozilla.org/places/colorAnalyzer;1 {d056186c-28a0-494e-aacc-9e433772b143}
+
+# UnifiedComplete.js
+component {f964a319-397a-4d21-8be6-5cdd1ee3e3ae} UnifiedComplete.js
+contract @mozilla.org/autocomplete/search;1?name=unifiedcomplete {f964a319-397a-4d21-8be6-5cdd1ee3e3ae}
+
+# PageIconProtocolHandler.js
+component {60a1f7c6-4ff9-4a42-84d3-5a185faa6f32} PageIconProtocolHandler.js
+contract @mozilla.org/network/protocol;1?name=page-icon {60a1f7c6-4ff9-4a42-84d3-5a185faa6f32}