import angular from 'angular' ; import { Injectable } from 'angular-ts-decorators' ; import autobind from 'autobind-decorator' ; import { Bookmarks as NativeBookmarks } from 'webextension-polyfill' ; import { BookmarkSearchResult } from '../../../app/app-search/app-search.interface' ; import { ApiService } from '../../api/api.interface' ; import { CryptoService } from '../../crypto/crypto.service' ; import { BookmarkNotFoundError , HttpRequestAbortedError } from '../../errors/errors' ; import Globals from '../../global-shared.constants' ; import { PlatformService } from '../../global-shared.interface' ; import { StoreKey } from '../../store/store.enum' ; import { StoreService } from '../../store/store.service' ; import { UtilityService } from '../../utility/utility.service' ; import { BookmarkContainer , BookmarkType } from '../bookmark.enum' ; import { Bookmark , BookmarkMetadata , BookmarkSearchQuery } from '../bookmark.interface' ; @ autobind @ Injectable ( 'BookmarkHelperService' ) export class BookmarkHelperService { Strings = require ( '../../../../../res/strings/en.json' ) ; $injector : ng . auto . IInjectorService ; $q : ng . IQService ; apiSvc : ApiService ; cryptoSvc : CryptoService ; _platformSvc : PlatformService | undefined ; storeSvc : StoreService ; utilitySvc : UtilityService ; cachedBookmarks_encrypted : string | undefined ; cachedBookmarks_plain : Bookmark [ ] | undefined ; static $inject = [ '$injector' , '$q' , 'ApiService' , 'CryptoService' , 'StoreService' , 'UtilityService' ] ; constructor ( $injector , $q , ApiSvc , CryptoSvc , StoreSvc , UtilitySvc ) { this . $injector = $injector ; this . $q = $q ; this . apiSvc = ApiSvc ; this . cryptoSvc = CryptoSvc ; this . storeSvc = StoreSvc ; this . utilitySvc = UtilitySvc ; } get platformSvc ( ) : PlatformService { if ( angular . isUndefined ( this . _platformSvc ) ) { this . _platformSvc = this . $injector . get ( 'PlatformService' ) ; } return this . _platformSvc as PlatformService ; } cleanAllBookmarks ( bookmarks ) { return bookmarks . map ( ( bookmark ) => { const cleanedBookmark = this . cleanBookmark ( bookmark ) ; if ( angular . isArray ( cleanedBookmark . children ) ) { cleanedBookmark . children = this . cleanAllBookmarks ( cleanedBookmark . children ) ; } return cleanedBookmark ; } ) ; } cleanBookmark ( bookmark ) { const validKeys = [ 'children' , 'description' , 'id' , 'tags' , 'title' , 'url' ] ; const cleanedBookmark = { } ; Object . keys ( bookmark ) . forEach ( ( key ) => { const keyValue = Object . entries ( bookmark ) . find ( ( { 0 : prop } ) => prop === key ) ?. [ 1 ] ; if ( ! validKeys . includes ( key ) ) { return ; } if ( angular . isUndefined ( keyValue ?? undefined ) ) { return ; } if ( key === 'description' && ( keyValue ?? '' ) . trim ( ) === '' ) { return ; } if ( key === 'tags' && ( keyValue ?? [ ] ) . length === 0 ) { return ; } Object . assign ( cleanedBookmark , { [ key ] : keyValue } ) ; } ) ; return cleanedBookmark ; } eachBookmark < T = Bookmark > ( iteratee , bookmarks = [ ] , untilCondition ? ) { const iterateBookmarks = ( bookmarksToIterate ) : void => { for ( let i = 0 ; i < bookmarksToIterate . length ; i += 1 ) { if ( untilCondition ?. ( ) === true ) { break ; } iteratee ( bookmarksToIterate [ i ] ) ; if ( ( bookmarksToIterate [ i ] as any ) . children ?. length ) { iterateBookmarks ( ( bookmarksToIterate [ i ] as any ) . children ) ; } } } ; iterateBookmarks ( bookmarks ) ; } extractBookmarkMetadata ( bookmark ) { const metadata = { description : ( bookmark as Bookmark ) . description , tags : ( bookmark as Bookmark ) . tags , title : bookmark . title , url : bookmark . url } ; if ( this . getBookmarkType ( bookmark as Bookmark ) === BookmarkType . Separator || this . nativeBookmarkIsSeparator ( bookmark as NativeBookmarks . BookmarkTreeNode ) ) { metadata . url = Globals . Bookmarks . SeparatorUrl ; } Object . keys ( metadata ) . forEach ( ( key ) => { if ( angular . isUndefined ( metadata [ key ] ?? undefined ) ) { delete metadata [ key ] ; } } ) ; return metadata ; } findBookmarkById ( id , bookmarks = [ ] ) { if ( angular . isUndefined ( id ) ) { return ; } let bookmark ; const index = bookmarks . findIndex ( ( x ) => { return x . id === id ; } ) ; if ( index === - 1 ) { ( bookmarks as Bookmark [ ] ) . forEach ( ( x ) => { if ( ! bookmark ) { bookmark = this . findBookmarkById ( id , x . children ) ; } } ) ; } else { bookmark = bookmarks [ index ] ; if ( ( bookmark as NativeBookmarks . BookmarkTreeNode ) . index != null ) { ( bookmark as NativeBookmarks . BookmarkTreeNode ) . index = index ; } } return bookmark ; } findCurrentUrlInBookmarks ( ) { return this . $q . all ( [ this . platformSvc . getCurrentUrl ( ) , this . platformSvc . getCurrentLocale ( ) ] ) . then ( ( results ) => { const [ currentUrl , currentLocale ] = results ; if ( ! currentUrl ) { return ; } return this . getCachedBookmarks ( ) . then ( ( bookmarks ) => { let targetBookmark ; this . eachBookmark ( ( bookmark ) => { if ( this . utilitySvc . stringsAreEquivalent ( bookmark ?. url , currentUrl , currentLocale ) ) { targetBookmark = bookmark ; } } , bookmarks , ( ) => ! ! targetBookmark ) ; return targetBookmark ; } ) ; } ) ; } getBookmarkById ( id ) { return this . getCachedBookmarks ( ) . then ( ( bookmarks ) => { let targetBookmark ; this . eachBookmark ( ( bookmark ) => { if ( bookmark . id === id ) { targetBookmark = bookmark ; } } , bookmarks , ( ) => ! ! targetBookmark ) ; return targetBookmark ; } ) ; } getBookmarkTitleForDisplay ( bookmark ) { const bookmarkType = this . getBookmarkType ( bookmark ) ; if ( bookmarkType === BookmarkType . Bookmark ) { return bookmark . title ? bookmark . title : bookmark . url . replace ( /^https?:\/\//i , '' ) ; } if ( bookmarkType === BookmarkType . Container ) { let containerTitle ; switch ( bookmark . title ) { case BookmarkContainer . Menu : containerTitle = this . platformSvc . getI18nString ( this . Strings . Bookmarks . Container . Menu ) ; break ; case BookmarkContainer . Other : containerTitle = this . platformSvc . getI18nString ( this . Strings . Bookmarks . Container . Other ) ; break ; case BookmarkContainer . Toolbar : containerTitle = this . platformSvc . getI18nString ( this . Strings . Bookmarks . Container . Toolbar ) ; break ; default : containerTitle = `${ undefined }` ; } return containerTitle ; } return bookmark . title ; } getBookmarkType ( bookmark ) { const bookmarkType = BookmarkType . Bookmark ; if ( bookmark . title === BookmarkContainer . Menu || bookmark . title === BookmarkContainer . Other || bookmark . title === BookmarkContainer . Toolbar ) { return BookmarkType . Container ; } if ( angular . isArray ( bookmark . children ) ) { return BookmarkType . Folder ; } if ( bookmark . url === Globals . Bookmarks . SeparatorUrl ) { return BookmarkType . Separator ; } return bookmarkType ; } getCachedBookmarks ( ) { return this . storeSvc . get < string > ( StoreKey . Bookmarks ) . then ( ( encryptedBookmarksFromStore ) => { return ( this . $q < Bookmark [ ] | undefined > ( ( resolve , reject ) => { if ( ! angular . isUndefined ( encryptedBookmarksFromStore ?? undefined ) && ! angular . isUndefined ( this . cachedBookmarks_encrypted ?? undefined ) && ! angular . isUndefined ( this . cachedBookmarks_plain ?? undefined ) && encryptedBookmarksFromStore === this . cachedBookmarks_encrypted ) { return resolve ( this . cachedBookmarks_plain ) ; } ( ! angular . isUndefined ( encryptedBookmarksFromStore ?? undefined ) ? this . $q . resolve ( encryptedBookmarksFromStore ) : this . apiSvc . getBookmarks ( ) . then ( ( response ) => { return response . bookmarks ; } ) ) . then ( ( encryptedBookmarks ) => { return this . cryptoSvc . decryptData ( encryptedBookmarks ) . then ( ( bookmarksJson ) => { const bookmarks = bookmarksJson ? JSON . parse ( bookmarksJson ) : [ ] ; return this . updateCachedBookmarks ( bookmarks , encryptedBookmarks ) . then ( ( ) => { resolve ( bookmarks ) ; } ) ; } ) ; } ) . catch ( reject ) ; } ) . then ( ( bookmarks ) => { return angular . copy ( bookmarks ) ; } ) ) ; } ) ; } getContainer ( containerName , bookmarks , createIfNotPresent = false ) { let container = bookmarks . find ( ( x ) => x . title === containerName ) ; if ( ! container && createIfNotPresent ) { container = this . newBookmark ( containerName , undefined , undefined , undefined , bookmarks ) ; bookmarks . push ( container ) ; } return container ; } getContainerByBookmarkId ( id , bookmarks ) { const bookmark = this . findBookmarkById ( id , bookmarks ) as Bookmark ; if ( this . getBookmarkType ( bookmark ) === BookmarkType . Container ) { return bookmark as Bookmark ; } let container ; bookmarks . forEach ( ( x ) => { this . eachBookmark ( ( child ) => { if ( child . id === id ) { container = x ; } } , x . children , ( ) => ! ! container ) ; } ) ; return container ; } getKeywordsFromBookmark ( bookmark , locale , tagsOnly = false , includeUrl = false ) { let keywords = [ ] ; if ( ! tagsOnly ) { keywords = keywords . concat ( this . utilitySvc . splitTextIntoWords ( bookmark . title , locale ) ) ; keywords = keywords . concat ( this . utilitySvc . splitTextIntoWords ( bookmark . description , locale ) ) ; if ( includeUrl ) { try { const url = new URL ( bookmark . url ) ; const noProtocolUrl = bookmark . url . replace ( new RegExp ( `^${ url . protocol }[/]*` ) , '' ) ; const relativeUrl = `${ url . pathname }${ url . search }${ url . hash }` ; if ( relativeUrl !== '/' ) { keywords . push ( noProtocolUrl . toLocaleLowerCase ( locale ) . replace ( /\/$/ , '' ) ) ; } else { keywords . push ( noProtocolUrl . substring ( 0 , noProtocolUrl . indexOf ( relativeUrl ) ) . toLocaleLowerCase ( locale ) ) ; } } catch { } } } keywords = keywords . concat ( this . utilitySvc . splitTextIntoWords ( bookmark . tags ?. join ( ' ' ) , locale ) ) ; keywords = keywords . filter ( ( item ) => { return item . length > 2 ; } ) ; return this . utilitySvc . sortWords ( keywords ) ; } getLookahead ( word , bookmarks , tagsOnly = false , exclusions = [ ] ) { if ( ! word ) { return this . $q . resolve ( '' ) ; } let getBookmarks ; if ( ( bookmarks ?? undefined ) === undefined ) { getBookmarks = this . getCachedBookmarks ( ) ; } else { getBookmarks = this . $q . resolve ( bookmarks ) ; } return this . $q . all ( [ getBookmarks , this . platformSvc . getCurrentLocale ( ) ] ) . then ( ( results ) => { const [ bookmarksToSearch , currentLocale ] = results ; return this . searchBookmarksForLookaheads ( word , currentLocale , tagsOnly , bookmarksToSearch ) ; } ) . then ( ( lookaheads ) => { if ( exclusions ) { lookaheads = lookaheads . filter ( ( x ) => ! exclusions . includes ( x ) ) ; } if ( lookaheads . length === 0 ) { return ; } const counts = lookaheads . reduce ( ( acc , val ) => { acc [ val ] = acc [ val ] === undefined ? 1 : ( acc [ val ] += 1 ) ; return acc ; } , { } ) ; const lookahead = Object . keys ( counts ) . reduce ( ( x , y ) => { return counts [ x ] > counts [ y ] ? x : y ; } ) ; return [ lookahead , word ] ; } ) . catch ( ( err ) => { if ( err instanceof HttpRequestAbortedError ) { return ; } throw err ; } ) ; } getNativeBookmarksAsBookmarks ( nativeBookmarks = [ ] ) { const bookmarks = [ ] ; for ( let i = 0 ; i < nativeBookmarks . length ; i += 1 ) { const nativeBookmark = nativeBookmarks [ i ] ; const metadata = this . extractBookmarkMetadata ( nativeBookmark ) ; const bookmark = this . newBookmark ( metadata . title , metadata . url , metadata . description , metadata . tags ) ; if ( nativeBookmark . children ?. length ) { bookmark . children = this . getNativeBookmarksAsBookmarks ( nativeBookmark . children ) ; } bookmarks . push ( bookmark ) ; } return bookmarks ; } getNewBookmarkId ( bookmarks , takenIds = [ 0 ] ) { let highestId = 0 ; this . eachBookmark ( ( bookmark ) => { if ( ! angular . isUndefined ( bookmark . id ?? undefined ) && parseInt ( bookmark . id ! . toString ( ) , 10 ) > highestId ) { highestId = parseInt ( bookmark . id ! . toString ( ) , 10 ) ; } } , bookmarks ) ; const highestTakenId = takenIds . reduce ( ( x , y ) => ( x > y ? x : y ) ) ; highestId = highestTakenId > highestId ? highestTakenId : highestId ; return highestId + 1 ; } nativeBookmarkIsSeparator ( nativeBookmark ) { if ( angular . isUndefined ( nativeBookmark ?? undefined ) ) { return false ; } return ( nativeBookmark . type === BookmarkType . Separator || ( ( nativeBookmark . title === Globals . Bookmarks . HorizontalSeparatorTitle || nativeBookmark . title === Globals . Bookmarks . VerticalSeparatorTitle ) && nativeBookmark . url === this . platformSvc . getNewTabUrl ( ) ) ) ; } modifyBookmarkById ( id , newMetadata , bookmarks ) { const updatedBookmarks = angular . copy ( bookmarks ) ; const bookmarkToModify = this . findBookmarkById ( id , updatedBookmarks ) as Bookmark ; if ( ! bookmarkToModify ) { throw new BookmarkNotFoundError ( ) ; } const bookmarkFromNewMetadata = this . newBookmark ( newMetadata . title , newMetadata . url , newMetadata . description , newMetadata . tags ) ; bookmarkFromNewMetadata . id = bookmarkToModify . id ; if ( this . getBookmarkType ( bookmarkToModify ) === BookmarkType . Folder && this . getBookmarkType ( bookmarkFromNewMetadata ) === BookmarkType . Folder ) { bookmarkFromNewMetadata . children = bookmarkToModify . children ; } angular . copy ( bookmarkFromNewMetadata , bookmarkToModify ) ; return this . $q . resolve ( updatedBookmarks ) ; } newBookmark ( title , url ? , description ? , tags ? , bookmarksToGenerateNewId ? ) { const newBookmark = { children : [ ] , description : this . utilitySvc . trimToNearestWord ( description , Globals . Bookmarks . DescriptionMaxLength ) , tags , title : title ?. trim ( ) , url : url ?. trim ( ) } ; if ( url ) { delete newBookmark . children ; } else { delete newBookmark . url ; } if ( this . getBookmarkType ( newBookmark ) === BookmarkType . Separator ) { Object . keys ( newBookmark ) . forEach ( ( key ) => { if ( key !== 'url' ) { delete newBookmark [ key ] ; } } ) ; } if ( bookmarksToGenerateNewId ) { newBookmark . id = this . getNewBookmarkId ( bookmarksToGenerateNewId ) ; } return this . cleanBookmark ( newBookmark ) ; } removeBookmarkById ( id , bookmarks ) { const updatedBookmarks = angular . copy ( bookmarks ) ; this . eachBookmark ( ( bookmark ) => { if ( ! bookmark . children ) { return ; } const indexToRemove = bookmark . children . findIndex ( ( child ) => child . id === id ) ; if ( indexToRemove >= 0 ) { bookmark . children . splice ( indexToRemove , 1 ) ; } } , updatedBookmarks ) ; return this . $q . resolve ( updatedBookmarks ) ; } removeEmptyContainers ( bookmarks ) { const menuContainer = this . getContainer ( BookmarkContainer . Menu , bookmarks ) ; const otherContainer = this . getContainer ( BookmarkContainer . Other , bookmarks ) ; const toolbarContainer = this . getContainer ( BookmarkContainer . Toolbar , bookmarks ) ; const removeArr = [ ] ; if ( ! menuContainer ?. children ?. length ) { removeArr . push ( menuContainer ) ; } if ( ! otherContainer ?. children ?. length ) { removeArr . push ( otherContainer ) ; } if ( ! toolbarContainer ?. children ?. length ) { removeArr . push ( toolbarContainer ) ; } return bookmarks . filter ( ( x ) => ! removeArr . includes ( x ) ) ; } searchBookmarks ( query ) { if ( ! query ) { query = { keywords : [ ] } ; } return this . $q . all ( [ this . getCachedBookmarks ( ) , this . platformSvc . getCurrentLocale ( ) ] ) . then ( ( response ) => { const [ bookmarks , currentLocale ] = response ; let results ; if ( query . url ) { results = this . searchBookmarksByUrl ( bookmarks , query . url , currentLocale ) ?? [ ] ; } results = this . searchBookmarksByKeywords ( results ?? ( bookmarks as BookmarkSearchResult [ ] ) , currentLocale , query . keywords ) ; return results . sort ( ( x , y ) => { return x . id - y . id ; } ) . sort ( ( x , y ) => { return x . score - y . score ; } ) . reverse ( ) ; } ) ; } searchBookmarksByKeywords ( bookmarks , locale , keywords = [ ] , results = [ ] ) { bookmarks . forEach ( ( bookmark ) => { const bookmarkType = this . getBookmarkType ( bookmark ) ; if ( bookmarkType === BookmarkType . Separator ) { return ; } if ( bookmarkType === BookmarkType . Container || bookmarkType === BookmarkType . Folder ) { if ( bookmark . children ?. length ) { this . searchBookmarksByKeywords ( bookmark . children , locale , keywords , results ) ; } } else { const bookmarkWords = this . getKeywordsFromBookmark ( bookmark , locale ) ; const scores = keywords . map ( ( keyword ) => { let count = 0 ; bookmarkWords . forEach ( ( word ) => { if ( word ?. toLocaleLowerCase ( locale ) . indexOf ( keyword . toLocaleLowerCase ( locale ) ) === 0 ) { count += 1 ; } } ) ; return count ; } ) ; if ( angular . isUndefined ( scores . find ( ( x ) => x === 0 ) ) ) { const score = scores . reduce ( ( memo , num ) => memo + num , 0 ) ; const result = angular . copy ( bookmark ) ; result . score = score ; results . push ( result ) ; } } } ) ; return results ; } searchBookmarksByUrl ( bookmarks , url , locale , results = [ ] ) { results = results . concat ( bookmarks . filter ( ( bookmark ) => { const bookmarkType = this . getBookmarkType ( bookmark ) ; if ( bookmarkType !== BookmarkType . Bookmark ) { return false ; } return bookmark . url . toLocaleLowerCase ( locale ) . indexOf ( url . toLocaleLowerCase ( locale ) ) >= 0 ; } ) ) ; for ( let i = 0 ; i < bookmarks . length ; i += 1 ) { if ( bookmarks [ i ] . children ?. length ) { results = this . searchBookmarksByUrl ( bookmarks [ i ] . children , url , locale , results ) ; } } return results ; } searchBookmarksForLookaheads ( word , locale , tagsOnly = false , bookmarks = [ ] , results = [ ] ) { bookmarks . forEach ( ( bookmark ) => { const bookmarkType = this . getBookmarkType ( bookmark ) ; if ( bookmarkType === BookmarkType . Separator ) { return ; } if ( bookmarkType === BookmarkType . Container || bookmarkType === BookmarkType . Folder ) { results = this . searchBookmarksForLookaheads ( word , locale , tagsOnly , bookmark . children , results ) ; } else { const bookmarkWords = this . getKeywordsFromBookmark ( bookmark , locale , tagsOnly , true ) ; results = results . concat ( bookmarkWords . filter ( ( innerbookmark ) => { return innerbookmark . indexOf ( word ) === 0 ; } ) ) ; } } ) ; return results ; } updateCachedBookmarks ( bookmarks , encryptedBookmarks ) { return this . $q < void > ( ( resolve ) => { if ( angular . isUndefined ( encryptedBookmarks ?? undefined ) ) { return resolve ( ) ; } return this . storeSvc . set ( StoreKey . Bookmarks , encryptedBookmarks ) . then ( ( ) => { this . cachedBookmarks_encrypted = angular . copy ( encryptedBookmarks ) ; if ( bookmarks !== undefined ) { this . cachedBookmarks_plain = angular . copy ( bookmarks ) ; } resolve ( ) ; } ) ; } ) ; } }