/* 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/. */ import Deferred import Foundation import Shared private let log = Logger.syncLogger private let desktopBookmarksLabel: String = NSLocalizedString("Desktop Bookmarks", tableName: "BookmarkPanel", comment: "The folder name for the virtual folder that contains all desktop bookmarks.") public enum Direction { case buffer case local var structureTable: String { switch self { case .local: return TableBookmarksLocalStructure case .buffer: return TableBookmarksBufferStructure } } var valueTable: String { switch self { case .local: return TableBookmarksLocal case .buffer: return TableBookmarksBuffer } } var valueView: String { switch self { case .local: return ViewBookmarksLocalOnMirror case .buffer: return ViewBookmarksBufferWithDeletionsOnMirror } } var structureView: String { switch self { case .local: return ViewBookmarksLocalStructureOnMirror case .buffer: return ViewBookmarksBufferStructureOnMirror } } } public protocol KeywordSearchSource { func getURLForKeywordSearch(_ keyword: String) -> Deferred> } open class SQLiteBookmarksModelFactory: BookmarksModelFactory { fileprivate let bookmarks: SQLiteBookmarks fileprivate let direction: Direction public init(bookmarks: SQLiteBookmarks, direction: Direction) { self.bookmarks = bookmarks self.direction = direction } public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory { return self } fileprivate func withDifferentDirection(_ direction: Direction) -> SQLiteBookmarksModelFactory { if self.direction == direction { return self } return SQLiteBookmarksModelFactory(bookmarks: self.bookmarks, direction: direction) } fileprivate func getChildrenWithParent(_ parentGUID: GUID, excludingGUIDs: [GUID]?=nil, includeIcon: Bool) -> Deferred>> { return self.bookmarks.getChildrenWithParent(parentGUID, direction: self.direction, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon) } fileprivate func getRootChildren() -> Deferred>> { return self.getChildrenWithParent(BookmarkRoots.RootGUID, excludingGUIDs: [BookmarkRoots.RootGUID], includeIcon: true) } fileprivate func getChildren(_ guid: String) -> Deferred>> { return self.getChildrenWithParent(guid, includeIcon: true) } func folderForGUID(_ guid: GUID, title: String) -> Deferred> { return self.getChildren(guid) >>== { cursor in if cursor.status == .failure { return deferMaybe(DatabaseError(description: "Couldn't get children: \(cursor.statusMessage).")) } return deferMaybe(SQLiteBookmarkFolder(guid: guid, title: title, children: cursor)) } } fileprivate func modelWithRoot(_ root: BookmarkFolder) -> Deferred> { return deferMaybe(BookmarksModel(modelFactory: self, root: root)) } open func modelForFolder(_ guid: String, title: String) -> Deferred> { if guid == BookmarkRoots.MobileFolderGUID { return self.modelForRoot() } if guid == BookmarkRoots.FakeDesktopFolderGUID { return self.modelForDesktopBookmarks() } let outputTitle = titleForSpecialGUID(guid) ?? title return self.folderForGUID(guid, title: outputTitle) >>== self.modelWithRoot } open func modelForFolder(_ folder: BookmarkFolder) -> Deferred> { return self.modelForFolder(folder.guid, title: folder.title) } open func modelForFolder(_ guid: String) -> Deferred> { return self.modelForFolder(guid, title: "") } open func modelForRoot() -> Deferred> { log.debug("Getting model for root.") let getFolder = self.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile) if self.direction == .buffer { return getFolder >>== self.modelWithRoot } // Return a virtual model containing "Desktop bookmarks" prepended to the local mobile bookmarks. return getFolder >>== { folder in self.extendWithDesktopBookmarksFolder(folder, factory: self) } } open var nullModel: BookmarksModel { let children = Cursor(status: .failure, msg: "Null model") let folder = SQLiteBookmarkFolder(guid: "Null", title: "Null", children: children) return BookmarksModel(modelFactory: self, root: folder) } open func isBookmarked(_ url: String) -> Deferred> { return self.bookmarks.isBookmarked(url, direction: self.direction) } open func removeByURL(_ url: String) -> Success { if self.direction == Direction.buffer { return deferMaybe(DatabaseError(description: "Refusing to remove URL from buffer in model.")) } // Find all of the records for the provided URL. Don't bother with // any that are already deleted! return self.bookmarks.nonDeletedGUIDsForURL(url) >>== self.bookmarks.removeGUIDs } open func removeByGUID(_ guid: GUID) -> Success { if self.direction == Direction.buffer { return deferMaybe(DatabaseError(description: "Refusing to remove GUID from buffer in model.")) } log.debug("removeByGUID: \(guid)") return self.bookmarks.removeGUIDs([guid]) } func hasDesktopBookmarks() -> Deferred> { // This is very lazy, but it has the nice property of keeping Desktop Bookmarks visible // for a while after you mark the last desktop child as deleted. let parents: Args = [ // Local. BookmarkRoots.MenuFolderGUID, BookmarkRoots.ToolbarFolderGUID, BookmarkRoots.UnfiledFolderGUID, // Mirror. BookmarkRoots.MenuFolderGUID, BookmarkRoots.ToolbarFolderGUID, BookmarkRoots.UnfiledFolderGUID, ] let sql = "SELECT 1 FROM \(self.direction.structureTable) WHERE parent IN (?, ?, ?)" + " UNION ALL " + "SELECT 1 FROM \(TableBookmarksMirrorStructure) WHERE parent IN (?, ?, ?)" + " LIMIT 1" return self.bookmarks.db.queryReturnsResults(sql, args: parents) } func getDesktopRoots() -> Deferred>> { if self.direction == .buffer { // DesktopRoots excludes the Mobile folder, local and non-local mobile are aggregated return self.bookmarks.getRecordsWithGUIDs(BookmarkRoots.DesktopRoots, direction: self.direction, includeIcon: false) } // We deliberately exclude the mobile folder, because we're inverting the containment // relationship here. let exclude = [BookmarkRoots.MobileFolderGUID, BookmarkRoots.RootGUID] return self.getChildrenWithParent(BookmarkRoots.RootGUID, excludingGUIDs: exclude, includeIcon: false) } /** * Prepend the provided mobile bookmarks folder with a single folder. * The prepended folder is "Desktop Bookmarks". It contains mirrored folders. */ open func extendWithDesktopBookmarksFolder(_ mobile: BookmarkFolder, factory: BookmarksModelFactory) -> Deferred> { func onlyMobile() -> Deferred> { // No desktop bookmarks. log.debug("No desktop bookmarks. Only showing mobile.") return deferMaybe(BookmarksModel(modelFactory: factory, root: mobile)) } return self.hasDesktopBookmarks() >>== { yes in if !yes { return onlyMobile() } return self.getDesktopRoots() >>== { cursor in if cursor.count == 0 { // This shouldn't occur. return onlyMobile() } let desktop = self.folderForDesktopBookmarksCursor(cursor) let prepended = PrependedBookmarkFolder(main: mobile, prepend: desktop) return deferMaybe(BookmarksModel(modelFactory: factory, root: prepended)) } } } fileprivate func modelForDesktopBookmarks() -> Deferred> { return self.getDesktopRoots() >>== { cursor in let desktop = self.folderForDesktopBookmarksCursor(cursor) return deferMaybe(BookmarksModel(modelFactory: self, root: desktop)) } } fileprivate func folderForDesktopBookmarksCursor(_ cursor: Cursor) -> SQLiteBookmarkFolder { return SQLiteBookmarkFolder(guid: BookmarkRoots.FakeDesktopFolderGUID, title: desktopBookmarksLabel, children: cursor) } } class EditableBufferBookmarksSQLiteBookmarksModelFactory: SQLiteBookmarksModelFactory { override func getChildrenWithParent(_ parentGUID: GUID, excludingGUIDs: [GUID]?, includeIcon: Bool) -> Deferred>> { if parentGUID == BookmarkRoots.MobileFolderGUID { return self.bookmarks.getChildrenWithParent(parentGUID, direction: self.direction, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon, factory: BookmarkFactory.editableItemsFactory) } return super.getChildrenWithParent(parentGUID, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon) } override func removeByGUID(_ guid: GUID) -> Success { log.debug("Removing \(guid) from buffer.") return self.bookmarks.markBufferBookmarkAsDeleted(guid) } } private func isEditableExpression(_ direction: Direction) -> String { if direction == .buffer { return "0" } return "SELECT exists( " + " SELECT exists(SELECT 1 FROM \(TableBookmarksBuffer)) AS hasBuffer, exists(SELECT 1 FROM \(TableBookmarksMirror)) AS hasMirror " + " WHERE hasBuffer IS 0 OR hasMirror IS 0" + ")" } extension SQLiteBookmarks { fileprivate func getRecordsWithGUIDs(_ guids: [GUID], direction: Direction, includeIcon: Bool) -> Deferred>> { let args: Args = guids let varlist = BrowserDB.varlist(args.count) let values = "SELECT -1 AS id, guid, type, date_added, is_deleted, parentid, parentName, feedUri, pos, title, bmkUri, siteUri, folderName, faviconID, (\(isEditableExpression(direction))) AS isEditable " + "FROM \(direction.valueView) WHERE guid IN \(varlist) AND NOT is_deleted" let withIcon = [ "SELECT bookmarks.id AS id, bookmarks.guid AS guid, bookmarks.type AS type,", " bookmarks.date_added AS date_added,", " bookmarks.is_deleted AS is_deleted,", " bookmarks.parentid AS parentid, bookmarks.parentName AS parentName,", " bookmarks.feedUri AS feedUri, bookmarks.pos AS pos, title AS title,", " bookmarks.bmkUri AS bmkUri, bookmarks.siteUri AS siteUri,", " bookmarks.folderName AS folderName,", " bookmarks.isEditable AS isEditable,", " favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType", "FROM (", values, ") AS bookmarks", "LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id", "ORDER BY title ASC", ].joined(separator: " ") let sql = (includeIcon ? withIcon : values) + " ORDER BY title ASC" return self.db.runQuery(sql, args: args, factory: BookmarkFactory.factory) } /** * Return the children of the provided parent. * Rows are ordered by positional index. * This method is aware of is_overridden and deletion, using local override structure by preference. * Note that a folder can be empty locally; we thus use the flag rather than looking at the structure itself. */ func getChildrenWithParent(_ parentGUID: GUID, direction: Direction, excludingGUIDs: [GUID]?=nil, includeIcon: Bool, factory: @escaping (SDRow) -> BookmarkNode = BookmarkFactory.factory) -> Deferred>> { precondition((excludingGUIDs ?? []).count < 100, "Sanity bound for the number of GUIDs we can exclude.") let valueView = direction.valueView let structureView = direction.structureView let structure = "SELECT parent, child AS guid, idx FROM \(structureView) " + "WHERE parent = ?" let values = "SELECT -1 AS id, guid, type, date_added, is_deleted, parentid, parentName, feedUri, pos, title, bmkUri, siteUri, folderName, faviconID, (\(isEditableExpression(direction))) AS isEditable " + "FROM \(valueView)" // We exclude queries and dynamic containers, because we can't // usefully display them. let typeQuery = BookmarkNodeType.query.rawValue let typeDynamic = BookmarkNodeType.dynamicContainer.rawValue let typeFilter = " vals.type NOT IN (\(typeQuery), \(typeDynamic))" let args: Args let exclusion: String if let excludingGUIDs = excludingGUIDs { args = ([parentGUID] + excludingGUIDs).map { $0 } exclusion = "\(typeFilter) AND vals.guid NOT IN " + BrowserDB.varlist(excludingGUIDs.count) } else { args = [parentGUID] exclusion = typeFilter } let fleshed = "SELECT vals.id AS id, vals.guid AS guid, vals.type AS type, vals.date_added AS date_added, vals.is_deleted AS is_deleted, " + " vals.parentid AS parentid, vals.parentName AS parentName, vals.feedUri AS feedUri, " + " vals.siteUri AS siteUri," + " vals.pos AS pos, vals.title AS title, vals.bmkUri AS bmkUri, vals.folderName AS folderName, " + " vals.faviconID AS faviconID, " + " vals.isEditable AS isEditable, " + " structure.idx AS idx, " + " structure.parent AS _parent " + "FROM (\(structure)) AS structure JOIN (\(values)) AS vals " + "ON vals.guid = structure.guid " + "WHERE " + exclusion let withIcon = "SELECT bookmarks.id AS id, bookmarks.guid AS guid, bookmarks.type AS type, " + " bookmarks.date_added AS date_added, " + " bookmarks.is_deleted AS is_deleted, " + " bookmarks.parentid AS parentid, bookmarks.parentName AS parentName, " + " bookmarks.feedUri AS feedUri, bookmarks.siteUri AS siteUri, " + " bookmarks.pos AS pos, title AS title, " + " bookmarks.bmkUri AS bmkUri, bookmarks.folderName AS folderName, " + " bookmarks.idx AS idx, bookmarks._parent AS _parent, " + " bookmarks.isEditable AS isEditable, " + " favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType " + "FROM (\(fleshed)) AS bookmarks " + "LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id" let sql = (includeIcon ? withIcon : fleshed) + " ORDER BY idx ASC" return self.db.runQuery(sql, args: args, factory: factory) } // This is only used from tests. func clearBookmarks() -> Success { log.warning("CALLING clearBookmarks -- this should only be used from tests.") return self.db.run([ ("DELETE FROM \(TableBookmarksLocal) WHERE parentid IS NOT ?", [BookmarkRoots.RootGUID]), self.favicons.getCleanupFaviconsQuery() ]) } public func removeGUIDs(_ guids: [GUID]) -> Success { log.debug("removeGUIDs: \(guids)") // Override any parents that aren't already overridden. We're about to remove some // of their children. return self.overrideParentsOfGUIDs(guids) // Find, recursively, any children of the provided GUIDs. This will only be the case // if you specify folders. This is a special case because we're removing *all* // children, so we don't need to do the reindexing dance. >>> { self.deleteChildrenOfGUIDs(guids) } // Override any records that aren't already overridden. >>> { self.overrideGUIDs(guids) } // Then delete the already-overridden records. We do this one at a time in order // to get indices correct in edge cases. (We do bulk-delete their children // one layer at a time, at least.) >>> { walk(guids, f: self.removeLocalByGUID) } } fileprivate func nonDeletedGUIDsForURL(_ url: String) -> Deferred> { let sql = "SELECT DISTINCT guid FROM \(ViewBookmarksLocalOnMirror) WHERE bmkUri = ? AND is_deleted = 0" let args: Args = [url] return self.db.runQuery(sql, args: args, factory: { $0[0] as! GUID }) >>== { guids in return deferMaybe(guids.asArray()) } } fileprivate func overrideParentsOfGUIDs(_ guids: [GUID]) -> Success { log.debug("Overriding parents of \(guids).") // TODO: Yes, this can be done in one go. let getParentsSQL = "SELECT DISTINCT parent FROM \(ViewBookmarksLocalStructureOnMirror) " + "WHERE child IN \(BrowserDB.varlist(guids.count)) AND is_overridden = 0" let getParentsArgs: Args = guids return self.db.runQuery(getParentsSQL, args: getParentsArgs, factory: { $0[0] as! GUID }) >>== { parentsCursor in let parents = parentsCursor.asArray() log.debug("Overriding parents: \(parents).") let (sql, args) = self.getSQLToOverrideFolders(parents, atModifiedTime: Date.now()) return self.db.run(sql.map { ($0, args) }) } } fileprivate func overrideGUIDs(_ guids: [GUID]) -> Success { log.debug("Overriding GUIDs: \(guids).") let (sql, args) = self.getSQLToOverrideNonFolders(guids, atModifiedTime: Date.now()) return self.db.run(sql.map { ($0, args) }) } // Recursive. fileprivate func deleteChildrenOfGUIDs(_ guids: [GUID]) -> Success { if guids.isEmpty { return succeed() } precondition(BookmarkRoots.All.intersection(guids).isEmpty, "You can't even touch the roots for removal.") log.debug("Deleting children of \(guids).") let topArgs: Args = guids let topVarlist = BrowserDB.varlist(topArgs.count) let query = "SELECT child FROM \(ViewBookmarksLocalStructureOnMirror) " + "WHERE parent IN \(topVarlist)" // We're deleting whole folders, so we don't need to worry about indices. return self.db.runQuery(query, args: topArgs, factory: { $0[0] as! GUID }) >>== { children in let childGUIDs = children.asArray() log.debug("… children of \(guids) are \(childGUIDs).") if childGUIDs.isEmpty { log.debug("No children; nothing more to do.") return succeed() } let childArgs: Args = childGUIDs let childVarlist = BrowserDB.varlist(childArgs.count) // Mirror the children if they're not already. // We use the non-folder version of this query because we're recursively // destroying structure right after this, so there's no point cloning the // mirror structure. // Then delete the children's children, so we don't leave orphans. This is // recursive, so by the time this succeeds we know that all of these records // have no remaining children. let (overrideSQL, overrideArgs) = self.getSQLToOverrideNonFolders(childGUIDs, atModifiedTime: Date.now()) return self.deleteChildrenOfGUIDs(childGUIDs) >>> { self.db.run(overrideSQL.map { ($0, overrideArgs) }) } >>> { // Delete the children themselves. // Remove each child from structure. We use the top list to save effort. let deleteStructure = "DELETE FROM \(TableBookmarksLocalStructure) WHERE parent IN \(topVarlist)" // If a bookmark is New, delete it outright. let deleteNew = "DELETE FROM \(TableBookmarksLocal) WHERE guid IN \(childVarlist) AND sync_status = \(SyncStatus.new.rawValue)" // If a bookmark is Changed, mark it as deleted and bump its modified time. let markChanged = self.getMarkDeletedSQLWithWhereFragment("guid IN \(childVarlist)") return self.db.run([ (deleteStructure, topArgs), (deleteNew, childArgs), (markChanged, childArgs), ]) } } } fileprivate func getMarkDeletedSQLWithWhereFragment(_ whereFragment: String) -> String { let sql = "UPDATE \(TableBookmarksLocal) SET" + " date_added = NULL" + ", is_deleted = 1" + ", local_modified = \(Date.now())" + ", bmkUri = NULL" + ", feedUri = NULL" + ", siteUri = NULL" + ", pos = NULL" + ", title = NULL" + ", tags = NULL" + ", keyword = NULL" + ", description = NULL" + ", parentid = NULL" + ", parentName = NULL" + ", folderName = NULL" + ", queryId = NULL" + " WHERE \(whereFragment) AND sync_status = \(SyncStatus.changed.rawValue)" return sql } /** * This depends on the record's parent already being overridden if necessary. */ fileprivate func removeLocalByGUID(_ guid: GUID) -> Success { let args: Args = [guid] // Find the index we're currently occupying. let previousIndexSubquery = "SELECT idx FROM \(TableBookmarksLocalStructure) WHERE child = ?" // Fix up the indices of subsequent siblings. let updateIndices = "UPDATE \(TableBookmarksLocalStructure) SET idx = (idx - 1) WHERE idx > (\(previousIndexSubquery))" // If the bookmark is New, delete it outright. let deleteNew = "DELETE FROM \(TableBookmarksLocal) WHERE guid = ? AND sync_status = \(SyncStatus.new.rawValue)" // If the bookmark is Changed, mark it as deleted and bump its modified time. let markChanged = self.getMarkDeletedSQLWithWhereFragment("guid = ?") // Its parent must be either New or Changed, so we don't need to re-mirror it. // TODO: bump the parent's modified time, because the child list changed? // Now delete from structure. let deleteStructure = "DELETE FROM \(TableBookmarksLocalStructure) WHERE child = ?" return self.db.run([ (updateIndices, args), (deleteNew, args), (markChanged, args), (deleteStructure, args), ]) } fileprivate func markBufferBookmarkAsDeleted(_ guid: GUID) -> Success { let insertInPendingDeletions = "INSERT OR IGNORE INTO \(TablePendingBookmarksDeletions) " + "(id) " + "VALUES (?)" let args: Args = [guid] return self.db.run(insertInPendingDeletions, withArgs: args) } } class SQLiteBookmarkFolder: BookmarkFolder { fileprivate let cursor: Cursor override var count: Int { return cursor.count } override subscript(index: Int) -> BookmarkNode { let bookmark = cursor[index] return bookmark! as BookmarkNode } init(guid: String, title: String, children: Cursor) { self.cursor = children super.init(guid: guid, title: title) } override func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? { let without = cursor.asArray().filter { $0.guid != guid } return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: without) } } class BookmarkFactory { fileprivate class func addIcon(_ bookmark: BookmarkNode, row: SDRow) { // TODO: share this logic with SQLiteHistory. if let faviconURL = row["iconURL"] as? String, let date = row["iconDate"] as? Double, let faviconType = row["iconType"] as? Int, let type = IconType(rawValue: faviconType) { bookmark.favicon = Favicon(url: faviconURL, date: Date(timeIntervalSince1970: date), type: type) } } fileprivate class func livemarkFactory(_ row: SDRow) -> BookmarkItem { let id = row["id"] as? Int let guid = row["guid"] as! String let url = row["siteUri"] as! String let title = row["title"] as? String ?? "Livemark" // TODO let isEditable = row.getBoolean("isEditable") // Defaults to false. let bookmark = BookmarkItem(guid: guid, title: title, url: url, isEditable: isEditable) bookmark.id = id BookmarkFactory.addIcon(bookmark, row: row) return bookmark } // We ignore queries altogether inside the model factory. fileprivate class func queryFactory(_ row: SDRow) -> BookmarkItem { log.warning("Creating a BookmarkItem from a query. This is almost certainly unexpected.") let id = row["id"] as? Int let guid = row["guid"] as! String let title = row["title"] as? String ?? SQLiteBookmarks.defaultItemTitle let isEditable = row.getBoolean("isEditable") // Defaults to false. let bookmark = BookmarkItem(guid: guid, title: title, url: "about:blank", isEditable: isEditable) bookmark.id = id BookmarkFactory.addIcon(bookmark, row: row) return bookmark } fileprivate class func separatorFactory(_ row: SDRow) -> BookmarkSeparator { let id = row["id"] as? Int let guid = row["guid"] as! String let separator = BookmarkSeparator(guid: guid) separator.id = id return separator } fileprivate class func itemRowFactory(_ row: SDRow, forceEditable: Bool = false) -> BookmarkItem { let id = row["id"] as? Int let guid = row["guid"] as! String let url = row["bmkUri"] as! String let title = row["title"] as? String ?? url let isEditable = forceEditable || row.getBoolean("isEditable") // Defaults to false. let bookmark = BookmarkItem(guid: guid, title: title, url: url, isEditable: isEditable) bookmark.id = id BookmarkFactory.addIcon(bookmark, row: row) return bookmark } fileprivate class func itemFactory(_ row: SDRow) -> BookmarkItem { return BookmarkFactory.itemRowFactory(row, forceEditable: false) } fileprivate class func folderFactory(_ row: SDRow) -> BookmarkFolder { let id = row["id"] as? Int let guid = row["guid"] as! String let isEditable = row.getBoolean("isEditable") // Defaults to false. let title = titleForSpecialGUID(guid) ?? row["title"] as? String ?? SQLiteBookmarks.defaultFolderTitle let folder = BookmarkFolder(guid: guid, title: title, isEditable: isEditable) folder.id = id BookmarkFactory.addIcon(folder, row: row) return folder } class func factory(_ row: SDRow) -> BookmarkNode { return BookmarkFactory.rowFactory(row, forceEditable: false) } class func editableItemsFactory(_ row: SDRow) -> BookmarkNode { return BookmarkFactory.rowFactory(row, forceEditable: true) } class func rowFactory(_ row: SDRow, forceEditable: Bool = false) -> BookmarkNode { if let typeCode = row["type"] as? Int, let type = BookmarkNodeType(rawValue: typeCode) { switch type { case .bookmark: return itemRowFactory(row, forceEditable: forceEditable) case .dynamicContainer: // This should never be hit: we exclude dynamic containers from our models. fallthrough case .folder: return folderFactory(row) case .separator: return separatorFactory(row) case .livemark: return livemarkFactory(row) case .query: // This should never be hit: we exclude queries from our models. return queryFactory(row) } } assert(false, "Invalid bookmark data.") return itemFactory(row) // This will fail, but it keeps the compiler happy. } // N.B., doesn't include children! class func mirrorItemFactory(_ row: SDRow) -> BookmarkMirrorItem { // TODO // let id = row["id"] as! Int let guid = row["guid"] as! GUID let typeCode = row["type"] as! Int let is_deleted = row.getBoolean("is_deleted") let parentid = row["parentid"] as? GUID let parentName = row["parentName"] as? String let feedUri = row["feedUri"] as? String let siteUri = row["siteUri"] as? String let pos = row["pos"] as? Int let title = row["title"] as? String let description = row["description"] as? String let bmkUri = row["bmkUri"] as? String let tags = row["tags"] as? String let keyword = row["keyword"] as? String let folderName = row["folderName"] as? String let queryId = row["queryId"] as? String let date_added = row.getTimestamp("date_added") // Local and mirror only. let faviconID = row["faviconID"] as? Int // Local only. let local_modified = row.getTimestamp("local_modified") // Mirror and buffer. let server_modified = row.getTimestamp("server_modified") let hasDupe = row.getBoolean("hasDupe") // Mirror only. TODO //let is_overridden = row.getBoolean("is_overridden") // Use the struct initializer directly. Yes, this doesn't validate as strongly as // using the static constructors, but it'll be as valid as the contents of the DB. let type = BookmarkNodeType(rawValue: typeCode)! // This one might really be missing (it's local-only), so do this the hard way. let syncStatus: SyncStatus? if let s = row["sync_status"] as? Int { syncStatus = SyncStatus(rawValue: s) } else { syncStatus = nil } let item = BookmarkMirrorItem(guid: guid, type: type, dateAdded: date_added, serverModified: server_modified ?? 0, isDeleted: is_deleted, hasDupe: hasDupe, parentID: parentid, parentName: parentName, feedURI: feedUri, siteURI: siteUri, pos: pos, title: title, description: description, bookmarkURI: bmkUri, tags: tags, keyword: keyword, folderName: folderName, queryID: queryId, children: nil, faviconID: faviconID, localModified: local_modified, syncStatus: syncStatus) return item } } extension SQLiteBookmarks: SearchableBookmarks { public func bookmarksByURL(_ url: URL) -> Deferred>> { let inner = "SELECT id, type, date_added, guid, bmkUri, title, faviconID FROM \(TableBookmarksLocal) " + "WHERE " + "type = \(BookmarkNodeType.bookmark.rawValue) AND is_deleted IS NOT 1 AND bmkUri = ? " + "UNION ALL " + "SELECT id, type, date_added, guid, bmkUri, title, faviconID FROM \(TableBookmarksMirror) " + "WHERE " + "type = \(BookmarkNodeType.bookmark.rawValue) AND is_overridden IS NOT 1 AND is_deleted IS NOT 1 AND bmkUri = ? " let sql = "SELECT bookmarks.id AS id, bookmarks.type AS type, bookmarks.date_added AS date_added, guid, bookmarks.bmkUri AS bmkUri, title, " + "favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType " + "FROM (\(inner)) AS bookmarks " + "LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id" let u = url.absoluteString let args: Args = [u, u] return db.runQuery(sql, args: args, factory: BookmarkFactory.itemFactory) } } extension SQLiteBookmarks { // We're in sync (even partially) if the mirror is non-empty. // We can show fallback desktop bookmarks if the mirror is empty and the buffer contains // children of the roots. func hasOnlyUnmergedRemoteBookmarks() -> Deferred> { let parents: Args = [ BookmarkRoots.MenuFolderGUID, BookmarkRoots.ToolbarFolderGUID, BookmarkRoots.UnfiledFolderGUID, BookmarkRoots.MobileFolderGUID, ] let sql = [ "SELECT", "not exists(SELECT 1 FROM \(TableBookmarksMirror))", "AND", "exists(SELECT 1 FROM \(TableBookmarksBufferStructure) WHERE parent IN (?, ?, ?, ?))", ].joined(separator: " ") return self.db.runQuery(sql, args: parents, factory: { $0[0] as! Int == 1 }) >>== { row in guard row.status == .success, let result = row[0] else { // if the query did not succeed, we should return false so that we can use local bookmarks return deferMaybe(false) } return deferMaybe(result) } } } // It's a factory where the root contains Desktop Bookmarks from the buffer, and // mobile bookmarks from local. open class UnsyncedBookmarksFallbackModelFactory: BookmarksModelFactory { let localFactory: SQLiteBookmarksModelFactory let bufferFactory: SQLiteBookmarksModelFactory init(bookmarks: SQLiteBookmarks) { // This relies on SQLiteBookmarks being the storage for both directions. self.localFactory = SQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .local) if AppConstants.MOZ_SIMPLE_BOOKMARKS_SYNCING { self.bufferFactory = EditableBufferBookmarksSQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .buffer) } else { self.bufferFactory = SQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .buffer) } } // This is a special-case class, so here's the special-case behavior to // know how to handle a folder that contains items drawn from different // parts of the database. We look for the special kinds of folders we // nest at the top level, and then we pick a folder to match. public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory { let concatenated: ConcatenatedBookmarkFolder let i: Int // We have either just remote and local mobile bookmarks, or we have Desktop Bookmarks // followed by remote and local mobile bookmarks. Handle either. if let prepended = folder as? PrependedBookmarkFolder { if index == 0 { return self } guard let c = prepended.main as? ConcatenatedBookmarkFolder else { return self } i = index - 1 // Drop the prepend. concatenated = c } else { guard let c = folder as? ConcatenatedBookmarkFolder else { return self } i = index concatenated = c } if i < concatenated.pivot { return self.bufferFactory // This comes first in our concatenation. } return self.localFactory } open func modelForFolder(_ folder: BookmarkFolder) -> Deferred> { return self.modelForFolder(folder.guid, title: folder.title) } open func modelForFolder(_ guid: GUID) -> Deferred> { return self.modelForFolder(guid, title: "") } open func modelForFolder(_ guid: GUID, title: String) -> Deferred> { if guid == BookmarkRoots.MobileFolderGUID { return self.modelForRoot() } if guid == BookmarkRoots.FakeDesktopFolderGUID { return self.bufferFactory.modelForFolder(guid, title: title) } return self.localFactory.modelForFolder(guid, title: title) } open func modelForRoot() -> Deferred> { log.debug("Getting model for fallback root.") // Return a virtual model containing "Desktop bookmarks" prepended to the local mobile bookmarks. return self.localFactory.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile) >>== { localMobileFolder in self.bufferFactory.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile) >>== { bufferMobileFolder in let bufferAndLocalMobile = ConcatenatedBookmarkFolder(main: bufferMobileFolder, append: localMobileFolder) return self.bufferFactory.hasDesktopBookmarks() >>== { yes in guard yes else { return deferMaybe(BookmarksModel(modelFactory: self, root: bufferAndLocalMobile)) } return self.bufferFactory.getDesktopRoots() >>== { cursor in let desktop = self.bufferFactory.folderForDesktopBookmarksCursor(cursor) let withDesktopPrepended = PrependedBookmarkFolder(main: bufferAndLocalMobile, prepend: desktop) return deferMaybe(BookmarksModel(modelFactory: self, root: withDesktopPrepended)) } } } } } // Whenever async construction is necessary, we fall into a pattern of needing // a placeholder that behaves correctly for the period between kickoff and set. open var nullModel: BookmarksModel { let children = Cursor(status: .failure, msg: "Null model") let folder = SQLiteBookmarkFolder(guid: "Null", title: "Null", children: children) return BookmarksModel(modelFactory: self, root: folder) } open func isBookmarked(_ url: String) -> Deferred> { // We don't include buffer items in this check, because we can't un-star them! return self.localFactory.isBookmarked(url) } open func removeByGUID(_ guid: GUID) -> Success { return self.localFactory.removeByGUID(guid) } open func removeByURL(_ url: String) -> Success { return self.localFactory.removeByURL(url) } } open class MergedSQLiteBookmarks: BookmarksModelFactorySource, KeywordSearchSource { let local: SQLiteBookmarks let buffer: SQLiteBookmarkBufferStorage // Figuring out our factory can require hitting the DB, so this is async. // Note that we check *every time* -- we don't want to get stuck in a dead // end when you might sync soon. open var modelFactory: Deferred> { return self.local.hasOnlyUnmergedRemoteBookmarks() >>== { yes in if yes { log.debug("Only unmerged remote bookmarks; using fallback factory.") return deferMaybe(UnsyncedBookmarksFallbackModelFactory(bookmarks: self.local)) } log.debug("Using local+mirror bookmark factory.") return self.local.modelFactory } } public init(db: BrowserDB) { self.local = SQLiteBookmarks(db: db) self.buffer = SQLiteBookmarkBufferStorage(db: db) } open func getURLForKeywordSearch(_ keyword: String) -> Deferred> { return self.local.getURLForKeywordSearch(keyword) } }