| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| // This file contains business logic for power bookmarks side panel content. |
| |
| import {loadTimeData} from '//resources/js/load_time_data.js'; |
| import {PluralStringProxyImpl} from '//resources/js/plural_string_proxy.js'; |
| |
| import {BookmarksApiProxy, BookmarksApiProxyImpl} from './bookmarks_api_proxy.js'; |
| |
| export interface Label { |
| label: string; |
| icon: string; |
| active: boolean; |
| } |
| |
| interface PowerBookmarksDelegate { |
| setCurrentUrl(url: string|undefined): void; |
| setCompactDescription( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, description: string): void; |
| setExpandedDescription( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, description: string): void; |
| onBookmarksLoaded(): void; |
| onBookmarkChanged(id: string, changedInfo: chrome.bookmarks.ChangeInfo): void; |
| onBookmarkCreated( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, |
| parent: chrome.bookmarks.BookmarkTreeNode): void; |
| onBookmarkMoved( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, |
| oldParent: chrome.bookmarks.BookmarkTreeNode, |
| newParent: chrome.bookmarks.BookmarkTreeNode): void; |
| onBookmarkRemoved(bookmark: chrome.bookmarks.BookmarkTreeNode): void; |
| isPriceTracked(bookmark: chrome.bookmarks.BookmarkTreeNode): boolean; |
| } |
| |
| export class PowerBookmarksService { |
| private delegate_: PowerBookmarksDelegate; |
| private bookmarksApi_: BookmarksApiProxy = |
| BookmarksApiProxyImpl.getInstance(); |
| private listeners_ = new Map<string, Function>(); |
| private folders_: chrome.bookmarks.BookmarkTreeNode[] = []; |
| |
| constructor(delegate: PowerBookmarksDelegate) { |
| this.delegate_ = delegate; |
| } |
| |
| /** |
| * Creates listeners for all relevant bookmark and shopping information. |
| * Invoke during setup. |
| */ |
| startListening() { |
| this.bookmarksApi_.getActiveUrl().then( |
| url => this.delegate_.setCurrentUrl(url)); |
| this.bookmarksApi_.getFolders().then(folders => { |
| this.folders_ = folders; |
| this.folders_.forEach(bookmark => { |
| this.findBookmarkDescriptions_(bookmark, true); |
| }); |
| this.addListener_( |
| 'onChanged', |
| (id: string, changedInfo: chrome.bookmarks.ChangeInfo) => |
| this.onChanged_(id, changedInfo)); |
| this.addListener_( |
| 'onCreated', |
| (_id: string, node: chrome.bookmarks.BookmarkTreeNode) => |
| this.onCreated_(node)); |
| this.addListener_( |
| 'onMoved', |
| (_id: string, movedInfo: chrome.bookmarks.MoveInfo) => |
| this.onMoved_(movedInfo)); |
| this.addListener_('onRemoved', (id: string) => this.onRemoved_(id)); |
| this.addListener_('onTabActivated', (_info: chrome.tabs.ActiveInfo) => { |
| this.bookmarksApi_.getActiveUrl().then( |
| url => this.delegate_.setCurrentUrl(url)); |
| }); |
| this.addListener_( |
| 'onTabUpdated', |
| (_tabId: number, _changeInfo: object, tab: chrome.tabs.Tab) => { |
| if (tab.active) { |
| this.delegate_.setCurrentUrl(tab.url); |
| } |
| }); |
| this.delegate_.onBookmarksLoaded(); |
| }); |
| } |
| |
| /** |
| * Cleans up any listeners created by the startListening method. |
| * Invoke during teardown. |
| */ |
| stopListening() { |
| for (const [eventName, callback] of this.listeners_.entries()) { |
| this.bookmarksApi_.callbackRouter[eventName]!.removeListener(callback); |
| } |
| } |
| |
| /** |
| * Returns a list of all root bookmark folders. |
| */ |
| getFolders() { |
| return this.folders_; |
| } |
| |
| /** |
| * Returns a list of all bookmarks defaulted to if no filter criteria are |
| * provided. |
| */ |
| getTopLevelBookmarks() { |
| return this.filterBookmarks(undefined, 0, undefined, []); |
| } |
| |
| /** |
| * Returns a list of bookmarks and folders filtered by the provided criteria. |
| */ |
| filterBookmarks( |
| activeFolder: chrome.bookmarks.BookmarkTreeNode|undefined, |
| activeSortIndex: number, searchQuery: string|undefined, |
| labels: Label[]): chrome.bookmarks.BookmarkTreeNode[] { |
| let shownBookmarks; |
| if (activeFolder) { |
| shownBookmarks = activeFolder.children!.slice(); |
| } else { |
| let topLevelBookmarks: chrome.bookmarks.BookmarkTreeNode[] = []; |
| this.folders_.forEach( |
| folder => topLevelBookmarks = topLevelBookmarks.concat( |
| (folder.id === loadTimeData.getString('bookmarksBarId')) ? |
| [folder] : |
| folder.children!)); |
| shownBookmarks = topLevelBookmarks; |
| } |
| if (searchQuery) { |
| shownBookmarks = this.applySearchQuery_(searchQuery!, shownBookmarks); |
| } |
| shownBookmarks = shownBookmarks.filter( |
| (b: chrome.bookmarks.BookmarkTreeNode) => |
| this.nodeMatchesContentFilters_(b, labels)); |
| const sortChangedPosition = |
| this.sortBookmarks(shownBookmarks, activeSortIndex); |
| if (sortChangedPosition) { |
| return shownBookmarks.slice(); |
| } else { |
| return shownBookmarks; |
| } |
| } |
| |
| /** |
| * Apply the current active sort type to the given bookmarks list. Returns |
| * true if any elements in the list changed position. |
| */ |
| sortBookmarks( |
| bookmarks: chrome.bookmarks.BookmarkTreeNode[], |
| activeSortIndex: number): boolean { |
| let changedPosition = false; |
| bookmarks.sort(function( |
| a: chrome.bookmarks.BookmarkTreeNode, |
| b: chrome.bookmarks.BookmarkTreeNode) { |
| // Always sort by folders first |
| if (!a.url && b.url) { |
| return -1; |
| } else if (a.url && !b.url) { |
| changedPosition = true; |
| return 1; |
| } else { |
| let toReturn; |
| if (activeSortIndex === 0) { |
| // Newest first |
| toReturn = b.dateAdded! - a.dateAdded!; |
| } else if (activeSortIndex === 1) { |
| // Oldest first |
| toReturn = a.dateAdded! - b.dateAdded!; |
| } else if (activeSortIndex === 2) { |
| // Alphabetical |
| toReturn = a.title!.localeCompare(b.title); |
| } else { |
| // Reverse alphabetical |
| toReturn = b.title!.localeCompare(a.title); |
| } |
| if (toReturn > 0) { |
| changedPosition = true; |
| } |
| return toReturn; |
| } |
| }); |
| return changedPosition; |
| } |
| |
| /** |
| * Returns the BookmarkTreeNode with the given id, or undefined if one does |
| * not exist. |
| */ |
| findBookmarkWithId(id: string): chrome.bookmarks.BookmarkTreeNode|undefined { |
| const path = this.findPathToId_(id); |
| if (path) { |
| return path[path.length - 1]; |
| } |
| return undefined; |
| } |
| |
| /** |
| * Returns true if the given url is not already present in the given folder. |
| * If the folder is undefined, will default to the "Other Bookmarks" folder. |
| */ |
| canAddUrl( |
| url: string|undefined, |
| folder: chrome.bookmarks.BookmarkTreeNode|undefined): boolean { |
| if (!folder) { |
| folder = |
| this.findBookmarkWithId(loadTimeData.getString('otherBookmarksId')); |
| if (!folder) { |
| return false; |
| } |
| } |
| return folder.children!.findIndex(b => b.url === url) === -1; |
| } |
| |
| private addListener_(eventName: string, callback: Function): void { |
| this.bookmarksApi_.callbackRouter[eventName]!.addListener(callback); |
| this.listeners_.set(eventName, callback); |
| } |
| |
| private onChanged_(id: string, changedInfo: chrome.bookmarks.ChangeInfo) { |
| const bookmark = this.findBookmarkWithId(id)!; |
| Object.assign(bookmark, changedInfo); |
| this.findBookmarkDescriptions_(bookmark, false); |
| this.delegate_.onBookmarkChanged(id, changedInfo); |
| } |
| |
| private onCreated_(node: chrome.bookmarks.BookmarkTreeNode) { |
| const parent = this.findBookmarkWithId(node.parentId as string)!; |
| if (!node.url && !node.children) { |
| // Newly created folders in this session may not have an array of |
| // children yet, so create an empty one. |
| node.children = []; |
| } |
| parent.children!.splice(node.index!, 0, node); |
| this.delegate_.onBookmarkCreated(node, parent); |
| this.findBookmarkDescriptions_(parent, false); |
| this.findBookmarkDescriptions_(node, false); |
| } |
| |
| private onMoved_(movedInfo: chrome.bookmarks.MoveInfo) { |
| // Remove node from oldParent at oldIndex. |
| const oldParent = this.findBookmarkWithId(movedInfo.oldParentId)!; |
| const movedNode = oldParent!.children![movedInfo.oldIndex]!; |
| Object.assign( |
| movedNode, {index: movedInfo.index, parentId: movedInfo.parentId}); |
| oldParent.children!.splice(movedInfo.oldIndex, 1); |
| |
| // Add the node to the new parent at index. |
| const newParent = this.findBookmarkWithId(movedInfo.parentId)!; |
| if (!newParent.children) { |
| newParent.children = []; |
| } |
| newParent.children!.splice(movedInfo.index, 0, movedNode); |
| this.delegate_.onBookmarkMoved(movedNode, oldParent, newParent); |
| |
| if (movedInfo.oldParentId !== movedInfo.parentId) { |
| this.findBookmarkDescriptions_(oldParent, false); |
| this.findBookmarkDescriptions_(newParent, false); |
| } |
| } |
| |
| private onRemoved_(id: string) { |
| const oldPath = this.findPathToId_(id); |
| const removedNode = oldPath.pop()!; |
| const oldParent = oldPath[oldPath.length - 1]!; |
| oldParent.children!.splice(oldParent.children!.indexOf(removedNode), 1); |
| this.delegate_.onBookmarkRemoved(removedNode); |
| this.findBookmarkDescriptions_(oldParent, false); |
| } |
| |
| /** |
| * Finds the node within all bookmarks and returns the path to the node in |
| * the tree. |
| */ |
| private findPathToId_(id: string): chrome.bookmarks.BookmarkTreeNode[] { |
| const path: chrome.bookmarks.BookmarkTreeNode[] = []; |
| |
| function findPathByIdInternal( |
| id: string, node: chrome.bookmarks.BookmarkTreeNode) { |
| if (node.id === id) { |
| path.push(node); |
| return true; |
| } |
| |
| if (!node.children) { |
| return false; |
| } |
| |
| path.push(node); |
| const foundInChildren = |
| node.children.some(child => findPathByIdInternal(id, child)); |
| if (!foundInChildren) { |
| path.pop(); |
| } |
| |
| return foundInChildren; |
| } |
| |
| this.folders_.some(bookmark => findPathByIdInternal(id, bookmark)); |
| return path; |
| } |
| |
| /** |
| * Assigns a text description for the given bookmark, to be displayed |
| * following the bookmark title. Also assigns a description to all |
| * descendants if recurse is true. |
| */ |
| private findBookmarkDescriptions_( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, recurse: boolean) { |
| if (bookmark.url) { |
| const url = new URL(bookmark.url); |
| // Show chrome:// if it's a chrome internal url |
| if (url.protocol === 'chrome:') { |
| this.delegate_.setExpandedDescription( |
| bookmark, 'chrome://' + url.hostname); |
| } else { |
| this.delegate_.setExpandedDescription(bookmark, url.hostname); |
| } |
| } else { |
| PluralStringProxyImpl.getInstance() |
| .getPluralString( |
| 'bookmarkFolderChildCount', |
| bookmark.children ? bookmark.children.length : 0) |
| .then(pluralString => { |
| this.delegate_.setCompactDescription(bookmark, pluralString); |
| }); |
| } |
| if (recurse && bookmark.children) { |
| bookmark.children.forEach( |
| child => this.findBookmarkDescriptions_(child, recurse)); |
| } |
| } |
| |
| // Return an array that includes folder and all its descendants. |
| private expandFolder_(folder: chrome.bookmarks.BookmarkTreeNode): |
| chrome.bookmarks.BookmarkTreeNode[] { |
| let expanded: chrome.bookmarks.BookmarkTreeNode[] = [folder]; |
| if (folder.children) { |
| folder.children.forEach((child: chrome.bookmarks.BookmarkTreeNode) => { |
| expanded = expanded.concat(this.expandFolder_(child)); |
| }); |
| } |
| return expanded; |
| } |
| |
| private applySearchQuery_( |
| searchQuery: string, |
| shownBookmarks: chrome.bookmarks.BookmarkTreeNode[]) { |
| let searchSpace: chrome.bookmarks.BookmarkTreeNode[] = []; |
| // Search space should include all descendants of the shown bookmarks, in |
| // addition to the shown bookmarks themselves. |
| shownBookmarks.forEach((bookmark: chrome.bookmarks.BookmarkTreeNode) => { |
| searchSpace = searchSpace.concat(this.expandFolder_(bookmark)); |
| }); |
| return searchSpace.filter( |
| (bookmark: chrome.bookmarks.BookmarkTreeNode) => |
| (bookmark.title && |
| bookmark.title.toLocaleLowerCase().includes(searchQuery)) || |
| (bookmark.url && |
| bookmark.url.toLocaleLowerCase().includes(searchQuery))); |
| } |
| |
| private nodeMatchesContentFilters_( |
| bookmark: chrome.bookmarks.BookmarkTreeNode, labels: Label[]): boolean { |
| // Price tracking label |
| if (labels[0] && labels[0]!.active && |
| !this.delegate_.isPriceTracked(bookmark)) { |
| return false; |
| } |
| return true; |
| } |
| } |