mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-08-20 19:33:08 +09:00
1216 lines
54 KiB
Swift
1216 lines
54 KiB
Swift
|
|
/* 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
|
||
|
|
import XCGLogger
|
||
|
|
|
||
|
|
private let log = Logger.syncLogger
|
||
|
|
|
||
|
|
extension SQLiteBookmarks: LocalItemSource {
|
||
|
|
public func getLocalItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.db.getMirrorItemFromTable(TableBookmarksLocal, guid: guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.db.getMirrorItemsFromTable(TableBookmarksLocal, guids: guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
log.debug("Not implemented for SQLiteBookmarks.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension SQLiteBookmarks: MirrorItemSource {
|
||
|
|
public func getMirrorItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.db.getMirrorItemFromTable(TableBookmarksMirror, guid: guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.db.getMirrorItemsFromTable(TableBookmarksMirror, guids: guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
log.debug("Not implemented for SQLiteBookmarks.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension SQLiteBookmarks {
|
||
|
|
func getSQLToOverrideFolder(_ folder: GUID, atModifiedTime modified: Timestamp) -> (sql: [String], args: Args) {
|
||
|
|
return self.getSQLToOverrideFolders([folder], atModifiedTime: modified)
|
||
|
|
}
|
||
|
|
|
||
|
|
func getSQLToOverrideFolders(_ folders: [GUID], atModifiedTime modified: Timestamp) -> (sql: [String], args: Args) {
|
||
|
|
if folders.isEmpty {
|
||
|
|
return (sql: [], args: [])
|
||
|
|
}
|
||
|
|
|
||
|
|
let vars = BrowserDB.varlist(folders.count)
|
||
|
|
let args: Args = folders
|
||
|
|
|
||
|
|
// Copy it to the local table.
|
||
|
|
// Most of these will be NULL, because we're only dealing with folders,
|
||
|
|
// and typically only the Mobile Bookmarks root.
|
||
|
|
let overrideSQL = "INSERT OR IGNORE INTO \(TableBookmarksLocal) " +
|
||
|
|
"(guid, type, date_added, bmkUri, title, parentid, parentName, feedUri, siteUri, pos," +
|
||
|
|
" description, tags, keyword, folderName, queryId, is_deleted, " +
|
||
|
|
" local_modified, sync_status, faviconID) " +
|
||
|
|
"SELECT guid, type, date_added, bmkUri, title, parentid, parentName, " +
|
||
|
|
"feedUri, siteUri, pos, description, tags, keyword, folderName, queryId, " +
|
||
|
|
"is_deleted, " +
|
||
|
|
"\(modified) AS local_modified, \(SyncStatus.changed.rawValue) AS sync_status, faviconID " +
|
||
|
|
"FROM \(TableBookmarksMirror) WHERE guid IN \(vars)"
|
||
|
|
|
||
|
|
// Copy its mirror structure.
|
||
|
|
let dropSQL = "DELETE FROM \(TableBookmarksLocalStructure) WHERE parent IN \(vars)"
|
||
|
|
let copySQL = "INSERT INTO \(TableBookmarksLocalStructure) " +
|
||
|
|
"SELECT * FROM \(TableBookmarksMirrorStructure) WHERE parent IN \(vars)"
|
||
|
|
|
||
|
|
// Mark as overridden.
|
||
|
|
let markSQL = "UPDATE \(TableBookmarksMirror) SET is_overridden = 1 WHERE guid IN \(vars)"
|
||
|
|
return (sql: [overrideSQL, dropSQL, copySQL, markSQL], args: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
func getSQLToOverrideNonFolders(_ records: [GUID], atModifiedTime modified: Timestamp) -> (sql: [String], args: Args) {
|
||
|
|
log.info("Getting SQL to override \(records).")
|
||
|
|
if records.isEmpty {
|
||
|
|
return (sql: [], args: [])
|
||
|
|
}
|
||
|
|
|
||
|
|
let vars = BrowserDB.varlist(records.count)
|
||
|
|
let args: Args = records.map { $0 }
|
||
|
|
|
||
|
|
// Copy any that aren't overridden to the local table.
|
||
|
|
let overrideSQL =
|
||
|
|
"INSERT OR IGNORE INTO \(TableBookmarksLocal) " +
|
||
|
|
"(guid, type, date_added, bmkUri, title, parentid, parentName, feedUri, siteUri, pos," +
|
||
|
|
" description, tags, keyword, folderName, queryId, is_deleted, " +
|
||
|
|
" local_modified, sync_status, faviconID) " +
|
||
|
|
"SELECT guid, type, date_added, bmkUri, title, parentid, parentName, " +
|
||
|
|
"feedUri, siteUri, pos, description, tags, keyword, folderName, queryId, " +
|
||
|
|
"is_deleted, " +
|
||
|
|
"\(modified) AS local_modified, \(SyncStatus.changed.rawValue) AS sync_status, faviconID " +
|
||
|
|
"FROM \(TableBookmarksMirror) WHERE guid IN \(vars) AND is_overridden = 0"
|
||
|
|
|
||
|
|
// Mark as overridden.
|
||
|
|
let markSQL = "UPDATE \(TableBookmarksMirror) SET is_overridden = 1 WHERE guid IN \(vars)"
|
||
|
|
return (sql: [overrideSQL, markSQL], args: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Insert a bookmark into the specified folder.
|
||
|
|
* If the folder doesn't exist, or is deleted, insertion will fail.
|
||
|
|
*
|
||
|
|
* Preconditions:
|
||
|
|
* * `deferred` has not been filled.
|
||
|
|
* * this function is called inside a transaction that hasn't been finished.
|
||
|
|
*
|
||
|
|
* Postconditions:
|
||
|
|
* * `deferred` has been filled with success or failure.
|
||
|
|
* * the transaction will include any status/overlay changes necessary to save the bookmark.
|
||
|
|
* * the return value determines whether the transaction should be committed, and
|
||
|
|
* matches the success-ness of the Deferred.
|
||
|
|
*
|
||
|
|
* Sorry about the long line. If we break it, the indenting below gets crazy.
|
||
|
|
*/
|
||
|
|
fileprivate func insertBookmarkInTransaction(url: URL, title: String, favicon: Favicon?, intoFolder parent: GUID, withTitle parentTitle: String, conn: SQLiteDBConnection) throws {
|
||
|
|
|
||
|
|
log.debug("Inserting bookmark in transaction on thread \(Thread.current)")
|
||
|
|
|
||
|
|
// Keep going if this does not throw.
|
||
|
|
func change(_ sql: String, args: Args?, desc: String) throws {
|
||
|
|
try conn.executeChange(sql, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
let urlString = url.absoluteString
|
||
|
|
let newGUID = Bytes.generateGUID()
|
||
|
|
let now = Date.now()
|
||
|
|
let parentArgs: Args = [parent]
|
||
|
|
|
||
|
|
//// Insert the new bookmark and icon without touching structure.
|
||
|
|
var args: Args = [
|
||
|
|
newGUID,
|
||
|
|
BookmarkNodeType.bookmark.rawValue,
|
||
|
|
now,
|
||
|
|
urlString,
|
||
|
|
title,
|
||
|
|
parent,
|
||
|
|
parentTitle,
|
||
|
|
Date.nowNumber(),
|
||
|
|
SyncStatus.new.rawValue,
|
||
|
|
]
|
||
|
|
|
||
|
|
let faviconID: Int?
|
||
|
|
|
||
|
|
// Insert the favicon.
|
||
|
|
if let icon = favicon {
|
||
|
|
faviconID = self.favicons.insertOrUpdateFaviconInTransaction(icon, conn: conn)
|
||
|
|
} else {
|
||
|
|
faviconID = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
log.debug("Inserting bookmark with GUID \(newGUID) and specified icon \(faviconID ??? "nil").")
|
||
|
|
|
||
|
|
// If the caller didn't provide an icon (and they usually don't!),
|
||
|
|
// do a reverse lookup in history. We use a view to make this simple.
|
||
|
|
let iconValue: String
|
||
|
|
if let faviconID = faviconID {
|
||
|
|
iconValue = "?"
|
||
|
|
args.append(faviconID )
|
||
|
|
} else {
|
||
|
|
iconValue = "(SELECT iconID FROM \(ViewIconForURL) WHERE url = ?)"
|
||
|
|
args.append(urlString )
|
||
|
|
}
|
||
|
|
|
||
|
|
let insertSQL = "INSERT INTO \(TableBookmarksLocal) " +
|
||
|
|
"(guid, type, date_added, bmkUri, title, parentid, parentName, local_modified, sync_status, faviconID) " +
|
||
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, \(iconValue))"
|
||
|
|
try change(insertSQL, args: args, desc: "Error inserting \(newGUID).")
|
||
|
|
|
||
|
|
func bumpParentStatus(_ status: Int) throws {
|
||
|
|
let bumpSQL = "UPDATE \(TableBookmarksLocal) SET sync_status = \(status), local_modified = \(now) WHERE guid = ?"
|
||
|
|
try change(bumpSQL, args: parentArgs, desc: "Error bumping \(parent)'s modified time.")
|
||
|
|
}
|
||
|
|
|
||
|
|
func overrideParentMirror() throws {
|
||
|
|
// We do this slightly tortured work so that we can reuse these queries
|
||
|
|
// in a different context.
|
||
|
|
let (sql, args) = getSQLToOverrideFolder(parent, atModifiedTime: now)
|
||
|
|
var generator = sql.makeIterator()
|
||
|
|
while let query = generator.next() {
|
||
|
|
try change(query, args: args, desc: "Error running overriding query.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
//// Make sure our parent is overridden and appropriately bumped.
|
||
|
|
// See discussion here: <https://github.com/mozilla/firefox-ios/commit/2041f1bbde430de29aefb803aae54ed26db47d23#commitcomment-14572312>
|
||
|
|
// Note that this isn't as obvious as you might think. We must:
|
||
|
|
let localStatusFactory: (SDRow) -> (Int, Bool) = { row in
|
||
|
|
let status = row["sync_status"] as! Int
|
||
|
|
let deleted = (row["is_deleted"] as! Int) != 0
|
||
|
|
return (status, deleted)
|
||
|
|
}
|
||
|
|
|
||
|
|
let overriddenFactory: (SDRow) -> Bool = { row in
|
||
|
|
row.getBoolean("is_overridden")
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO: these can be merged into a single query.
|
||
|
|
let mirrorStatusSQL = "SELECT is_overridden FROM \(TableBookmarksMirror) WHERE guid = ?"
|
||
|
|
let localStatusSQL = "SELECT sync_status, is_deleted FROM \(TableBookmarksLocal) WHERE guid = ?"
|
||
|
|
let mirrorStatus = conn.executeQuery(mirrorStatusSQL, factory: overriddenFactory, withArgs: parentArgs)[0]
|
||
|
|
let localStatus = conn.executeQuery(localStatusSQL, factory: localStatusFactory, withArgs: parentArgs)[0]
|
||
|
|
|
||
|
|
let parentExistsInMirror = mirrorStatus != nil
|
||
|
|
let parentExistsLocally = localStatus != nil
|
||
|
|
|
||
|
|
// * Figure out if we were already overridden. We only want to re-clone
|
||
|
|
// if we weren't.
|
||
|
|
if !parentExistsLocally {
|
||
|
|
if !parentExistsInMirror {
|
||
|
|
throw DatabaseError(description: "Folder \(parent) doesn't exist in either mirror or local.")
|
||
|
|
}
|
||
|
|
// * Mark the parent folder as overridden if necessary.
|
||
|
|
// Overriding the parent involves copying the parent's structure, so that
|
||
|
|
// we can amend it, but also the parent's row itself so that we know it's
|
||
|
|
// changed.
|
||
|
|
try overrideParentMirror()
|
||
|
|
} else {
|
||
|
|
let (status, deleted) = localStatus!
|
||
|
|
if deleted {
|
||
|
|
throw DatabaseError(description: "Local folder \(parent) is deleted.")
|
||
|
|
}
|
||
|
|
|
||
|
|
// * Bump the overridden parent's modified time. We already copied its
|
||
|
|
// structure and values, and if it's in the local table it'll either
|
||
|
|
// already be New or Changed.
|
||
|
|
|
||
|
|
if let syncStatus = SyncStatus(rawValue: status) {
|
||
|
|
switch syncStatus {
|
||
|
|
case .synced:
|
||
|
|
log.debug("We don't expect folders to ever be marked as Synced.")
|
||
|
|
try bumpParentStatus(SyncStatus.changed.rawValue)
|
||
|
|
case .new:
|
||
|
|
fallthrough
|
||
|
|
case .changed:
|
||
|
|
// Leave it marked as new or changed, but bump the timestamp.
|
||
|
|
try bumpParentStatus(syncStatus.rawValue)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
log.warning("Local folder marked with unknown state \(status). This should never occur.")
|
||
|
|
try bumpParentStatus(SyncStatus.changed.rawValue)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Add the new bookmark as a child in the modified local structure.
|
||
|
|
// We always append the new row: after insertion, the new item will have the largest index.
|
||
|
|
let newIndex = "(SELECT (COALESCE(MAX(idx), -1) + 1) AS newIndex FROM \(TableBookmarksLocalStructure) WHERE parent = ?)"
|
||
|
|
let structureSQL = "INSERT INTO \(TableBookmarksLocalStructure) (parent, child, idx) " +
|
||
|
|
"VALUES (?, ?, \(newIndex))"
|
||
|
|
let structureArgs: Args = [parent, newGUID, parent]
|
||
|
|
|
||
|
|
try change(structureSQL, args: structureArgs, desc: "Error adding new item \(newGUID) to local structure.")
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Assumption: the provided folder GUID exists in either the local table or the mirror table.
|
||
|
|
*/
|
||
|
|
func insertBookmark(_ url: URL, title: String, favicon: Favicon?, intoFolder parent: GUID, withTitle parentTitle: String) -> Success {
|
||
|
|
log.debug("Inserting bookmark task on thread \(Thread.current)")
|
||
|
|
return db.transaction { conn -> Void in
|
||
|
|
try self.insertBookmarkInTransaction(url: url, title: title, favicon: favicon, intoFolder: parent, withTitle: parentTitle, conn: conn)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private extension BookmarkMirrorItem {
|
||
|
|
// Let's say the buffer structure table looks like this:
|
||
|
|
// ===============================
|
||
|
|
// | parent | child | idx |
|
||
|
|
// -------------------------------
|
||
|
|
// | aaaa | bbbb | 5 |
|
||
|
|
// | aaaa | cccc | 8 |
|
||
|
|
// | aaaa | dddd | 19 |
|
||
|
|
// ===============================
|
||
|
|
// And the self.children array has 5 children (2 new to insert).
|
||
|
|
// Then this function should be called with offset = 3 and nextIdx = 20
|
||
|
|
func getChildrenArgs(offset: Int = 0, nextIdx: Int = 0) -> [Args] {
|
||
|
|
// Only folders have children, and we manage roots ourselves.
|
||
|
|
if self.type != .folder ||
|
||
|
|
self.guid == BookmarkRoots.RootGUID {
|
||
|
|
return []
|
||
|
|
}
|
||
|
|
let parent = self.guid
|
||
|
|
var idx = nextIdx
|
||
|
|
return self.children?.suffix(from: offset).map { child in
|
||
|
|
let ret: Args = [parent, child, idx]
|
||
|
|
idx += 1
|
||
|
|
return ret
|
||
|
|
} ?? []
|
||
|
|
}
|
||
|
|
|
||
|
|
func getUpdateOrInsertArgs() -> Args {
|
||
|
|
let args: Args = [
|
||
|
|
self.type.rawValue ,
|
||
|
|
self.dateAdded,
|
||
|
|
self.serverModified,
|
||
|
|
self.isDeleted ? 1 : 0 ,
|
||
|
|
self.hasDupe ? 1 : 0,
|
||
|
|
self.parentID,
|
||
|
|
self.parentName ?? "", // Workaround for dirty data before Bug 1318414.
|
||
|
|
self.feedURI,
|
||
|
|
self.siteURI,
|
||
|
|
self.pos,
|
||
|
|
self.title,
|
||
|
|
self.description,
|
||
|
|
self.bookmarkURI,
|
||
|
|
self.tags,
|
||
|
|
self.keyword,
|
||
|
|
self.folderName,
|
||
|
|
self.queryID,
|
||
|
|
self.guid,
|
||
|
|
]
|
||
|
|
|
||
|
|
return args
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func deleteStructureForGUIDs(_ guids: [GUID], fromTable table: String, connection: SQLiteDBConnection, withMaxVars maxVars: Int=BrowserDB.MaxVariableNumber) throws {
|
||
|
|
log.debug("Deleting \(guids.count) parents from \(table).")
|
||
|
|
let chunks = chunk(guids, by: maxVars)
|
||
|
|
for chunk in chunks {
|
||
|
|
let delStructure = "DELETE FROM \(table) WHERE parent IN \(BrowserDB.varlist(chunk.count))"
|
||
|
|
|
||
|
|
let args: Args = chunk.flatMap { $0 }
|
||
|
|
try connection.executeChange(delStructure, withArgs: args)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func insertStructureIntoTable(_ table: String, connection: SQLiteDBConnection, children: [Args], maxVars: Int) throws {
|
||
|
|
if children.isEmpty {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Insert the new structure rows. This uses three vars per row.
|
||
|
|
let maxRowsPerInsert: Int = maxVars / 3
|
||
|
|
let chunks = chunk(children, by: maxRowsPerInsert)
|
||
|
|
for chunk in chunks {
|
||
|
|
log.verbose("Inserting \(chunk.count)…")
|
||
|
|
let childArgs: Args = chunk.flatMap { $0 } // Flatten [[a, b, c], [...]] into [a, b, c, ...].
|
||
|
|
let ins = "INSERT INTO \(table) (parent, child, idx) VALUES " +
|
||
|
|
Array<String>(repeating: "(?, ?, ?)", count: chunk.count).joined(separator: ", ")
|
||
|
|
log.debug("Inserting \(chunk.count) records (out of \(children.count)).")
|
||
|
|
try connection.executeChange(ins, withArgs: childArgs)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* This stores incoming records in a buffer.
|
||
|
|
* When appropriate, the buffer is merged with the mirror and local storage
|
||
|
|
* in the DB.
|
||
|
|
*/
|
||
|
|
open class SQLiteBookmarkBufferStorage: BookmarkBufferStorage {
|
||
|
|
let db: BrowserDB
|
||
|
|
|
||
|
|
public init(db: BrowserDB) {
|
||
|
|
self.db = db
|
||
|
|
}
|
||
|
|
|
||
|
|
open func synchronousBufferCount() -> Int? {
|
||
|
|
return self.db.runQuery("SELECT COUNT(*) FROM \(TableBookmarksBuffer)", args: nil, factory: IntFactory).value.successValue?[0]
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getUpstreamRecordCount() -> Deferred<Int?> {
|
||
|
|
let sql =
|
||
|
|
"SELECT (SELECT COUNT(*) FROM \(TableBookmarksBuffer)) + " +
|
||
|
|
"(SELECT COUNT(*) FROM \(TableBookmarksMirror) WHERE is_overridden = 0) " +
|
||
|
|
"AS c"
|
||
|
|
|
||
|
|
return self.db.runQuery(sql, args: nil, factory: IntFactory).bind { result in
|
||
|
|
return Deferred(value: result.successValue?[0]!)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove child records for any folders that've been deleted or are empty.
|
||
|
|
*/
|
||
|
|
fileprivate func deleteChildrenInTransactionWithGUIDs(_ guids: [GUID], connection: SQLiteDBConnection, withMaxVars maxVars: Int=BrowserDB.MaxVariableNumber) throws {
|
||
|
|
try deleteStructureForGUIDs(guids, fromTable: TableBookmarksBufferStructure, connection: connection, withMaxVars: maxVars)
|
||
|
|
}
|
||
|
|
|
||
|
|
open func isEmpty() -> Deferred<Maybe<Bool>> {
|
||
|
|
return self.db.queryReturnsNoResults("SELECT 1 FROM \(TableBookmarksBuffer)")
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* This is a little gnarly because our DB access layer is rough.
|
||
|
|
* Within a single transaction, we walk the list of items, attempting to update
|
||
|
|
* and inserting if the update failed. (TODO: batch the inserts!)
|
||
|
|
* Once we've added all of the records, we flatten all of their children
|
||
|
|
* into big arg lists and hard-update the structure table.
|
||
|
|
*/
|
||
|
|
open func applyRecords(_ records: [BookmarkMirrorItem]) -> Success {
|
||
|
|
return self.applyRecords(records, withMaxVars: BrowserDB.MaxVariableNumber)
|
||
|
|
}
|
||
|
|
|
||
|
|
open func applyRecords(_ records: [BookmarkMirrorItem], withMaxVars maxVars: Int) -> Success {
|
||
|
|
let guids = records.map { $0.guid }
|
||
|
|
let deleted = records.filter { $0.isDeleted }.map { $0.guid }
|
||
|
|
let values = records.map { $0.getUpdateOrInsertArgs() }
|
||
|
|
let children = records.filter { !$0.isDeleted }.flatMap { $0.getChildrenArgs() }
|
||
|
|
let folders = records.filter { $0.type == BookmarkNodeType.folder }.map { $0.guid }
|
||
|
|
|
||
|
|
return db.transaction { conn -> Void in
|
||
|
|
// These have the same values in the same order.
|
||
|
|
let update =
|
||
|
|
"UPDATE \(TableBookmarksBuffer) SET " +
|
||
|
|
"type = ?, date_added = ?, server_modified = ?, is_deleted = ?, " +
|
||
|
|
"hasDupe = ?, parentid = ?, parentName = ?, " +
|
||
|
|
"feedUri = ?, siteUri = ?, pos = ?, title = ?, " +
|
||
|
|
"description = ?, bmkUri = ?, tags = ?, keyword = ?, " +
|
||
|
|
"folderName = ?, queryId = ? " +
|
||
|
|
"WHERE guid = ?"
|
||
|
|
|
||
|
|
// We used to use INSERT OR IGNORE here, but it muffles legitimate errors. The only
|
||
|
|
// real use for that is/was to catch duplicates, but the UPDATE we run first should
|
||
|
|
// serve that purpose just as well.
|
||
|
|
let insert =
|
||
|
|
"INSERT INTO \(TableBookmarksBuffer) " +
|
||
|
|
"(type, date_added, server_modified, is_deleted, hasDupe, parentid, parentName, " +
|
||
|
|
"feedUri, siteUri, pos, title, description, bmkUri, tags, keyword, folderName, queryId, guid) " +
|
||
|
|
"VALUES " +
|
||
|
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||
|
|
|
||
|
|
for args in values {
|
||
|
|
try conn.executeChange(update, withArgs: args)
|
||
|
|
|
||
|
|
if conn.numberOfRowsModified > 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
try conn.executeChange(insert, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete existing structure for any folders we've seen. We always trust the folders,
|
||
|
|
// not the children's parent pointers, so we do this here: we'll insert their current
|
||
|
|
// children right after, when we process the child structure rows.
|
||
|
|
// We only drop the child structure for deleted folders, not the record itself.
|
||
|
|
// Deleted records stay in the buffer table so that we know about the deletion
|
||
|
|
// when we do a real sync!
|
||
|
|
|
||
|
|
log.debug("\(folders.count) folders and \(deleted.count) deleted maybe-folders to drop from buffer structure table.")
|
||
|
|
|
||
|
|
try self.deleteChildrenInTransactionWithGUIDs(folders + deleted, connection: conn)
|
||
|
|
|
||
|
|
// (Re-)insert children in chunks.
|
||
|
|
log.debug("Inserting \(children.count) children.")
|
||
|
|
try insertStructureIntoTable(TableBookmarksBufferStructure, connection: conn, children: children, maxVars: maxVars)
|
||
|
|
|
||
|
|
// Drop pending deletions of items we just received.
|
||
|
|
// In practice that means we have made the choice that we will always
|
||
|
|
// discard local deletions if there was a modification or a deletion made remotely.
|
||
|
|
log.debug("Deleting \(guids.count) pending deletions.")
|
||
|
|
let chunks = chunk(guids, by: BrowserDB.MaxVariableNumber)
|
||
|
|
for chunk in chunks {
|
||
|
|
let delPendingDeletions = "DELETE FROM \(TablePendingBookmarksDeletions) WHERE id IN \(BrowserDB.varlist(chunk.count))"
|
||
|
|
|
||
|
|
let args: Args = chunk.flatMap { $0 }
|
||
|
|
try conn.executeChange(delPendingDeletions, withArgs: args)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
open func doneApplyingRecordsAfterDownload() -> Success {
|
||
|
|
self.db.checkpoint()
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension SQLiteBookmarkBufferStorage: BufferItemSource {
|
||
|
|
public func getBufferItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.db.getMirrorItemFromTable(TableBookmarksBuffer, guid: guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.db.getMirrorItemsFromTable(TableBookmarksBuffer, guids: guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferChildrenGUIDsForParent(_ guid: GUID) -> Deferred<Maybe<[GUID]>> {
|
||
|
|
return self.getChildrenGUIDsOf(guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
log.debug("Not implemented.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension BrowserDB {
|
||
|
|
fileprivate func getMirrorItemFromTable(_ table: String, guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
let args: Args = [guid]
|
||
|
|
let sql = "SELECT * FROM \(table) WHERE guid = ?"
|
||
|
|
return self.runQuery(sql, args: args, factory: BookmarkFactory.mirrorItemFactory)
|
||
|
|
>>== { cursor in
|
||
|
|
guard let item = cursor[0] else {
|
||
|
|
return deferMaybe(DatabaseError(description: "Expected to find \(guid) in \(table) but did not."))
|
||
|
|
}
|
||
|
|
return deferMaybe(item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fileprivate func getMirrorItemsFromTable<T: Collection>(_ table: String, guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
var acc: [GUID: BookmarkMirrorItem] = [:]
|
||
|
|
func accumulate(_ args: Args) -> Success {
|
||
|
|
let sql = "SELECT * FROM \(table) WHERE guid IN \(BrowserDB.varlist(args.count))"
|
||
|
|
return self.runQuery(sql, args: args, factory: BookmarkFactory.mirrorItemFactory)
|
||
|
|
>>== { cursor in
|
||
|
|
cursor.forEach { row in
|
||
|
|
guard let row = row else { return } // Oh, Cursor.
|
||
|
|
acc[row.guid] = row
|
||
|
|
}
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
if args.count < BrowserDB.MaxVariableNumber {
|
||
|
|
return accumulate(args) >>> { deferMaybe(acc) }
|
||
|
|
}
|
||
|
|
|
||
|
|
let chunks = chunk(args, by: BrowserDB.MaxVariableNumber)
|
||
|
|
return walk(chunks.lazy.map { Array($0) }, f: accumulate)
|
||
|
|
>>> { deferMaybe(acc) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: BookmarkBufferStorage {
|
||
|
|
public func synchronousBufferCount() -> Int? {
|
||
|
|
return self.buffer.synchronousBufferCount()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getUpstreamRecordCount() -> Deferred<Int?> {
|
||
|
|
return self.buffer.getUpstreamRecordCount()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func isEmpty() -> Deferred<Maybe<Bool>> {
|
||
|
|
return self.buffer.isEmpty()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func applyRecords(_ records: [BookmarkMirrorItem]) -> Success {
|
||
|
|
return self.buffer.applyRecords(records)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func doneApplyingRecordsAfterDownload() -> Success {
|
||
|
|
// It doesn't really matter which one we checkpoint -- they're both backed by the same DB.
|
||
|
|
return self.buffer.doneApplyingRecordsAfterDownload()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func validate() -> Success {
|
||
|
|
return self.buffer.validate()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferedDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>> {
|
||
|
|
return self.buffer.getBufferedDeletions()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func applyBufferCompletionOp(_ op: BufferCompletionOp, itemSources: ItemSources) -> Success {
|
||
|
|
return self.buffer.applyBufferCompletionOp(op, itemSources: itemSources)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: BufferItemSource {
|
||
|
|
public func getBufferItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.buffer.getBufferItemWithGUID(guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.buffer.getBufferItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferChildrenGUIDsForParent(_ guid: GUID) -> Deferred<Maybe<[GUID]>> {
|
||
|
|
return self.buffer.getChildrenGUIDsOf(guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
return self.buffer.prefetchBufferItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: MirrorItemSource {
|
||
|
|
public func getMirrorItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.local.getMirrorItemWithGUID(guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.local.getMirrorItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
return self.local.prefetchMirrorItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: LocalItemSource {
|
||
|
|
public func getLocalItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
|
||
|
|
return self.local.getLocalItemWithGUID(guid)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
|
||
|
|
return self.local.getLocalItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func prefetchLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
|
||
|
|
return self.local.prefetchLocalItemsWithGUIDs(guids)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: ShareToDestination {
|
||
|
|
public func shareItem(_ item: ShareItem) -> Success {
|
||
|
|
return self.local.shareItem(item)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Not actually implementing SyncableBookmarks, just a utility for MergedSQLiteBookmarks to do so.
|
||
|
|
extension SQLiteBookmarks {
|
||
|
|
public func isUnchanged() -> Deferred<Maybe<Bool>> {
|
||
|
|
return self.db.queryReturnsNoResults("SELECT 1 FROM \(TableBookmarksLocal)")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Retrieve all the local bookmarks that are not present remotely in order to avoid merge logic later.
|
||
|
|
public func getLocalBookmarksModifications(limit: Int) -> Deferred<Maybe<(deletions: [GUID], additions: [BookmarkMirrorItem])>> {
|
||
|
|
let deletionsQuery =
|
||
|
|
"SELECT id FROM \(TablePendingBookmarksDeletions) " +
|
||
|
|
"LIMIT ?"
|
||
|
|
let deletionsArgs: Args = [limit]
|
||
|
|
|
||
|
|
return db.runQuery(deletionsQuery, args: deletionsArgs, factory: StringFactory) >>== {
|
||
|
|
let deletedGUIDs = $0.asArray()
|
||
|
|
let newLimit = limit - deletedGUIDs.count
|
||
|
|
|
||
|
|
let additionsQuery =
|
||
|
|
"SELECT * FROM \(TableBookmarksLocal) AS bookmarks " +
|
||
|
|
"WHERE type = \(BookmarkNodeType.bookmark.rawValue) AND sync_status = \(SyncStatus.new.rawValue) AND parentID = ? " +
|
||
|
|
"AND NOT EXISTS (SELECT 1 FROM \(TableBookmarksBuffer) buf WHERE buf.guid = bookmarks.guid) " +
|
||
|
|
"LIMIT ?"
|
||
|
|
let additionsArgs: Args = [BookmarkRoots.MobileFolderGUID, newLimit]
|
||
|
|
|
||
|
|
return self.db.runQuery(additionsQuery, args: additionsArgs, factory: BookmarkFactory.mirrorItemFactory) >>== {
|
||
|
|
return deferMaybe((deletedGUIDs, $0.asArray()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getLocalDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>> {
|
||
|
|
let sql =
|
||
|
|
"SELECT guid, local_modified FROM \(TableBookmarksLocal) " +
|
||
|
|
"WHERE is_deleted = 1"
|
||
|
|
|
||
|
|
return self.db.runQuery(sql, args: nil, factory: { ($0["guid"] as! GUID, $0.getTimestamp("local_modified")!) })
|
||
|
|
>>== { deferMaybe($0.asArray()) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks: SyncableBookmarks {
|
||
|
|
public func isUnchanged() -> Deferred<Maybe<Bool>> {
|
||
|
|
return self.local.isUnchanged()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getLocalBookmarksModifications(limit: Int) -> Deferred<Maybe<(deletions: [GUID], additions: [BookmarkMirrorItem])>> {
|
||
|
|
return self.local.getLocalBookmarksModifications(limit: limit)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getLocalDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>> {
|
||
|
|
return self.local.getLocalDeletions()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func treeForMirror() -> Deferred<Maybe<BookmarkTree>> {
|
||
|
|
return self.local.treeForMirror()
|
||
|
|
}
|
||
|
|
|
||
|
|
public func treesForEdges() -> Deferred<Maybe<(local: BookmarkTree, buffer: BookmarkTree)>> {
|
||
|
|
return self.local.treeForLocal() >>== { local in
|
||
|
|
return self.local.treeForBuffer() >>== { buffer in
|
||
|
|
return deferMaybe((local: local, buffer: buffer))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Validation of buffer contents.
|
||
|
|
|
||
|
|
// Note that these queries tend to not have exceptions for deletions.
|
||
|
|
// That's because a record can't be deleted in isolation -- if it's
|
||
|
|
// deleted its parent should be changed, too -- and so our views will
|
||
|
|
// correctly reflect that. We'll have updated rows in the structure table,
|
||
|
|
// and updated values -- and thus a complete override -- for the parent and
|
||
|
|
// the deleted child.
|
||
|
|
private let allBufferStructuresReferToRecords = [
|
||
|
|
"SELECT s.child AS pointee, s.parent AS pointer FROM",
|
||
|
|
ViewBookmarksBufferStructureOnMirror,
|
||
|
|
"s LEFT JOIN",
|
||
|
|
ViewBookmarksBufferOnMirror,
|
||
|
|
"b ON b.guid = s.child WHERE b.guid IS NULL",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
private let allNonDeletedBufferRecordsAreInStructure = [
|
||
|
|
"SELECT b.guid AS missing, b.parentid AS parent FROM",
|
||
|
|
ViewBookmarksBufferOnMirror, "b LEFT JOIN",
|
||
|
|
ViewBookmarksBufferStructureOnMirror,
|
||
|
|
"s ON b.guid = s.child WHERE s.child IS NULL AND",
|
||
|
|
"b.is_deleted IS 0 AND b.parentid IS NOT '\(BookmarkRoots.RootGUID)'",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
private let allRecordsAreChildrenOnce = [
|
||
|
|
"SELECT s.child FROM",
|
||
|
|
ViewBookmarksBufferStructureOnMirror,
|
||
|
|
"s INNER JOIN (",
|
||
|
|
"SELECT child, COUNT(*) AS dupes FROM", ViewBookmarksBufferStructureOnMirror,
|
||
|
|
"GROUP BY child HAVING dupes > 1",
|
||
|
|
") i ON s.child = i.child",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
private let bufferParentidMatchesStructure = [
|
||
|
|
"SELECT b.guid, b.parentid, s.parent, s.child, s.idx FROM",
|
||
|
|
TableBookmarksBuffer, "b JOIN", TableBookmarksBufferStructure,
|
||
|
|
"s ON b.guid = s.child WHERE b.is_deleted IS 0 AND b.parentid IS NOT s.parent",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
public enum BufferInconsistency {
|
||
|
|
case missingValues
|
||
|
|
case missingStructure
|
||
|
|
case overlappingStructure
|
||
|
|
case parentIDDisagreement
|
||
|
|
|
||
|
|
public var query: String {
|
||
|
|
switch self {
|
||
|
|
case .missingValues:
|
||
|
|
return allBufferStructuresReferToRecords
|
||
|
|
case .missingStructure:
|
||
|
|
return allNonDeletedBufferRecordsAreInStructure
|
||
|
|
case .overlappingStructure:
|
||
|
|
return allRecordsAreChildrenOnce
|
||
|
|
case .parentIDDisagreement:
|
||
|
|
return bufferParentidMatchesStructure
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public var trackingEvent: String {
|
||
|
|
switch self {
|
||
|
|
case .missingValues:
|
||
|
|
return "missingvalues"
|
||
|
|
case .missingStructure:
|
||
|
|
return "missingstructure"
|
||
|
|
case .overlappingStructure:
|
||
|
|
return "overlappingstructure"
|
||
|
|
case .parentIDDisagreement:
|
||
|
|
return "parentiddisagreement"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public var description: String {
|
||
|
|
switch self {
|
||
|
|
case .missingValues:
|
||
|
|
return "Not all buffer structures refer to records."
|
||
|
|
case .missingStructure:
|
||
|
|
return "Not all buffer records are in structure."
|
||
|
|
case .overlappingStructure:
|
||
|
|
return "Some buffer structures refer to the same records."
|
||
|
|
case .parentIDDisagreement:
|
||
|
|
return "Some buffer record parent IDs don't match structure."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var idsFactory: (SDRow) -> [String] {
|
||
|
|
switch self {
|
||
|
|
case .missingValues:
|
||
|
|
return self.getConcernedIDs(colNames: ["pointee", "pointer"])
|
||
|
|
case .missingStructure:
|
||
|
|
return self.getConcernedIDs(colNames: ["missing", "parent"])
|
||
|
|
case .overlappingStructure:
|
||
|
|
return self.getConcernedIDs(colNames: ["child"])
|
||
|
|
case .parentIDDisagreement:
|
||
|
|
return self.getConcernedIDs(colNames: ["guid", "parentid", "parent", "child"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func getConcernedIDs(colNames: [String]) -> ((SDRow) -> [String]) {
|
||
|
|
return { (row: SDRow) in
|
||
|
|
colNames.flatMap({ row[$0] as? String})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public static let all: [BufferInconsistency] = [.missingValues, .missingStructure, .overlappingStructure, .parentIDDisagreement]
|
||
|
|
}
|
||
|
|
|
||
|
|
public struct BufferInvalidError: MaybeErrorType {
|
||
|
|
public let description = "Bookmarks buffer contains invalid data"
|
||
|
|
public let inconsistencies: [BufferInconsistency: [GUID]]
|
||
|
|
public let validationDuration: Int64
|
||
|
|
|
||
|
|
public init(inconsistencies: [BufferInconsistency: [GUID]], validationDuration: Int64) {
|
||
|
|
self.inconsistencies = inconsistencies
|
||
|
|
self.validationDuration = validationDuration
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension SQLiteBookmarkBufferStorage {
|
||
|
|
public func validate() -> Success {
|
||
|
|
func idsFor(inconsistency inc: BufferInconsistency) -> () -> Deferred<Maybe<(type: BufferInconsistency, ids: [String])>> {
|
||
|
|
return {
|
||
|
|
self.db.runQuery(inc.query, args: nil, factory: inc.idsFactory)
|
||
|
|
>>== { deferMaybe((type: inc, ids: $0.asArray().reduce([], +))) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let start = Date.now()
|
||
|
|
let ops = BufferInconsistency.all.map { idsFor(inconsistency: $0) }
|
||
|
|
return accumulate(ops) >>== { results in
|
||
|
|
var inconsistencies = [BufferInconsistency: [GUID]]()
|
||
|
|
results.forEach { type, ids in
|
||
|
|
guard !ids.isEmpty else { return }
|
||
|
|
inconsistencies[type] = ids
|
||
|
|
}
|
||
|
|
|
||
|
|
return inconsistencies.isEmpty ? succeed() :
|
||
|
|
deferMaybe(BufferInvalidError(inconsistencies: inconsistencies, validationDuration: Int64(Date.now() - start)))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getBufferedDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>> {
|
||
|
|
let sql =
|
||
|
|
"SELECT guid, server_modified FROM \(TableBookmarksBuffer) " +
|
||
|
|
"WHERE is_deleted = 1"
|
||
|
|
|
||
|
|
return self.db.runQuery(sql, args: nil, factory: { ($0["guid"] as! GUID, $0.getTimestamp("server_modified")!) })
|
||
|
|
>>== { deferMaybe($0.asArray()) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension SQLiteBookmarks {
|
||
|
|
fileprivate func structureQueryForTable(_ table: String, structure: String) -> String {
|
||
|
|
// We use a subquery so we get back rows for overridden folders, even when their
|
||
|
|
// children aren't in the shadowing table.
|
||
|
|
let sql =
|
||
|
|
"SELECT s.parent AS parent, s.child AS child, COALESCE(m.type, -1) AS type " +
|
||
|
|
"FROM \(structure) s LEFT JOIN \(table) m ON s.child = m.guid AND m.is_deleted IS NOT 1 " +
|
||
|
|
"ORDER BY s.parent, s.idx ASC"
|
||
|
|
return sql
|
||
|
|
}
|
||
|
|
|
||
|
|
fileprivate func remainderQueryForTable(_ table: String, structure: String) -> String {
|
||
|
|
// This gives us value rows that aren't children of a folder.
|
||
|
|
// You might notice that these complementary LEFT JOINs are how you
|
||
|
|
// express a FULL OUTER JOIN in sqlite.
|
||
|
|
// We exclude folders here because if they have children, they'll appear
|
||
|
|
// in the structure query, and if they don't, they'll appear in the bottom
|
||
|
|
// half of this query.
|
||
|
|
let sql =
|
||
|
|
"SELECT m.guid AS guid, m.type AS type " +
|
||
|
|
"FROM \(table) m LEFT JOIN \(structure) s ON s.child = m.guid " +
|
||
|
|
"WHERE m.is_deleted IS NOT 1 AND m.type IS NOT \(BookmarkNodeType.folder.rawValue) AND s.child IS NULL " +
|
||
|
|
|
||
|
|
"UNION ALL " +
|
||
|
|
|
||
|
|
// This gives us folders with no children.
|
||
|
|
"SELECT m.guid AS guid, m.type AS type " +
|
||
|
|
"FROM \(table) m LEFT JOIN \(structure) s ON s.parent = m.guid " +
|
||
|
|
"WHERE m.is_deleted IS NOT 1 AND m.type IS \(BookmarkNodeType.folder.rawValue) AND s.parent IS NULL "
|
||
|
|
|
||
|
|
return sql
|
||
|
|
}
|
||
|
|
|
||
|
|
fileprivate func statusQueryForTable(_ table: String) -> String {
|
||
|
|
return "SELECT guid, is_deleted FROM \(table)"
|
||
|
|
}
|
||
|
|
|
||
|
|
fileprivate func treeForTable(_ table: String, structure: String, alwaysIncludeRoots includeRoots: Bool) -> Deferred<Maybe<BookmarkTree>> {
|
||
|
|
// The structure query doesn't give us non-structural rows -- that is, if you
|
||
|
|
// make a value-only change to a record, and it's not otherwise mentioned by
|
||
|
|
// way of being a child of a structurally modified folder, it won't appear here at all.
|
||
|
|
// It also doesn't give us empty folders, because they have no children.
|
||
|
|
// We run a separate query to get those.
|
||
|
|
let structureSQL = self.structureQueryForTable(table, structure: structure)
|
||
|
|
let remainderSQL = self.remainderQueryForTable(table, structure: structure)
|
||
|
|
let statusSQL = self.statusQueryForTable(table)
|
||
|
|
|
||
|
|
func structureFactory(_ row: SDRow) -> StructureRow {
|
||
|
|
let typeCode = row["type"] as! Int
|
||
|
|
let type = BookmarkNodeType(rawValue: typeCode) // nil if typeCode is invalid (e.g., -1).
|
||
|
|
return (parent: row["parent"] as! GUID, child: row["child"] as! GUID, type: type)
|
||
|
|
}
|
||
|
|
|
||
|
|
func nonStructureFactory(_ row: SDRow) -> BookmarkTreeNode {
|
||
|
|
let guid = row["guid"] as! GUID
|
||
|
|
let typeCode = row["type"] as! Int
|
||
|
|
if let type = BookmarkNodeType(rawValue: typeCode) {
|
||
|
|
switch type {
|
||
|
|
case .folder:
|
||
|
|
return BookmarkTreeNode.folder(guid: guid, children: [])
|
||
|
|
default:
|
||
|
|
return BookmarkTreeNode.nonFolder(guid: guid)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
return BookmarkTreeNode.unknown(guid: guid)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func statusFactory(_ row: SDRow) -> (GUID, Bool) {
|
||
|
|
return (row["guid"] as! GUID, row.getBoolean("is_deleted"))
|
||
|
|
}
|
||
|
|
|
||
|
|
return self.db.runQuery(statusSQL, args: nil, factory: statusFactory)
|
||
|
|
>>== { cursor in
|
||
|
|
var deleted = Set<GUID>()
|
||
|
|
var modified = Set<GUID>()
|
||
|
|
cursor.forEach { pair in
|
||
|
|
let (guid, del) = pair! // Oh, cursor.
|
||
|
|
if del {
|
||
|
|
deleted.insert(guid)
|
||
|
|
} else {
|
||
|
|
modified.insert(guid)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return self.db.runQuery(remainderSQL, args: nil, factory: nonStructureFactory)
|
||
|
|
>>== { cursor in
|
||
|
|
let nonFoldersAndEmptyFolders = cursor.asArray()
|
||
|
|
return self.db.runQuery(structureSQL, args: nil, factory: structureFactory)
|
||
|
|
>>== { cursor in
|
||
|
|
let structureRows = cursor.asArray()
|
||
|
|
let tree = BookmarkTree.mappingsToTreeForStructureRows(structureRows, withNonFoldersAndEmptyFolders: nonFoldersAndEmptyFolders, withDeletedRecords: deleted, modifiedRecords: modified, alwaysIncludeRoots: includeRoots)
|
||
|
|
return deferMaybe(tree)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public func treeForMirror() -> Deferred<Maybe<BookmarkTree>> {
|
||
|
|
return self.treeForTable(TableBookmarksMirror, structure: TableBookmarksMirrorStructure, alwaysIncludeRoots: true)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func treeForBuffer() -> Deferred<Maybe<BookmarkTree>> {
|
||
|
|
return self.treeForTable(TableBookmarksBuffer, structure: TableBookmarksBufferStructure, alwaysIncludeRoots: false)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func treeForLocal() -> Deferred<Maybe<BookmarkTree>> {
|
||
|
|
return self.treeForTable(TableBookmarksLocal, structure: TableBookmarksLocalStructure, alwaysIncludeRoots: false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Applying merge operations.
|
||
|
|
|
||
|
|
public extension SQLiteBookmarkBufferStorage {
|
||
|
|
public func applyBufferCompletionOp(_ op: BufferCompletionOp, itemSources: ItemSources) -> Success {
|
||
|
|
log.debug("Marking buffer rows as applied.")
|
||
|
|
if op.isNoOp {
|
||
|
|
log.debug("Nothing to do.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
|
||
|
|
var queries: [(sql: String, args: Args?)] = []
|
||
|
|
op.processedBufferChanges.subsetsOfSize(BrowserDB.MaxVariableNumber).forEach { guids in
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
queries.append((sql: "DELETE FROM \(TableBookmarksBufferStructure) WHERE parent IN \(varlist)", args: args))
|
||
|
|
queries.append((sql: "DELETE FROM \(TableBookmarksBuffer) WHERE guid IN \(varlist)", args: args))
|
||
|
|
}
|
||
|
|
|
||
|
|
return self.db.run(queries)
|
||
|
|
}
|
||
|
|
|
||
|
|
public func getChildrenGUIDsOf(_ guid: GUID) -> Deferred<Maybe<[GUID]>> {
|
||
|
|
let sql = "SELECT child FROM \(TableBookmarksBufferStructure) WHERE parent = ? ORDER BY idx ASC"
|
||
|
|
let args: Args = [guid]
|
||
|
|
|
||
|
|
return self.db.runQuery(sql, args: args, factory: StringFactory) >>== { deferMaybe($0.asArray()) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension MergedSQLiteBookmarks {
|
||
|
|
public func applyLocalOverrideCompletionOp(_ op: LocalOverrideCompletionOp, itemSources: ItemSources) -> Success {
|
||
|
|
log.debug("Applying local op to merged.")
|
||
|
|
if op.isNoOp {
|
||
|
|
log.debug("Nothing to do.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
|
||
|
|
// This is a little tortured because we want it all to happen in a single transaction.
|
||
|
|
// We walk through the accrued work items, applying them in the right order (e.g., structure
|
||
|
|
// then value). If at any point we fail, we abort, roll back the transaction by throwing.
|
||
|
|
return local.db.transaction { conn -> Void in
|
||
|
|
// So we can trample the DB in any order.
|
||
|
|
try conn.executeChange("PRAGMA defer_foreign_keys = ON")
|
||
|
|
|
||
|
|
log.debug("Deleting \(op.mirrorItemsToDelete.count) mirror items.")
|
||
|
|
|
||
|
|
try op.mirrorItemsToDelete.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
|
||
|
|
let sqlMirrorStructure = "DELETE FROM \(TableBookmarksMirrorStructure) WHERE parent IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlMirrorStructure, withArgs: args)
|
||
|
|
|
||
|
|
let sqlMirror = "DELETE FROM \(TableBookmarksMirror) WHERE guid IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlMirror, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Copy from other tables for simplicity.
|
||
|
|
// Do this *before* we throw away local and buffer changes!
|
||
|
|
// This is one reason why the local override step needs to be processed before the buffer is cleared.
|
||
|
|
try op.mirrorValuesToCopyFromBuffer.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
let copySQL = [
|
||
|
|
"INSERT OR REPLACE INTO \(TableBookmarksMirror)",
|
||
|
|
"(guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId, server_modified)",
|
||
|
|
"SELECT guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId, server_modified",
|
||
|
|
"FROM \(TableBookmarksBuffer)",
|
||
|
|
"WHERE guid IN",
|
||
|
|
varlist
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
try conn.executeChange(copySQL, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
try op.mirrorValuesToCopyFromLocal.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
let copySQL = [
|
||
|
|
"INSERT OR REPLACE INTO \(TableBookmarksMirror)",
|
||
|
|
"(guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId, faviconID, server_modified)",
|
||
|
|
"SELECT guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId, faviconID,",
|
||
|
|
|
||
|
|
// This will be fixed up in batches after the initial copy.
|
||
|
|
"0 AS server_modified",
|
||
|
|
"FROM \(TableBookmarksLocal) WHERE guid IN",
|
||
|
|
varlist
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
try conn.executeChange(copySQL, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
try op.modifiedTimes.forEach { (time, guids) in
|
||
|
|
// This will never be too big: we upload in chunks
|
||
|
|
// smaller than 999!
|
||
|
|
precondition(guids.count < BrowserDB.MaxVariableNumber)
|
||
|
|
|
||
|
|
log.debug("Swizzling server modified time to \(time) for \(guids.count) GUIDs.")
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
let updateSQL = [
|
||
|
|
"UPDATE \(TableBookmarksMirror) SET server_modified = \(time)",
|
||
|
|
"WHERE guid IN",
|
||
|
|
varlist,
|
||
|
|
].joined(separator: " ")
|
||
|
|
try conn.executeChange(updateSQL, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
log.debug("Marking \(op.processedLocalChanges.count) local changes as processed.")
|
||
|
|
try op.processedLocalChanges.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
|
||
|
|
let sqlLocalStructure = "DELETE FROM \(TableBookmarksLocalStructure) WHERE parent IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlLocalStructure, withArgs: args)
|
||
|
|
|
||
|
|
let sqlLocal = "DELETE FROM \(TableBookmarksLocal) WHERE guid IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlLocal, withArgs: args)
|
||
|
|
|
||
|
|
// If the values change, we'll handle those elsewhere, but at least we need to mark these as non-overridden.
|
||
|
|
let sqlMirrorOverride = "UPDATE \(TableBookmarksMirror) SET is_overridden = 0 WHERE guid IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlMirrorOverride, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
if !op.mirrorItemsToUpdate.isEmpty {
|
||
|
|
let updateSQL = [
|
||
|
|
"UPDATE \(TableBookmarksMirror) SET",
|
||
|
|
"type = ?, date_added = ?, server_modified = ?, is_deleted = ?,",
|
||
|
|
"hasDupe = ?, parentid = ?, parentName = ?,",
|
||
|
|
"feedUri = ?, siteUri = ?, pos = ?, title = ?,",
|
||
|
|
"description = ?, bmkUri = ?, tags = ?, keyword = ?,",
|
||
|
|
"folderName = ?, queryId = ?, is_overridden = 0",
|
||
|
|
"WHERE guid = ?",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
try op.mirrorItemsToUpdate.forEach { (_, mirrorItem) in
|
||
|
|
let args = mirrorItem.getUpdateOrInsertArgs()
|
||
|
|
try conn.executeChange(updateSQL, withArgs: args)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !op.mirrorItemsToInsert.isEmpty {
|
||
|
|
let insertSQL = [
|
||
|
|
"INSERT OR IGNORE INTO \(TableBookmarksMirror) (",
|
||
|
|
"type, date_added, server_modified, is_deleted,",
|
||
|
|
"hasDupe, parentid, parentName,",
|
||
|
|
"feedUri, siteUri, pos, title,",
|
||
|
|
"description, bmkUri, tags, keyword,",
|
||
|
|
"folderName, queryId, guid",
|
||
|
|
"VALUES",
|
||
|
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
try op.mirrorItemsToInsert.forEach { (_, mirrorItem) in
|
||
|
|
let args = mirrorItem.getUpdateOrInsertArgs()
|
||
|
|
try conn.executeChange(insertSQL, withArgs: args)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !op.mirrorStructures.isEmpty {
|
||
|
|
let structureRows =
|
||
|
|
op.mirrorStructures.flatMap { (parent, children) in
|
||
|
|
return children.enumerated().map { (idx, child) -> Args in
|
||
|
|
let vals: Args = [parent, child, idx]
|
||
|
|
return vals
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let parents = op.mirrorStructures.map { $0.0 }
|
||
|
|
try deleteStructureForGUIDs(parents, fromTable: TableBookmarksMirrorStructure, connection: conn)
|
||
|
|
try insertStructureIntoTable(TableBookmarksMirrorStructure, connection: conn, children: structureRows, maxVars: BrowserDB.MaxVariableNumber)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public func applyBufferUpdatedCompletionOp(_ op: BufferUpdatedCompletionOp) -> Success {
|
||
|
|
log.debug("Applying buffer updates.")
|
||
|
|
if op.isNoOp {
|
||
|
|
log.debug("Nothing to do.")
|
||
|
|
return succeed()
|
||
|
|
}
|
||
|
|
|
||
|
|
return local.db.transaction { conn -> Void in
|
||
|
|
// So we can trample the DB in any order.
|
||
|
|
try conn.executeChange("PRAGMA defer_foreign_keys = ON")
|
||
|
|
|
||
|
|
try op.deletedValues.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
|
||
|
|
// This will leave the idx column sparse for the buffer children.
|
||
|
|
let sqlBufferStructure = "DELETE FROM \(TableBookmarksBufferStructure) WHERE child IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlBufferStructure, withArgs: args)
|
||
|
|
|
||
|
|
// This will also delete items from the pending deletions table on cascade.
|
||
|
|
let sqlBuffer = "DELETE FROM \(TableBookmarksBuffer) WHERE guid IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlBuffer, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
let insertSQL = [
|
||
|
|
"INSERT OR IGNORE INTO \(TableBookmarksBuffer) (",
|
||
|
|
"type, date_added, server_modified, is_deleted,",
|
||
|
|
"hasDupe, parentid, parentName,",
|
||
|
|
"feedUri, siteUri, pos, title,",
|
||
|
|
"description, bmkUri, tags, keyword,",
|
||
|
|
"folderName, queryId, guid)",
|
||
|
|
"VALUES",
|
||
|
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
|
|
].joined(separator: " ")
|
||
|
|
|
||
|
|
let args = op.mobileRoot.getUpdateOrInsertArgs()
|
||
|
|
try conn.executeChange(insertSQL, withArgs: args)
|
||
|
|
|
||
|
|
// We update server_modified in another operation because the first statement is an INSERT OR IGNORE
|
||
|
|
// and we can't turn that into a INSERT OR REPLACE because this will cascade delete children
|
||
|
|
// in the buffer structure table.
|
||
|
|
let updateMobileRootTimeSQL = [
|
||
|
|
"UPDATE \(TableBookmarksBuffer) SET server_modified = \(op.modifiedTime)",
|
||
|
|
"WHERE guid = ?",
|
||
|
|
].joined(separator: " ")
|
||
|
|
try conn.executeChange(updateMobileRootTimeSQL, withArgs: [op.mobileRoot.guid])
|
||
|
|
|
||
|
|
try op.bufferValuesToMoveFromLocal.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
|
||
|
|
let copySQL = [
|
||
|
|
"INSERT OR REPLACE INTO \(TableBookmarksBuffer)",
|
||
|
|
"(guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId, server_modified)",
|
||
|
|
"SELECT guid, type, date_added, parentid, parentName, feedUri, siteUri, pos, title, description,",
|
||
|
|
"bmkUri, tags, keyword, folderName, queryId,",
|
||
|
|
"\(op.modifiedTime) AS server_modified",
|
||
|
|
"FROM \(TableBookmarksLocal) WHERE guid IN",
|
||
|
|
varlist
|
||
|
|
].joined(separator: " ")
|
||
|
|
try conn.executeChange(copySQL, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
try op.bufferValuesToMoveFromLocal.withSubsetsOfSize(BrowserDB.MaxVariableNumber) { guids in
|
||
|
|
let args: Args = guids.map { $0 }
|
||
|
|
let varlist = BrowserDB.varlist(guids.count)
|
||
|
|
|
||
|
|
// This will leave the idx column sparse for the local children.
|
||
|
|
let sqlLocalStructure = "DELETE FROM \(TableBookmarksLocalStructure) WHERE child IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlLocalStructure, withArgs: args)
|
||
|
|
|
||
|
|
let sqlLocal = "DELETE FROM \(TableBookmarksLocal) WHERE guid IN \(varlist)"
|
||
|
|
try conn.executeChange(sqlLocal, withArgs: args)
|
||
|
|
}
|
||
|
|
|
||
|
|
if op.bufferValuesToMoveFromLocal.count > 0 {
|
||
|
|
guard let allChildren = op.mobileRoot.children else {
|
||
|
|
let err = DatabaseError(description: "Absent mobileRoot children. Aborting.")
|
||
|
|
log.error("applyBufferUpdatedCompletionOp(_:) encountered error: \(err.localizedDescription)")
|
||
|
|
throw err
|
||
|
|
}
|
||
|
|
let offset = allChildren.count - op.bufferValuesToMoveFromLocal.count
|
||
|
|
|
||
|
|
let sqlNextIdx = "SELECT (COALESCE(MAX(idx), -1) + 1) AS newIndex FROM \(TableBookmarksBufferStructure) WHERE parent = ?"
|
||
|
|
let nextIdxArgs: Args = [BookmarkRoots.MobileFolderGUID]
|
||
|
|
let nextIdx = conn.executeQuery(sqlNextIdx, factory: IntFactory, withArgs: nextIdxArgs).asArray()[0]
|
||
|
|
|
||
|
|
let toInsertArgs = Array(op.mobileRoot.getChildrenArgs(offset: offset, nextIdx: nextIdx))
|
||
|
|
try insertStructureIntoTable(TableBookmarksBufferStructure, connection: conn, children: toInsertArgs, maxVars: BrowserDB.MaxVariableNumber)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|