1 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
2 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
3 // Copyright (c) 2008-2014 Kris Maglione <maglione.k@gmail.com>
5 // This work is licensed for reuse under an MIT license. Details are
6 // given in the LICENSE.txt file included with this file.
9 // also includes methods for dealing with keywords and search engines
10 var Bookmarks = Module("bookmarks", {
12 this.timer = Timer(0, 100, function () {
13 this.checkBookmarked(buffer.uri);
16 storage.addObserver("bookmark-cache", function (key, event, arg) {
17 if (["add", "change", "remove"].indexOf(event) >= 0)
18 autocommands.trigger("Bookmark" + util.capitalize(event),
21 toString: function () "bookmarkcache.bookmarks[" + arg.id + "]",
22 valueOf: function () arg
25 bookmarks.timer.tell();
30 "browser.locationChange": function (webProgress, request, uri) {
31 statusline.bookmarked = false;
32 this.checkBookmarked(uri);
38 title: ["URL", "Info"],
39 keys: { text: "url", description: "title", icon: "icon", extra: "extra", tags: "tags", isURI: function () true },
40 process: [template.icon, template.bookmarkDescription]
43 // TODO: why is this a filter? --djk
44 get: function get(filter, tags, maxItems, extra) {
45 return completion.runCompleter("bookmark", filter, maxItems, tags, extra);
49 * Adds a new bookmark. The first parameter should be an object with
50 * any of the following properties:
52 * @param {boolean} unfiled If true, the bookmark is added to the
53 * Unfiled Bookmarks Folder.
54 * @param {string} title The title of the new bookmark.
55 * @param {string} url The URL of the new bookmark.
56 * @param {string} keyword The keyword of the new bookmark.
58 * @param {[string]} tags The tags for the new bookmark.
60 * @param {boolean} force If true, a new bookmark is always added.
61 * Otherwise, if a bookmark for the given URL exists it is
64 * @returns {boolean} True if the bookmark was updated, false if a
65 * new bookmark was added.
67 add: function add(unfiled, title, url, keyword, tags, force) {
69 if (isObject(unfiled))
70 var { id, unfiled, title, url, keyword, tags, post, charset, force } = unfiled;
72 let uri = util.createURI(url);
74 var bmark = bookmarkcache.bookmarks[id];
76 if (keyword && hasOwnProperty(bookmarkcache.keywords, keyword))
77 bmark = bookmarkcache.keywords[keyword];
78 else if (bookmarkcache.isBookmarked(uri))
79 for (bmark in bookmarkcache)
80 if (bmark.url == uri.spec)
85 PlacesUtils.tagging.untagURI(uri, null);
86 PlacesUtils.tagging.tagURI(uri, tags);
89 let updated = !!bmark;
90 if (bmark == undefined)
91 bmark = bookmarkcache.bookmarks[
92 services.bookmarks.insertBookmark(
93 services.bookmarks[unfiled ? "unfiledBookmarksFolder" : "bookmarksMenuFolder"],
94 uri, -1, title || url)];
98 if (!uri.equals(bmark.uri))
104 if (charset !== undefined)
105 bmark.charset = charset;
106 if (post !== undefined)
109 bmark.keyword = keyword;
115 * Opens the command line in Ex mode pre-filled with a :bmark
116 * command to add a new search keyword for the given form element.
118 * @param {Element} elem A form element for which to add a keyword.
120 addSearchKeyword: function addSearchKeyword(elem) {
121 if (elem instanceof Ci.nsIDOMHTMLFormElement || elem.form)
122 var { url, postData, charset } = DOM(elem).formData;
124 var [url, postData, charset] = [elem.href || elem.src, null, elem.ownerDocument.characterSet];
126 let options = { "-title": "Search " + elem.ownerDocument.title };
127 if (postData != null)
128 options["-post"] = postData;
129 if (charset != null && charset !== "UTF-8")
130 options["-charset"] = charset;
132 CommandExMode().open(
133 commands.commandToString({ command: "bmark", options: options, arguments: [url] }) + " -keyword ");
136 checkBookmarked: function checkBookmarked(uri) {
137 if (PlacesUtils.asyncGetBookmarkIds)
138 PlacesUtils.asyncGetBookmarkIds(uri, function withBookmarkIDs(ids) {
139 statusline.bookmarked = ids.length;
142 this.timeout(function () {
143 statusline.bookmarked = bookmarkcache.isBookmarked(uri);
148 * Toggles the bookmarked state of the given URL. If the URL is
149 * bookmarked, all bookmarks for said URL are removed.
150 * If it is not, a new bookmark is added to the Unfiled Bookmarks
151 * Folder. The new bookmark has the title of the current buffer if
152 * its URL is identical to *url*, otherwise its title will be the
155 * @param {string} url The URL of the bookmark to toggle.
157 toggle: function toggle(url) {
161 let count = this.remove(url);
163 dactyl.echomsg({ domains: [util.getHost(url)], message: _("bookmark.removed", url) });
165 let title = buffer.uri.spec == url && buffer.title || url;
168 extra = " (" + title + ")";
170 this.add({ unfiled: true, title: title, url: url });
171 dactyl.echomsg({ domains: [util.getHost(url)], message: _("bookmark.added", url + extra) });
175 isBookmarked: deprecated("bookmarkcache.isBookmarked", { get: function isBookmarked() bookmarkcache.bound.isBookmarked }),
178 * Remove a bookmark or bookmarks. If *ids* is an array, removes the
179 * bookmarks with those IDs. If it is a string, removes all
180 * bookmarks whose URLs match that string.
182 * @param {string|[number]} ids The IDs or URL of the bookmarks to
184 * @returns {number} The number of bookmarks removed.
186 remove: function remove(ids) {
189 let uri = util.newURI(ids);
190 ids = services.bookmarks
191 .getBookmarkIdsForURI(uri, {})
192 .filter(bookmarkcache.bound.isRegularBookmark);
194 ids.forEach(function (id) {
195 let bmark = bookmarkcache.bookmarks[id];
197 PlacesUtils.tagging.untagURI(bmark.uri, null);
198 bmark.charset = null;
200 services.bookmarks.removeItem(id);
205 dactyl.reportError(e, true);
210 getSearchEngines: deprecated("bookmarks.searchEngines", function getSearchEngines() this.searchEngines),
212 * Returns a list of all visible search engines in the search
213 * services, augmented with keyword, title, and icon properties for
214 * use in completion functions.
216 get searchEngines() {
217 let searchEngines = [];
219 return iter(services.browserSearch.getVisibleEngines({})).map(function ([, engine]) {
220 let alias = engine.alias;
221 if (!alias || !/^[a-z0-9-]+$/.test(alias))
222 alias = engine.name.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/, "").toLowerCase();
224 alias = "search"; // for search engines which we can't find a suitable alias
226 if (hasOwnProperty(aliases, alias))
227 alias += ++aliases[alias];
231 return [alias, { keyword: alias, __proto__: engine, title: engine.description, icon: engine.iconURI && engine.iconURI.spec }];
236 * Returns true if the given search engine provides suggestions.
237 * engine based on the given *query*. The results are always in the
238 * form of an array of strings. If *callback* is provided, the
239 * request is executed asynchronously and *callback* is called on
240 * completion. Otherwise, the request is executed synchronously and
241 * the results are returned.
243 * @param {string} engineName The name of the search engine from
244 * which to request suggestions.
247 hasSuggestions: function hasSuggestions(engineName, query, callback) {
248 const responseType = "application/x-suggestions+json";
250 if (hasOwnProperty(this.suggestionProviders, engineName))
253 let engine = hasOwnProperty(this.searchEngines, engineName) && this.searchEngines[engineName];
254 if (engine && engine.supportsResponseType(responseType))
261 * Retrieves a list of search suggestions from the named search
262 * engine based on the given *query*. The results are always in the
263 * form of an array of strings. If *callback* is provided, the
264 * request is executed asynchronously and *callback* is called on
265 * completion. Otherwise, the request is executed synchronously and
266 * the results are returned.
268 * @param {string} engineName The name of the search engine from
269 * which to request suggestions.
270 * @param {string} query The query string for which to request
272 * @param {function([string])} callback The function to call when
273 * results are returned.
274 * @returns {[string] | null}
276 getSuggestions: function getSuggestions(engineName, query, callback) {
277 const responseType = "application/x-suggestions+json";
279 if (hasOwnProperty(this.suggestionProviders, engineName))
280 return this.suggestionProviders[engineName](query, callback);
282 let engine = hasOwnProperty(this.searchEngines, engineName) && this.searchEngines[engineName];
283 if (engine && engine.supportsResponseType(responseType))
284 var queryURI = engine.getSubmission(query, responseType).uri.spec;
287 return promises.fail();
289 function parse(req) JSON.parse(req.responseText)[1].filter(isString);
290 return this.makeSuggestions(queryURI, parse, callback);
294 * Given a query URL, response parser, and optionally a callback,
295 * fetch and parse search query results for {@link getSuggestions}.
297 * @param {string} url The URL to fetch.
298 * @param {function(XMLHttpRequest):[string]} parser The function which
299 * parses the response.
300 * @returns {Promise<Array>}
302 makeSuggestions: function makeSuggestions(url, parser) {
303 let deferred = Promise.defer();
305 let req = util.fetchUrl(url);
306 req.then(function process(req) {
309 results = parser(req);
312 return deferred.reject(e);
314 deferred.resolve(results);
317 promises.oncancel(deferred, r => promises.cancel(req, reason));
318 return deferred.promise;
321 suggestionProviders: {},
324 * Returns an array containing a search URL and POST data for the
325 * given search string. If *useDefsearch* is true, the string is
326 * always passed to the default search engine. If it is not, the
327 * search engine name is retrieved from the first space-separated
328 * token of the given string.
330 * Returns null if no search engine is found for the passed string.
332 * @param {string} text The text for which to retrieve a search URL.
333 * @param {boolean} useDefsearch Whether to use the default search
335 * @returns {[string, string | null] | null}
337 getSearchURL: function getSearchURL(text, useDefsearch) {
338 let query = (useDefsearch ? options["defsearch"] + " " : "") + text;
340 // ripped from Firefox
343 var offset = query.indexOf(" ");
345 keyword = query.substr(0, offset);
346 param = query.substr(offset + 1);
349 var engine = hasOwnProperty(bookmarks.searchEngines, keyword) && bookmarks.searchEngines[keyword];
351 if (engine.searchForm && !param)
352 return engine.searchForm;
353 let submission = engine.getSubmission(param, null);
354 return [submission.uri.spec, submission.postData];
357 let [url, postData] = PlacesUtils.getURLAndPostDataForKeyword(keyword);
361 let data = window.unescape(postData || "");
362 if (/%s/i.test(url) || /%s/i.test(data)) {
364 var matches = url.match(/^(.*)\&mozcharset=([a-zA-Z][_\-a-zA-Z0-9]+)\s*$/);
366 [, url, charset] = matches;
369 charset = services.history.getCharsetForURI(util.newURI(url));
374 var encodedParam = escape(window.convertFromUnicode(charset, param)).replace(/\+/g, encodeURIComponent);
376 encodedParam = bookmarkcache.keywords[keyword.toLowerCase()].encodeURIComponent(param);
378 url = url.replace(/%s/g, () => encodedParam)
379 .replace(/%S/g, () => param);
380 if (/%s/i.test(data))
381 postData = window.getPostDataStream(data, param, encodedParam, "application/x-www-form-urlencoded");
387 return [url, postData];
392 * Lists all bookmarks whose URLs match *filter*, tags match *tags*,
393 * and other properties match the properties of *extra*. If
394 * *openItems* is true, the items are opened in tabs rather than
397 * @param {string} filter A URL filter string which the URLs of all
398 * matched items must contain.
399 * @param {[string]} tags An array of tags each of which all matched
400 * items must contain.
401 * @param {boolean} openItems If true, items are opened rather than
403 * @param {object} extra Extra properties which must be matched.
405 list: function list(filter, tags, openItems, maxItems, extra) {
406 // FIXME: returning here doesn't make sense
407 // Why the hell doesn't it make sense? --Kris
408 // Because it unconditionally bypasses the final error message
409 // block and does so only when listing items, not opening them. In
410 // short it breaks the :bmarks command which doesn't make much
411 // sense to me but I'm old-fashioned. --djk
413 return completion.listCompleter("bookmark", filter, maxItems, tags, extra);
414 let items = completion.runCompleter("bookmark", filter, maxItems, tags, extra);
417 return dactyl.open(items.map(i => i.url), dactyl.NEW_TAB);
419 if (filter.length > 0 && tags.length > 0)
420 dactyl.echoerr(_("bookmark.noMatching", tags.map(String.quote), filter.quote()));
421 else if (filter.length > 0)
422 dactyl.echoerr(_("bookmark.noMatchingString", filter.quote()));
423 else if (tags.length > 0)
424 dactyl.echoerr(_("bookmark.noMatchingTags", tags.map(String.quote)));
426 dactyl.echoerr(_("bookmark.none"));
431 commands: function initCommands() {
432 // TODO: Clean this up.
434 names: ["-tags", "-T"],
435 description: "A comma-separated list of tags",
436 completer: function tags(context, args) {
437 context.generate = function () array(b.tags
438 for (b in bookmarkcache)
440 .flatten().uniq().array;
441 context.keys = { text: util.identity, description: util.identity };
443 type: CommandOption.LIST
447 names: ["-title", "-t"],
448 description: "Bookmark page title or description",
449 completer: function title(context, args) {
450 let frames = buffer.allFrames();
453 [win.document.title, frames.length == 1 ? /*L*/"Current Location" : /*L*/"Frame: " + win.location.href]
454 for ([, win] in Iterator(frames))];
455 context.keys.text = "title";
456 context.keys.description = "url";
457 return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], title: context.filter });
459 type: CommandOption.STRING
463 names: ["-post", "-p"],
464 description: "Bookmark POST data",
465 completer: function post(context, args) {
466 context.keys.text = "post";
467 context.keys.description = "url";
468 return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: args["-keyword"], post: context.filter });
470 type: CommandOption.STRING
474 names: ["-keyword", "-k"],
475 description: "Keyword by which this bookmark may be opened (:open {keyword})",
476 completer: function keyword(context, args) {
477 context.keys.text = "keyword";
478 return bookmarks.get(args.join(" "), args["-tags"], null, { keyword: context.filter, title: args["-title"] });
480 type: CommandOption.STRING,
481 validator: bind("test", /^\S+$/)
484 commands.add(["bma[rk]"],
487 dactyl.assert(!args.bang || args["-id"] == null,
488 _("bookmark.bangOrID"));
494 keyword: args["-keyword"] || null,
495 charset: args["-charset"],
497 tags: args["-tags"] || [],
498 title: args["-title"] || (args.length === 0 ? buffer.title : null),
499 url: args.length === 0 ? buffer.uri.spec : args[0]
502 let updated = bookmarks.add(opts);
503 let action = updated ? "updated" : "added";
505 let extra = (opts.title && opts.title != opts.url) ? " (" + opts.title + ")" : "";
507 dactyl.echomsg({ domains: [util.getHost(opts.url)], message: _("bookmark." + action, opts.url + extra) },
508 1, commandline.FORCE_SINGLELINE);
512 completer: function (context, args) {
514 context.title = ["Page URL"];
515 let frames = buffer.allFrames();
516 context.completions = [
517 [win.document.documentURI, frames.length == 1 ? /*L*/"Current Location" : /*L*/"Frame: " + win.document.title]
518 for ([, win] in Iterator(frames))];
521 completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
523 options: [keyword, title, tags, post,
525 names: ["-charset", "-c"],
526 description: "The character encoding of the bookmark",
527 type: CommandOption.STRING,
528 completer: function (context) completion.charset(context),
529 validator: Option.validateCompleter
533 description: "The ID of the bookmark to update",
534 type: CommandOption.INT
539 commands.add(["bmarks"],
540 "List or open multiple bookmarks",
542 bookmarks.list(args.join(" "), args["-tags"] || [], args.bang, args["-max"],
543 { keyword: args["-keyword"], title: args["-title"] });
547 completer: function completer(context, args) {
548 context.filter = args.join(" ");
549 completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
551 options: [tags, keyword, title,
553 names: ["-max", "-m"],
554 description: "The maximum number of items to list or open",
555 type: CommandOption.INT
558 // Not privateData, since we don't treat bookmarks as private
561 commands.add(["delbm[arks]"],
565 commandline.input(_("bookmark.prompt.deleteAll") + " ").then(
567 if (resp && resp.match(/^y(es)?$/i)) {
568 bookmarks.remove(Object.keys(bookmarkcache.bookmarks));
569 dactyl.echomsg(_("bookmark.allDeleted"));
573 if (!(args.length || args["-tags"] || args["-keyword"] || args["-title"]))
574 var deletedCount = bookmarks.remove(buffer.uri.spec);
576 let context = CompletionContext(args.join(" "));
577 context.fork("bookmark", 0, completion, "bookmark",
578 args["-tags"], { keyword: args["-keyword"], title: args["-title"] });
580 deletedCount = bookmarks.remove(context.allItems.items
581 .map(item => item.item.id));
584 dactyl.echomsg({ message: _("bookmark.deleted", deletedCount) });
591 completer: function completer(context, args)
592 completion.bookmark(context, args["-tags"], { keyword: args["-keyword"], title: args["-title"] }),
593 domains: function (args) array.compact(args.map(util.getHost)),
595 options: [tags, title, keyword],
599 mappings: function initMappings() {
600 var myModes = config.browserModes;
602 mappings.add(myModes, ["a"],
603 "Open a prompt to bookmark the current URL",
607 let url = buffer.uri.spec;
608 let bmarks = bookmarks.get(url).filter(bmark => bmark.url == url);
610 if (bmarks.length == 1) {
611 let bmark = bmarks[0];
613 options["-id"] = bmark.id;
614 options["-title"] = bmark.title;
616 options["-charset"] = bmark.charset;
618 options["-keyword"] = bmark.keyword;
620 options["-post"] = bmark.post;
621 if (bmark.tags.length > 0)
622 options["-tags"] = bmark.tags;
625 if (buffer.title != buffer.uri.spec)
626 options["-title"] = buffer.title;
627 if (content.document.characterSet !== "UTF-8")
628 options["-charset"] = content.document.characterSet;
631 CommandExMode().open(
632 commands.commandToString({ command: "bmark", options: options, arguments: [buffer.uri.spec] }));
635 mappings.add(myModes, ["A"],
636 "Toggle bookmarked state of current URL",
637 function () { bookmarks.toggle(buffer.uri.spec); });
639 options: function initOptions() {
640 options.add(["defsearch", "ds"],
641 "The default search engine",
644 completer: function completer(context) {
645 completion.search(context, true);
646 context.completions = [{ keyword: "", title: "Don't perform searches by default" }].concat(context.completions);
650 options.add(["suggestengines"],
651 "Search engines used for search suggestions",
652 "stringlist", "google",
653 { completer: function completer(context) completion.searchEngine(context, true), });
656 completion: function initCompletion() {
657 completion.bookmark = function bookmark(context, tags, extra={}) {
658 context.title = ["Bookmark", "Title"];
659 context.format = bookmarks.format;
660 iter(extra).forEach(function ([k, v]) {
662 context.filters.push(function (item) item.item[k] != null && this.matchString(v, item.item[k]));
664 context.generate = () => values(bookmarkcache.bookmarks);
665 completion.urls(context, tags);
668 completion.search = function search(context, noSuggest) {
669 let [, keyword, space, args] = context.filter.match(/^\s*(\S*)(\s*)(.*)$/);
670 let keywords = bookmarkcache.keywords;
671 let engines = bookmarks.searchEngines;
673 context.title = ["Search Keywords"];
674 context.completions = iter(values(keywords), values(engines));
675 context.keys = { text: "keyword", description: "title", icon: "icon" };
677 if (!space || noSuggest)
680 context.fork("suggest", keyword.length + space.length, this, "searchEngineSuggest",
683 let item = keywords[keyword];
684 if (item && item.url.contains("%s"))
685 context.fork("keyword/" + keyword, keyword.length + space.length, null, function (context) {
686 context.format = history.format;
687 context.title = [/*L*/keyword + " Quick Search"];
688 context.keys = { text: "url", description: "title", icon: "icon" };
689 // context.background = true;
690 context.compare = CompletionContext.Sort.unsorted;
691 context.generate = function () {
692 let [begin, end] = item.url.split("%s");
694 return history.get({ uri: util.newURI(begin), uriIsPrefix: true }).map(function (item) {
695 let rest = item.url.length - end.length;
696 let query = item.url.substring(begin.length, rest);
697 if (item.url.substr(rest) == end && query.contains("&"))
699 item.url = decodeURIComponent(query.replace(/#.*/, "").replace(/\+/g, " "));
704 }).filter(util.identity);
709 completion.searchEngine = function searchEngine(context, suggest) {
710 context.title = ["Suggest Engine", "Description"];
711 context.keys = { text: "keyword", description: "title", icon: "icon" };
712 context.completions = values(bookmarks.searchEngines);
714 context.filters.push(({ item }) => item.supportsResponseType("application/x-suggestions+json"));
718 completion.searchEngineSuggest = function searchEngineSuggest(context, engineAliases, kludge) {
722 let engineList = (engineAliases || options["suggestengines"].join(",") || "google").split(",");
724 engineList.forEach(function (name) {
725 if (!bookmarks.hasSuggestions(name))
729 let engine = bookmarks.searchEngines[name];
731 desc = engine.description;
734 let [, word] = /^\s*(\S+)/.exec(context.filter) || [];
735 if (!kludge && word == name) // FIXME: Check for matching keywords
738 let ctxt = context.fork(name, 0);
740 ctxt.title = [/*L*/desc + " Suggestions"];
741 ctxt.keys = { text: util.identity, description: function () "" };
742 ctxt.compare = CompletionContext.Sort.unsorted;
743 ctxt.filterFunc = null;
745 if (ctxt.waitingForTab)
748 let words = ctxt.filter.toLowerCase().split(/\s+/g);
749 ctxt.completions = ctxt.completions.filter(i => words.every(w => i.toLowerCase().contains(w)));
751 ctxt.hasItems = ctxt.completions.length;
752 ctxt.incomplete = true;
753 ctxt.cache.request = bookmarks.getSuggestions(name, ctxt.filter);
754 ctxt.cache.request.then(function (compl) {
755 ctxt.incomplete = false;
756 ctxt.completions = array.uniq(ctxt.completions.filter(c => compl.contains(c))
757 .concat(compl), true);
759 ctxt.incomplete = false;
760 ctxt.completions = [];
767 completion.addUrlCompleter("suggestion", "Search engine suggestions", completion.searchEngineSuggest);
768 completion.addUrlCompleter("bookmark", "Bookmarks", completion.bookmark);
769 completion.addUrlCompleter("search", "Search engines and keyword URLs", completion.search);
773 // vim: set fdm=marker sw=4 sts=4 ts=8 et: