diff options
author | Matt A. Tobin <email@mattatobin.com> | 2018-02-10 02:51:36 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2018-02-10 02:51:36 -0500 |
commit | 37d5300335d81cecbecc99812747a657588c63eb (patch) | |
tree | 765efa3b6a56bb715d9813a8697473e120436278 /toolkit/jetpack/sdk/places | |
parent | b2bdac20c02b12f2057b9ef70b0a946113a00e00 (diff) | |
parent | 4fb11cd5966461bccc3ed1599b808237be6b0de9 (diff) | |
download | UXP-37d5300335d81cecbecc99812747a657588c63eb.tar UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.gz UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.lz UXP-37d5300335d81cecbecc99812747a657588c63eb.tar.xz UXP-37d5300335d81cecbecc99812747a657588c63eb.zip |
Merge branch 'ext-work'
Diffstat (limited to 'toolkit/jetpack/sdk/places')
-rw-r--r-- | toolkit/jetpack/sdk/places/bookmarks.js | 395 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/contract.js | 73 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/events.js | 128 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/favicon.js | 49 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/history.js | 65 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/host/host-bookmarks.js | 238 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/host/host-query.js | 179 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/host/host-tags.js | 92 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/places/utils.js | 268 |
9 files changed, 1487 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/places/bookmarks.js b/toolkit/jetpack/sdk/places/bookmarks.js new file mode 100644 index 000000000..c4f9528f1 --- /dev/null +++ b/toolkit/jetpack/sdk/places/bookmarks.js @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { send } = require('../addon/events'); +const { defer, reject, all, resolve, promised } = require('../core/promise'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { identity, defer:async } = require('../lang/functional'); +const { extend, merge } = require('../util/object'); +const { fromIterator } = require('../util/array'); +const { + constructTree, fetchItem, createQuery, + isRootGroup, createQueryOptions +} = require('./utils'); +const { + bookmarkContract, groupContract, separatorContract +} = require('./contract'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +/* + * Mapping of uncreated bookmarks with their created + * counterparts + */ +const itemMap = new WeakMap(); + +/* + * Constant used by nsIHistoryQuery; 1 is a bookmark query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const BOOKMARK_QUERY = 1; + +/* + * Bookmark Item classes + */ + +const Bookmark = Class({ + extends: [ + bookmarkContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, bookmarkContract(extend(defaults, options))); + }, + type: 'bookmark', + toString: () => '[object Bookmark]' +}); +exports.Bookmark = Bookmark; + +const Group = Class({ + extends: [ + groupContract.properties(identity) + ], + initialize: function initialize (options) { + // Don't validate if root group + if (isRootGroup(options)) + merge(this, options); + else + merge(this, groupContract(extend(defaults, options))); + }, + type: 'group', + toString: () => '[object Group]' +}); +exports.Group = Group; + +const Separator = Class({ + extends: [ + separatorContract.properties(identity) + ], + initialize: function initialize (options) { + merge(this, separatorContract(extend(defaults, options))); + }, + type: 'separator', + toString: () => '[object Separator]' +}); +exports.Separator = Separator; + +/* + * Functions + */ + +function save (items, options) { + items = [].concat(items); + options = options || {}; + let emitter = EventTarget(); + let results = []; + let errors = []; + let root = constructTree(items); + let cache = new Map(); + + let isExplicitSave = item => !!~items.indexOf(item); + // `walk` returns an aggregate promise indicating the completion + // of the `commitItem` on each node, not whether or not that + // commit was successful + + // Force this to be async, as if a ducktype fails validation, + // the promise implementation will fire an error event, which will + // not trigger the handler as it's not yet bound + // + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => root.walk(preCommitItem).then(commitComplete))(); + + function preCommitItem ({value:item}) { + // Do nothing if tree root, default group (unsavable), + // or if it's a dependency and not explicitly saved (in the list + // of items to be saved), and not needed to be saved + if (item === null || // node is the tree root + isRootGroup(item) || + (getId(item) && !isExplicitSave(item))) + return; + + return promised(validate)(item) + .then(() => commitItem(item, options)) + .then(data => construct(data, cache)) + .then(savedItem => { + // If item was just created, make a map between + // the creation object and created object, + // so we can reference the item that doesn't have an id + if (!getId(item)) + saveId(item, savedItem.id); + + // Emit both the processed item, and original item + // so a mapping can be understood in handler + emit(emitter, 'data', savedItem, item); + + // Push to results iff item was explicitly saved + if (isExplicitSave(item)) + results[items.indexOf(item)] = savedItem; + }, reason => { + // Force reason to be a string for consistency + reason = reason + ''; + // Emit both the reason, and original item + // so a mapping can be understood in handler + emit(emitter, 'error', reason + '', item); + // Store unsaved item in results list + results[items.indexOf(item)] = item; + errors.push(reason); + }); + } + + // Called when traversal of the node tree is completed and all + // items have been committed + function commitComplete () { + emit(emitter, 'end', results); + } + + return emitter; +} +exports.save = save; + +function search (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let cache = new Map(); + let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY)); + let optionsObj = createQueryOptions(BOOKMARK_QUERY, options); + + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => { + send('sdk-places-query', { queries: queryObjs, options: optionsObj }) + .then(handleQueryResponse); + })(); + + function handleQueryResponse (data) { + let deferreds = data.map(item => { + return construct(item, cache).then(bookmark => { + emit(emitter, 'data', bookmark); + return bookmark; + }, reason => { + emit(emitter, 'error', reason); + errors.push(reason); + }); + }); + + all(deferreds).then(data => { + emit(emitter, 'end', data); + }, () => emit(emitter, 'end', [])); + } + + return emitter; +} +exports.search = search; + +function remove (items) { + return [].concat(items).map(item => { + item.remove = true; + return item; + }); +} + +exports.remove = remove; + +/* + * Internal Utilities + */ + +function commitItem (item, options) { + // Get the item's ID, or getId it's saved version if it exists + let id = getId(item); + let data = normalize(item); + let promise; + + data.id = id; + + if (!id) { + promise = send('sdk-places-bookmarks-create', data); + } else if (item.remove) { + promise = send('sdk-places-bookmarks-remove', { id: id }); + } else { + promise = send('sdk-places-bookmarks-last-updated', { + id: id + }).then(function (updated) { + // If attempting to save an item that is not the + // latest snapshot of a bookmark item, execute + // the resolution function + if (updated !== item.updated && options.resolve) + return fetchItem(id) + .then(options.resolve.bind(null, data)); + else + return data; + }).then(send.bind(null, 'sdk-places-bookmarks-save')); + } + + return promise; +} + +/* + * Turns a bookmark item into a plain object, + * converts `tags` from Set to Array, group instance to an id + */ +function normalize (item) { + let data = merge({}, item); + // Circumvent prototype property of `type` + delete data.type; + data.type = item.type; + data.tags = []; + if (item.tags) { + data.tags = fromIterator(item.tags); + } + data.group = getId(data.group) || exports.UNSORTED.id; + + return data; +} + +/* + * Takes a data object and constructs a BookmarkItem instance + * of it, recursively generating parent instances as well. + * + * Pass in a `cache` Map to reuse instances of + * bookmark items to reduce overhead; + * The cache object is a map of id to a deferred with a + * promise that resolves to the bookmark item. + */ +function construct (object, cache, forced) { + let item = instantiate(object); + let deferred = defer(); + + // Item could not be instantiated + if (!item) + return resolve(null); + + // Return promise for item if found in the cache, + // and not `forced`. `forced` indicates that this is the construct + // call that should not read from cache, but should actually perform + // the construction, as it was set before several async calls + if (cache.has(item.id) && !forced) + return cache.get(item.id).promise; + else if (cache.has(item.id)) + deferred = cache.get(item.id); + else + cache.set(item.id, deferred); + + // When parent group is found in cache, use + // the same deferred value + if (item.group && cache.has(item.group)) { + cache.get(item.group).promise.then(group => { + item.group = group; + deferred.resolve(item); + }); + + // If not in the cache, and a root group, return + // the premade instance + } else if (rootGroups.get(item.group)) { + item.group = rootGroups.get(item.group); + deferred.resolve(item); + + // If not in the cache or a root group, fetch the parent + } else { + cache.set(item.group, defer()); + fetchItem(item.group).then(group => { + return construct(group, cache, true); + }).then(group => { + item.group = group; + deferred.resolve(item); + }, deferred.reject); + } + + return deferred.promise; +} + +function instantiate (object) { + if (object.type === 'bookmark') + return Bookmark(object); + if (object.type === 'group') + return Group(object); + if (object.type === 'separator') + return Separator(object); + return null; +} + +/** + * Validates a bookmark item; will throw an error if ininvalid, + * to be used with `promised`. As bookmark items check on their class, + * this only checks ducktypes + */ +function validate (object) { + if (!isDuckType(object)) return true; + let contract = object.type === 'bookmark' ? bookmarkContract : + object.type === 'group' ? groupContract : + object.type === 'separator' ? separatorContract : + null; + if (!contract) { + throw Error('No type specified'); + } + + // If object has a property set, and undefined, + // manually override with default as it'll fail otherwise + let withDefaults = Object.keys(defaults).reduce((obj, prop) => { + if (obj[prop] == null) obj[prop] = defaults[prop]; + return obj; + }, extend(object)); + + contract(withDefaults); +} + +function isDuckType (item) { + return !(item instanceof Bookmark) && + !(item instanceof Group) && + !(item instanceof Separator); +} + +function saveId (unsaved, id) { + itemMap.set(unsaved, id); +} + +// Fetches an item's ID from itself, or from the mapped items +function getId (item) { + return typeof item === 'number' ? item : + item ? item.id || itemMap.get(item) : + null; +} + +/* + * Set up the default, root groups + */ + +var defaultGroupMap = { + MENU: bmsrv.bookmarksMenuFolder, + TOOLBAR: bmsrv.toolbarFolder, + UNSORTED: bmsrv.unfiledBookmarksFolder +}; + +var rootGroups = new Map(); + +for (let i in defaultGroupMap) { + let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] })); + rootGroups.set(defaultGroupMap[i], group); + exports[i] = group; +} + +var defaults = { + group: exports.UNSORTED, + index: -1 +}; diff --git a/toolkit/jetpack/sdk/places/contract.js b/toolkit/jetpack/sdk/places/contract.js new file mode 100644 index 000000000..a3541c34d --- /dev/null +++ b/toolkit/jetpack/sdk/places/contract.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +module.metadata = { + "stability": "unstable" +}; + +const { Cc, Ci } = require('chrome'); +const { isValidURI, URL } = require('../url'); +const { contract } = require('../util/contract'); +const { extend } = require('../util/object'); + +// map of property validations +const validItem = { + id: { + is: ['number', 'undefined', 'null'], + }, + group: { + is: ['object', 'number', 'undefined', 'null'], + ok: function (value) { + return value && + (value.toString && value.toString() === '[object Group]') || + typeof value === 'number' || + value.type === 'group'; + }, + msg: 'The `group` property must be a valid Group object' + }, + index: { + is: ['undefined', 'null', 'number'], + map: value => value == null ? -1 : value, + msg: 'The `index` property must be a number.' + }, + updated: { + is: ['number', 'undefined'] + } +}; + +const validTitle = { + title: { + is: ['string'], + msg: 'The `title` property must be defined.' + } +}; + +const validURL = { + url: { + is: ['string'], + ok: isValidURI, + msg: 'The `url` property must be a valid URL.' + } +}; + +const validTags = { + tags: { + is: ['object'], + ok: tags => tags instanceof Set, + map: function (tags) { + if (Array.isArray(tags)) + return new Set(tags); + if (tags == null) + return new Set(); + return tags; + }, + msg: 'The `tags` property must be a Set, or an array' + } +}; + +exports.bookmarkContract = contract( + extend(validItem, validTitle, validURL, validTags)); +exports.separatorContract = contract(validItem); +exports.groupContract = contract(extend(validItem, validTitle)); diff --git a/toolkit/jetpack/sdk/places/events.js b/toolkit/jetpack/sdk/places/events.js new file mode 100644 index 000000000..a3f95ee03 --- /dev/null +++ b/toolkit/jetpack/sdk/places/events.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +module.metadata = { + 'stability': 'experimental', + 'engines': { + 'Firefox': '*', + "SeaMonkey": '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { Unknown } = require('../platform/xpcom'); +const { Class } = require('../core/heritage'); +const { merge } = require('../util/object'); +const bookmarkService = Cc['@mozilla.org/browser/nav-bookmarks-service;1'] + .getService(Ci.nsINavBookmarksService); +const historyService = Cc['@mozilla.org/browser/nav-history-service;1'] + .getService(Ci.nsINavHistoryService); +const { mapBookmarkItemType } = require('./utils'); +const { EventTarget } = require('../event/target'); +const { emit } = require('../event/core'); +const { when } = require('../system/unload'); + +const emitter = EventTarget(); + +var HISTORY_ARGS = { + onBeginUpdateBatch: [], + onEndUpdateBatch: [], + onClearHistory: [], + onDeleteURI: ['url'], + onDeleteVisits: ['url', 'visitTime'], + onPageChanged: ['url', 'property', 'value'], + onTitleChanged: ['url', 'title'], + onVisit: [ + 'url', 'visitId', 'time', 'sessionId', 'referringId', 'transitionType' + ] +}; + +var HISTORY_EVENTS = { + onBeginUpdateBatch: 'history-start-batch', + onEndUpdateBatch: 'history-end-batch', + onClearHistory: 'history-start-clear', + onDeleteURI: 'history-delete-url', + onDeleteVisits: 'history-delete-visits', + onPageChanged: 'history-page-changed', + onTitleChanged: 'history-title-changed', + onVisit: 'history-visit' +}; + +var BOOKMARK_ARGS = { + onItemAdded: [ + 'id', 'parentId', 'index', 'type', 'url', 'title', 'dateAdded' + ], + onItemChanged: [ + 'id', 'property', null, 'value', 'lastModified', 'type', 'parentId' + ], + onItemMoved: [ + 'id', 'previousParentId', 'previousIndex', 'currentParentId', + 'currentIndex', 'type' + ], + onItemRemoved: ['id', 'parentId', 'index', 'type', 'url'], + onItemVisited: ['id', 'visitId', 'time', 'transitionType', 'url', 'parentId'] +}; + +var BOOKMARK_EVENTS = { + onItemAdded: 'bookmark-item-added', + onItemChanged: 'bookmark-item-changed', + onItemMoved: 'bookmark-item-moved', + onItemRemoved: 'bookmark-item-removed', + onItemVisited: 'bookmark-item-visited', +}; + +function createHandler (type, propNames) { + propNames = propNames || []; + return function (...args) { + let data = propNames.reduce((acc, prop, i) => { + if (prop) + acc[prop] = formatValue(prop, args[i]); + return acc; + }, {}); + + emit(emitter, 'data', { + type: type, + data: data + }); + }; +} + +/* + * Creates an observer, creating handlers based off of + * the `events` names, and ordering arguments from `propNames` hash + */ +function createObserverInstance (events, propNames) { + let definition = Object.keys(events).reduce((prototype, eventName) => { + prototype[eventName] = createHandler(events[eventName], propNames[eventName]); + return prototype; + }, {}); + + return Class(merge(definition, { extends: Unknown }))(); +} + +/* + * Formats `data` based off of the value of `type` + */ +function formatValue (type, data) { + if (type === 'type') + return mapBookmarkItemType(data); + if (type === 'url' && data) + return data.spec; + return data; +} + +var historyObserver = createObserverInstance(HISTORY_EVENTS, HISTORY_ARGS); +historyService.addObserver(historyObserver, false); + +var bookmarkObserver = createObserverInstance(BOOKMARK_EVENTS, BOOKMARK_ARGS); +bookmarkService.addObserver(bookmarkObserver, false); + +when(() => { + historyService.removeObserver(historyObserver); + bookmarkService.removeObserver(bookmarkObserver); +}); + +exports.events = emitter; diff --git a/toolkit/jetpack/sdk/places/favicon.js b/toolkit/jetpack/sdk/places/favicon.js new file mode 100644 index 000000000..05b057db1 --- /dev/null +++ b/toolkit/jetpack/sdk/places/favicon.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci, Cu } = require("chrome"); +const { defer, reject } = require("../core/promise"); +const FaviconService = Cc["@mozilla.org/browser/favicon-service;1"]. + getService(Ci.nsIFaviconService); +const AsyncFavicons = FaviconService.QueryInterface(Ci.mozIAsyncFavicons); +const { isValidURI } = require("../url"); +const { newURI, getURL } = require("../url/utils"); + +/** + * Takes an object of several possible types and + * returns a promise that resolves to the page's favicon URI. + * @param {String|Tab} object + * @param {Function} (callback) + * @returns {Promise} + */ + +function getFavicon (object, callback) { + let url = getURL(object); + let deferred = defer(); + + if (url && isValidURI(url)) { + AsyncFavicons.getFaviconURLForPage(newURI(url), function (aURI) { + if (aURI && aURI.spec) + deferred.resolve(aURI.spec.toString()); + else + deferred.reject(null); + }); + } else { + deferred.reject(null); + } + + if (callback) deferred.promise.then(callback, callback); + return deferred.promise; +} +exports.getFavicon = getFavicon; diff --git a/toolkit/jetpack/sdk/places/history.js b/toolkit/jetpack/sdk/places/history.js new file mode 100644 index 000000000..b243b024c --- /dev/null +++ b/toolkit/jetpack/sdk/places/history.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.metadata = { + "stability": "unstable", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +/* + * Requiring hosts so they can subscribe to client messages + */ +require('./host/host-bookmarks'); +require('./host/host-tags'); +require('./host/host-query'); + +const { Cc, Ci } = require('chrome'); +const { Class } = require('../core/heritage'); +const { events, send } = require('../addon/events'); +const { defer, reject, all } = require('../core/promise'); +const { uuid } = require('../util/uuid'); +const { flatten } = require('../util/array'); +const { has, extend, merge, pick } = require('../util/object'); +const { emit } = require('../event/core'); +const { defer: async } = require('../lang/functional'); +const { EventTarget } = require('../event/target'); +const { + urlQueryParser, createQuery, createQueryOptions +} = require('./utils'); + +/* + * Constant used by nsIHistoryQuery; 0 is a history query + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + */ +const HISTORY_QUERY = 0; + +var search = function query (queries, options) { + queries = [].concat(queries); + let emitter = EventTarget(); + let queryObjs = queries.map(createQuery.bind(null, HISTORY_QUERY)); + let optionsObj = createQueryOptions(HISTORY_QUERY, options); + + // Can remove after `Promise.jsm` is implemented in Bug 881047, + // which will guarantee next tick execution + async(() => { + send('sdk-places-query', { + query: queryObjs, + options: optionsObj + }).then(results => { + results.map(item => emit(emitter, 'data', item)); + emit(emitter, 'end', results); + }, reason => { + emit(emitter, 'error', reason); + emit(emitter, 'end', []); + }); + })(); + + return emitter; +}; +exports.search = search; diff --git a/toolkit/jetpack/sdk/places/host/host-bookmarks.js b/toolkit/jetpack/sdk/places/host/host-bookmarks.js new file mode 100644 index 000000000..3245c4070 --- /dev/null +++ b/toolkit/jetpack/sdk/places/host/host-bookmarks.js @@ -0,0 +1,238 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const browserHistory = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsIBrowserHistory); +const asyncHistory = Cc["@mozilla.org/browser/history;1"]. + getService(Ci.mozIAsyncHistory); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { query } = require('./host-query'); +const { + defer, all, resolve, promised, reject +} = require('../../core/promise'); +const { request, response } = require('../../addon/host'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); +const { URL, isValidURI } = require('../../url'); +const { newURI } = require('../../url/utils'); + +const DEFAULT_INDEX = bmsrv.DEFAULT_INDEX; +const UNSORTED_ID = bmsrv.unfiledBookmarksFolder; +const ROOT_FOLDERS = [ + bmsrv.unfiledBookmarksFolder, bmsrv.toolbarFolder, + bmsrv.tagsFolder, bmsrv.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-bookmarks-create': createBookmarkItem, + 'sdk-places-bookmarks-save': saveBookmarkItem, + 'sdk-places-bookmarks-last-updated': getBookmarkLastUpdated, + 'sdk-places-bookmarks-get': getBookmarkItem, + 'sdk-places-bookmarks-remove': removeBookmarkItem, + 'sdk-places-bookmarks-get-all': getAllBookmarks, + 'sdk-places-bookmarks-get-children': getChildren +}; + +function typeMap (type) { + if (typeof type === 'number') { + if (bmsrv.TYPE_BOOKMARK === type) return 'bookmark'; + if (bmsrv.TYPE_FOLDER === type) return 'group'; + if (bmsrv.TYPE_SEPARATOR === type) return 'separator'; + } else { + if ('bookmark' === type) return bmsrv.TYPE_BOOKMARK; + if ('group' === type) return bmsrv.TYPE_FOLDER; + if ('separator' === type) return bmsrv.TYPE_SEPARATOR; + } +} + +function getBookmarkLastUpdated ({id}) { + return resolve(bmsrv.getItemLastModified(id)); +} +exports.getBookmarkLastUpdated; + +function createBookmarkItem (data) { + let error; + + if (data.group == null) data.group = UNSORTED_ID; + if (data.index == null) data.index = DEFAULT_INDEX; + + if (data.type === 'group') + data.id = bmsrv.createFolder( + data.group, data.title, data.index + ); + else if (data.type === 'separator') + data.id = bmsrv.insertSeparator( + data.group, data.index + ); + else + data.id = bmsrv.insertBookmark( + data.group, newURI(data.url), data.index, data.title + ); + + // In the event where default or no index is provided (-1), + // query the actual index for the response + if (data.index === -1) + data.index = bmsrv.getItemIndex(data.id); + + try { + data.updated = bmsrv.getItemLastModified(data.id); + } + catch (e) { + console.exception(e); + } + + return tag(data, true).then(() => data); +} +exports.createBookmarkItem = createBookmarkItem; + +function saveBookmarkItem (data) { + let id = data.id; + if (!id) + reject('Item is missing id'); + + let group = bmsrv.getFolderIdForItem(id); + let index = bmsrv.getItemIndex(id); + let type = bmsrv.getItemType(id); + let title = typeMap(type) !== 'separator' ? + bmsrv.getItemTitle(id) : + undefined; + let url = typeMap(type) === 'bookmark' ? + bmsrv.getBookmarkURI(id).spec : + undefined; + + if (url != data.url) + bmsrv.changeBookmarkURI(id, newURI(data.url)); + else if (typeMap(type) === 'bookmark') + data.url = url; + + if (title != data.title) + bmsrv.setItemTitle(id, data.title); + else if (typeMap(type) !== 'separator') + data.title = title; + + if (data.group && data.group !== group) + bmsrv.moveItem(id, data.group, data.index || -1); + else if (data.index != null && data.index !== index) { + // We use moveItem here instead of setItemIndex + // so we don't have to manage the indicies of the siblings + bmsrv.moveItem(id, group, data.index); + } else if (data.index == null) + data.index = index; + + data.updated = bmsrv.getItemLastModified(data.id); + + return tag(data).then(() => data); +} +exports.saveBookmarkItem = saveBookmarkItem; + +function removeBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + bmsrv.removeItem(id); + return resolve(null); +} +exports.removeBookmarkItem = removeBookmarkItem; + +function getBookmarkItem (data) { + let id = data.id; + + if (!id) + reject('Item is missing id'); + + let type = bmsrv.getItemType(id); + + data.type = typeMap(type); + + if (type === bmsrv.TYPE_BOOKMARK || type === bmsrv.TYPE_FOLDER) + data.title = bmsrv.getItemTitle(id); + + if (type === bmsrv.TYPE_BOOKMARK) { + data.url = bmsrv.getBookmarkURI(id).spec; + // Should be moved into host-tags as a method + data.tags = taggingService.getTagsForURI(newURI(data.url), {}); + } + + data.group = bmsrv.getFolderIdForItem(id); + data.index = bmsrv.getItemIndex(id); + data.updated = bmsrv.getItemLastModified(data.id); + + return resolve(data); +} +exports.getBookmarkItem = getBookmarkItem; + +function getAllBookmarks () { + return query({}, { queryType: 1 }).then(bookmarks => + all(bookmarks.map(getBookmarkItem))); +} +exports.getAllBookmarks = getAllBookmarks; + +function getChildren ({ id }) { + if (typeMap(bmsrv.getItemType(id)) !== 'group') return []; + let ids = []; + for (let i = 0; ids[ids.length - 1] !== -1; i++) + ids.push(bmsrv.getIdForItemAt(id, i)); + ids.pop(); + return all(ids.map(id => getBookmarkItem({ id: id }))); +} +exports.getChildren = getChildren; + +/* + * Hook into host + */ + +var reqStream = filter(request, (data) => /sdk-places-bookmarks/.test(data.event)); +on(reqStream, 'data', ({ event, id, data }) => { + if (!EVENT_MAP[event]) return; + + let resData = { id: id, event: event }; + + promised(EVENT_MAP[event])(data). + then(res => resData.data = res, e => resData.error = e). + then(() => emit(response, 'data', resData)); +}); + +function tag (data, isNew) { + // If a new item, we can skip checking what other tags + // are on the item + if (data.type !== 'bookmark') { + return resolve(); + } + else if (!isNew) { + return send('sdk-places-tags-get-tags-by-url', { url: data.url }) + .then(tags => { + return send('sdk-places-tags-untag', { + tags: tags.filter(tag => !~data.tags.indexOf(tag)), + url: data.url + }); + }).then(() => send('sdk-places-tags-tag', { + url: data.url, tags: data.tags + })); + } + else if (data.tags && data.tags.length) { + return send('sdk-places-tags-tag', { url: data.url, tags: data.tags }); + } + else + return resolve(); +} + diff --git a/toolkit/jetpack/sdk/places/host/host-query.js b/toolkit/jetpack/sdk/places/host/host-query.js new file mode 100644 index 000000000..f2dbd6550 --- /dev/null +++ b/toolkit/jetpack/sdk/places/host/host-query.js @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const { all } = require('../../core/promise'); +const { safeMerge, omit } = require('../../util/object'); +const historyService = Cc['@mozilla.org/browser/nav-history-service;1'] + .getService(Ci.nsINavHistoryService); +const bookmarksService = Cc['@mozilla.org/browser/nav-bookmarks-service;1'] + .getService(Ci.nsINavBookmarksService); +const { request, response } = require('../../addon/host'); +const { newURI } = require('../../url/utils'); +const { send } = require('../../addon/events'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const ROOT_FOLDERS = [ + bookmarksService.unfiledBookmarksFolder, bookmarksService.toolbarFolder, + bookmarksService.bookmarksMenuFolder +]; + +const EVENT_MAP = { + 'sdk-places-query': queryReceiver +}; + +// Properties that need to be manually +// copied into a nsINavHistoryQuery object +const MANUAL_QUERY_PROPERTIES = [ + 'uri', 'folder', 'tags', 'url', 'folder' +]; + +const PLACES_PROPERTIES = [ + 'uri', 'title', 'accessCount', 'time' +]; + +function execute (queries, options) { + return new Promise(resolve => { + let root = historyService + .executeQueries(queries, queries.length, options).root; + // Let's extract an eventual uri wildcard, if both domain and uri are set. + // See utils.js::urlQueryParser() for more details. + // In case of multiple queries, we only retain the first found wildcard. + let uriWildcard = queries.reduce((prev, query) => { + if (query.uri && query.domain) { + if (!prev) + prev = query.uri.spec; + query.uri = null; + } + return prev; + }, ""); + resolve(collect([], root, uriWildcard)); + }); +} + +function collect (acc, node, uriWildcard) { + node.containerOpen = true; + for (let i = 0; i < node.childCount; i++) { + let child = node.getChild(i); + + if (!uriWildcard || child.uri.startsWith(uriWildcard)) { + acc.push(child); + } + if (child.type === child.RESULT_TYPE_FOLDER) { + let container = child.QueryInterface(Ci.nsINavHistoryContainerResultNode); + collect(acc, container, uriWildcard); + } + } + node.containerOpen = false; + return acc; +} + +function query (queries, options) { + return new Promise((resolve, reject) => { + queries = queries || []; + options = options || {}; + let optionsObj, queryObjs; + + optionsObj = historyService.getNewQueryOptions(); + queryObjs = [].concat(queries).map(createQuery); + if (!queryObjs.length) { + queryObjs = [historyService.getNewQuery()]; + } + safeMerge(optionsObj, options); + + /* + * Currently `places:` queries are not supported + */ + optionsObj.excludeQueries = true; + + execute(queryObjs, optionsObj).then((results) => { + if (optionsObj.queryType === 0) { + return results.map(normalize); + } + else if (optionsObj.queryType === 1) { + // Formats query results into more standard + // data structures for returning + return all(results.map(({itemId}) => + send('sdk-places-bookmarks-get', { id: itemId }))); + } + }).then(resolve, reject); + }); +} +exports.query = query; + +function createQuery (query) { + query = query || {}; + let queryObj = historyService.getNewQuery(); + + safeMerge(queryObj, omit(query, MANUAL_QUERY_PROPERTIES)); + + if (query.tags && Array.isArray(query.tags)) + queryObj.tags = query.tags; + if (query.uri || query.url) + queryObj.uri = newURI(query.uri || query.url); + if (query.folder) + queryObj.setFolders([query.folder], 1); + return queryObj; +} + +function queryReceiver (message) { + let queries = message.data.queries || message.data.query; + let options = message.data.options; + let resData = { + id: message.id, + event: message.event + }; + + query(queries, options).then(results => { + resData.data = results; + respond(resData); + }, reason => { + resData.error = reason; + respond(resData); + }); +} + +/* + * Converts a nsINavHistoryResultNode into a plain object + * + * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryResultNode + */ +function normalize (historyObj) { + return PLACES_PROPERTIES.reduce((obj, prop) => { + if (prop === 'uri') + obj.url = historyObj.uri; + else if (prop === 'time') { + // Cast from microseconds to milliseconds + obj.time = Math.floor(historyObj.time / 1000) + } + else if (prop === 'accessCount') + obj.visitCount = historyObj[prop]; + else + obj[prop] = historyObj[prop]; + return obj; + }, {}); +} + +/* + * Hook into host + */ + +var reqStream = filter(request, data => /sdk-places-query/.test(data.event)); +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/toolkit/jetpack/sdk/places/host/host-tags.js b/toolkit/jetpack/sdk/places/host/host-tags.js new file mode 100644 index 000000000..929a5d5af --- /dev/null +++ b/toolkit/jetpack/sdk/places/host/host-tags.js @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci } = require('chrome'); +const taggingService = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +const ios = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const { URL } = require('../../url'); +const { newURI } = require('../../url/utils'); +const { request, response } = require('../../addon/host'); +const { on, emit } = require('../../event/core'); +const { filter } = require('../../event/utils'); + +const EVENT_MAP = { + 'sdk-places-tags-tag': tag, + 'sdk-places-tags-untag': untag, + 'sdk-places-tags-get-tags-by-url': getTagsByURL, + 'sdk-places-tags-get-urls-by-tag': getURLsByTag +}; + +function tag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.tagURI(newURI(data.url), data.tags); + respond(resData); +} + +function untag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.untagURI(newURI(data.url), data.tags); + respond(resData); +} + +function getURLsByTag (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService + .getURIsForTag(data.tag).map(uri => uri.spec); + respond(resData); +} + +function getTagsByURL (message) { + let data = message.data; + let resData = { + id: message.id, + event: message.event + }; + + resData.data = taggingService.getTagsForURI(newURI(data.url), {}); + respond(resData); +} + +/* + * Hook into host + */ + +var reqStream = filter(request, function (data) { + return /sdk-places-tags/.test(data.event); +}); + +on(reqStream, 'data', function (e) { + if (EVENT_MAP[e.event]) EVENT_MAP[e.event](e); +}); + +function respond (data) { + emit(response, 'data', data); +} diff --git a/toolkit/jetpack/sdk/places/utils.js b/toolkit/jetpack/sdk/places/utils.js new file mode 100644 index 000000000..44366d2aa --- /dev/null +++ b/toolkit/jetpack/sdk/places/utils.js @@ -0,0 +1,268 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +module.metadata = { + "stability": "experimental", + "engines": { + "Firefox": "*", + "SeaMonkey": "*" + } +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { Class } = require('../core/heritage'); +const { method } = require('../lang/functional'); +const { defer, promised, all } = require('../core/promise'); +const { send } = require('../addon/events'); +const { EventTarget } = require('../event/target'); +const { merge } = require('../util/object'); +const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +Cu.importGlobalProperties(["URL"]); + +/* + * TreeNodes are used to construct dependency trees + * for BookmarkItems + */ +var TreeNode = Class({ + initialize: function (value) { + this.value = value; + this.children = []; + }, + add: function (values) { + [].concat(values).forEach(value => { + this.children.push(value instanceof TreeNode ? value : TreeNode(value)); + }); + }, + get length () { + let count = 0; + this.walk(() => count++); + // Do not count the current node + return --count; + }, + get: method(get), + walk: method(walk), + toString: () => '[object TreeNode]' +}); +exports.TreeNode = TreeNode; + +/* + * Descends down from `node` applying `fn` to each in order. + * `fn` can return values or promises -- if promise returned, + * children are not processed until resolved. `fn` is passed + * one argument, the current node, `curr`. + */ +function walk (curr, fn) { + return promised(fn)(curr).then(val => { + return all(curr.children.map(child => walk(child, fn))); + }); +} + +/* + * Descends from the TreeNode `node`, returning + * the node with value `value` if found or `null` + * otherwise + */ +function get (node, value) { + if (node.value === value) return node; + for (let child of node.children) { + let found = get(child, value); + if (found) return found; + } + return null; +} + +/* + * Constructs a tree of bookmark nodes + * returning the root (value: null); + */ + +function constructTree (items) { + let root = TreeNode(null); + items.forEach(treeify.bind(null, root)); + + function treeify (root, item) { + // If node already exists, skip + let node = root.get(item); + if (node) return node; + node = TreeNode(item); + + let parentNode = item.group ? treeify(root, item.group) : root; + parentNode.add(node); + + return node; + } + + return root; +} +exports.constructTree = constructTree; + +/* + * Shortcut for converting an id, or an object with an id, into + * an object with corresponding bookmark data + */ +function fetchItem (item) { + return send('sdk-places-bookmarks-get', { id: item.id || item }); +} +exports.fetchItem = fetchItem; + +/* + * Takes an ID or an object with ID and checks it against + * the root bookmark folders + */ +function isRootGroup (id) { + id = id && id.id; + return ~[bmsrv.bookmarksMenuFolder, bmsrv.toolbarFolder, + bmsrv.unfiledBookmarksFolder + ].indexOf(id); +} +exports.isRootGroup = isRootGroup; + +/* + * Merges appropriate options into query based off of url + * 4 scenarios: + * + * 'moz.com' // domain: moz.com, domainIsHost: true + * --> 'http://moz.com', 'http://moz.com/thunderbird' + * '*.moz.com' // domain: moz.com, domainIsHost: false + * --> 'http://moz.com', 'http://moz.com/index', 'http://ff.moz.com/test' + * 'http://moz.com' // uri: http://moz.com/ + * --> 'http://moz.com/' + * 'http://moz.com/*' // uri: http://moz.com/, domain: moz.com, domainIsHost: true + * --> 'http://moz.com/', 'http://moz.com/thunderbird' + */ + +function urlQueryParser (query, url) { + if (!url) return; + if (/^https?:\/\//.test(url)) { + query.uri = url.charAt(url.length - 1) === '/' ? url : url + '/'; + if (/\*$/.test(url)) { + // Wildcard searches on URIs are not supported, so try to extract a + // domain and filter the data later. + url = url.replace(/\*$/, ''); + try { + query.domain = new URL(url).hostname; + query.domainIsHost = true; + // Unfortunately here we cannot use an expando to store the wildcard, + // cause the query is a wrapped native XPCOM object, so we reuse uri. + // We clearly don't want to query for both uri and domain, thus we'll + // have to handle this in host-query.js::execute() + query.uri = url; + } catch (ex) { + // Cannot extract an host cause it's not a valid uri, the query will + // just return nothing. + } + } + } else { + if (/^\*/.test(url)) { + query.domain = url.replace(/^\*\./, ''); + query.domainIsHost = false; + } else { + query.domain = url; + query.domainIsHost = true; + } + } +} +exports.urlQueryParser = urlQueryParser; + +/* + * Takes an EventEmitter and returns a promise that + * aggregates results and handles a bulk resolve and reject + */ + +function promisedEmitter (emitter) { + let { promise, resolve, reject } = defer(); + let errors = []; + emitter.on('error', error => errors.push(error)); + emitter.on('end', (items) => { + if (errors.length) reject(errors[0]); + else resolve(items); + }); + return promise; +} +exports.promisedEmitter = promisedEmitter; + + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions +function createQuery (type, query) { + query = query || {}; + let qObj = { + searchTerms: query.query + }; + + urlQueryParser(qObj, query.url); + + // 0 === history + if (type === 0) { + // PRTime used by query is in microseconds, not milliseconds + qObj.beginTime = (query.from || 0) * 1000; + qObj.endTime = (query.to || new Date()) * 1000; + + // Set reference time to Epoch + qObj.beginTimeReference = 0; + qObj.endTimeReference = 0; + } + // 1 === bookmarks + else if (type === 1) { + qObj.tags = query.tags; + qObj.folder = query.group && query.group.id; + } + // 2 === unified (not implemented on platform) + else if (type === 2) { + + } + + return qObj; +} +exports.createQuery = createQuery; + +// https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions + +const SORT_MAP = { + title: 1, + date: 3, // sort by visit date + url: 5, + visitCount: 7, + // keywords currently unsupported + // keyword: 9, + dateAdded: 11, // bookmarks only + lastModified: 13 // bookmarks only +}; + +function createQueryOptions (type, options) { + options = options || {}; + let oObj = {}; + oObj.sortingMode = SORT_MAP[options.sort] || 0; + if (options.descending && options.sort) + oObj.sortingMode++; + + // Resolve to default sort if ineligible based on query type + if (type === 0 && // history + (options.sort === 'dateAdded' || options.sort === 'lastModified')) + oObj.sortingMode = 0; + + oObj.maxResults = typeof options.count === 'number' ? options.count : 0; + + oObj.queryType = type; + + return oObj; +} +exports.createQueryOptions = createQueryOptions; + + +function mapBookmarkItemType (type) { + if (typeof type === 'number') { + if (bmsrv.TYPE_BOOKMARK === type) return 'bookmark'; + if (bmsrv.TYPE_FOLDER === type) return 'group'; + if (bmsrv.TYPE_SEPARATOR === type) return 'separator'; + } else { + if ('bookmark' === type) return bmsrv.TYPE_BOOKMARK; + if ('group' === type) return bmsrv.TYPE_FOLDER; + if ('separator' === type) return bmsrv.TYPE_SEPARATOR; + } +} +exports.mapBookmarkItemType = mapBookmarkItemType; |