Dactyloidae/mobile/ios/Storage/SQL/SQLiteBookmarksModel.swift

948 lines
40 KiB
Swift
Raw Normal View History

2026-06-26 21:04:09 -07:00
/* 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<Maybe<String>>
}
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<Maybe<Cursor<BookmarkNode>>> {
return self.bookmarks.getChildrenWithParent(parentGUID, direction: self.direction, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon)
}
fileprivate func getRootChildren() -> Deferred<Maybe<Cursor<BookmarkNode>>> {
return self.getChildrenWithParent(BookmarkRoots.RootGUID, excludingGUIDs: [BookmarkRoots.RootGUID], includeIcon: true)
}
fileprivate func getChildren(_ guid: String) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
return self.getChildrenWithParent(guid, includeIcon: true)
}
func folderForGUID(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarkFolder>> {
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<Maybe<BookmarksModel>> {
return deferMaybe(BookmarksModel(modelFactory: self, root: root))
}
open func modelForFolder(_ guid: String, title: String) -> Deferred<Maybe<BookmarksModel>> {
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<Maybe<BookmarksModel>> {
return self.modelForFolder(folder.guid, title: folder.title)
}
open func modelForFolder(_ guid: String) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(guid, title: "")
}
open func modelForRoot() -> Deferred<Maybe<BookmarksModel>> {
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<BookmarkNode>(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<Maybe<Bool>> {
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<Maybe<Bool>> {
// 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<Maybe<Cursor<BookmarkNode>>> {
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<Maybe<BookmarksModel>> {
func onlyMobile() -> Deferred<Maybe<BookmarksModel>> {
// 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<Maybe<BookmarksModel>> {
return self.getDesktopRoots() >>== { cursor in
let desktop = self.folderForDesktopBookmarksCursor(cursor)
return deferMaybe(BookmarksModel(modelFactory: self, root: desktop))
}
}
fileprivate func folderForDesktopBookmarksCursor(_ cursor: Cursor<BookmarkNode>) -> SQLiteBookmarkFolder {
return SQLiteBookmarkFolder(guid: BookmarkRoots.FakeDesktopFolderGUID, title: desktopBookmarksLabel, children: cursor)
}
}
class EditableBufferBookmarksSQLiteBookmarksModelFactory: SQLiteBookmarksModelFactory {
override func getChildrenWithParent(_ parentGUID: GUID, excludingGUIDs: [GUID]?, includeIcon: Bool) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
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<Maybe<Cursor<BookmarkNode>>> {
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<Maybe<Cursor<BookmarkNode>>> {
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<Maybe<([GUID])>> {
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<BookmarkNode>
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<BookmarkNode>) {
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<Maybe<Cursor<BookmarkItem>>> {
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<Maybe<Bool>> {
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<Maybe<BookmarksModel>> {
return self.modelForFolder(folder.guid, title: folder.title)
}
open func modelForFolder(_ guid: GUID) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(guid, title: "")
}
open func modelForFolder(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarksModel>> {
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<Maybe<BookmarksModel>> {
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<BookmarkNode>(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<Maybe<Bool>> {
// 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<Maybe<BookmarksModelFactory>> {
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<Maybe<String>> {
return self.local.getURLForKeywordSearch(keyword)
}
}