···11+// Convert all links to open in new tab
22+var matches = window.document.querySelectorAll('a');
33+for (var i = 0; i < matches.length; i++)
44+ matches[i].setAttribute('target', '_blank');
+2
doc/main.md
···11+The main module is a program that creates a widget. When a user clicks on
22+the widget, the program loads the mozilla.org website in a new tab.
+167
lib/main.js
···11+/*
22+33+The new app tab feature in Firefox is great. I use it a lot... which has shown starkly how apps and tabs have completely different use-cases and usage patterns. Often I will check my Gmail app tab because I see the glowing notification that a new email has arrived, do something (or nothing), and then pop back to where I was browsing - in one of those 78 tabs I have open. Well, not "pop" really.
44+55+The windowing model in operating systems allows me to do this with ease. But app tabs do not:
66+77+* If I opened no new tabs while using Gmail, I can still see the last tab I was at, and click on it. But I'm force to use the mouse.
88+* Out of sheer muscle memory and mouse-averseness, sometimes I can traverse tabs via the next/previous-tab keyboard shortcuts to get back to where I was. Sometimes it's a *lot* of tabs, so either I'll hold the arrow key down, speeding past the tab I wanted, or I'll just hit that arrow key a bunch of times in quick succession. Both options are sub-optimal.
99+* Or I have to expend mental energy to search in the awesomebar and switch to that tab, which often looks like this: "hm, type 'bug' and then try to remember some words in the bug summary, but those words match a bunch of other bugs, and i don't know the bug number, and also I'm on an attachment page because I'm reviewing a patch on the bug, so the summary won't be in the page title..." and on and on.
1010+* Then there's link opening. Links opened in app tabs are put at the beginning of the tab set, and the tab strip is animatedly scrolled there. Boom, already lost where I was before checking my email. We tried an experiment where they open at the end of the set of open tabs, but I found that to have serious "out of sight, out of mind" problems. That experiment was rolled back. Both approaches cause excess amounts of whizzing animations, either when you want to "go around the horn" to get to the tabs you just opened from app tabs, or when you want to go to them and then get back to where you were.
1111+* And the biggest problem in my opinion: The user is not in control of where these links are opened. Part of me thinks that I actually might work best in a one-tab-group-per-app-tab world... but that's a vision for another day (and blog post and add-on!).
1212+1313+1414+So I've tried to build a hybrid solution: Instead of making you go to your app tabs, your app tabs can come to you. Peek allows you to open your app tabs in a floating panel that opens on top of wherever you are in your tabs. Links open to the right of whatever your current active tab is, and in the background, so that when you're done peeking, you are exactly where you left off.
1515+1616+To use Peek, first create some app tabs. Then you can peek at them using the keyboard shortcut "ALT+SHIFT+1-9" where the number corresponds with the order your app tabs are in. To stop peeking, hit escape (or switch apps or anything else that takes focus away from the panel).
1717+1818+Features:
1919+- be able to interact with your apps and go exactly back to where you left off browsing.
2020+- links opened from app tabs are in context of... well, at least something! not at beginning or end of tabstrip, which gives you at least more control over where they end up.
2121+2222+*/
2323+2424+/*
2525+TODO
2626+- add a UI launcher somehow (drop button?)
2727+- remove pinned tabs altogether!
2828+*/
2929+3030+const {Cc, Ci, Cu, Cm} = require('chrome');
3131+const { Hotkey } = require('hotkeys');
3232+const { Panel } = require('panel-custom-frame');
3333+const Data = require('self').data;
3434+const Observers = require('observer-service');
3535+const Prefs = require('preferences-service');
3636+const Tabs = require('tabs');
3737+const Timers = require('timer');
3838+const Windows = require('windows').browserWindows;
3939+const WinUtils = require("window-utils");
4040+4141+const TAB_PREF = 'browser.tabs.loadDivertedInBackground';
4242+const COMBO = 'alt-shift-';
4343+4444+// 5 minutes
4545+const INTERVAL = 1000 * 60 * 5;
4646+4747+let inited,
4848+ lastPrefVal,
4949+ cache = [],
5050+ windowHeight,
5151+ windowWidth;
5252+5353+function getPanel() {
5454+ let panel = Panel({
5555+ contentURL: 'about:blank',
5656+ height: getPanelDimension(windowHeight),
5757+ width: getPanelDimension(windowWidth),
5858+ contentScriptFile: Data.url('panel.js'),
5959+ contentScriptWhen: 'ready',
6060+ onShow: function() {
6161+ lastPrefVal = Prefs.get(TAB_PREF);
6262+ if (!lastPrefVal)
6363+ Prefs.set(TAB_PREF, true);
6464+ },
6565+ onHide: function() {
6666+ if (lastPrefVal != Prefs.get(TAB_PREF))
6767+ Prefs.set(TAB_PREF, lastPrefVal);
6868+ }
6969+ });
7070+ return panel;
7171+}
7272+7373+function getPanelDimension(amount) Math.round(amount * 0.9)
7474+7575+// Build and cache hotkey+panels for each app tab
7676+function setup() {
7777+ // destroy existing hotkey+panel combos
7878+ cache.forEach(function(hotkey) {
7979+ hotkey.destroy();
8080+ });
8181+ cache = [];
8282+8383+ for (var i = 0; i < 10; i++) {
8484+ var jetpackTab = Windows.activeWindow.tabs[i];
8585+ if (jetpackTab && jetpackTab.isPinned) {
8686+ let index = jetpackTab.index;
8787+ let hotkeyNum = index == 9 ? 0 : (index + 1);
8888+ let hotkey = Hotkey({
8989+ // WTF - setting this to typo'd 'hotkeynum' doesn't throw!
9090+ combo: (COMBO + hotkeyNum),
9191+ onPress: function() {
9292+ console.log('onPress(): index ', index);
9393+ let panel = getPanel();
9494+ //let oldFrame = panel.frame;
9595+ //console.log('onPress(): oldFrame ', frame);
9696+ let tabbrowser = WinUtils.activeBrowserWindow.gBrowser;
9797+ let frame = tabbrowser.getBrowserAtIndex(index);
9898+ console.log('onPress(): frame ', frame.currentURI.spec);
9999+ panel.frame = frame;
100100+ console.log('onPress(): new frame uri ', panel.frame.currentURI.spec);
101101+ // need to support switching while panel is open
102102+ panel.on('hide', function() {
103103+ panel.frame = null;
104104+ panel.destroy();
105105+ });
106106+ panel.show();
107107+ console.log('onPress(): done');
108108+ }
109109+ });
110110+111111+ cache[i] = hotkey;
112112+ }
113113+ }
114114+}
115115+116116+// Window resize event handler
117117+function onResize(msg) {
118118+ windowHeight = msg.height;
119119+ windowWidth = msg.width;
120120+121121+ // Initializing on the first received resize event ensures
122122+ // that it occurs for both running and startup installs.
123123+ if (!inited) {
124124+ setup();
125125+ inited = true;
126126+ }
127127+}
128128+129129+// When a tab activates, attach our content script
130130+// and remove when deactivated. Content script is for:
131131+// - getting window size
132132+function onTabActivate(tab) {
133133+ let worker = tab.attach({
134134+ contentScriptFile: Data.url('content.js'),
135135+ contentScriptWhen: 'ready'
136136+ });
137137+ worker.port.on('resize', onResize);
138138+ tab.on('deactivate', function(tab) {
139139+ worker.destroy();
140140+ });
141141+}
142142+143143+// Handle current tab activation manually in case we're installed
144144+// in a running instance. This gets us initial window size which
145145+// triggers initial filling of cache.
146146+onTabActivate(Tabs.activeTab);
147147+148148+// Listen for tab switching
149149+Tabs.on('activate', onTabActivate);
150150+151151+let delegate = {
152152+ onTrack: function (window) {
153153+ if (window.document.documentElement.getAttribute('windowtype') == 'navigator:browser') {
154154+ let container = window.gBrowser.tabContainer;
155155+ container.addEventListener("TabPinned", setup, false);
156156+ container.addEventListener("TabUnpinned", setup, false);
157157+ }
158158+ },
159159+ onUntrack: function (window) {
160160+ if (window.document.documentElement.getAttribute('windowtype') == 'navigator:browser') {
161161+ let container = window.gBrowser.tabContainer;
162162+ container.removeEventListener("TabPinned", setup, false);
163163+ container.removeEventListener("TabUnpinned", setup, false);
164164+ }
165165+ }
166166+};
167167+new WinUtils.WindowTracker(delegate);
+380
lib/panel-custom-frame.js
···11+/* This Source Code Form is subject to the terms of the Mozilla Public
22+ * License, v. 2.0. If a copy of the MPL was not distributed with this
33+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44+55+"use strict";
66+77+if (!require("api-utils/xul-app").is("Firefox")) {
88+ throw new Error([
99+ "The panel module currently supports only Firefox. In the future ",
1010+ "we would like it to support other applications, however. Please see ",
1111+ "https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps ",
1212+ "for more information."
1313+ ].join(""));
1414+}
1515+1616+const { Cc, Ci } = require("chrome");
1717+1818+const { validateOptions: valid } = require("api-utils/api-utils");
1919+const { Symbiont } = require("api-utils/content");
2020+const { EventEmitter } = require('api-utils/events');
2121+const timer = require("api-utils/timer");
2222+const runtime = require("api-utils/runtime");
2323+2424+const windowMediator = Cc['@mozilla.org/appshell/window-mediator;1'].
2525+ getService(Ci.nsIWindowMediator);
2626+2727+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
2828+ ON_SHOW = 'popupshown',
2929+ ON_HIDE = 'popuphidden',
3030+ validNumber = { is: ['number', 'undefined', 'null'] };
3131+3232+/**
3333+ * Emits show and hide events.
3434+ */
3535+const Panel = Symbiont.resolve({
3636+ constructor: '_init',
3737+ _onInit: '_onSymbiontInit',
3838+ destroy: '_symbiontDestructor',
3939+ _documentUnload: '_workerDocumentUnload'
4040+}).compose({
4141+ _frame: Symbiont.required,
4242+ _init: Symbiont.required,
4343+ _onSymbiontInit: Symbiont.required,
4444+ _symbiontDestructor: Symbiont.required,
4545+ _emit: Symbiont.required,
4646+ _asyncEmit: Symbiont.required,
4747+ on: Symbiont.required,
4848+ removeListener: Symbiont.required,
4949+5050+ _inited: false,
5151+5252+ /**
5353+ * If set to `true` frame loaders between xul panel frame and
5454+ * hidden frame are swapped. If set to `false` frame loaders are
5555+ * set back to normal. Setting the value that was already set will
5656+ * have no effect.
5757+ */
5858+ set _frameLoadersSwapped(value) {
5959+ if (this.__frameLoadersSwapped == value) return;
6060+ console.log('swapping fls, frame: ', this._frame);
6161+ //console.log('uri:', this._frame.currentURI.spec);
6262+ this._frame.QueryInterface(Ci.nsIFrameLoaderOwner)
6363+ .swapFrameLoaders(this._viewFrame);
6464+ this.__frameLoadersSwapped = value;
6565+ },
6666+ __frameLoadersSwapped: false,
6767+6868+ constructor: function Panel(options) {
6969+ this._onShow = this._onShow.bind(this);
7070+ this._onHide = this._onHide.bind(this);
7171+ this.on('inited', this._onSymbiontInit.bind(this));
7272+7373+ options = options || {};
7474+ if ('onShow' in options)
7575+ this.on('show', options.onShow);
7676+ if ('onHide' in options)
7777+ this.on('hide', options.onHide);
7878+ if ('width' in options)
7979+ this.width = options.width;
8080+ if ('height' in options)
8181+ this.height = options.height;
8282+ if ('contentURL' in options)
8383+ this.contentURL = options.contentURL;
8484+8585+ this._init(options);
8686+ },
8787+ _destructor: function _destructor() {
8888+ this.hide();
8989+ this._removeAllListeners('show');
9090+ // defer cleanup to be performed after panel gets hidden
9191+ this._xulPanel = null;
9292+ this._symbiontDestructor(this);
9393+ this._removeAllListeners();
9494+ },
9595+ destroy: function destroy() {
9696+ this._destructor();
9797+ },
9898+ /* Public API: Panel.width */
9999+ get width() this._width,
100100+ set width(value)
101101+ this._width = valid({ $: value }, { $: validNumber }).$ || this._width,
102102+ _width: 320,
103103+ /* Public API: Panel.height */
104104+ get height() this._height,
105105+ set height(value)
106106+ this._height = valid({ $: value }, { $: validNumber }).$ || this._height,
107107+ _height: 240,
108108+109109+ get frame() this._frame,
110110+ set frame(value) {
111111+ console.log('set frame(): ', value);
112112+ this._frame = value;
113113+ },
114114+115115+ /* Public API: Panel.isShowing */
116116+ get isShowing() !!this._xulPanel && this._xulPanel.state == "open",
117117+118118+ /* Public API: Panel.show */
119119+ show: function show(anchor) {
120120+ anchor = anchor || null;
121121+ let document = getWindow(anchor).document;
122122+ let xulPanel = this._xulPanel;
123123+ if (!xulPanel) {
124124+ xulPanel = this._xulPanel = document.createElementNS(XUL_NS, 'panel');
125125+ xulPanel.setAttribute("type", "arrow");
126126+127127+ // One anonymous node has a big padding that doesn't work well with
128128+ // Jetpack, as we would like to display an iframe that completely fills
129129+ // the panel.
130130+ // -> Use a XBL wrapper with inner stylesheet to remove this padding.
131131+ let css = ".panel-inner-arrowcontent, .panel-arrowcontent {padding: 0;}";
132132+ let originalXBL = "chrome://global/content/bindings/popup.xml#arrowpanel";
133133+ let binding =
134134+ '<bindings xmlns="http://www.mozilla.org/xbl">' +
135135+ '<binding id="id" extends="' + originalXBL + '">' +
136136+ '<resources>' +
137137+ '<stylesheet src="data:text/css,' +
138138+ document.defaultView.encodeURIComponent(css) + '"/>' +
139139+ '</resources>' +
140140+ '</binding>' +
141141+ '</bindings>';
142142+ xulPanel.style.MozBinding = 'url("data:text/xml,' +
143143+ document.defaultView.encodeURIComponent(binding) + '")';
144144+145145+ let frame = document.createElementNS(XUL_NS, 'iframe');
146146+ frame.setAttribute('type', 'content');
147147+ frame.setAttribute('flex', '1');
148148+ frame.setAttribute('transparent', 'transparent');
149149+ if (runtime.OS === "Darwin") {
150150+ frame.style.borderRadius = "6px";
151151+ frame.style.padding = "1px";
152152+ }
153153+154154+ // Load an empty document in order to have an immediatly loaded iframe,
155155+ // so swapFrameLoaders is going to work without having to wait for load.
156156+ frame.setAttribute("src","data:,");
157157+158158+ xulPanel.appendChild(frame);
159159+ document.getElementById("mainPopupSet").appendChild(xulPanel);
160160+ }
161161+ let { width, height } = this, x, y, position;
162162+163163+ if (!anchor) {
164164+ // Open the popup in the middle of the window.
165165+ x = document.documentElement.clientWidth / 2 - width / 2;
166166+ y = document.documentElement.clientHeight / 2 - height / 2;
167167+ position = null;
168168+ }
169169+ else {
170170+ // Open the popup by the anchor.
171171+ let rect = anchor.getBoundingClientRect();
172172+173173+ let window = anchor.ownerDocument.defaultView;
174174+175175+ let zoom = window.mozScreenPixelsPerCSSPixel;
176176+ let screenX = rect.left + window.mozInnerScreenX * zoom;
177177+ let screenY = rect.top + window.mozInnerScreenY * zoom;
178178+179179+ // Set up the vertical position of the popup relative to the anchor
180180+ // (always display the arrow on anchor center)
181181+ let horizontal, vertical;
182182+ if (screenY > window.screen.availHeight / 2 + height)
183183+ vertical = "top";
184184+ else
185185+ vertical = "bottom";
186186+187187+ if (screenY > window.screen.availWidth / 2 + width)
188188+ horizontal = "left";
189189+ else
190190+ horizontal = "right";
191191+192192+ let verticalInverse = vertical == "top" ? "bottom" : "top";
193193+ position = vertical + "center " + verticalInverse + horizontal;
194194+195195+ // Allow panel to flip itself if the panel can't be displayed at the
196196+ // specified position (useful if we compute a bad position or if the
197197+ // user moves the window and panel remains visible)
198198+ xulPanel.setAttribute("flip","both");
199199+ }
200200+201201+ // Resize the iframe instead of using panel.sizeTo
202202+ // because sizeTo doesn't work with arrow panels
203203+ xulPanel.firstChild.style.width = width + "px";
204204+ xulPanel.firstChild.style.height = height + "px";
205205+206206+ // Wait for the XBL binding to be constructed
207207+ function waitForBinding() {
208208+ if (!xulPanel.openPopup) {
209209+ timer.setTimeout(waitForBinding, 50);
210210+ return;
211211+ }
212212+ xulPanel.openPopup(anchor, position, x, y);
213213+ }
214214+ waitForBinding();
215215+216216+ return this._public;
217217+ },
218218+ /* Public API: Panel.hide */
219219+ hide: function hide() {
220220+ // The popuphiding handler takes care of swapping back the frame loaders
221221+ // and removing the XUL panel from the application window, we just have to
222222+ // trigger it by hiding the popup.
223223+ // XXX Sometimes I get "TypeError: xulPanel.hidePopup is not a function"
224224+ // when quitting the host application while a panel is visible. To suppress
225225+ // them, this now checks for "hidePopup" in xulPanel before calling it.
226226+ // It's not clear if there's an actual issue or the error is just normal.
227227+ let xulPanel = this._xulPanel;
228228+ if (xulPanel && "hidePopup" in xulPanel)
229229+ xulPanel.hidePopup();
230230+ return this._public;
231231+ },
232232+233233+ /* Public API: Panel.resize */
234234+ resize: function resize(width, height) {
235235+ this.width = width;
236236+ this.height = height;
237237+ // Resize the iframe instead of using panel.sizeTo
238238+ // because sizeTo doesn't work with arrow panels
239239+ let xulPanel = this._xulPanel;
240240+ if (xulPanel) {
241241+ xulPanel.firstChild.style.width = width + "px";
242242+ xulPanel.firstChild.style.height = height + "px";
243243+ }
244244+ },
245245+246246+ // While the panel is visible, this is the XUL <panel> we use to display it.
247247+ // Otherwise, it's null.
248248+ get _xulPanel() this.__xulPanel,
249249+ set _xulPanel(value) {
250250+ let xulPanel = this.__xulPanel;
251251+ if (value === xulPanel) return;
252252+ if (xulPanel) {
253253+ xulPanel.removeEventListener(ON_HIDE, this._onHide, false);
254254+ xulPanel.removeEventListener(ON_SHOW, this._onShow, false);
255255+ xulPanel.parentNode.removeChild(xulPanel);
256256+ }
257257+ if (value) {
258258+ value.addEventListener(ON_HIDE, this._onHide, false);
259259+ value.addEventListener(ON_SHOW, this._onShow, false);
260260+ }
261261+ this.__xulPanel = value;
262262+ },
263263+ __xulPanel: null,
264264+ get _viewFrame() this.__xulPanel.children[0],
265265+ /**
266266+ * When the XUL panel becomes hidden, we swap frame loaders back to move
267267+ * the content of the panel to the hidden frame & remove panel element.
268268+ */
269269+ _onHide: function _onHide() {
270270+ try {
271271+ this._frameLoadersSwapped = false;
272272+ this._xulPanel = null;
273273+ this._emit('hide');
274274+ } catch(e) {
275275+ this._emit('error', e);
276276+ }
277277+ },
278278+ /**
279279+ * When the XUL panel becomes shown, we swap frame loaders between panel
280280+ * frame and hidden frame to preserve state of the content dom.
281281+ */
282282+ _onShow: function _onShow() {
283283+ try {
284284+ if (!this._inited) { // defer if not initialized yet
285285+ this.on('inited', this._onShow.bind(this));
286286+ } else {
287287+ console.log('_onShow(): frame', typeof this._frame);
288288+ //console.log('_onShow(): ', this._frame.currentURI.spec);
289289+ //this._frameLoadersSwapped.bind(this);
290290+ this._frameLoadersSwapped = true;
291291+292292+ // Retrieve computed text color style in order to apply to the iframe
293293+ // document. As MacOS background is dark gray, we need to use skin's
294294+ // text color.
295295+ let win = this._xulPanel.ownerDocument.defaultView;
296296+ let node = win.document.getAnonymousElementByAttribute(this._xulPanel,
297297+ "class", "panel-inner-arrowcontent");
298298+ let textColor = win.getComputedStyle(node).getPropertyValue("color");
299299+ let doc = this._xulPanel.firstChild.contentDocument;
300300+ let style = doc.createElement("style");
301301+ style.textContent = "body { color: " + textColor + "; }";
302302+ let container = doc.head ? doc.head : doc.documentElement;
303303+304304+ if (container.firstChild)
305305+ container.insertBefore(style, container.firstChild);
306306+ else
307307+ container.appendChild(style);
308308+309309+ this._emit('show');
310310+ }
311311+ } catch(e) {
312312+ this._emit('error', e);
313313+ }
314314+ },
315315+ /**
316316+ * Notification that panel was fully initialized.
317317+ */
318318+ _onInit: function _onInit() {
319319+ this._inited = true;
320320+321321+ // Avoid panel document from resizing the browser window
322322+ // New platform capability added through bug 635673
323323+ if ("allowWindowControl" in this._frame.docShell)
324324+ this._frame.docShell.allowWindowControl = false;
325325+326326+ // perform all deferred tasks like initSymbiont, show, hide ...
327327+ // TODO: We're publicly exposing a private event here; this
328328+ // 'inited' event should really be made private, somehow.
329329+ this._emit('inited');
330330+ },
331331+332332+ // Catch document unload event in order to rebind load event listener with
333333+ // Symbiont._initFrame if Worker._documentUnload destroyed the worker
334334+ _documentUnload: function(subject, topic, data) {
335335+ if (this._workerDocumentUnload(subject, topic, data)) {
336336+ this._initFrame(this._frame);
337337+ return true;
338338+ }
339339+ return false;
340340+ }
341341+});
342342+exports.Panel = function(options) Panel(options)
343343+exports.Panel.prototype = Panel.prototype;
344344+345345+function getWindow(anchor) {
346346+ let window;
347347+348348+ if (anchor) {
349349+ let anchorWindow = anchor.ownerDocument.defaultView.top;
350350+ let anchorDocument = anchorWindow.document;
351351+352352+ let enumerator = windowMediator.getEnumerator("navigator:browser");
353353+ while (enumerator.hasMoreElements()) {
354354+ let enumWindow = enumerator.getNext();
355355+356356+ // Check if the anchor is in this browser window.
357357+ if (enumWindow == anchorWindow) {
358358+ window = anchorWindow;
359359+ break;
360360+ }
361361+362362+ // Check if the anchor is in a browser tab in this browser window.
363363+ let browser = enumWindow.gBrowser.getBrowserForDocument(anchorDocument);
364364+ if (browser) {
365365+ window = enumWindow;
366366+ break;
367367+ }
368368+369369+ // Look in other subdocuments (sidebar, etc.)?
370370+ }
371371+ }
372372+373373+ // If we didn't find the anchor's window (or we have no anchor),
374374+ // return the most recent browser window.
375375+ if (!window)
376376+ window = windowMediator.getMostRecentWindow("navigator:browser");
377377+378378+ return window;
379379+}
380380+
+2270
lib/places.js
···11+/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
22+/* vim:set ts=2 sw=2 sts=2 et: */
33+/* ***** BEGIN LICENSE BLOCK *****
44+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
55+ *
66+ * The contents of this file are subject to the Mozilla Public License Version
77+ * 1.1 (the "License"); you may not use this file except in compliance with
88+ * the License. You may obtain a copy of the License at
99+ * http://www.mozilla.org/MPL/
1010+ *
1111+ * Software distributed under the License is distributed on an "AS IS" basis,
1212+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
1313+ * for the specific language governing rights and limitations under the
1414+ * License.
1515+ *
1616+ * The Original Code is Jetpack.
1717+ *
1818+ * The Initial Developer of the Original Code is the Mozilla Foundation.
1919+ * Portions created by the Initial Developer are Copyright (C) 2010
2020+ * the Initial Developer. All Rights Reserved.
2121+ *
2222+ * Contributor(s):
2323+ * Marco Bonardo <mak77@bonardo.net> (Original Author)
2424+ *
2525+ * Alternatively, the contents of this file may be used under the terms of
2626+ * either the GNU General Public License Version 2 or later (the "GPL"), or
2727+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
2828+ * in which case the provisions of the GPL or the LGPL are applicable instead
2929+ * of those above. If you wish to allow use of your version of this file only
3030+ * under the terms of either the GPL or the LGPL, and not to allow others to
3131+ * use your version of this file under the terms of the MPL, indicate your
3232+ * decision by deleting the provisions above and replace them with the notice
3333+ * and other provisions required by the GPL or the LGPL. If you do not delete
3434+ * the provisions above, a recipient may use your version of this file under
3535+ * the terms of any one of the MPL, the GPL or the LGPL.
3636+ *
3737+ * ***** END LICENSE BLOCK ***** */
3838+3939+const {Cc, Ci, Cr, Cu} = require("chrome");
4040+4141+// PlacesQuery is currently included at the bottom of this file,
4242+// to ease distribution by not tying Jetpack's usage of it to a
4343+// particular Firefox version.
4444+//Cu.import("resource://gre/modules/PlacesQuery.jsm", this);
4545+Cu.import("resource://gre/modules/PlacesUtils.jsm", this);
4646+4747+const apiUtils = require("api-utils");
4848+const collection = require("collection");
4949+const errors = require("errors");
5050+5151+5252+// Main search function.
5353+exports.search = (new PlacesHandler()).search;
5454+5555+5656+// Shortcut helper to search visited places.
5757+exports.history = new PlacesHandler({ visited: {} });
5858+// Shortcut helper to search bookmarked places.
5959+exports.bookmarks = new PlacesHandler({ bookmarked: {} });
6060+6161+6262+// Add bookmark root folder shortcuts.
6363+exports.bookmarks.unfiled = PlacesUtils.unfiledBookmarksFolderId;
6464+exports.bookmarks.toolbar = PlacesUtils.toolbarFolderId;
6565+exports.bookmarks.menu = PlacesUtils.bookmarksMenuFolderId;
6666+6767+6868+// Method for creating a bookmark. Can take an options object or an array of
6969+// options objects to create bookmarks in batch.
7070+exports.bookmarks.create = function PF_create(aOptions) {
7171+ let options = validateBookmarkInfo(aOptions, true);
7272+7373+ let bs = PlacesUtils.bookmarks;
7474+7575+ // Create the bookmark item.
7676+ try {
7777+ switch(options.type) {
7878+ case "bookmark":
7979+ options._itemId =
8080+ bs.insertBookmark(options.folder,
8181+ PlacesUtils._uri(options.location),
8282+ options.position,
8383+ options.title);
8484+8585+ if (options.tags.length > 0) {
8686+ PlacesUtils.tagging.tagURI(PlacesUtils._uri(options.location),
8787+ options.tags);
8888+ }
8989+ break;
9090+ case "folder":
9191+ options._itemId =
9292+ PlacesUtils.bookmarks.createFolder(options.folder,
9393+ options.title,
9494+ options.position);
9595+ break;
9696+ case "separator":
9797+ options._itemId =
9898+ PlacesUtils.bookmarks.insertSeparator(options.folder,
9999+ options.position);
100100+ }
101101+102102+ /*
103103+ if (options.annotations) {
104104+ PlacesUtils.setAnnotationsForItem(options._itemId,
105105+ options.annotations);
106106+ }
107107+ */
108108+ }
109109+ catch (err) {
110110+ console.exception("Failed to create new bookmark. " + err);
111111+ }
112112+113113+ if (options.onCreate) {
114114+ safeCallback(undefined, options.onCreate, options);
115115+ }
116116+};
117117+118118+119119+/**
120120+ * Wrapper for safely calling user-callback functions.
121121+ * TODO: file a bug for getting this into api-utils.
122122+ */
123123+function safeCallback(aArgument, aCallbackFunc, aCallbackScope) {
124124+ if (aCallbackFunc) {
125125+ require("timer").setTimeout(function() {
126126+ try {
127127+ if (aCallbackScope)
128128+ aCallbackFunc.call(aCallbackScope, aArgument);
129129+ else
130130+ aCallbackFunc.call(exports, aArgument); // safe "this".
131131+ }
132132+ catch (err) {
133133+ console.exception(err);
134134+ }
135135+ }, 0);
136136+ }
137137+}
138138+139139+140140+/**
141141+ * This is the basic exposed object for searching.
142142+ * The caller will get access to it via an alias such as "bookmarks"
143143+ * or "history" and call it's .search() method.
144144+ */
145145+function PlacesHandler(helperOptions) {
146146+ this.search = function PH_createNewFilter(userOptions) {
147147+ // Merge helper configuration to user configurations.
148148+ let options = validateAndMergeConfigs(userOptions, helperOptions);
149149+ // Create and return a PlacesSearch.
150150+ return new PlacesSearch(options);
151151+ }
152152+}
153153+PlacesHandler.prototype = {}
154154+155155+156156+/**
157157+ * apiUtils method does not support "date" type.
158158+ */
159159+function checkType(entry, type) {
160160+ switch (type) {
161161+ case "undefined":
162162+ return entry === undefined;
163163+ case "null":
164164+ return entry === null;
165165+ case "date":
166166+ return Object.prototype.toString.call(entry) === "[object Date]";
167167+ case "array":
168168+ return Object.prototype.toString.call(entry) === "[object Array]";
169169+ default:
170170+ return typeof(entry) == type;
171171+ }
172172+}
173173+174174+/**
175175+ * apiUtils method does not support things like "array of optional string" or
176176+ * "array of positive optional number".
177177+ */
178178+function checkArrayElementsType(array, type, allowOptionalElement) {
179179+ let arrayIsValid = true;
180180+ array.every(function(elm) {
181181+ if (allowOptionalElement && (elm === undefined || elm === null))
182182+ return true;
183183+ return arrayIsValid = checkType(elm, type);
184184+ });
185185+ return arrayIsValid;
186186+}
187187+188188+/**
189189+ * Take caller-supplied options and merge them with a set of default
190190+ * options.
191191+ */
192192+function validateAndMergeConfigs(userOptions, additionalOptions) {
193193+ userOptions = apiUtils.validateOptions(userOptions, {
194194+ phrase: {
195195+ map: function(v) v.toString(),
196196+ is: ["undefined", "string"],
197197+ ok: function(v) !v || v.length > 0,
198198+ msg: "Provided phrase must be a non-empty string."
199199+ },
200200+ host: {
201201+ map: function(v) v.toString(),
202202+ is: ["undefined", "string"],
203203+ ok: function(v) !v || v.length > 0,
204204+ msg: "Provided host must be a non-empty string."
205205+ },
206206+ uri: {
207207+ map: function(v) v.toString(),
208208+ is: ["undefined", "string"],
209209+ ok: function(v) !v || v.length > 0,
210210+ msg: "Provided uri must be a non empty string."
211211+ },
212212+ annotated: {
213213+ is: ["undefined", "array"],
214214+ ok: function (v) !v || v.length > 0,
215215+ msg: "Required annotations must be a valid array of strings."
216216+ },
217217+ bookmarked: apiUtils.validateOptions(userOptions.bookmarked, {
218218+ is: ["undefined", "boolean"],
219219+ ok: function (v) !v || apiUtils.validateOptions(userOptions.bookmarked, {
220220+ tags: {
221221+ is: ["undefined", "array"],
222222+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "string")),
223223+ msg: "Tags must be a valid array of strings."
224224+ },
225225+ folder: {
226226+ is: ["undefined", "number"],
227227+ ok: function(v) !v || v > 0,
228228+ msg: "Folder id must be a positive number."
229229+ },
230230+ position: {
231231+ is: ["undefined", "number"],
232232+ ok: function(v) !v || v > 0,
233233+ msg: "Position must be a positive number."
234234+ },
235235+ id: {
236236+ is: ["undefined", "number"],
237237+ ok: function(v) !v || v > 0,
238238+ msg: "Bookmark id must be a positive number."
239239+ },
240240+ created: {
241241+ is: ["undefined", "array"],
242242+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "date", true)),
243243+ msg: "Bookmark creation times must be an array of two optional Date objects."
244244+ },
245245+ modified: {
246246+ is: ["undefined", "array"],
247247+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "date", true)),
248248+ msg: "Bookmark modification times must be an array of two optional Date objects."
249249+ }
250250+ }),
251251+ msg: "Bookmarked configuration is incorrect."
252252+ }),
253253+ visited: apiUtils.validateOptions(userOptions.visited, {
254254+ is: ["undefined", "object", "boolean"],
255255+ ok: function (v) !v || apiUtils.validateOptions(userOptions.visited, {
256256+ count: {
257257+ is: ["undefined", "array"],
258258+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "number", true)),
259259+ msg: "Visit count must be an array of two optional numbers."
260260+ },
261261+ transitions: {
262262+ is: ["undefined", "array"],
263263+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "number", true)),
264264+ msg: "Transitions must be an array of valid transition values."
265265+ },
266266+ when: {
267267+ is: ["undefined", "array"],
268268+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "date", true)),
269269+ msg: "Visit times must be an array of two optional Date objects."
270270+ },
271271+ includeAllVisits: {
272272+ is: ["undefined", "boolean"]
273273+ }
274274+ }),
275275+ msg: "Visited configuration is incorrect."
276276+ }),
277277+ sortBy: {
278278+ is: ["undefined", "string"],
279279+ ok: function(v) !v || ["none", "title", "time", "uri", "accessCount",
280280+ "lastModified", "frecency"].indexOf(v) != -1,
281281+ msg: "Sorting must define an acceptable string for by."
282282+ },
283283+ sortDir: {
284284+ is: ["undefined", "string"],
285285+ ok: function(v) !v || ["asc", "desc"].indexOf(v) != -1,
286286+ msg: "sorting must define an acceptable direction."
287287+ },
288288+ limit: {
289289+ is: ["undefined", "number"],
290290+ ok: function (v) !v || v > 0,
291291+ msg: "Can limit only on positive number of results."
292292+ },
293293+ onResult: {
294294+ is: ["undefined", "function"]
295295+ },
296296+ onComplete: {
297297+ is: ["undefined", "function"]
298298+ },
299299+ onChange: {
300300+ is: ["undefined", "function"]
301301+ },
302302+ onRemove: {
303303+ is: ["undefined", "function"]
304304+ }
305305+ });
306306+307307+ // This will only contain visited or bookmarked properties.
308308+ additionalOptions = apiUtils.validateOptions(additionalOptions, {
309309+ visited: {
310310+ is: ["undefined", "object", "boolean"]
311311+ },
312312+ bookmarked: {
313313+ is: ["undefined", "object", "boolean"]
314314+ },
315315+ });
316316+317317+ // Do the merge.
318318+ for (let prop in additionalOptions) {
319319+ if (!userOptions[prop])
320320+ userOptions[prop] = additionalOptions[prop];
321321+ }
322322+323323+ return userOptions;
324324+}
325325+326326+327327+// Defaults for bookmark properties.
328328+let bookmarkDefaults = {
329329+ title: null,
330330+ type: "bookmark",
331331+ folder: PlacesUtils.unfiledBookmarksFolderId,
332332+ position: PlacesUtils.bookmarks.DEFAULT_INDEX,
333333+ tags: []
334334+};
335335+336336+function validateBookmarkInfo(aOptions, aProvideDefaults) {
337337+ aOptions = apiUtils.validateOptions(aOptions, {
338338+ location: {
339339+ map: function(v) v.toString(),
340340+ is: ["undefined", "string"],
341341+ ok: function(v) !v || v.length > 0,
342342+ msg: "Bookmark location must be a non empty string."
343343+ },
344344+ title: {
345345+ map: function(v) v.toString(),
346346+ is: ["undefined", "string"],
347347+ ok: function(v) !v || v.length > 0
348348+ },
349349+ folder: {
350350+ is: ["undefined", "number"],
351351+ ok: function(v) !v || v > 0,
352352+ msg: "Required containing folder id must be a positive number."
353353+ },
354354+ position: {
355355+ is: ["undefined", "number"],
356356+ ok: function(v) !v || v >= 0,
357357+ msg: "Bookmark position, if present, must be a non-negative number."
358358+ },
359359+ tags: {
360360+ is: ["undefined", "array"],
361361+ ok: function(v) !v || (v.length > 0 && checkArrayElementsType(v, "string")),
362362+ msg: "Tags must be a valid array of strings."
363363+ },
364364+ //annotations: validateAnnotations,
365365+ type: {
366366+ is: ["undefined", "string"],
367367+ ok: function(v) !v || ["bookmark", "separator", "folder"].indexOf(v) != -1,
368368+ msg: "Bookmark type must be one of: bookmark, separator or folder."
369369+ },
370370+ onCreate: {
371371+ is: ["undefined", "function"],
372372+ }
373373+ });
374374+375375+ if (aProvideDefaults && !aOptions.type) {
376376+ function checkProps(aObject, aDefaultObject) {
377377+ for (let prop in aDefaultObject) {
378378+ if (!(prop in aObject))
379379+ aObject[prop] = aDefaultObject[prop];
380380+ else if (typeof(aObject[prop]) == "object")
381381+ checkProps(aObject[prop], aDefaultObject[prop])
382382+ }
383383+ }
384384+ checkProps(aOptions, bookmarkDefaults);
385385+386386+ if (aOptions.type == "bookmark" &&
387387+ !("location" in aOptions) || aOptions.title.length == 0)
388388+ throw new Error("Must provide a valid location for the bookmark.");
389389+390390+ }
391391+ return aOptions;
392392+}
393393+394394+let validateAnnotations = {
395395+ is: ["undefined", "array"],
396396+ ok: function(v) {
397397+ return !v || (v.length > 0 &&
398398+ checkArrayElementsType(v, "object") &&
399399+ v.every(function(a) a.name && a.value))
400400+ },
401401+ msg: "Annotations must be a valid array of { name: '', value: '' } objects."
402402+};
403403+404404+405405+/**
406406+ * An object that is returned by .search() and can be used to act on
407407+ * entries.
408408+ */
409409+function PlacesSearch(aOptions) {
410410+ let query = new PlacesQuery(aOptions);
411411+412412+ this.change = function PS_change(aChangeOptions) {
413413+ // Allow editing of bookmark properties if a bookmark query.
414414+ if ("bookmarked" in aOptions) {
415415+ validateBookmarkInfo(aChangeOptions);
416416+ }
417417+ else {
418418+ throw new Error("Editing of history is not supported at this time.");
419419+ }
420420+ /*
421421+ // Otherwise only validate annotation properties
422422+ else {
423423+ aChangeOptions = apiUtils.validateOptions(aChangeOptions, {
424424+ annotations: validateAnnotations
425425+ });
426426+ }
427427+ */
428428+429429+ // When the owning query has finished, pass results to the walker that
430430+ // will make the changes, re-query to update the results, and then call
431431+ // the user's callback.
432432+ function changeCallback() {
433433+ new QueryExecutor(query, null, aOptions.onChange, aOptions);
434434+ }
435435+436436+ let walker = new Walker(changeCallback, {}, function(result) {
437437+ let txns = [];
438438+ if ("bookmarked" in aOptions) {
439439+ let bs = PlacesUtils.bookmarks;
440440+ // location
441441+ if (aChangeOptions.location)
442442+ txns.push(new PlacesEditBookmarkURITransaction(result._itemId,
443443+ PlacesUtils._uri(aChangeOptions.location)));
444444+ // title
445445+ if (aChangeOptions.title) {
446446+ txns.push(new PlacesEditItemTitleTransaction(result._itemId, aChangeOptions.title));
447447+ }
448448+ // folder & position
449449+ if (aChangeOptions.folder != undefined || aChangeOptions.position != undefined) {
450450+ let position = (aChangeOptions.position === undefined) ? -1 : aChangeOptions.position;
451451+ txns.push(new PlacesMoveItemTransaction(result._itemId,
452452+ aChangeOptions.folder || result.folder,
453453+ position));
454454+ }
455455+ // tags
456456+ if (aChangeOptions.tags) {
457457+ let uri = PlacesUtils._uri(aChangeOptions.location || result.location);
458458+ txns.push(new PlacesTagURITransaction(uri, aChangeOptions.tags));
459459+ }
460460+ /*
461461+ // annotations
462462+ if (aChangeOptions.annotations) {
463463+ aChangeOptions.annotations.forEach(function(anno) {
464464+ txns.push(new PlacesSetItemAnnotationTransaction(result._itemId, anno));
465465+ });
466466+ }
467467+ */
468468+ }
469469+ else if (aChangeOptions.annotations) {
470470+ aChangeOptions.annotations.forEach(function(anno) {
471471+ txns.push(new PlacesSetPageAnnotationTransaction(result.location, anno));
472472+ });
473473+ }
474474+475475+ (new PlacesAggregatedTransaction("Changing " + result.title, txns)).doTransaction();
476476+ });
477477+ new QueryExecutor(query, null, walker.run, walker);
478478+ };
479479+480480+ this.remove = function PS_remove() {
481481+ if (!("bookmarked" in aOptions)) {
482482+ throw new Error("Removal of history is not supported at this time.");
483483+ }
484484+485485+ // When the owning query has finished, pass results to the walker that
486486+ // will make the changes, re-query to update the results, and then call
487487+ // the user's callback.
488488+ function removeCallback() {
489489+ new QueryExecutor(query, null, aOptions.onRemove, aOptions);
490490+ }
491491+492492+ // When the owning query has finished, pass results to the walker that
493493+ // will remove them from the database.
494494+ let walker = new Walker(removeCallback, aOptions, function(result) {
495495+ (new PlacesRemoveItemTransaction(result._itemId)).doTransaction();
496496+ });
497497+ new QueryExecutor(query, null, walker.run, walker);
498498+ };
499499+500500+ if (aOptions.onResult || aOptions.onComplete)
501501+ new QueryExecutor(query, aOptions.onResult, aOptions.onComplete, aOptions);
502502+}
503503+PlacesSearch.prototype = {}
504504+505505+506506+/**
507507+ * Executes a query and receives results from it.
508508+ */
509509+function QueryExecutor(aPlacesQuery, aOnResult, aOnComplete, aScope) {
510510+ this.onResult = aOnResult;
511511+ this.onComplete = aOnComplete;
512512+ this.scope = aScope;
513513+ this.results = [];
514514+515515+ aPlacesQuery.execute(this.resultsCallback, this);
516516+}
517517+518518+QueryExecutor.prototype = {
519519+ resultsCallback: function QX_resultsCallback(aResult) {
520520+ // Query has finished returning results.
521521+ if (!aResult && this.onComplete) {
522522+ this.scope.results = this.results;
523523+ safeCallback(null, this.onComplete, this.scope);
524524+ }
525525+526526+ // There are results but we don't want to notify caller before completion.
527527+ else if (this.onComplete)
528528+ this.results.push(new Place(aResult));
529529+530530+ // Send result to the caller.
531531+ if (this.onResult)
532532+ safeCallback(new Place(aResult), this.onResult, this.scope);
533533+ }
534534+}
535535+536536+537537+/**
538538+ * Walks through all results from an owning query that are passed to run,
539539+ * then calls aUserCallback in the scope of aUserScope. It will also set
540540+ * the results of the owning query as aUserScope.results. This ensures that
541541+ * when query results have their change/remove methods called, the result set
542542+ * is updated to reflect those calls.
543543+ */
544544+function Walker(aUserCallback, aUserScope, aMapFunction) {
545545+ this.userCallback = aUserCallback;
546546+ this.userScope = aUserScope;
547547+ this.mapFunction = aMapFunction;
548548+}
549549+550550+Walker.prototype = {
551551+ run: function WLKR_run() {
552552+ // Process results.
553553+ this.results.forEach(this.mapFunction);
554554+ if (this.userScope)
555555+ this.userScope.results = this.results;
556556+ if (this.userCallback)
557557+ safeCallback(null, this.userCallback, this.userScope || undefined);
558558+ }
559559+}
560560+561561+562562+/**
563563+ * Place object, representing a single result in a set of search results.
564564+ */
565565+function Place(aOptions) {
566566+ for (var i in aOptions) {
567567+ switch (i) {
568568+ // Omitted for now.
569569+ case "pageId": // id from moz_places table, used for sql queries
570570+ case "referringVisitId":
571571+ case "revHost":
572572+ case "sessionId":
573573+ case "transitionType":
574574+ case "type": // type const from nsINavBookmarksService
575575+ case "visitId":
576576+ continue;
577577+ break;
578578+ // Rename bookmarkIndex to position.
579579+ case "bookmarkIndex":
580580+ this.position = aOptions[i];
581581+ break;
582582+ // Id from moz_bookmarks table, used for the internal boomark apis.
583583+ // HACK: Expose as "private" because we need to access it for
584584+ // internal use such as deletion, and query folders.
585585+ case "itemId":
586586+ this._itemId = aOptions[i];
587587+ break;
588588+ // Rename parentId to folder.
589589+ case "parentId":
590590+ this.folder = aOptions[i];
591591+ // Rename readableType to bookmarkType.
592592+ case "readableType":
593593+ this.type = aOptions[i] == "container" ? "folder" : aOptions[i];
594594+ break;
595595+ // Rename referringUri to referrer.
596596+ case "referringUri":
597597+ this.referrer = aOptions[i];
598598+ break;
599599+ // Rename uri to location.
600600+ case "uri":
601601+ this.location = aOptions[i];
602602+ break;
603603+ // Name/value does not need to change.
604604+ case "accessCount":
605605+ case "dateAdded":
606606+ case "frecency":
607607+ case "host":
608608+ case "icon": // is a URL, should rename to iconURL?
609609+ case "isBookmarked":
610610+ case "lastModified":
611611+ case "tags":
612612+ case "title":
613613+ case "time":
614614+ this[i] = aOptions[i];
615615+ break;
616616+ }
617617+ }
618618+}
619619+620620+/******************************************************************************
621621+ * THE CODE FROM HERE TO THE END OF THE FILE IS THE JETPACK-LESS PLACES QUERY
622622+ * MODULE. IT MUST REMAIN JETPACK-FREE. ANY MODIFICATIONS MUST BE FILED AS
623623+ * BUGS AND MARKED AS BLOCKING BUG 522572.
624624+ *****************************************************************************/
625625+626626+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*-
627627+ * vim: sw=2 ts=2 sts=2 expandtab
628628+ * ***** BEGIN LICENSE BLOCK *****
629629+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
630630+ *
631631+ * The contents of this file are subject to the Mozilla Public License Version
632632+ * 1.1 (the "License"); you may not use this file except in compliance with
633633+ * the License. You may obtain a copy of the License at
634634+ * http://www.mozilla.org/MPL/
635635+ *
636636+ * Software distributed under the License is distributed on an "AS IS" basis,
637637+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
638638+ * for the specific language governing rights and limitations under the
639639+ * License.
640640+ *
641641+ * The Original Code is mozilla.org code.
642642+ *
643643+ * The Initial Developer of the Original Code is
644644+ * Mozilla Corporation.
645645+ * Portions created by the Initial Developer are Copyright (C) 2010
646646+ * the Initial Developer. All Rights Reserved.
647647+ *
648648+ * Contributor(s):
649649+ * Marco Bonardo <mak77@bonardo.net> (original author)
650650+ * David Dahl <ddahl@mozilla.com>
651651+ * Dietrich Ayala <dietrich@mozilla.com>
652652+ *
653653+ * Alternatively, the contents of this file may be used under the terms of
654654+ * either the GNU General Public License Version 2 or later (the "GPL"), or
655655+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
656656+ * in which case the provisions of the GPL or the LGPL are applicable instead
657657+ * of those above. If you wish to allow use of your version of this file only
658658+ * under the terms of either the GPL or the LGPL, and not to allow others to
659659+ * use your version of this file under the terms of the MPL, indicate your
660660+ * decision by deleting the provisions above and replace them with the notice
661661+ * and other provisions required by the GPL or the LGPL. If you do not delete
662662+ * the provisions above, a recipient may use your version of this file under
663663+ * the terms of any one of the MPL, the GPL or the LGPL.
664664+ *
665665+ * ***** END LICENSE BLOCK ***** */
666666+667667+//const EXPORTED_SYMBOLS = ["PlacesQuery"];
668668+669669+/* This is a pure async querying API.
670670+ * It provides non-liveupdating query results passed to a callback function.
671671+ *
672672+ * NOTE: history results returned by this object may be up to two minutes behind
673673+ * since it does not handle TEMP tables. We plan to remove them, so this bad
674674+ * behavior will be rectified at that point.
675675+ *
676676+ * TODO:
677677+ * - Hierarchal queries.
678678+ * - Accept a place: uri as input.
679679+ * - Par querying capabilities with the old querying API.
680680+ * - Faster queries.
681681+ * - Create a PlacesLiveQuery wrapper that will query through an internal
682682+ * PlacesQuery object and then will maintain an updated copy of the results.
683683+ * - Use PlacesLiveQuery wrapper in our views.
684684+ * - Add further querying capabilities.
685685+ *
686686+ * EXAMPLE:
687687+ *
688688+ * let query = new PlacesQuery({_QueryConf_});
689689+ *
690690+ * query.execute(function(result) {
691691+ * if (result)
692692+ * dump("Got a result: " + [result.title, result.uri, result.readableType].join(", "));
693693+ * else
694694+ * dump("Finished executing query!\n");
695695+ * }, this);
696696+ *
697697+ *
698698+ * _QueryConf_ = {
699699+ * phrase: string.
700700+ * Containing this string in either title, uri or tags. Case
701701+ * insensitive. Can use ^ and $ to match at beginning or end.
702702+ * host: string.
703703+ * Containing this string in the host. Case insensitive.
704704+ * Can use ^ and $ to match at beginning or end.
705705+ * uri: string.
706706+ * Containing this string in the uri. Case insensitive.
707707+ * Can use ^ and $ to match beginning or end.
708708+ * annotated: array of strings.
709709+ * With these annotations (Either page or item).
710710+ * bookmarked: object
711711+ * {
712712+ * tags: array of strings.
713713+ * Tagged with these tags.
714714+ * folder: number.
715715+ * Inside this folder. (non-recursive)
716716+ * position: number.
717717+ * At this position. (relative to folder).
718718+ * If undefined or null matches all children.
719719+ * If no folder is defined, position is ignored.
720720+ * id: number.
721721+ * Bookmarked with this id.
722722+ * createdBegin: optional Date object
723723+ * Bookmarks created after this time (included).
724724+ * Defaults to epoch.
725725+ * createdEnd: optional Date object
726726+ * Bookmarks created before this time (included).
727727+ * Defaults to now.
728728+ * modifiedBegin: optional Date object
729729+ * Bookmarks modified after this time (included).
730730+ * Defaults to epoch.
731731+ * modifiedEnd: optional Date object
732732+ * Bookmarks modified before this time (included).
733733+ * Defaults to now.
734734+ * onlyContainers: boolean.
735735+ * Removes any non-container from results.
736736+ * Default is false.
737737+ * excludeReadOnlyContainers: boolean.
738738+ * Removes read only containers from results.
739739+ * Default is false.
740740+ * }
741741+ * visited: object
742742+ * {
743743+ * countMin: optional number.
744744+ * With more than this many visits.
745745+ * Defaults to 0.
746746+ * This is lazily based on visit_count, thus is not going to work
747747+ * for not counted transitions: embed, download, framed_link.
748748+ * countMax: optional number.
749749+ * With less than this many visits.
750750+ * Defaults to inf.
751751+ * This is lazily based on visit_count, thus is not going to work
752752+ * for not counted transitions: embed, download, framed_link.
753753+ * transitions: array of transition types.
754754+ * With at least one visit for each of these transitions.
755755+ * begin: optional Date object
756756+ * With visits after this time (included).
757757+ * Defaults to epoch.
758758+ * end: optional Date object
759759+ * With visits before this time (included).
760760+ * Defaults to now.
761761+ * excludeRedirectSources: boolean.
762762+ * Removes redirects sources from results.
763763+ * Default is false.
764764+ * excludeRedirectTargets: boolean.
765765+ * Removes redirects targets from results.
766766+ * Default is false.
767767+ * includeHidden: boolean.
768768+ * Includes also pages marked as hidden.
769769+ * Default is false.
770770+ * includeAllVisits: boolean.
771771+ * Returns all visits ungrouped.
772772+ * Default is false, that means visits are grouped by uri.
773773+ * }
774774+ * sortBy: string.
775775+ * Either "none", "title", "time", "uri", "accessCount", "lastModified",
776776+ * "frecency". Defaults to "none".
777777+ * sortDir: string.
778778+ * Either "asc" or "desc". Defaults to "asc".
779779+ * group: string.
780780+ * Either "tags", "containers", "days", "months", "years" or "domains".
781781+ * Defaults to "none".
782782+ * NOTE: Not yet implemented.
783783+ * limit: number.
784784+ * Maximum number of results to return. Defaults to all results.
785785+ * merge: string.
786786+ * How to merge this query's results with others in the same request.
787787+ * Valid values:
788788+ * - "union": merge results from the 2 queries.
789789+ * - "except": exclude current results from the previous ones.
790790+ * - "intersect": only current results that are also in previous ones.
791791+ * }
792792+ *
793793+ */
794794+795795+////////////////////////////////////////////////////////////////////////////////
796796+//// Constants and Getters
797797+798798+//const Cc = Components.classes;
799799+//const Ci = Components.interfaces;
800800+//const Cr = Components.results;
801801+//const Cu = Components.utils;
802802+803803+const TAGS_SEPARATOR = ", ";
804804+805805+const TAGS_SQL_FRAGMENT =
806806+ "("
807807++ "SELECT GROUP_CONCAT(tag_title, ', ') "
808808++ "FROM ( "
809809++ "SELECT t_t.title AS tag_title "
810810++ "FROM moz_bookmarks b_t "
811811++ "JOIN moz_bookmarks t_t ON t_t.id = b_t.parent "
812812++ "WHERE b_t.fk = h.id "
813813++ "AND LENGTH(t_t.title) > 0 "
814814++ "AND t_t.parent = :tags_folder "
815815++ "ORDER BY t_t.title COLLATE NOCASE ASC "
816816++ ") WHERE b.id NOTNULL "
817817++ ")";
818818+819819+const REFERRING_URI_SQL_FRAGMENT =
820820+ "("
821821++ "SELECT refh.url FROM moz_places refh "
822822++ "JOIN moz_historyvisits refv ON refh.id = refv.place_id "
823823++ "WHERE refv.id = v.from_visit "
824824++ ")";
825825+826826+Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
827827+Cu.import("resource://gre/modules/Services.jsm", this);
828828+Cu.import("resource://gre/modules/PlacesUtils.jsm", this);
829829+830830+XPCOMUtils.defineLazyGetter(this, "DB", function() {
831831+ return PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
832832+ .DBConnection;
833833+});
834834+835835+836836+////////////////////////////////////////////////////////////////////////////////
837837+//// Utils and Helpers
838838+839839+function checkType(entry, type) {
840840+ switch (type) {
841841+ case "undefined":
842842+ return entry === undefined;
843843+ case "null":
844844+ return entry === null;
845845+ case "date":
846846+ return Object.prototype.toString.call(entry) === "[object Date]";
847847+ case "array":
848848+ // TODO: Use ES5 isArray() once available.
849849+ // NOTE: current method fails if the array comes from JSON.parse.
850850+ return Object.prototype.toString.call(entry) === "[object Array]";
851851+ default:
852852+ if (entry === null) // typeof(null) == "object" but we have a "null" type.
853853+ return false;
854854+ return typeof(entry) == type;
855855+ }
856856+}
857857+858858+859859+function checkArrayElementsType(array, type, allowOptionalElement) {
860860+ let arrayIsValid = true;
861861+ array.every(function(elm) {
862862+ if (allowOptionalElement && (elm === undefined || elm === null))
863863+ return true;
864864+ return arrayIsValid = checkType(elm, type);
865865+ });
866866+ return arrayIsValid;
867867+}
868868+869869+870870+function isValidArray(aObj, aValidator)
871871+{
872872+ let validArray = checkType(aObj, "array");
873873+ if (validArray && aValidator) {
874874+ if (!checkType(aValidator, "function"))
875875+ throw new Error("Array validator must be a function.");
876876+ return aValidator(aObj);
877877+ }
878878+ return validArray;
879879+}
880880+881881+882882+function getReadableItemType(aResultItem, aItemType)
883883+{
884884+ if (aItemType) {
885885+ // it's a bookmark.
886886+ if (!aResultItem.uri || aResultItem.uri.substr(0, 6) == "place:") {
887887+ switch (aItemType) {
888888+ case Ci.nsINavBookmarksService.TYPE_SEPARATOR:
889889+ return "separator";
890890+ default:
891891+ return "container";
892892+ }
893893+ }
894894+ return "bookmark";
895895+ }
896896+ if (aResultItem.visitId)
897897+ return "visit";
898898+ return "page";
899899+}
900900+901901+902902+function getNodeType(aResultItem, aItemType) {
903903+ if (aResultItem.transitionType)
904904+ return Ci.nsINavHistoryResultNode.RESULT_TYPE_FULL_VISIT;
905905+906906+ let isQuery = aResultItem.uri && aResultItem.uri.substr(0, 6) == "place:";
907907+ if (aResultItem.isBookmarked) {
908908+ if (aItemType == PlacesUtils.bookmarks.TYPE_FOLDER)
909909+ return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER;
910910+ if (aItemType == PlacesUtils.bookmarks.TYPE_SEPARATOR)
911911+ return Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR;
912912+913913+ if (isQuery && /^place:folder=[^&]+$/i.test(aResultItem.uri))
914914+ return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
915915+ }
916916+917917+ return isQuery ? Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY
918918+ : Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
919919+}
920920+921921+922922+function TextMatch(aText)
923923+{
924924+ if (aText.substr(0, 1) == "^")
925925+ this.matchBegin = true;
926926+ if (aText.substr(-1, 1) == "$")
927927+ this.matchEnd = true;
928928+ this.exactMatch = this.matchEnd && this.matchBegin;
929929+ let cutFrom = this.matchBegin ? 1 : 0;
930930+ let cutLen = aText.length - cutFrom - (this.matchEnd ? 1 : 0);
931931+ this.value = aText.substr(cutFrom, cutLen);
932932+}
933933+934934+TextMatch.prototype = {
935935+ matchBegin: false,
936936+ matchEnd: false,
937937+ exactMatch: false,
938938+ value: "",
939939+940940+ getStringForLike: function TM_getStringForLike(aStmt) {
941941+ let escValue = aStmt.escapeStringForLIKE(this.value, '/');
942942+ if (this.exactMatch)
943943+ return escValue;
944944+ if (this.matchBegin)
945945+ return escValue + "%";
946946+ if (this.matchEnd)
947947+ return "%" + escValue;
948948+ return "%" + escValue + "%";
949949+ },
950950+951951+ getRegExp: function TM_getRegExp(aUseWordBoundaries) {
952952+ let beginMod = aUseWordBoundaries ? "\\b" : "^";
953953+ let endMod = aUseWordBoundaries ? "\\b" : "$";
954954+ if (this.exactMatch)
955955+ return new RegExp(beginMod + this.value + endMod, "i");
956956+ if (this.matchBegin)
957957+ return new RegExp(beginMod + this.value, "i");
958958+ if (this.matchEnd)
959959+ return new RegExp(this.value + endMod, "i");
960960+ return new RegExp(this.value, "i");
961961+ }
962962+}
963963+964964+965965+////////////////////////////////////////////////////////////////////////////////
966966+//// (De)Serializers
967967+968968+const SPECIAL_FOLDERS = {
969969+ BOOKMARKS_MENU_FOLDER: PlacesUtils.bookmarksMenuFolderId
970970+, TAGS_FOLDER: PlacesUtils.tagsFolderId
971971+, UNFILED_BOOKMARKS_FOLDER: PlacesUtils.unfiledBookmarksFolderId
972972+, TOOLBAR_FOLDER: PlacesUtils.toolbarFolderId
973973+};
974974+975975+const TIME_REFERENCE = {
976976+ 0: 0 // EPOCH
977977+, 1: new Date().setHours(0,0,0,0) * 1000 // TODAY'S START
978978+, 2: Date.now() * 1000 // NOW
979979+};
980980+981981+function deserializeLegacyPlaceUrl(aUrl) {
982982+ let queryConfs = [];
983983+ // Strip "place:" scheme.
984984+ aUrl = aUrl.substr(7);
985985+ // Split multiple queries.
986986+ let queries = aQuery.split("OR");
987987+ // Put each param in an array of objects like { name:, value: }.
988988+ let re = new RegExp("([^?=&]+)(=([^&]*))?", "gi");
989989+ queries.forEach(function(aQuery) {
990990+ let params = {};
991991+ let match = null;
992992+ while ((match = re.exec(aQuery))) {
993993+ let name = match[1].toLowerCase();
994994+ let value = match[3];
995995+ // Same key can have multiple params, for simplicity make values arrays.
996996+ if (!(name in params))
997997+ params[name] = [];
998998+ params[name].push(value);
999999+ };
10001000+10011001+ let conf = {}
10021002+ for (let name in params) {
10031003+ switch(name) {
10041004+ case "begintime":
10051005+ if (!("visited" in conf))
10061006+ conf.visited = {}
10071007+ let begintimeref = 0;
10081008+ if ("begintimeref" in params)
10091009+ begintimeref += params["begintimeref"];
10101010+ conf.visited.begin = new Date((timeref + params["begintime"][0])/1000);
10111011+ break;
10121012+ case "endtime":
10131013+ if (!("visited" in conf))
10141014+ conf.visited = {}
10151015+ let endtimeref = 0;
10161016+ if ("endtimeref" in params)
10171017+ endtimeref += params["endtimeref"];
10181018+ conf.visited.end = new Date((timeref + params["endtime"][0])/1000);
10191019+ break;
10201020+ case "terms":
10211021+ conf.phrase = params["terms"][0];
10221022+ break;
10231023+ case "minvisits":
10241024+ if (!("visited" in conf))
10251025+ conf.visited = {}
10261026+ conf.visited.countMin = params["minvisits"][0];
10271027+ break;
10281028+ case "maxvisits":
10291029+ if (!("visited" in conf))
10301030+ conf.visited = {}
10311031+ conf.visited.countMax = params["maxvisits"][0];
10321032+ break;
10331033+ case "onlybookmarked":
10341034+ if (!("bookmarked" in conf))
10351035+ conf.bookmarked = {};
10361036+ break;
10371037+ case "domain":
10381038+ if ("domainishost" in params) {
10391039+ if (params["domainishost"] == 1)
10401040+ conf.host = "^" + params["domain"][0] + "$";
10411041+ }
10421042+ else
10431043+ conf.host = params["domain"][0];
10441044+ break;
10451045+ case "folder":
10461046+ if (!("bookmarked" in conf))
10471047+ conf.bookmarked = {};
10481048+ if (params["folder"].length == 1) {
10491049+ let folderId;
10501050+ if (/[a-z]/.test(folderId)) {
10511051+ if (folderId in SPECIAL_FOLDERS)
10521052+ folderId = SPECIAL_FOLDERS[folderId];
10531053+ }
10541054+ else {
10551055+ folderId = params["folder"][0];
10561056+ }
10571057+ if (folderId)
10581058+ conf.bookmarked.folder = folderId;
10591059+ }
10601060+ // TODO: due to a nice API confusion, if there is more than one folder
10611061+ // they are wrongly ORed.
10621062+ break;
10631063+ case "annotation":
10641064+ if ("!annotation" in params) {
10651065+ // TODO: handle !annotation=1, should split an except query.
10661066+ }
10671067+ else {
10681068+ conf.annotated = params["annotation"];
10691069+ }
10701070+ break;
10711071+ case "uri":
10721072+ if ("uriisprefix" in params) {
10731073+ if (params["uriisprefix"] == 1)
10741074+ conf.host = "^" + params["uri"][0];
10751075+ }
10761076+ else
10771077+ conf.uri = "^" + params["domain"][0] + "$";
10781078+ break;
10791079+ case "group":
10801080+ // Sadly this has been killed lot of time ago for result type.
10811081+ break;
10821082+ case "sort":
10831083+ let opts = Ci.nsINavHistoryQueryOptions;
10841084+ switch(params["sort"][0]) {
10851085+ case opts.SORT_BY_TITLE_ASCENDING:
10861086+ conf.sortBy = "title";
10871087+ conf.sortDir = "ASC";
10881088+ break;
10891089+ case opts.SORT_BY_TITLE_DESCENDING:
10901090+ conf.sortBy = "title";
10911091+ conf.sortDir = "DESC";
10921092+ break;
10931093+ case opts.SORT_BY_DATE_ASCENDING:
10941094+ conf.sortBy = "time";
10951095+ conf.sortDir = "ASC";
10961096+ break;
10971097+ case opts.SORT_BY_DATE_DESCENDING:
10981098+ conf.sortBy = "time";
10991099+ conf.sortDir = "DESC";
11001100+ break;
11011101+ case opts.SORT_BY_URI_ASCENDING:
11021102+ conf.sortBy = "uri";
11031103+ conf.sortDir = "ASC";
11041104+ break;
11051105+ case opts.SORT_BY_URI_DESCENDING:
11061106+ conf.sortBy = "uri";
11071107+ conf.sortDir = "DESC";
11081108+ break;
11091109+ case opts.SORT_BY_VISITCOUNT_ASCENDING:
11101110+ conf.sortBy = "accessCount";
11111111+ conf.sortDir = "ASC";
11121112+ break;
11131113+ case opts.SORT_BY_VISITCOUNT_DESCENDING:
11141114+ conf.sortBy = "accessCount";
11151115+ conf.sortDir = "DESC";
11161116+ break;
11171117+ case opts.SORT_BY_DATEADDED_ASCENDING:
11181118+ conf.sortBy = "dateAdded";
11191119+ conf.sortDir = "ASC";
11201120+ break;
11211121+ case opts.SORT_BY_DATEADDED_DESCENDING:
11221122+ conf.sortBy = "dateAdded";
11231123+ conf.sortDir = "DESC";
11241124+ break;
11251125+ case opts.SORT_BY_LASTMODIFIED_ASCENDING:
11261126+ conf.sortBy = "lastModified";
11271127+ conf.sortDir = "ASC";
11281128+ break;
11291129+ case opts.SORT_BY_LASTMODIFIED_DESCENDING:
11301130+ conf.sortBy = "lastModified";
11311131+ conf.sortDir = "DESC";
11321132+ break;
11331133+ case opts.SORT_BY_NONE:
11341134+ // default.
11351135+ break;
11361136+ case opts.SORT_BY_KEYWORD_ASCENDING:
11371137+ case opts.SORT_BY_KEYWORD_DESCENDING:
11381138+ case opts.SORT_BY_TAGS_ASCENDING:
11391139+ case opts.SORT_BY_TAGS_DESCENDING:
11401140+ case opts.SORT_BY_ANNOTATION_ASCENDING:
11411141+ case opts.SORT_BY_ANNOTATION_DESCENDING:
11421142+ // Not supported.
11431143+ break;
11441144+ };
11451145+ break;
11461146+ case "type":
11471147+ switch (params["type"][0]) {
11481148+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_VISIT:
11491149+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_FULL_VISIT:
11501150+ if (!("visited" in conf))
11511151+ conf.visited = {};
11521152+ conf.visited.includeAllVisits = true;
11531153+ break;
11541154+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY:
11551155+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY:
11561156+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY:
11571157+ // TODO, need to define how to make date grouping.
11581158+ break;
11591159+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY:
11601160+ case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS:
11611161+ conf.group = "tags";
11621162+ break;
11631163+ }
11641164+ break;
11651165+ case "excludeitems":
11661166+ if (!("bookmarked" in conf))
11671167+ conf.bookmarked = {};
11681168+ if (params["excludeitems"][0] == 1)
11691169+ conf.bookmarked.onlyContainers = true;
11701170+ break;
11711171+ case "excludequeries":
11721172+ if (!("bookmarked" in conf))
11731173+ conf.bookmarked = {};
11741174+ break;
11751175+ case "excludereadonlyfolders":
11761176+ if (!("bookmarked" in conf))
11771177+ conf.bookmarked = {};
11781178+ if (params["excludereadonlyfolders"][0] == 1)
11791179+ conf.bookmarked.excludeReadOnlyContainers = true;
11801180+ break;
11811181+ case "excludeitemifparenthasannotation":
11821182+ // Not supported, we should remove livemarks children from bookmarks
11831183+ // instead.
11841184+ break;
11851185+ case "expandqueries":
11861186+ // Not supported, the query should have onlyContainers instead.
11871187+ break;
11881188+ case "originaltitle":
11891189+ // Never been implemented, and it is not yet.
11901190+ break;
11911191+ case "includehidden":
11921192+ if (!("visited" in conf))
11931193+ conf.visited = {};
11941194+ if (params["includehidden"][0] == 1)
11951195+ conf.visited.includeHidden = true;
11961196+ break;
11971197+ case "redirectsmode":
11981198+ if (!("visited" in conf))
11991199+ conf.visited = {};
12001200+ switch(params["redirectsMode"][0]) {
12011201+ case Ci.nsINavHistoryQueryOptions.REDIRECTS_MODE_TARGET:
12021202+ conf.visited.excludeRedirectSources = true;
12031203+ break;
12041204+ case Ci.nsINavHistoryQueryOptions.REDIRECTS_MODE_SOURCE:
12051205+ conf.visited.excludeRedirectTargets = true;
12061206+ break;
12071207+ }
12081208+ break;
12091209+ case "maxresults":
12101210+ conf.limit = params["maxresults"][0];
12111211+ break;
12121212+ case "querytype":
12131213+ if (params["querytype"][0] == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS &&
12141214+ !("bookmarked" in conf))
12151215+ conf.bookmarked = {};
12161216+ break;
12171217+ case "tag":
12181218+ if ("!tags" in params) {
12191219+ // TODO: handle !annotation=1, should split out an except query.
12201220+ }
12211221+ else {
12221222+ if (!("bookmarked" in conf))
12231223+ conf.bookmarked = {};
12241224+ conf.bookmarked.tags = params["tag"];
12251225+ }
12261226+ break;
12271227+ case "asyncenabled":
12281228+ // Ignored, this is always async.
12291229+ break;
12301230+ }
12311231+ }
12321232+12331233+ // Folder shortcuts.
12341234+ if (/place:folder=[^&]/.test(aUrl))
12351235+ conf.group = "containers";
12361236+12371237+ queryConfs.push(conf);
12381238+ });
12391239+ return queryConfs;
12401240+}
12411241+12421242+12431243+////////////////////////////////////////////////////////////////////////////////
12441244+//// QueryConf
12451245+12461246+function QueryConf(aQueryConf)
12471247+{
12481248+ // Safe initialize: use default values if inputs are invalid.
12491249+ for (let prop in aQueryConf) {
12501250+ this[prop] = aQueryConf[prop];
12511251+ }
12521252+}
12531253+12541254+QueryConf.prototype = {
12551255+ _phrase: new TextMatch(""),
12561256+ get phrase() this._phrase.value,
12571257+ set phrase(aVal)
12581258+ {
12591259+ if (checkType(aVal, "string"))
12601260+ this._phrase = new TextMatch(aVal);
12611261+ return this._phrase.value;
12621262+ },
12631263+12641264+ _host: new TextMatch(""),
12651265+ get host() this._host.value,
12661266+ set host(aVal)
12671267+ {
12681268+ if (checkType(aVal, "string"))
12691269+ this._host = new TextMatch(aVal);
12701270+ return this._host.value;
12711271+ },
12721272+12731273+ _uri: new TextMatch(""),
12741274+ get uri() this._uri.value,
12751275+ set uri(aVal)
12761276+ {
12771277+ if (checkType(aVal, "string"))
12781278+ this._uri = new TextMatch(aVal);
12791279+ return this._uri.value;
12801280+ },
12811281+12821282+ _annotated: [],
12831283+ get annotated() this._annotated,
12841284+ set annotated(aVal)
12851285+ {
12861286+ if (isValidArray(aVal, function(v) v.length > 0 && checkArrayElementsType(v, "string")))
12871287+ this._annotated = aVal;
12881288+ return this._annotated;
12891289+ },
12901290+12911291+ _bookmarked: null,
12921292+ get bookmarked() this._bookmarked,
12931293+ set bookmarked(aVal)
12941294+ {
12951295+ if (checkType(aVal, "object")) {
12961296+ let options = { tags: []
12971297+ , folder: null
12981298+ , position: null
12991299+ , id: null
13001300+ , createdBegin: null
13011301+ , createdEnd: null
13021302+ , modifiedBegin: null
13031303+ , modifiedEnd: null
13041304+ , onlyContainers: false
13051305+ , excludeReadOnlyContainers: false
13061306+ };
13071307+13081308+ if ("tags" in aVal &&
13091309+ isValidArray(aVal.tags, function(v) v.length > 0 && checkArrayElementsType(v, "string")))
13101310+ options.tags = aVal.tags;
13111311+13121312+ if ("folder" in aVal && aVal.folder > 0)
13131313+ options.folder = aVal.folder;
13141314+13151315+ if ("position" in aVal && aVal.position >= 0)
13161316+ options.position = aVal.position;
13171317+13181318+ if ("id" in aVal && checkType(aVal.id, "number"))
13191319+ options.id = aVal.id;
13201320+13211321+ if ("createdBegin" in aVal && checkType(aVal.createdBegin, "date"))
13221322+ options.createdBegin = aVal.createdBegin;
13231323+13241324+ if ("createdEnd" in aVal && checkType(aVal.createdEnd, "date"))
13251325+ options.createdEnd = aVal.createdEnd;
13261326+13271327+ if ("modifiedBegin" in aVal && checkType(aVal.modifiedBegin, "date"))
13281328+ modifiedBegin = aVal.modifiedBegin;
13291329+ if ("end" in aVal && checkType(aVal.modifiedEnd, "date"))
13301330+ modifiedEnd = aVal.modifiedEnd;
13311331+13321332+ if ("onlyContainers" in aVal &&
13331333+ checkType(aVal.onlyContainers, "boolean"))
13341334+ options.onlyContainers = aVal.onlyContainers;
13351335+13361336+ if ("excludeReadOnlyContainers" in aVal &&
13371337+ checkType(aVal.excludeReadOnlyContainers, "boolean"))
13381338+ options.excludeReadOnlyContainers = aVal.excludeReadOnlyContainers;
13391339+13401340+ this._bookmarked = options;
13411341+ }
13421342+ // Set simple bool for isBookmarked with no other parameters.
13431343+ else if(aVal) {
13441344+ this._bookmarked = true;
13451345+ }
13461346+ return this._bookmarked;
13471347+ },
13481348+13491349+ _visited: null,
13501350+ get visited() this._visited,
13511351+ set visited(aVal)
13521352+ {
13531353+ if (checkType(aVal, "object")) {
13541354+ let options = { countMin: null
13551355+ , countMax: null
13561356+ , transitions: []
13571357+ , begin: null
13581358+ , end: null
13591359+ , exclude: []
13601360+ , include: []
13611361+ };
13621362+13631363+ if ("countMin" in aVal && checkType(aVal.countMin, "number"))
13641364+ options.countMin = aVal.countMin;
13651365+ if ("countMax" in aVal && checkType(aVal.countMax, "number"))
13661366+ options.countMax = aVal.countMax;
13671367+13681368+ if ("transitions" in aVal &&
13691369+ isValidArray(aVal.transitions, function(v) v.length > 0 && checkArrayElementsType(v, "number")))
13701370+ options.transitions = aVal.transitions;
13711371+13721372+ if ("begin" in aVal && checkType(aVal.begin, "date"))
13731373+ options.begin = aVal.begin;
13741374+13751375+ if ("end" in aVal && checkType(aVal.end, "date"))
13761376+ options.end = aVal.end;
13771377+13781378+ if ("excludeRedirectSources" in aVal &&
13791379+ checkType(aVal.excludeRedirectSources, "boolean"))
13801380+ options.excludeRedirectSources = aVal.excludeRedirectSources;
13811381+13821382+ if ("excludeRedirectTargets" in aVal &&
13831383+ checkType(aVal.excludeRedirectTargets, "boolean"))
13841384+ options.excludeRedirectTargets = aVal.excludeRedirectTargets;
13851385+13861386+ if ("includeHidden" in aVal && checkType(aVal.includeHidden, "boolean"))
13871387+ options.includeHidden = aVal.includeHidden;
13881388+13891389+ if ("includeAllVisits" in aVal && checkType(aVal.includeAllVisits, "boolean"))
13901390+ options.includeAllVisits = aVal.includeAllVisits;
13911391+13921392+ this._visited = options;
13931393+ }
13941394+ // Set simple bool for isVisited with no other parameters.
13951395+ else if(aVal) {
13961396+ this._visited = true;
13971397+ }
13981398+ return this._visited;
13991399+ },
14001400+14011401+ // TODO: not yet implemented for callers. Internally
14021402+ // it's used already, so just disabling the setter.
14031403+ _group: "none",
14041404+ get group() this._group,
14051405+ /*
14061406+ set group(aVal)
14071407+ {
14081408+ if (["none", "containers"
14091409+ //, "tags", "month", "year", "host"
14101410+ ].indexOf(aVal) != -1)
14111411+ this._group = aVal;
14121412+ return this._group;
14131413+ },
14141414+ */
14151415+14161416+ _sortBy: "none",
14171417+ get sortBy() this._sortBy,
14181418+ set sortBy(aVal)
14191419+ {
14201420+ let sortingOptions = ["none", "title", "time", "uri", "accessCount", "lastModified", "frecency"];
14211421+ if (checkType(aVal, "string") && sortingOptions.indexOf(aVal) != -1) {
14221422+ this._sortBy = aVal;
14231423+ }
14241424+ return this._sortBy;
14251425+ },
14261426+14271427+ _sortDir: "ASC",
14281428+ get sortDir() this._sortDir,
14291429+ set sortDir(aVal)
14301430+ {
14311431+ this._sortDir = ["asc", "desc"].indexOf(aVal) != -1 ? aVal.toUpperCase() : "ASC";
14321432+ return this._sortDir;
14331433+ },
14341434+14351435+ _limit: -1,
14361436+ get limit() this._limit,
14371437+ set limit(aVal)
14381438+ {
14391439+ if (checkType(aVal, "number"))
14401440+ this._limit = aVal;
14411441+ return this._limit;
14421442+ },
14431443+14441444+ /*
14451445+ _merge: "union",
14461446+ get merge() this._merge,
14471447+ set merge(aVal)
14481448+ {
14491449+ if (["union", "intersect", "except"].indexOf(aVal) != -1)
14501450+ this._merge = aVal;
14511451+ return this._merge;
14521452+ }
14531453+ */
14541454+}
14551455+14561456+14571457+////////////////////////////////////////////////////////////////////////////////
14581458+//// PlacesQuery
14591459+14601460+// Note: Multiple queryconf support is currently denied from callers
14611461+// as the API is not complete. However, the support is kept in the back-end
14621462+// so we're just wrapping the query conf in an array in two places
14631463+// in this ctor for now.
14641464+function PlacesQuery(aQueryConf)
14651465+{
14661466+ let queryConfs = [];
14671467+14681468+ // Allow passing a place: url.
14691469+ if (checkType(aQueryConf, "string") &&
14701470+ aQueryConf.substr(0, 6) == "place:") {
14711471+ queryConfs.push(deserializeLegacyPlaceUrl(aQueryConf));
14721472+ // TODO: check valid conf object here
14731473+ }
14741474+ // Allow array for future compat, but currently
14751475+ // only accepting single query.
14761476+ //else if (checkType(aQueryConf, "array"))
14771477+ // queryConfs.push(new QueryConf(aQueryConf[0]));
14781478+ else if (checkType(aQueryConf, "object"))
14791479+ queryConfs.push(new QueryConf(aQueryConf));
14801480+ else
14811481+ throw Cr.NS_ERROR_INVALID_ARG; // TODO: nice errors please.
14821482+14831483+ let pendingQuery = this;
14841484+ this.execute = function PQ_execute(aCallback, aThisObject)
14851485+ {
14861486+ // A callback is required, otherwise running the query would be useless.
14871487+ if (!aCallback || !checkType(aCallback, "function"))
14881488+ throw Cr.NS_ERROR_INVALID_ARG; // TODO: nice errors please.
14891489+14901490+ let [sql, stmt] = QueryBuilder.build(queryConfs);
14911491+14921492+ // DEBUG
14931493+ //dump("\nDEBUG SQL PRINT\n" + sql + "\n\n");
14941494+14951495+ // Run query, call back.
14961496+ let stmtCallback = new StmtCallback(queryConfs, aCallback, aThisObject);
14971497+ pendingQuery._pending = stmt.executeAsync(stmtCallback);
14981498+ stmtCallback.pendingQuery = pendingQuery;
14991499+ stmt.finalize();
15001500+ }
15011501+15021502+ this.cancel = function PQ_cancel()
15031503+ {
15041504+ if (pendingQuery._pending) {
15051505+ pendingQuery._pending.cancel();
15061506+ delete pendingQuery._pending;
15071507+ }
15081508+ }
15091509+}
15101510+15111511+PlacesQuery.prototype = {}
15121512+15131513+15141514+////////////////////////////////////////////////////////////////////////////////
15151515+//// QueryBuilder
15161516+15171517+let QueryBuilder = {
15181518+ build: function QB_build(aQueryConfs)
15191519+ {
15201520+ // Get global options, we use the first query's ones.
15211521+ let globalGroup = aQueryConfs[0].group;
15221522+ let globalSortBy = aQueryConfs[0].sortBy;
15231523+ let globalSortDir = aQueryConfs[0].sortDir;
15241524+ let globalLimit = aQueryConfs[0].limit;
15251525+15261526+ // Optimizations will come later, but some query could be hard to silent.
15271527+ let sql = "/* do not warn (bug 522572) */";
15281528+ for (let i = 0; i < aQueryConfs.length; i++) {
15291529+ aQueryConfs[i]._qIndex = i;
15301530+ if (i > 0) {
15311531+ switch (aQueryConfs[i - 1].merge) {
15321532+ case "intersect":
15331533+ sql += " INSERSECT ";
15341534+ break;
15351535+ case "except":
15361536+ sql += " EXCEPT ";
15371537+ break;
15381538+ case "union":
15391539+ default:
15401540+ sql += " UNION ";
15411541+ break;
15421542+ }
15431543+ }
15441544+ sql += this._SQLFor(aQueryConfs[i], globalGroup);
15451545+ }
15461546+15471547+ if (globalSortBy || globalSortDir) {
15481548+ function getNeutralSorting() {
15491549+ if (globalGroup == "containers" || globalGroup == "tags")
15501550+ return "position";
15511551+ return "page_id";
15521552+ }
15531553+ const COLUMNS = { none: getNeutralSorting()
15541554+ , title: "page_title COLLATE NOCASE"
15551555+ , time: "visit_date"
15561556+ , uri: "page_url"
15571557+ , accessCount: "visit_count"
15581558+ , lastModified: "lastModified"
15591559+ , frecency: "frecency"
15601560+ };
15611561+ sql += " ORDER BY " + COLUMNS[globalSortBy] + " " + globalSortDir;
15621562+ }
15631563+15641564+ // Check if we can apply a direct LIMIT to the query, if there is any sort
15651565+ // of post filtering or processing, we clearly can't.
15661566+ if (globalLimit != -1) {
15671567+ let canSQLLimit = globalGroup == "none";
15681568+ for (let i = 0; i < aQueryConfs.length && canSQLLimit; i++) {
15691569+ canSQLLimit = aQueryConfs[i]._postFilteringTasks.length == 0 &&
15701570+ aQueryConfs[i]._postProcessingTasks.length == 0;
15711571+ }
15721572+ if (canSQLLimit)
15731573+ sql += " LIMIT " + globalLimit;
15741574+ }
15751575+15761576+ let stmt = DB.createAsyncStatement(sql);
15771577+ this._bind(stmt, aQueryConfs);
15781578+15791579+ return [sql, stmt];
15801580+ },
15811581+15821582+ _bind: function QB__bind(aStmt, aQueryConfs)
15831583+ {
15841584+ // Collect all binding params.
15851585+ let params = [];
15861586+ aQueryConfs.forEach(function(aQueryConf) {
15871587+ params = params.concat(aQueryConf._params);
15881588+ });
15891589+15901590+ params.forEach(function(aParam) {
15911591+ let value = aParam.value;
15921592+ if (checkType(value, "object") &&
15931593+ TextMatch.prototype.isPrototypeOf(value)) {
15941594+ // This is a LIKE clause, thus value must be escaped.
15951595+ aStmt.params[aParam.name] = value.getStringForLike(aStmt);
15961596+ }
15971597+ else {
15981598+ aStmt.params[aParam.name] = value;
15991599+ }
16001600+ });
16011601+ },
16021602+16031603+ _SQLFor: function QB__SQLFor(aQueryConf, aGroup)
16041604+ {
16051605+ aQueryConf._params = [];
16061606+ aQueryConf._postFilteringTasks = [];
16071607+ aQueryConf._postProcessingTasks = [];
16081608+16091609+ // Used in all queries.
16101610+ aQueryConf._params.push({ name: "tags_folder",
16111611+ value: PlacesUtils.tagsFolderId });
16121612+16131613+ if (aQueryConf.visited && aQueryConf.visited.includeAllVisits)
16141614+ return this._SQLForVisitsQuery(aQueryConf, aGroup);
16151615+16161616+ if (aQueryConf.bookmarked)
16171617+ return this._SQLForBookmarksQuery(aQueryConf, aGroup);
16181618+16191619+ return this._SQLForPagesQuery(aQueryConf, aGroup);
16201620+ },
16211621+16221622+ _SQLForVisitsQuery: function QB__SQLForVisitsQuery(aQueryConf, aGroup)
16231623+ {
16241624+ let sql
16251625+ = "SELECT h.id AS page_id, h.url AS page_url, "
16261626+ + "COALESCE(b.title, h.title) AS page_title, h.rev_host AS rev_host, "
16271627+ + "h.visit_count AS visit_count, v.visit_date AS visit_date, "
16281628+ + "f.url AS icon_url, v.session AS session, b.id AS item_id, "
16291629+ + "b.dateAdded AS dateAdded, b.lastModified AS lastModified, "
16301630+ + "b.parent AS parent_id, " + TAGS_SQL_FRAGMENT + " AS tags, "
16311631+ + "b.position AS position, b.type AS item_type, "
16321632+ + "h.frecency AS frecency, v.id AS visit_id, "
16331633+ + "v.from_visit AS from_visit, "
16341634+ + REFERRING_URI_SQL_FRAGMENT + " AS from_visit_uri, "
16351635+ + "v.visit_type AS visit_type "
16361636+ + "FROM moz_places h "
16371637+ + "JOIN moz_historyvisits v ON v.place_id = h.id "
16381638+ + "LEFT JOIN moz_bookmarks b ON b.fk = h.id "
16391639+ + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id "
16401640+ ;
16411641+16421642+ return sql + this._getConditions([], aQueryConf, "visits", aGroup);
16431643+ },
16441644+16451645+ _SQLForPagesQuery: function QB__SQLForPagesQuery(aQueryConf, aGroup)
16461646+ {
16471647+ let sql
16481648+ = "SELECT h.id AS page_id, h.url AS page_url, "
16491649+ + "COALESCE(b.title, h.title) AS page_title, h.rev_host AS rev_host, "
16501650+ + "h.visit_count AS visit_count, h.last_visit_date AS visit_date, "
16511651+ + "f.url AS icon_url, NULL AS session, b.id AS item_id, "
16521652+ + "b.dateAdded AS dateAdded, b.lastModified AS lastModified, "
16531653+ + "b.parent AS parent_id, " + TAGS_SQL_FRAGMENT + " AS tags, "
16541654+ + "b.position AS position, b.type AS item_type, "
16551655+ + "h.frecency AS frecency, NULL AS visit_id, NULL AS from_visit, "
16561656+ + "NULL AS from_visit_uri, NULL AS visit_type "
16571657+ + "FROM moz_places h "
16581658+ + "LEFT JOIN moz_bookmarks b ON b.fk = h.id "
16591659+ + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id "
16601660+ ;
16611661+16621662+ return sql + this._getConditions([], aQueryConf, "pages", aGroup);
16631663+ },
16641664+16651665+ _SQLForBookmarksQuery: function QB__SQLForBookmarksQuery(aQueryConf, aGroup)
16661666+ {
16671667+ // If we are grouping, or querying a folder's contents, show containers.
16681668+ let showContainers = (aGroup != "none" ||
16691669+ (aQueryConf.bookmarked && aQueryConf.bookmarked.folder));
16701670+ let join = showContainers ? "LEFT JOIN" : "JOIN";
16711671+16721672+ let sql
16731673+ = "SELECT h.id AS page_id, h.url AS page_url, "
16741674+ + "COALESCE(b.title, h.title) AS page_title, h.rev_host AS rev_host, "
16751675+ + "h.visit_count AS visit_count, h.last_visit_date AS visit_date, "
16761676+ + "f.url AS icon_url, NULL AS session, b.id AS item_id, "
16771677+ + "b.dateAdded AS dateAdded, b.lastModified AS lastModified, "
16781678+ + "b.parent AS parent_id, " + TAGS_SQL_FRAGMENT + " AS tags, "
16791679+ + "b.position AS position, b.type AS item_type, "
16801680+ + "h.frecency AS frecency, NULL AS visit_id, NULL AS from_visit, "
16811681+ + "NULL AS from_visit_uri, NULL AS visit_type "
16821682+ + "FROM moz_bookmarks b "
16831683+ + join + " moz_places h ON b.fk = h.id "
16841684+ + "LEFT JOIN moz_favicons f ON f.id = h.favicon_id "
16851685+ ;
16861686+16871687+ let conditions = []
16881688+ //if (aGroup == "none") {
16891689+ if (!showContainers) {
16901690+ // We want a flat list, exclude query containers.
16911691+ conditions.push("SUBSTR(page_url, 0, 6) <> 'place:'");
16921692+ }
16931693+16941694+ return sql + this._getConditions(conditions, aQueryConf, "bookmarks", aGroup);
16951695+ },
16961696+16971697+ _getConditions: function QB__getConditions(aConditions, aQueryConf, aBaseQuery, aGroup)
16981698+ {
16991699+ // We can use LIKE only for ASCII searches, for anything other we must
17001700+ // fallback to post-filtering. Otherwise we should bundle a lib like ICU.
17011701+ // Moreover searching "bàr" won't match "bar" and viceversa.
17021702+ function needsRegExpLIKE(aText) /[^a-z0-9]/i.test(aText);
17031703+17041704+17051705+ if (aQueryConf.phrase) {
17061706+ let textMatch = aQueryConf._phrase;
17071707+ if (needsRegExpLIKE(aQueryConf.phrase)) {
17081708+ aQueryConf._postFilteringTasks.push(function filter(aResultItem) {
17091709+ let reTags = textMatch.getRegExp(true);
17101710+ let re = textMatch.getRegExp(false);
17111711+ return re.test(aResultItem.title) ||
17121712+ re.test(aResultItem.uri) ||
17131713+ reTags.test(aResultItem.tags.join(" "));
17141714+ });
17151715+ }
17161716+ else {
17171717+ let param = "phrase" + aQueryConf._qIndex;
17181718+ aConditions.push(
17191719+ "("
17201720+ + "page_url LIKE :" + param + " ESCAPE '/' OR "
17211721+ + "page_title LIKE :" + param + " ESCAPE '/' OR "
17221722+ + "tags LIKE :" + param + " ESCAPE '/' "
17231723+ + ")"
17241724+ );
17251725+ aQueryConf._params.push({ name: param,
17261726+ value: textMatch });
17271727+ }
17281728+ }
17291729+17301730+17311731+ if (aQueryConf.host) {
17321732+ let textMatch = aQueryConf._host;
17331733+ if (needsRegExpLIKE(aQueryConf.host)) {
17341734+ aQueryConf._postFilteringTasks.push(function filter(aResultItem) {
17351735+ let re = textMatch.getRegExp(false);
17361736+ return re.test(aResultItem.host);
17371737+ });
17381738+ }
17391739+ else {
17401740+ let param = "revHost" + aQueryConf._qIndex;
17411741+ aConditions.push("(rev_host LIKE :" + param + " ESCAPE '/')");
17421742+ let value = textMatch.value.split("").reverse().join("");
17431743+ if (textMatch.exactMatch)
17441744+ value = "^" + value + ".$";
17451745+ else if (textMatch.matchBegin)
17461746+ value = value + ".$";
17471747+ else if (textMatch.matchEnd)
17481748+ value = "^" + value;
17491749+ aQueryConf._params.push({ name: param,
17501750+ value: new TextMatch(value) });
17511751+ }
17521752+ }
17531753+17541754+17551755+ if (aQueryConf.uri) {
17561756+ let textMatch = aQueryConf._uri;
17571757+ if (needsRegExpLIKE(aQueryConf.uri)) {
17581758+ aQueryConf._postFilteringTasks.push(function filter(aResultItem) {
17591759+ let re = textMatch.getRegExp(false);
17601760+ return re.test(aResultItem.uri);
17611761+ });
17621762+ }
17631763+ else {
17641764+ let param = "uri" + aQueryConf._qIndex;
17651765+ aConditions.push("(page_url LIKE :" + param + " ESCAPE '/')");
17661766+ aQueryConf._params.push({ name: param,
17671767+ value: textMatch });
17681768+ }
17691769+ }
17701770+17711771+17721772+ if (aQueryConf.bookmarked) {
17731773+ let options = aQueryConf.bookmarked;
17741774+17751775+ // Exclude bookmarks in tags folders.
17761776+ aConditions.push(
17771777+ "NOT EXISTS ("
17781778+ + "SELECT 1 FROM moz_bookmarks parents "
17791779+ + "WHERE parents.id = parent_id AND parents.parent = :tags_folder "
17801780+ + "LIMIT 1 "
17811781+ + ") "
17821782+ );
17831783+17841784+ if (checkType(options, "object")) {
17851785+17861786+ if (options.tags.length > 0) {
17871787+ // tags filtering.
17881788+ let paramPrefix = "tag" + aQueryConf._qIndex;
17891789+ let params = [];
17901790+ for (let i = 0; i < options.tags.length; i++) {
17911791+ let param = paramPrefix + "_" + i
17921792+ params.push(":" + param);
17931793+ aQueryConf._params.push({ name: param,
17941794+ value: options.tags[i] });
17951795+ }
17961796+ let param = "tagsCount" + aQueryConf._qIndex;
17971797+ aQueryConf._params.push({ name: param,
17981798+ value: options.tags.length });
17991799+ aConditions.push(
18001800+ ":" + param + " = ( "
18011801+ + "SELECT count(*) FROM moz_bookmarks tags_map "
18021802+ + "JOIN moz_bookmarks tags ON tags_map.parent = tags.id "
18031803+ + "WHERE tags.parent = :tags_folder "
18041804+ + "AND tags.title IN (" + params.join(",") + ") "
18051805+ + "AND tags_map.fk = page_id "
18061806+ + ") "
18071807+ );
18081808+ }
18091809+18101810+ if (options.folder) {
18111811+ // folder id filtering.
18121812+ let param = "folderId" + aQueryConf._qIndex;
18131813+ aConditions.push("parent_id = :" + param);
18141814+ aQueryConf._params.push({ name: param,
18151815+ value: options.folder });
18161816+ if (checkType(options.position, "number")) {
18171817+ // position in folder filtering.
18181818+ let param = "positionInFolder" + aQueryConf._qIndex;
18191819+ aConditions.push("position = :" + param);
18201820+ aQueryConf._params.push({ name: param,
18211821+ value: options.position });
18221822+ }
18231823+ }
18241824+18251825+ if (options.id) {
18261826+ // id filtering.
18271827+ let param = "itemId" + aQueryConf._qIndex;
18281828+ aConditions.push("item_id = :" + param);
18291829+ aQueryConf._params.push({ name: param,
18301830+ value: options.id });
18311831+ }
18321832+18331833+ if (options.createdBegin) {
18341834+ let param = "createdBeginTime" + aQueryConf._qIndex;
18351835+ let beginTime = options.createdBegin.getTime() * 1000;
18361836+ aConditions.push("dateAdded >= :" + param);
18371837+ aQueryConf._params.push({ name: param,
18381838+ value: beginTime });
18391839+ }
18401840+ if (options.createdEnd) {
18411841+ let param = "createdEndTime" + aQueryConf._qIndex;
18421842+ let endTime = options.createdEnd.getTime() * 1000;
18431843+ aConditions.push("dateAdded <= :" + param);
18441844+ aQueryConf._params.push({ name: param,
18451845+ value: endTime });
18461846+ }
18471847+18481848+ if (options.modifiedBegin) {
18491849+ let param = "modifiedBeginTime" + aQueryConf._qIndex;
18501850+ let beginTime = options.modifiedBegin.getTime() * 1000;
18511851+ aConditions.push("lastModified >= :" + param);
18521852+ aQueryConf._params.push({ name: param,
18531853+ value: beginTime });
18541854+ }
18551855+ if (options.modifiedEnd) {
18561856+ let param = "modifiedEndTime" + aQueryConf._qIndex;
18571857+ let endTime = options.modifiedEnd.getTime() * 1000;
18581858+ aConditions.push("lastModified <= :" + param);
18591859+ aQueryConf._params.push({ name: param,
18601860+ value: endTime });
18611861+ }
18621862+18631863+ if (options.excludeReadOnlyContainers) {
18641864+ // Exclude queries and folders annotated as read-only.
18651865+ aConditions.push("SUBSTR(page_url, 0, 6) <> 'place:'");
18661866+ aConditions.push(
18671867+ "NOT EXISTS( "
18681868+ + "SELECT 1 FROM moz_items_annos a "
18691869+ + "JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id "
18701870+ + "WHERE a.item_id = b.id AND n.name = :readOnlyAnno "
18711871+ + "LIMIT 1 "
18721872+ + ")"
18731873+ );
18741874+ aQueryConf._params.push({ name: "readOnlyAnno",
18751875+ value: PlacesUtils.READ_ONLY_ANNO });
18761876+ }
18771877+18781878+ if (options.onlyContainers) {
18791879+ aConditions.push(
18801880+ "(item_type <> :bookmark_type OR "
18811881+ + "(page_url >= 'place:' AND page_url < 'place;') "
18821882+ + ")"
18831883+ );
18841884+ aConditions.push("item_type <> :separator_type");
18851885+ aQueryConf._params.push({ name: "separator_type",
18861886+ value: PlacesUtils.bookmarks.TYPE_SEPARATOR });
18871887+ }
18881888+18891889+ }
18901890+ }
18911891+18921892+18931893+ if (aQueryConf.visited) {
18941894+ if (aBaseQuery != "visits") {
18951895+ aConditions.push(
18961896+ "EXISTS (SELECT id FROM moz_historyvisits WHERE place_id = h.id LIMIT 1)"
18971897+ );
18981898+ }
18991899+ let options = aQueryConf.visited;
19001900+19011901+ if (checkType(options, "object")) {
19021902+ if (options.countMin) {
19031903+ let param = "minVisitCount" + aQueryConf._qIndex;
19041904+ aConditions.push("visit_count >= :" + param);
19051905+ aQueryConf._params.push({ name: param,
19061906+ value: options.countMin });
19071907+ }
19081908+ if (options.countMax) {
19091909+ let param = "maxVisitCount" + aQueryConf._qIndex;
19101910+ aConditions.push("visit_count <= :" + param);
19111911+ aQueryConf._params.push({ name: param,
19121912+ value: options.countMax });
19131913+ }
19141914+19151915+ if (options.transitions.length > 0) {
19161916+ let paramPrefix = "transition" + aQueryConf._qIndex;
19171917+ if (aBaseQuery == "visits" && options.transitions.length == 1) {
19181918+ aConditions.push("visit_type = : " + paramPrefix);
19191919+ aQueryConf._params.push({ name: paramPrefix,
19201920+ value: options.transitions[0] });
19211921+ }
19221922+ else {
19231923+ let params = [];
19241924+ for (let i = 0; i < options.transitions.length; i++) {
19251925+ let param = paramPrefix + "_" + i
19261926+ params.push(":" + param);
19271927+ aQueryConf._params.push({ name: param,
19281928+ value: options.transitions[i] });
19291929+ aConditions.push(
19301930+ "EXISTS ( "
19311931+ + "SELECT 1 FROM moz_historyvisits "
19321932+ + "WHERE place_id = page_id AND visit_type = :" + param + " "
19331933+ + "LIMIT 1 "
19341934+ + ")"
19351935+ );
19361936+ }
19371937+ }
19381938+ }
19391939+19401940+ if (options.begin) {
19411941+ let param = "visitedBeginTime" + aQueryConf._qIndex;
19421942+ let beginTime = options.begin.getTime() * 1000;
19431943+ aQueryConf._params.push({ name: param,
19441944+ value: beginTime });
19451945+ if (aBaseQuery == "visits") {
19461946+ aConditions.push("visit_date >= :" + param);
19471947+ }
19481948+ else {
19491949+ aConditions.push(
19501950+ "EXISTS( "
19511951+ + "SELECT 1 FROM moz_historyvisits "
19521952+ + "WHERE visit_date >= :" + param + " AND place_id = page_id "
19531953+ + "LIMIT 1 "
19541954+ + ")"
19551955+ );
19561956+ }
19571957+ }
19581958+ if (options.end) {
19591959+ let param = "visitedEndTime" + aQueryConf._qIndex;
19601960+ let endTime = options.end.getTime() * 1000;
19611961+ aQueryConf._params.push({ name: param,
19621962+ value: endTime });
19631963+ if (aBaseQuery == "visits") {
19641964+ aConditions.push("visit_date <= :" + param);
19651965+ }
19661966+ else {
19671967+ aConditions.push(
19681968+ "EXISTS( "
19691969+ + "SELECT 1 FROM moz_historyvisits "
19701970+ + "WHERE visit_date <= :" + param + " AND place_id = page_id "
19711971+ + "LIMIT 1 "
19721972+ + ")"
19731973+ );
19741974+ }
19751975+ }
19761976+19771977+ if (options.excludeRedirectSources) {
19781978+ aQueryConf._params.push({ name: "transition_redirect_permanent",
19791979+ value: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT });
19801980+ aQueryConf._params.push({ name: "transition_redirect_temporary",
19811981+ value: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY });
19821982+ if (aBaseQuery == "visits") {
19831983+ aConditions.push(
19841984+ "NOT EXISTS ( "
19851985+ + "SELECT id FROM moz_historyvisits "
19861986+ + "WHERE from_visit = visit_id AND visit_type IN (:transition_redirect_permanent, :transition_redirect_temporary) "
19871987+ + ")"
19881988+ );
19891989+ }
19901990+ else {
19911991+ // Exclude pages that are only redirect sources.
19921992+ aConditions.push(
19931993+ "EXISTS ( "
19941994+ + "SELECT 1 FROM moz_historyvisits srcs "
19951995+ + "LEFT JOIN moz_historyvisits dests ON dests.from_visit = srcs.id "
19961996+ + "WHERE srcs.place_id = page_id "
19971997+ + "AND (dests.id IS NULL OR dests.visit_type NOT IN (:transition_redirect_permanent, :transition_redirect_temporary)) "
19981998+ + "LIMIT 1 "
19991999+ + ")"
20002000+ );
20012001+ }
20022002+ }
20032003+20042004+ if (options.excludeRedirectTargets) {
20052005+ aQueryConf._params.push({ name: "transition_redirect_permanent",
20062006+ value: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT });
20072007+ aQueryConf._params.push({ name: "transition_redirect_temporary",
20082008+ value: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY });
20092009+ if (aBaseQuery == "visits") {
20102010+ aConditions.push(
20112011+ "visit_type NOT IN (:transition_redirect_permanent, :transition_redirect_temporary)"
20122012+ );
20132013+ }
20142014+ else {
20152015+ // Exclude pages that are only redirect targets.
20162016+ aConditions.push(
20172017+ "EXISTS ( "
20182018+ + "SELECT 1 FROM moz_historyvisits "
20192019+ + "WHERE place_id = page_id "
20202020+ + "AND visit_type NOT IN (:transition_redirect_permanent, :transition_redirect_temporary) "
20212021+ + "LIMIT 1 "
20222022+ + ")"
20232023+ );
20242024+ }
20252025+ }
20262026+20272027+ if (!options.includeHidden) {
20282028+ aConditions.push("h.hidden = 0");
20292029+ if (aBaseQuery == "visits") {
20302030+ aConditions.push(
20312031+ "visit_type NOT IN (:transition_embed, :transition_framed_link)"
20322032+ );
20332033+ aQueryConf._params.push({ name: "transition_embed",
20342034+ value: Ci.nsINavHistoryService.TRANSITION_EMBED });
20352035+ aQueryConf._params.push({ name: "transition_framed_link",
20362036+ value: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK });
20372037+ }
20382038+ }
20392039+ }
20402040+ }
20412041+20422042+20432043+ if (aQueryConf.annotated.length > 0) {
20442044+ let paramPrefix = "annoName" + aQueryConf._qIndex;
20452045+ let params = [];
20462046+ for (let i = 0; i < aQueryConf.annotated.length; i++) {
20472047+ let param = paramPrefix + "_" + i;
20482048+ params.push(":" + param);
20492049+ aQueryConf._params.push({ name: param,
20502050+ value: aQueryConf.annotated[i] });
20512051+ }
20522052+ let param = "annosCount" + aQueryConf._qIndex;
20532053+ aQueryConf._params.push({ name: param,
20542054+ value: aQueryConf.annotated.length });
20552055+ aConditions.push(
20562056+ ":" + param + " = ( "
20572057+ + "SELECT count(*) FROM moz_anno_attributes n "
20582058+ + "LEFT JOIN moz_annos a ON n.id = a.anno_attribute_id AND a.place_id = h.id "
20592059+ + "LEFT JOIN moz_items_annos ia ON n.id =ia.anno_attribute_id AND ia.item_id = b.id "
20602060+ + "WHERE name IN (" + params.join(",") + ") "
20612061+ + "AND IFNULL(place_id, item_id) NOTNULL "
20622062+ + ") "
20632063+ );
20642064+ }
20652065+20662066+20672067+ return aConditions.length > 0 ? "WHERE " + aConditions.join(" AND ")
20682068+ : "";
20692069+ }
20702070+}
20712071+20722072+////////////////////////////////////////////////////////////////////////////////
20732073+//// StmtCallback
20742074+20752075+function StmtCallback(aQueryConfs, aCallback, aThisObject)
20762076+{
20772077+ this._queryConfs = aQueryConfs;
20782078+ this._callbackInfo = { cb: aCallback, scope: aThisObject };
20792079+ this._results = [];
20802080+ this._limit = aQueryConfs[0].limit;
20812081+ this._currentResultsCount = 0;
20822082+ this.pendingQuery = null;
20832083+20842084+ // Collect all post-filtering tasks. These are run as soon as results are
20852085+ // available. Filtered results are then immediately returned to the caller
20862086+ // unless post-processing has to happen.
20872087+ this._postFilteringTasks = [];
20882088+ this._queryConfs.forEach(function(aQueryConf) {
20892089+ this._postFilteringTasks =
20902090+ this._postFilteringTasks.concat(aQueryConf._postFilteringTasks.slice());
20912091+ }, this);
20922092+20932093+ // Collect all post-processing tasks. These are run after all results have
20942094+ // been cached. If any post-processing task is defined then no results are
20952095+ // returned to the caller till all post-processing is finished.
20962096+ this._postProcessingTasks = [];
20972097+ this._queryConfs.forEach(function(aQueryConf) {
20982098+ this._postProcessingTasks =
20992099+ this._postProcessingTasks.concat(aQueryConf._postProcessingTasks.slice());
21002100+ }, this);
21012101+}
21022102+21032103+StmtCallback.prototype = {
21042104+ handleResult: function SC_handleResult(aResultSet)
21052105+ {
21062106+ let row;
21072107+ let results = [];
21082108+ while ((row = aResultSet.getNextRow()) != null) {
21092109+ results.push(new ResultItem(row, this._queryConfs));
21102110+ }
21112111+21122112+ this._postFilter(results);
21132113+21142114+ this._currentResultsCount += results.length;
21152115+ if (this._limit != -1 && this._postProcessingTasks.length == 0 &&
21162116+ this._currentResultsCount >= this._limit) {
21172117+ // No reason for this query to return other results.
21182118+ if (this.pendingQuery)
21192119+ this.pendingQuery.cancel();
21202120+ // Remove exceeding results.
21212121+ let excess = this._currentResultsCount - this._limit;
21222122+ results.splice(results.length - excess, excess);
21232123+ }
21242124+21252125+ // If this query does not require postProcessing, just push results to the
21262126+ // caller. Notice that pushing an empty result set means that we are done
21272127+ // so we must avoid it.
21282128+ if (this._postProcessingTasks.length == 0 && results.length > 0)
21292129+ this._callback(results);
21302130+ else
21312131+ this._results = this._results.concat(results);
21322132+ },
21332133+21342134+ handleError: function SC_handleError(aError)
21352135+ {
21362136+ Cu.reportError("PlacesQuery: An error occured while executing a query.");
21372137+ },
21382138+21392139+ handleCompletion: function SC_handleCompletion(aReason)
21402140+ {
21412141+ if (aReason == Ci.mozIStorageStatementCallback.REASON_FINISHED ||
21422142+ aReason == Ci.mozIStorageStatementCallback.REASON_CANCELED &&
21432143+ this._results.length > 0) {
21442144+ this._postProcess(this._results);
21452145+21462146+ if (this._limit != -1 && this._results.length > this._limit) {
21472147+ // Remove exceeding results.
21482148+ let excess = this._results.length - this._limit;
21492149+ this._results.splice(this._results.length - excess, excess);
21502150+ }
21512151+ }
21522152+21532153+ // Notify the caller we have finished pushing results, by pushing an empty
21542154+ // set. This happens regardless completion reason.
21552155+ this._callback(this._results);
21562156+ },
21572157+21582158+ _postProcess: function SC__postProcess(aResults) {
21592159+ // Bail out if there is nothing to post-process.
21602160+ if (this._postProcessingTasks.length == 0)
21612161+ return;
21622162+21632163+ for (let i = 0; i < this._results.length; i++) {
21642164+ this._postProcessingTasks.forEach(function(aPPTask) {
21652165+ // Each post-processing task should be able to work on the full set.
21662166+ aPPTask(this._results); // TODO: define this better.
21672167+ }, this);
21682168+ }
21692169+21702170+ // Push results to the caller, if we have any.
21712171+ if (aResults.length)
21722172+ this._callback(aResults);
21732173+ },
21742174+21752175+ _postFilter: function SC__postFilter(aResults) {
21762176+ // Bail out if there is nothing to post-filter.
21772177+ if (this._postFilteringTasks.length == 0)
21782178+ return;
21792179+21802180+ for (let i = 0; i < aResults.length; i++) {
21812181+ this._postFilteringTasks.forEach(function(aPassFilter) {
21822182+ if (!aPassFilter(aResults[i])) {
21832183+ // Be sure to decrease i since we are removing one element.
21842184+ aResults.splice(i--, 1);
21852185+ }
21862186+ }, this);
21872187+ }
21882188+ },
21892189+21902190+ _callback: function SC__callback(aResults) {
21912191+ // Enqueue the call, so it runs out of the current task.
21922192+ Services.tm.mainThread.dispatch({
21932193+ _callbackInfo: this._callbackInfo,
21942194+ run: function() {
21952195+ let callback = this._callbackInfo.cb;
21962196+ let scope = this._callbackInfo.scope ||
21972197+ Cu.getGlobalForObject(this._callbackInfo.cb);
21982198+ // If there are results, call the callback for each individual result.
21992199+ if (aResults.length)
22002200+ aResults.forEach(callback, scope);
22012201+ // Otherwise, the query must be complete.
22022202+ else
22032203+ callback.call(scope, false);
22042204+ }
22052205+ }, Ci.nsIThread.DISPATCH_NORMAL);
22062206+ }
22072207+}
22082208+22092209+22102210+////////////////////////////////////////////////////////////////////////////////
22112211+//// ResultItem
22122212+22132213+function ResultItem(aResultRow, aQueryConfs){
22142214+ if (!(aResultRow instanceof Ci.mozIStorageRow))
22152215+ return;
22162216+ this.pageId = aResultRow.getResultByName("page_id");
22172217+ this.uri = aResultRow.getResultByName("page_url");
22182218+ this.title = aResultRow.getResultByName("page_title");
22192219+ let revHost = aResultRow.getResultByName("rev_host");
22202220+ this.host = revHost ? revHost.split("").reverse().join("").substr(1) : "";
22212221+ this.accessCount = aResultRow.getResultByName("visit_count");
22222222+ // TODO: if there is a time constraint time should be the last visit in
22232223+ // that time instead.
22242224+ this.time = new Date(aResultRow.getResultByName("visit_date")/1000);
22252225+ this.icon = aResultRow.getResultByName("icon_url");
22262226+ this.sessionId = aResultRow.getResultByName("session");
22272227+ this.itemId = aResultRow.getResultByName("item_id");
22282228+ this.isBookmarked = !!this.itemId;
22292229+ this.dateAdded = new Date(aResultRow.getResultByName("dateAdded")/1000);
22302230+ this.lastModified = new Date(aResultRow.getResultByName("lastModified")/1000);
22312231+ this.parentId = aResultRow.getResultByName("parent_id");
22322232+ let tags = aResultRow.getResultByName("tags");
22332233+ this.tags = tags ? tags.split(TAGS_SEPARATOR) : [];
22342234+ this.bookmarkIndex = aResultRow.getResultByName("position");
22352235+ this.frecency = aResultRow.getResultByName("frecency");
22362236+ this.visitId = aResultRow.getResultByName("visit_id");
22372237+ this.referringVisitId = aResultRow.getResultByName("from_visit");
22382238+ this.referringUri = aResultRow.getResultByName("from_visit_uri");
22392239+ this.transitionType = aResultRow.getResultByName("visit_type");
22402240+ let itemType = aResultRow.getResultByName("item_type");
22412241+ this.type = getNodeType(this, itemType);
22422242+ this.readableType = getReadableItemType(this, itemType);
22432243+22442244+ let currentResult = this;
22452245+ // Index of the query that generated this result.
22462246+22472247+ XPCOMUtils.defineLazyGetter(this, "query", function() {
22482248+ if (currentResult.readableType != "container")
22492249+ throw new Error("Cannot get query for a non container.");
22502250+22512251+ if (currentResult.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) {
22522252+ // PlacesQuery can deserialize most place: uris.
22532253+ // This is not completely correct, since options from the place: uri and
22542254+ // current ones should be merged.
22552255+ return new PlacesQuery(currentResult.uri);
22562256+ }
22572257+22582258+ if (currentResult.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER) {
22592259+ let queryConfs = [].concat(aQueryConfs);
22602260+ queryConfs.forEach(function(aQueryConf) {
22612261+ aQueryConf.bookmarked.folder = currentResult.itemId;
22622262+ });
22632263+ return new PlacesQuery(queryConfs);
22642264+ }
22652265+22662266+ throw new Error("Cannot get query for this kind of container.");
22672267+ });
22682268+}
22692269+22702270+ResultItem.prototype = {}
+9
package.json
···11+{
22+ "name": "peek",
33+ "license": "MPL 1.1/GPL 2.0/LGPL 2.1",
44+ "author": "Dietrich Ayala",
55+ "version": "2.0",
66+ "fullName": "Peek",
77+ "id": "jid1-xEAuRv8GzibUdw",
88+ "description": "Peek at your app tabs without breaking your flow."
99+}