/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * The panel is initialized based on data given in the js object passed * as window.arguments[0]. The object must have the following fields set: * @ action (String). Possible values: * - "add" - for adding a new item. * @ type (String). Possible values: * - "bookmark" * @ loadBookmarkInSidebar - optional, the default state for the * "Load this bookmark in the sidebar" field. * - "folder" * @ URIList (Array of nsIURI objects) - optional, list of uris to * be bookmarked under the new folder. * - "livemark" * @ uri (nsIURI object) - optional, the default uri for the new item. * The property is not used for the "folder with items" type. * @ title (String) - optional, the default title for the new item. * @ description (String) - optional, the default description for the new * item. * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the * default insertion point for the new item. * @ keyword (String) - optional, the default keyword for the new item. * @ postData (String) - optional, POST data to accompany the keyword. * @ charSet (String) - optional, character-set to accompany the keyword. * Notes: * 1) If |uri| is set for a bookmark/livemark item and |title| isn't, * the dialog will query the history tables for the title associated * with the given uri. If the dialog is set to adding a folder with * bookmark items under it (see URIList), a default static title is * used ("[Folder Name]"). * 2) The index field of the default insertion point is ignored if * the folder picker is shown. * - "edit" - for editing a bookmark item or a folder. * @ type (String). Possible values: * - "bookmark" * @ node (an nsINavHistoryResultNode object) - a node representing * the bookmark. * - "folder" (also applies to livemarks) * @ node (an nsINavHistoryResultNode object) - a node representing * the folder. * @ hiddenRows (Strings array) - optional, list of rows to be hidden * regardless of the item edited or added by the dialog. * Possible values: * - "title" * - "location" * - "description" * - "keyword" * - "tags" * - "loadInSidebar" * - "folderPicker" - hides both the tree and the menu. * * window.arguments[0].performed is set to true if any transaction has * been performed by the dialog. */ Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", "resource://gre/modules/PromiseUtils.jsm"); const BOOKMARK_ITEM = 0; const BOOKMARK_FOLDER = 1; const LIVEMARK_CONTAINER = 2; const ACTION_EDIT = 0; const ACTION_ADD = 1; var elementsHeight = new Map(); var BookmarkPropertiesPanel = { /** UI Text Strings */ __strings: null, get _strings() { if (!this.__strings) { this.__strings = document.getElementById("stringBundle"); } return this.__strings; }, _action: null, _itemType: null, _itemId: -1, _uri: null, _loadInSidebar: false, _title: "", _description: "", _URIs: [], _keyword: "", _postData: null, _charSet: "", _feedURI: null, _siteURI: null, _defaultInsertionPoint: null, _hiddenRows: [], _batching: false, /** * This method returns the correct label for the dialog's "accept" * button based on the variant of the dialog. */ _getAcceptLabel: function BPP__getAcceptLabel() { if (this._action == ACTION_ADD) { if (this._URIs.length) return this._strings.getString("dialogAcceptLabelAddMulti"); if (this._itemType == LIVEMARK_CONTAINER) return this._strings.getString("dialogAcceptLabelAddLivemark"); if (this._dummyItem || this._loadInSidebar) return this._strings.getString("dialogAcceptLabelAddItem"); return this._strings.getString("dialogAcceptLabelSaveItem"); } return this._strings.getString("dialogAcceptLabelEdit"); }, /** * This method returns the correct title for the current variant * of this dialog. */ _getDialogTitle: function BPP__getDialogTitle() { if (this._action == ACTION_ADD) { if (this._itemType == BOOKMARK_ITEM) return this._strings.getString("dialogTitleAddBookmark"); if (this._itemType == LIVEMARK_CONTAINER) return this._strings.getString("dialogTitleAddLivemark"); // add folder NS_ASSERT(this._itemType == BOOKMARK_FOLDER, "Unknown item type"); if (this._URIs.length) return this._strings.getString("dialogTitleAddMulti"); return this._strings.getString("dialogTitleAddFolder"); } if (this._action == ACTION_EDIT) { return this._strings.getFormattedString("dialogTitleEdit", [this._title]); } return ""; }, /** * Determines the initial data for the item edited or added by this dialog */ _determineItemInfo() { let dialogInfo = window.arguments[0]; this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; if (this._action == ACTION_ADD) { NS_ASSERT("type" in dialogInfo, "missing type property for add action"); if ("title" in dialogInfo) this._title = dialogInfo.title; if ("defaultInsertionPoint" in dialogInfo) { this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; } else { this._defaultInsertionPoint = new InsertionPoint(PlacesUtils.bookmarksMenuFolderId, PlacesUtils.bookmarks.DEFAULT_INDEX, Ci.nsITreeView.DROP_ON); } switch (dialogInfo.type) { case "bookmark": this._itemType = BOOKMARK_ITEM; if ("uri" in dialogInfo) { NS_ASSERT(dialogInfo.uri instanceof Ci.nsIURI, "uri property should be a uri object"); this._uri = dialogInfo.uri; if (typeof(this._title) != "string") { this._title = this._getURITitleFromHistory(this._uri) || this._uri.spec; } } else { this._uri = PlacesUtils._uri("about:blank"); this._title = this._strings.getString("newBookmarkDefault"); this._dummyItem = true; } if ("loadBookmarkInSidebar" in dialogInfo) this._loadInSidebar = dialogInfo.loadBookmarkInSidebar; if ("keyword" in dialogInfo) { this._keyword = dialogInfo.keyword; this._isAddKeywordDialog = true; if ("postData" in dialogInfo) this._postData = dialogInfo.postData; if ("charSet" in dialogInfo) this._charSet = dialogInfo.charSet; } break; case "folder": this._itemType = BOOKMARK_FOLDER; if (!this._title) { if ("URIList" in dialogInfo) { this._title = this._strings.getString("bookmarkAllTabsDefault"); this._URIs = dialogInfo.URIList; } else this._title = this._strings.getString("newFolderDefault"); this._dummyItem = true; } break; case "livemark": this._itemType = LIVEMARK_CONTAINER; if ("feedURI" in dialogInfo) this._feedURI = dialogInfo.feedURI; if ("siteURI" in dialogInfo) this._siteURI = dialogInfo.siteURI; if (!this._title) { if (this._feedURI) { this._title = this._getURITitleFromHistory(this._feedURI) || this._feedURI.spec; } else this._title = this._strings.getString("newLivemarkDefault"); } } if ("description" in dialogInfo) this._description = dialogInfo.description; } else { // edit this._node = dialogInfo.node; this._title = this._node.title; if (PlacesUtils.nodeIsFolder(this._node)) this._itemType = BOOKMARK_FOLDER; else if (PlacesUtils.nodeIsURI(this._node)) this._itemType = BOOKMARK_ITEM; } }, /** * This method returns the title string corresponding to a given URI. * If none is available from the bookmark service (probably because * the given URI doesn't appear in bookmarks or history), we synthesize * a title from the first 100 characters of the URI. * * @param aURI * nsIURI object for which we want the title * * @returns a title string */ _getURITitleFromHistory: function BPP__getURITitleFromHistory(aURI) { NS_ASSERT(aURI instanceof Ci.nsIURI); // get the title from History return PlacesUtils.history.getPageTitle(aURI); }, /** * This method should be called by the onload of the Bookmark Properties * dialog to initialize the state of the panel. */ onDialogLoad: Task.async(function* () { this._determineItemInfo(); document.title = this._getDialogTitle(); var acceptButton = document.documentElement.getButton("accept"); acceptButton.label = this._getAcceptLabel(); // Do not use sizeToContent, otherwise, due to bug 90276, the dialog will // grow at every opening. // Since elements can be uncollapsed asynchronously, we must observe their // mutations and resize the dialog using a cached element size. this._height = window.outerHeight; this._mutationObserver = new MutationObserver(mutations => { for (let mutation of mutations) { let target = mutation.target; let id = target.id; if (!/^editBMPanel_.*(Row|Checkbox)$/.test(id)) continue; let collapsed = target.getAttribute("collapsed") === "true"; let wasCollapsed = mutation.oldValue === "true"; if (collapsed == wasCollapsed) continue; if (collapsed) { this._height -= elementsHeight.get(id); elementsHeight.delete(id); } else { elementsHeight.set(id, target.boxObject.height); this._height += elementsHeight.get(id); } window.resizeTo(window.outerWidth, this._height); } }); this._mutationObserver.observe(document, { subtree: true, attributeOldValue: true, attributeFilter: ["collapsed"] }); // Some controls are flexible and we want to update their cached size when // the dialog is resized. window.addEventListener("resize", this); this._beginBatch(); switch (this._action) { case ACTION_EDIT: gEditItemOverlay.initPanel({ node: this._node , hiddenRows: this._hiddenRows , focusedElement: "first" }); acceptButton.disabled = gEditItemOverlay.readOnly; break; case ACTION_ADD: this._node = yield this._promiseNewItem(); // Edit the new item gEditItemOverlay.initPanel({ node: this._node , hiddenRows: this._hiddenRows , postData: this._postData , focusedElement: "first" }); // Empty location field if the uri is about:blank, this way inserting a new // url will be easier for the user, Accept button will be automatically // disabled by the input listener until the user fills the field. let locationField = this._element("locationField"); if (locationField.value == "about:blank") locationField.value = ""; // if this is an uri related dialog disable accept button until // the user fills an uri value. if (this._itemType == BOOKMARK_ITEM) acceptButton.disabled = !this._inputIsValid(); break; } if (!gEditItemOverlay.readOnly) { // Listen on uri fields to enable accept button if input is valid if (this._itemType == BOOKMARK_ITEM) { this._element("locationField") .addEventListener("input", this); if (this._isAddKeywordDialog) { this._element("keywordField") .addEventListener("input", this); } } } }), // nsIDOMEventListener handleEvent: function BPP_handleEvent(aEvent) { var target = aEvent.target; switch (aEvent.type) { case "input": if (target.id == "editBMPanel_locationField" || target.id == "editBMPanel_keywordField") { // Check uri fields to enable accept button if input is valid document.documentElement .getButton("accept").disabled = !this._inputIsValid(); } break; case "resize": for (let [id, oldHeight] of elementsHeight) { let newHeight = document.getElementById(id).boxObject.height; this._height += -oldHeight + newHeight; elementsHeight.set(id, newHeight); } break; } }, // Hack for implementing batched-Undo around the editBookmarkOverlay // instant-apply code. For all the details see the comment above beginBatch // in browser-places.js _batchBlockingDeferred: null, _beginBatch() { if (this._batching) return; if (PlacesUIUtils.useAsyncTransactions) { this._batchBlockingDeferred = PromiseUtils.defer(); PlacesTransactions.batch(function* () { yield this._batchBlockingDeferred.promise; }.bind(this)); } else { PlacesUtils.transactionManager.beginBatch(null); } this._batching = true; }, _endBatch() { if (!this._batching) return; if (PlacesUIUtils.useAsyncTransactions) { this._batchBlockingDeferred.resolve(); this._batchBlockingDeferred = null; } else { PlacesUtils.transactionManager.endBatch(false); } this._batching = false; }, // nsISupports QueryInterface: function BPP_QueryInterface(aIID) { if (aIID.equals(Ci.nsIDOMEventListener) || aIID.equals(Ci.nsISupports)) return this; throw Cr.NS_NOINTERFACE; }, _element: function BPP__element(aID) { return document.getElementById("editBMPanel_" + aID); }, onDialogUnload() { // gEditItemOverlay does not exist anymore here, so don't rely on it. this._mutationObserver.disconnect(); delete this._mutationObserver; window.removeEventListener("resize", this); // Calling removeEventListener with arguments which do not identify any // currently registered EventListener on the EventTarget has no effect. this._element("locationField") .removeEventListener("input", this); }, onDialogAccept() { // We must blur current focused element to save its changes correctly document.commandDispatcher.focusedElement.blur(); // The order here is important! We have to uninit the panel first, otherwise // late changes could force it to commit more transactions. gEditItemOverlay.uninitPanel(true); this._endBatch(); window.arguments[0].performed = true; }, onDialogCancel() { // The order here is important! We have to uninit the panel first, otherwise // changes done as part of Undo may change the panel contents and by // that force it to commit more transactions. gEditItemOverlay.uninitPanel(true); this._endBatch(); if (PlacesUIUtils.useAsyncTransactions) PlacesTransactions.undo().catch(Components.utils.reportError); else PlacesUtils.transactionManager.undoTransaction(); window.arguments[0].performed = false; }, /** * This method checks to see if the input fields are in a valid state. * * @returns true if the input is valid, false otherwise */ _inputIsValid: function BPP__inputIsValid() { if (this._itemType == BOOKMARK_ITEM && !this._containsValidURI("locationField")) return false; if (this._isAddKeywordDialog && !this._element("keywordField").value.length) return false; return true; }, /** * Determines whether the XUL textbox with the given ID contains a * string that can be converted into an nsIURI. * * @param aTextboxID * the ID of the textbox element whose contents we'll test * * @returns true if the textbox contains a valid URI string, false otherwise */ _containsValidURI: function BPP__containsValidURI(aTextboxID) { try { var value = this._element(aTextboxID).value; if (value) { PlacesUIUtils.createFixedURI(value); return true; } } catch (e) { } return false; }, /** * [New Item Mode] Get the insertion point details for the new item, given * dialog state and opening arguments. * * The container-identifier and insertion-index are returned separately in * the form of [containerIdentifier, insertionIndex] */ _getInsertionPointDetails: function BPP__getInsertionPointDetails() { var containerId = this._defaultInsertionPoint.itemId; var indexInContainer = this._defaultInsertionPoint.index; return [containerId, indexInContainer]; }, /** * Returns a transaction for creating a new bookmark item representing the * various fields and opening arguments of the dialog. */ _getCreateNewBookmarkTransaction: function BPP__getCreateNewBookmarkTransaction(aContainer, aIndex) { var annotations = []; var childTransactions = []; if (this._description) { let annoObj = { name : PlacesUIUtils.DESCRIPTION_ANNO, type : Ci.nsIAnnotationService.TYPE_STRING, flags : 0, value : this._description, expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; let editItemTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); childTransactions.push(editItemTxn); } if (this._loadInSidebar) { let annoObj = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, value : true }; let setLoadTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); childTransactions.push(setLoadTxn); } // XXX TODO: this should be in a transaction! if (this._charSet && !PrivateBrowsingUtils.isWindowPrivate(window)) PlacesUtils.setCharsetForURI(this._uri, this._charSet); let createTxn = new PlacesCreateBookmarkTransaction(this._uri, aContainer, aIndex, this._title, this._keyword, annotations, childTransactions, this._postData); return new PlacesAggregatedTransaction(this._getDialogTitle(), [createTxn]); }, /** * Returns a childItems-transactions array representing the URIList with * which the dialog has been opened. */ _getTransactionsForURIList: function BPP__getTransactionsForURIList() { var transactions = []; for (let uri of this._URIs) { // uri should be an object in the form { url, title }. Though add-ons // could still use the legacy form, where it's an nsIURI. let [_uri, _title] = uri instanceof Ci.nsIURI ? [uri, this._getURITitleFromHistory(uri)] : [uri.uri, uri.title]; let createTxn = new PlacesCreateBookmarkTransaction(_uri, -1, PlacesUtils.bookmarks.DEFAULT_INDEX, _title); transactions.push(createTxn); } return transactions; }, /** * Returns a transaction for creating a new folder item representing the * various fields and opening arguments of the dialog. */ _getCreateNewFolderTransaction: function BPP__getCreateNewFolderTransaction(aContainer, aIndex) { var annotations = []; var childItemsTransactions; if (this._URIs.length) childItemsTransactions = this._getTransactionsForURIList(); if (this._description) annotations.push(this._getDescriptionAnnotation(this._description)); return new PlacesCreateFolderTransaction(this._title, aContainer, aIndex, annotations, childItemsTransactions); }, _createNewItem: Task.async(function* () { let [container, index] = this._getInsertionPointDetails(); let txn; switch (this._itemType) { case BOOKMARK_FOLDER: txn = this._getCreateNewFolderTransaction(container, index); break; case LIVEMARK_CONTAINER: txn = new PlacesCreateLivemarkTransaction(this._feedURI, this._siteURI, this._title, container, index); break; default: // BOOKMARK_ITEM txn = this._getCreateNewBookmarkTransaction(container, index); } PlacesUtils.transactionManager.doTransaction(txn); // This is a temporary hack until we use PlacesTransactions.jsm if (txn._promise) { yield txn._promise; } let folderGuid = yield PlacesUtils.promiseItemGuid(container); let bm = yield PlacesUtils.bookmarks.fetch({ parentGuid: folderGuid, index }); this._itemId = yield PlacesUtils.promiseItemId(bm.guid); return Object.freeze({ itemId: this._itemId, bookmarkGuid: bm.guid, title: this._title, uri: this._uri ? this._uri.spec : "", type: this._itemType == BOOKMARK_ITEM ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER }); }), _promiseNewItem: Task.async(function* () { if (!PlacesUIUtils.useAsyncTransactions) return this._createNewItem(); let [containerId, index] = this._getInsertionPointDetails(); let parentGuid = yield PlacesUtils.promiseItemGuid(containerId); let annotations = []; if (this._description) { annotations.push({ name: PlacesUIUtils.DESCRIPTION_ANNO , value: this._description }); } if (this._loadInSidebar) { annotations.push({ name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO , value: true }); } let itemGuid; let info = { parentGuid, index, title: this._title, annotations }; if (this._itemType == BOOKMARK_ITEM) { info.url = this._uri; if (this._keyword) info.keyword = this._keyword; if (this._postData) info.postData = this._postData; if (this._charSet && !PrivateBrowsingUtils.isWindowPrivate(window)) PlacesUtils.setCharsetForURI(this._uri, this._charSet); itemGuid = yield PlacesTransactions.NewBookmark(info).transact(); } else if (this._itemType == LIVEMARK_CONTAINER) { info.feedUrl = this._feedURI; if (this._siteURI) info.siteUrl = this._siteURI; itemGuid = yield PlacesTransactions.NewLivemark(info).transact(); } else if (this._itemType == BOOKMARK_FOLDER) { itemGuid = yield PlacesTransactions.NewFolder(info).transact(); for (let uri of this._URIs) { let placeInfo = yield PlacesUtils.promisePlaceInfo(uri); let title = placeInfo ? placeInfo.title : ""; yield PlacesTransactions.transact({ parentGuid: itemGuid, uri, title }); } } else { throw new Error(`unexpected value for _itemType: ${this._itemType}`); } this._itemGuid = itemGuid; this._itemId = yield PlacesUtils.promiseItemId(itemGuid); return Object.freeze({ itemId: this._itemId, bookmarkGuid: this._itemGuid, title: this._title, uri: this._uri ? this._uri.spec : "", type: this._itemType == BOOKMARK_ITEM ? Ci.nsINavHistoryResultNode.RESULT_TYPE_URI : Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER }); }) };