Dactyloidae/mobile/ios/Storage/Bookmarks/BookmarksModel.swift
2026-06-26 21:04:09 -07:00

417 lines
13 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
private let log = Logger.syncLogger
/**
* The kinda-immutable base interface for bookmarks and folders.
*/
open class BookmarkNode {
open var id: Int?
open let guid: GUID
open let title: String
open let isEditable: Bool
open var favicon: Favicon?
init(guid: GUID, title: String, isEditable: Bool=false) {
self.guid = guid
self.title = title
self.isEditable = isEditable
}
open var canDelete: Bool {
return self.isEditable
}
}
open class BookmarkSeparator: BookmarkNode {
init(guid: GUID) {
super.init(guid: guid, title: "")
}
}
/**
* An immutable item representing a bookmark.
*
* To modify this, issue changes against the backing store and get an updated model.
*/
open class BookmarkItem: BookmarkNode {
open let url: String!
public init(guid: String, title: String, url: String, isEditable: Bool=false) {
self.url = url
super.init(guid: guid, title: title, isEditable: isEditable)
}
}
/**
* A folder is an immutable abstraction over a named
* thing that can return its child nodes by index.
*/
open class BookmarkFolder: BookmarkNode {
open var count: Int { return 0 }
open subscript(index: Int) -> BookmarkNode? { return nil }
open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return self[index]?.canDelete ?? false
}
override open var canDelete: Bool {
return false
}
open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
return nil
}
}
/**
* A model is a snapshot of the bookmarks store, suitable for backing a table view.
*
* Navigation through the folder hierarchy produces a sequence of models.
*
* Changes to the backing store implicitly invalidates a subset of models.
*
* 'Refresh' means requesting a new model from the store.
*/
open class BookmarksModel: BookmarksModelFactorySource {
fileprivate let factory: BookmarksModelFactory
open let modelFactory: Deferred<Maybe<BookmarksModelFactory>>
open let current: BookmarkFolder
public init(modelFactory: BookmarksModelFactory, root: BookmarkFolder) {
self.factory = modelFactory
self.modelFactory = deferMaybe(modelFactory)
self.current = root
}
/**
* Produce a new model rooted at the appropriate folder. Fails if the folder doesn't exist.
*/
open func selectFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(folder)
}
/**
* Produce a new model rooted at the appropriate folder. Fails if the folder doesn't exist.
*/
open func selectFolder(_ guid: String) -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(guid)
}
/**
* Produce a new model rooted at the base of the hierarchy. Should never fail.
*/
open func selectRoot() -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForRoot()
}
/**
* Produce a new model with a memory-backed root with the given GUID removed from the current folder
*/
open func removeGUIDFromCurrent(_ guid: GUID) -> BookmarksModel {
if let removedRoot = self.current.removeItemWithGUID(guid) {
return BookmarksModel(modelFactory: self.factory, root: removedRoot)
}
log.warning("BookmarksModel.removeGUIDFromCurrent did not remove anything. Check to make sure you're not using the abstract BookmarkFolder class.")
return self
}
/**
* Produce a new model rooted at the same place as this model. Can fail if
* the folder has been deleted from the backing store.
*/
open func reloadData() -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(current)
}
open var canDelete: Bool {
return false
}
}
public protocol BookmarksModelFactorySource {
var modelFactory: Deferred<Maybe<BookmarksModelFactory>> { get }
}
public protocol BookmarksModelFactory {
func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory
func modelForFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>>
func modelForFolder(_ guid: GUID) -> Deferred<Maybe<BookmarksModel>>
func modelForFolder(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarksModel>>
func modelForRoot() -> Deferred<Maybe<BookmarksModel>>
// Whenever async construction is necessary, we fall into a pattern of needing
// a placeholder that behaves correctly for the period between kickoff and set.
var nullModel: BookmarksModel { get }
func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>>
func removeByGUID(_ guid: GUID) -> Success
@discardableResult func removeByURL(_ url: String) -> Success
}
/*
* A folder that contains an array of children.
*/
open class MemoryBookmarkFolder: BookmarkFolder, Sequence {
let children: [BookmarkNode]
public init(guid: GUID, title: String, children: [BookmarkNode]) {
self.children = children
super.init(guid: guid, title: title)
}
public struct BookmarkNodeGenerator: IteratorProtocol {
public typealias Element = BookmarkNode
let children: [BookmarkNode]
var index: Int = 0
init(children: [BookmarkNode]) {
self.children = children
}
public mutating func next() -> BookmarkNode? {
if index < children.count {
defer { index += 1 }
return children[index]
}
return nil
}
}
override open var favicon: Favicon? {
get {
if let path = Bundle.main.path(forResource: "bookmarkFolder", ofType: "png") {
let url = URL(fileURLWithPath: path)
return Favicon(url: url.absoluteString, date: Date(), type: IconType.local)
}
return nil
}
set {
}
}
override open var count: Int {
return children.count
}
override open subscript(index: Int) -> BookmarkNode {
get {
return children[index]
}
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return true
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
let without = children.filter { $0.guid != guid }
return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: without)
}
open func makeIterator() -> BookmarkNodeGenerator {
return BookmarkNodeGenerator(children: self.children)
}
/**
* Return a new immutable folder that's just like this one,
* but also contains the new items.
*/
func append(_ items: [BookmarkNode]) -> MemoryBookmarkFolder {
if items.isEmpty {
return self
}
return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: self.children + items)
}
}
open class MemoryBookmarksSink: ShareToDestination {
var queue: [BookmarkNode] = []
public init() { }
open func shareItem(_ item: ShareItem) -> Success {
let title = item.title == nil ? "Untitled" : item.title!
func exists(_ e: BookmarkNode) -> Bool {
if let bookmark = e as? BookmarkItem {
return bookmark.url == item.url
}
return false
}
// Don't create duplicates.
if !queue.contains(where: exists) {
queue.append(BookmarkItem(guid: Bytes.generateGUID(), title: title, url: item.url))
}
return succeed()
}
}
private extension SuggestedSite {
func asBookmark() -> BookmarkNode {
let b = BookmarkItem(guid: self.guid ?? Bytes.generateGUID(), title: self.title, url: self.url)
b.favicon = self.icon
return b
}
}
open class PrependedBookmarkFolder: BookmarkFolder {
let main: BookmarkFolder
fileprivate let prepend: BookmarkNode
init(main: BookmarkFolder, prepend: BookmarkNode) {
self.main = main
self.prepend = prepend
super.init(guid: main.guid, title: main.guid)
}
override open var count: Int {
return self.main.count + 1
}
override open subscript(index: Int) -> BookmarkNode? {
if index == 0 {
return self.prepend
}
return self.main[index - 1]
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return index > 0 && self.main.itemIsEditableAtIndex(index - 1)
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
guard let removedFolder = main.removeItemWithGUID(guid) else {
log.warning("Failed to remove child item from prepended folder. Check that main folder overrides removeItemWithGUID.")
return nil
}
return PrependedBookmarkFolder(main: removedFolder, prepend: prepend)
}
}
open class ConcatenatedBookmarkFolder: BookmarkFolder {
fileprivate let main: BookmarkFolder
fileprivate let append: BookmarkFolder
init(main: BookmarkFolder, append: BookmarkFolder) {
self.main = main
self.append = append
super.init(guid: main.guid, title: main.title)
}
var pivot: Int {
return main.count
}
override open var count: Int {
return main.count + append.count
}
override open subscript(index: Int) -> BookmarkNode? {
return index < main.count ? main[index] : append[index - main.count]
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return index < main.count ? main.itemIsEditableAtIndex(index) : append.itemIsEditableAtIndex(index - main.count)
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
let newMain = main.removeItemWithGUID(guid) ?? main
let newAppend = append.removeItemWithGUID(guid) ?? append
return ConcatenatedBookmarkFolder(main: newMain, append: newAppend)
}
}
/**
* A trivial offline model factory that represents a simple hierarchy.
*/
open class MockMemoryBookmarksStore: BookmarksModelFactory, ShareToDestination {
let mobile: MemoryBookmarkFolder
let root: MemoryBookmarkFolder
var unsorted: MemoryBookmarkFolder
let sink: MemoryBookmarksSink
public init() {
let res = [BookmarkItem]()
mobile = MemoryBookmarkFolder(guid: BookmarkRoots.MobileFolderGUID, title: "Mobile Bookmarks", children: res)
unsorted = MemoryBookmarkFolder(guid: BookmarkRoots.UnfiledFolderGUID, title: "Unsorted Bookmarks", children: [])
sink = MemoryBookmarksSink()
root = MemoryBookmarkFolder(guid: BookmarkRoots.RootGUID, title: "Root", children: [mobile, unsorted])
}
public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory {
return self
}
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>> {
var m: BookmarkFolder
switch guid {
case BookmarkRoots.MobileFolderGUID:
// Transparently merges in any queued items.
m = self.mobile.append(self.sink.queue)
break
case BookmarkRoots.RootGUID:
m = self.root
break
case BookmarkRoots.UnfiledFolderGUID:
m = self.unsorted
break
default:
return deferMaybe(DatabaseError(description: "No such folder \(guid)."))
}
return deferMaybe(BookmarksModel(modelFactory: self, root: m))
}
open func modelForRoot() -> Deferred<Maybe<BookmarksModel>> {
return deferMaybe(BookmarksModel(modelFactory: self, root: self.root))
}
/**
* This class could return the full data immediately. We don't, because real DB-backed code won't.
*/
open var nullModel: BookmarksModel {
let f = MemoryBookmarkFolder(guid: BookmarkRoots.RootGUID, title: "Root", children: [])
return BookmarksModel(modelFactory: self, root: f)
}
open func shareItem(_ item: ShareItem) -> Success {
return self.sink.shareItem(item)
}
open func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>> {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func removeByGUID(_ guid: GUID) -> Success {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func removeByURL(_ url: String) -> Success {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func clearBookmarks() -> Success {
return succeed()
}
}