diff --git a/MAPP.xcodeproj/project.pbxproj b/MAPP.xcodeproj/project.pbxproj index d818f58..755488c 100644 --- a/MAPP.xcodeproj/project.pbxproj +++ b/MAPP.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 331000043066ED47005D07A2 /* Marfa in Frameworks */ = {isa = PBXBuildFile; productRef = 331000053066ED47005D07A2 /* Marfa */; }; 331000073066ED47005D07A2 /* MarfaTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 331000083066ED47005D07A2 /* MarfaTesting */; }; + 331000093066ED47005D07A2 /* MarfaAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 3310000A3066ED47005D07A2 /* MarfaAPI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -77,6 +78,7 @@ isa = PBXFrameworksBuildPhase; files = ( 331000043066ED47005D07A2 /* Marfa in Frameworks */, + 331000093066ED47005D07A2 /* MarfaAPI in Frameworks */, 331000073066ED47005D07A2 /* MarfaTesting in Frameworks */, ); }; @@ -141,6 +143,7 @@ name = MAPP; packageProductDependencies = ( 331000053066ED47005D07A2 /* Marfa */, + 3310000A3066ED47005D07A2 /* MarfaAPI */, 331000083066ED47005D07A2 /* MarfaTesting */, ); productName = MAPP; @@ -720,6 +723,11 @@ package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */; productName = Marfa; }; + 3310000A3066ED47005D07A2 /* MarfaAPI */ = { + isa = XCSwiftPackageProductDependency; + package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */; + productName = MarfaAPI; + }; 331000083066ED47005D07A2 /* MarfaTesting */ = { isa = XCSwiftPackageProductDependency; package = 331000063066ED47005D07A2 /* XCRemoteSwiftPackageReference "marfa-swift" */; diff --git a/MAPP/Editor/EditorWebView.swift b/MAPP/Editor/EditorWebView.swift index c137af0..c00abf6 100644 --- a/MAPP/Editor/EditorWebView.swift +++ b/MAPP/Editor/EditorWebView.swift @@ -163,6 +163,19 @@ final class EditorWebView: WKWebView { controller.titleDrafts?.typeChange = TypeChangeRequest(ids: [id], undoManager: windowUndoManager) } + @objc private func versionHistory(_ sender: NSMenuItem) { + guard let controller, let id = controller.noteID else { return } + controller.titleDrafts?.history = VersionHistoryRequest(item: id, undoManager: windowUndoManager) + } + + /// Version History, for the note on the page, from the window the editor belongs to. + private var versionHistoryItem: NSMenuItem? { + guard let controller, controller.noteID != nil, controller.titleDrafts != nil else { return nil } + let item = NSMenuItem(title: String(localized: "Version History…"), action: #selector(versionHistory(_:)), keyEquivalent: "") + item.target = self + return item + } + /// Change Type, for the note on the page, from the window the editor belongs to. private var changeTypeItem: NSMenuItem? { guard let controller, let id = controller.noteID, controller.titleDrafts != nil, let library = controller.library, @@ -215,9 +228,10 @@ final class EditorWebView: WKWebView { let isRichText = item.submenu?.items.contains { $0.action.map(Self.richTextActions.contains) ?? false } ?? false if isUnwanted || isRichText { menu.removeItem(item) } } - if !isEditingTitle, let changeType = changeTypeItem { + if !isEditingTitle, changeTypeItem != nil || versionHistoryItem != nil { menu.addItem(.separator()) - menu.addItem(changeType) + if let changeType = changeTypeItem { menu.addItem(changeType) } + if let history = versionHistoryItem { menu.addItem(history) } } // Removing items can leave two separators together, or one at an end. while menu.items.first?.isSeparatorItem == true { menu.removeItem(at: 0) } diff --git a/MAPP/Localizable.xcstrings b/MAPP/Localizable.xcstrings index 646a717..e6f055c 100644 --- a/MAPP/Localizable.xcstrings +++ b/MAPP/Localizable.xcstrings @@ -1,6 +1,40 @@ { "sourceLanguage" : "en", "strings" : { +"Version History" : { "extractionState" : "manual" }, +"Version History…" : { "extractionState" : "manual", "generatesSymbol" : false }, +"Loading versions…" : { "extractionState" : "manual" }, +"No Versions to Show" : { "extractionState" : "manual" }, +"Load More" : { "extractionState" : "manual" }, +"No Earlier Versions" : { "extractionState" : "manual" }, +"This item hasn’t been changed since it was made, or the server has kept no earlier version of it." : { "extractionState" : "manual" }, +"Choose a Version" : { "extractionState" : "manual" }, +"Pick one on the left to read it beside the item as it is now." : { "extractionState" : "manual" }, +"No text" : { "extractionState" : "manual" }, +"Other Fields in This Version" : { "extractionState" : "manual" }, +"%@ more characters aren’t shown." : { "extractionState" : "manual" }, +"Earlier versions of “%@”." : { "extractionState" : "manual" }, +"This item is no longer in the library." : { "extractionState" : "manual" }, +"This item is in Recently Deleted, where its history can’t be read. Recover it to see its versions." : { "extractionState" : "manual" }, +"This Version" : { "extractionState" : "manual" }, +"Now" : { "extractionState" : "manual" }, +"Version %lld" : { "extractionState" : "manual" }, +"Browser" : { "extractionState" : "manual" }, +"App" : { "extractionState" : "manual" }, +"This device" : { "extractionState" : "manual" }, +"The item already has this version’s text and title." : { "extractionState" : "manual" }, +"Can’t Restore Version" : { "extractionState" : "manual" }, +"Restore Version" : { "extractionState" : "manual" }, +"This item can’t be changed right now." : { "extractionState" : "manual" }, +"“%@” has changed since, so that can’t be put back here. Earlier versions are in Version History." : { "extractionState" : "manual" }, +"MAPP can’t reach the server. Version history comes from the server. If its address starts with http://, macOS may be refusing it. Your notes and changes aren’t affected." : { "extractionState" : "manual" }, +"The key MAPP uses isn’t allowed to read this item’s history. Ask whoever runs the server to allow it." : { "extractionState" : "manual" }, +"The server has no history to show for this item. It may be new and not sent yet, deleted, or of a kind your key can’t read." : { "extractionState" : "manual" }, +"The server didn’t accept MAPP’s key. Choose a new key in Settings." : { "extractionState" : "manual" }, +"This library doesn’t keep version history." : { "extractionState" : "manual" }, +"This item is in Recently Deleted. Recover it to restore a version." : { "extractionState" : "manual" }, +"A file’s versions can be read here, but not restored." : { "extractionState" : "manual" }, +"This version has no text or title that fits this item, so it can only be read." : { "extractionState" : "manual" }, "Change Type" : { "extractionState" : "manual" }, "Change Type…" : { "extractionState" : "manual", "generatesSymbol" : false }, "Can’t Change Type" : { "extractionState" : "manual" }, diff --git a/MAPP/Model/AGENTS.md b/MAPP/Model/AGENTS.md index 139546f..2203af2 100644 --- a/MAPP/Model/AGENTS.md +++ b/MAPP/Model/AGENTS.md @@ -8,6 +8,10 @@ The data model, free of SwiftUI: the `ItemLibrary` protocol, `MarfaLibrary` over - Change Type (`ItemLibrary.changeType`) changes an item's type in one queued edit, so it works offline. Marfa keeps the properties the new type does not declare, refuses a type whose required fields the item lacks, and reads the title and text from the new type's own keys without moving them (`device/body-retype`). So `TypeChange.plan` moves the title and the text into the new type's keys in the same edit: a title that the new type reads from the first line goes to the top of the text, and a first line becomes the title of a type that has one. Nothing is left only under a key the page no longer reads, written twice, or dropped: where the new type's title key already holds a different title, as another app may leave, that title stays the item's and the old one stays under its own key, which the sheet names as unused. `ItemLibrary.typeChangeOptions` offers only the types every selected item can become: never a file type, never a type with no text for an item that has text, and never one where a value does not fit the new field. The sheet (`TypeChangeSheet`) asks for each required field the items lack and names the fields the new type does not use, which stay on the items. It runs once every open page has handed over its last edit (`changeTypeAfterSaving`), and closes only once the change is sent, so a change refused first keeps what was typed in it. A change the package refuses before queueing gives back the text values it carried, those typed in the sheet among them (`MarfaLibrary.unqueuedText`). Undo and Redo also wait for the open pages to hand over their last edit. Undo puts back exactly the values the change replaced; if the item has changed since, its text or its type, Undo moves it back from what it is now as a new change, so what was typed is kept and no text is left under a key the old type does not read. - Folders are facts on items, never places: MAPP syncs no folders and has no Folders section, Move To or New Folder. +## Version History + +- Version History (the File and context menus, the More menu, the palette and the page's context menu) opens `VersionHistorySheet` for one item: its snapshots from the server (`ItemLibrary.history`), the chosen one beside the item as it is now, the version's other fields to read, and Restore. The command stays available offline and for a deleted item, and the sheet says plainly what it can't show. Restore waits for the open pages to hand over their last edit (`restoreVersionAfterSaving`) and its availability is judged on the item as it stands (`ItemLibrary.restoreCheck`), so an item changed or deleted elsewhere while the sheet is open is refused, not overwritten. See `Marfa/AGENTS.md`. + ## Writes the library cannot always make - Deleting for good (`purge`) needs the server, so `ItemLibrary.canPurge` is true only while it can be reached, and the views dim the command until then. Saving, updating, renaming and deleting a view also need the server and are never queued, so `ItemLibrary.canEditViews` is true only while it can be reached, and the views dim those commands until then. An update made on a version changed elsewhere is refused and told, overwrites nothing, and the window goes back to the view as it is (`Browser.viewRefused`). A deleted view is gone for every app at once; there is no bin for views. Every other write goes to the copy's queue and can always be made. diff --git a/MAPP/Model/ItemHistoryModel.swift b/MAPP/Model/ItemHistoryModel.swift new file mode 100644 index 0000000..2755a9b --- /dev/null +++ b/MAPP/Model/ItemHistoryModel.swift @@ -0,0 +1,107 @@ +import Foundation +import Observation + +/// The versions of one item that the Version History sheet lists: read a page at a time from the library's source, +/// oldest first as the server gives them, never all at once. +@Observable final class ItemHistoryModel { + enum Phase: Equatable { + case idle + /// The first page is on its way. + case loading + case loaded + /// Nothing could be listed; a failure after the first page keeps the list and is in `moreFailure`. + case failed(ItemHistoryFailure) + } + + /// How many snapshots one request asks for. The server allows 200. + static let pageSize = 100 + /// Empty pages followed in a row before the list stops and offers Load More, so a server that answers only + /// empty pages with a cursor never keeps the sheet busy. + private static let emptyPagesFollowed = 20 + + let id: ItemID + @ObservationIgnored private let source: (any ItemHistorySource)? + private(set) var snapshots: [ItemSnapshot] = [] + private(set) var phase = Phase.idle + /// Whether the server has more versions after those listed. + private(set) var hasMore = false + private(set) var isLoadingMore = false + /// Why the last Load More failed. The versions listed stay. + private(set) var moreFailure: ItemHistoryFailure? + var selection: ItemSnapshot.ID? + + @ObservationIgnored private var cursor: String? + @ObservationIgnored private var loading: Task? + + init(id: ItemID, source: (any ItemHistorySource)?) { + self.id = id + self.source = source + } + + var selected: ItemSnapshot? { + selection.flatMap { id in snapshots.first { $0.id == id } } + } + + /// Reads the first page. Run again to try again after a failure. + func load() { + guard let source else { + phase = .failed(.unsupported) + return + } + loading?.cancel() + snapshots = [] + cursor = nil + hasMore = false + moreFailure = nil + selection = nil + phase = .loading + isLoadingMore = false + loading = Task { await self.fetch(from: source, first: true) } + } + + func loadMore() { + guard let source, hasMore, !isLoadingMore, phase == .loaded else { return } + moreFailure = nil + isLoadingMore = true + loading = Task { await self.fetch(from: source, first: false) } + } + + /// Stops a read still under way, as the sheet closes. + func cancel() { + loading?.cancel() + loading = nil + } + + private func fetch(from source: any ItemHistorySource, first: Bool) async { + var empty = 0 + while !Task.isCancelled { + let page: ItemHistoryPage + do { + page = try await source.page(of: id, after: cursor, limit: Self.pageSize) + } catch { + guard !Task.isCancelled else { return } + if first { phase = .failed(error) } else { moreFailure = error } + isLoadingMore = false + return + } + guard !Task.isCancelled else { return } + // Versions are not contiguous, since the server thins old ones, so they are told apart by number alone. + let held = Set(snapshots.map(\.version)) + let fresh = page.snapshots.filter { !held.contains($0.version) } + snapshots = (snapshots + fresh).sorted { $0.version < $1.version } + // A cursor that leads back to the page just read ends the list instead of looping. + let next = page.next == cursor ? nil : page.next + cursor = next + hasMore = next != nil + phase = .loaded + isLoadingMore = false + if fresh.isEmpty && next != nil { + empty += 1 + guard empty < Self.emptyPagesFollowed else { return } + isLoadingMore = true + continue + } + return + } + } +} diff --git a/MAPP/Model/Items/ItemHistory.swift b/MAPP/Model/Items/ItemHistory.swift new file mode 100644 index 0000000..dc166c9 --- /dev/null +++ b/MAPP/Model/Items/ItemHistory.swift @@ -0,0 +1,191 @@ +import Foundation + +/// An earlier version of an item as the library kept it: the item as it stood at `version`, before the write that +/// replaced it. The version the item holds now has no snapshot. +nonisolated struct ItemSnapshot: Identifiable, Hashable, Sendable { + nonisolated struct Writer: Hashable, Sendable { + enum Kind: Hashable, Sendable { case browser, app, key, local } + let kind: Kind + /// What the library calls the sign-in that wrote it, such as a key's label or an app's name. + let name: String + } + + let id: String + let version: Int + /// The type the item had at this version, which may not be the type it has now. + let type: TypeID + let properties: [String: PropertyValue] + /// When this version was written; nil where the library did not say or the time could not be read. + let written: Date? + /// Nil where the library does not know who wrote it. + let writer: Writer? +} + +/// One page of an item's snapshots, oldest first. A page can be short or empty while `next` still leads on. +nonisolated struct ItemHistoryPage: Hashable, Sendable { + var snapshots: [ItemSnapshot] + /// Where the next page starts; nil on the last page. + var next: String? +} + +/// Why an item's history could not be read, in terms the sheet can word for a person. +nonisolated enum ItemHistoryFailure: Error, Hashable, Sendable { + /// The server could not be reached. The package reports a refused plain-http address the same way as a network + /// that is down, so the wording does not say which. + case unreachable + /// The key MAPP uses may not read this item's history. + case notPermitted + /// The server holds no readable history for the item: it is deleted, of a type the key cannot read, or not sent + /// to the server yet. + case unavailable + /// The server did not accept the key. + case keyRefused + /// This library keeps no history to read. + case unsupported + case failed(String) + + var message: String { + switch self { + case .unreachable: + String(localized: "MAPP can’t reach the server. Version history comes from the server. If its address starts with http://, macOS may be refusing it. Your notes and changes aren’t affected.") + case .notPermitted: + String(localized: "The key MAPP uses isn’t allowed to read this item’s history. Ask whoever runs the server to allow it.") + case .unavailable: + String(localized: "The server has no history to show for this item. It may be new and not sent yet, deleted, or of a kind your key can’t read.") + case .keyRefused: + String(localized: "The server didn’t accept MAPP’s key. Choose a new key in Settings.") + case .unsupported: + String(localized: "This library doesn’t keep version history.") + case .failed(let message): + message + } + } +} + +/// Where an item's snapshots come from. Reading them is a request to the server, which the working copy does not +/// keep, so it can fail while the rest of the library works. +nonisolated protocol ItemHistorySource: Sendable { + /// A page of at most `limit` snapshots after `cursor` (nil for the first), oldest first. + func page(of id: ItemID, after cursor: String?, limit: Int) async throws(ItemHistoryFailure) -> ItemHistoryPage +} + +nonisolated extension ItemSnapshot { + /// The text this version held, read from the field its type keeps text in. Nil for a type that holds none or + /// that the catalogue does not know. A type with a text field and no value held empty text. + func text(in catalogue: Catalogue) -> String? { + guard let key = catalogue.itemTypes[type]?.bodyKey else { return nil } + switch properties[key] { + case nil, .null?: return "" + case .string(let text)?: return text + default: return nil + } + } + + /// The title this version held, where its type keeps one as a property. + func title(in catalogue: Catalogue) -> String? { + guard let key = catalogue.itemTypes[type]?.titleKey else { return nil } + return properties[key]?.string + } + + /// The other fields of this version, by label, for reading: not the title and the text, which are shown + /// beside the current ones. + func fields(in catalogue: Catalogue) -> [(label: String, value: String)] { + let info = catalogue.itemTypes[type] + let hidden = Set([info?.bodyKey, info?.titleKey].compactMap { $0 }) + return properties.filter { !hidden.contains($0.key) && $0.value != .null } + .sorted { $0.key.localizedStandardCompare($1.key) == .orderedAscending } + .map { key, value in + (info?.fields.first { $0.key == key }?.displayLabel ?? key, value.displayText) + } + } +} + +nonisolated extension PropertyValue { + /// The value as a line of text for reading. + var displayText: String { + switch self { + case .null: "" + case .bool(let value): value ? String(localized: "Yes") : String(localized: "No") + case .number(let value): value.formatted(.number) + case .string(let value): value + case .array(let values): values.map(\.displayText).joined(separator: ", ") + case .object(let values): + values.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value.displayText)" }.joined(separator: ", ") + } + } +} + +/// How much of a version's text the sheet lays out. A long note is cut here, for the person to read the start of it +/// and to see that it was cut, rather than the sheet laying out a text of any length. +nonisolated struct TextExcerpt: Hashable, Sendable { + static let limit = 20_000 + + let text: String + /// How many characters were left out. + let omitted: Int + + init(_ full: String, limit: Int = Self.limit) { + let shown = full.prefix(limit) + text = String(shown) + omitted = full.count - shown.count + } +} + +// MARK: Restoring + +/// What a restore changes on the item as it stands: the text and the title it would be given, each nil where it +/// stays as it is. +nonisolated struct RestorePlan: Hashable, Sendable { + var text: String? + var title: String? + + var isEmpty: Bool { text == nil && title == nil } +} + +nonisolated enum RestoreCheck: Hashable, Sendable { + case ready(RestorePlan) + /// The item already holds this version's text and title. + case unchanged + /// Why this version can't be restored, in the person's words. + case unavailable(String) +} + +extension ItemLibrary { + /// What restoring `snapshot` over item `id` would change, judged on the item as the library holds it now. + /// + /// Only the text and the title are restored: they are what a note is, and they map between types whenever both + /// types keep them. The item's other fields stay as they are, so a restore never brings back a value another + /// device has since changed on purpose, and the version's own fields are shown for reading. + func restoreCheck(of snapshot: ItemSnapshot, for id: ItemID) -> RestoreCheck { + guard let item = item(id) else { + return .unavailable(String(localized: "This item is no longer in the library.")) + } + if item.state == .trashed { + return .unavailable(String(localized: "This item is in Recently Deleted. Recover it to restore a version.")) + } + if item.isFile { + return .unavailable(String(localized: "A file’s versions can be read here, but not restored.")) + } + guard item.isWritable else { + return .unavailable(String(localized: "This item can’t be changed right now.")) + } + let current = catalogue.itemTypes[item.type] + let text = snapshot.text(in: catalogue) + let canWriteText = current?.bodyKey != nil && body(of: id) != nil + let title = snapshot.title(in: catalogue) + var plan = RestorePlan() + var comparable = false + if let text, canWriteText { + comparable = true + if text != body(of: id)?.text { plan.text = text } + } + if let title, !NoteName.trimmed(title).isEmpty, case .property = item.titleRule { + comparable = true + if title != item.title { plan.title = title } + } + guard comparable else { + return .unavailable(String(localized: "This version has no text or title that fits this item, so it can only be read.")) + } + return plan.isEmpty ? .unchanged : .ready(plan) + } +} diff --git a/MAPP/Model/Items/ItemLibrary.swift b/MAPP/Model/Items/ItemLibrary.swift index b7eb16c..e542877 100644 --- a/MAPP/Model/Items/ItemLibrary.swift +++ b/MAPP/Model/Items/ItemLibrary.swift @@ -67,6 +67,12 @@ protocol ItemLibrary: AnyObject, Observable { func bodyMentions(for id: ItemID) -> [ItemRelationshipTarget] func creationParentCandidates(typeID: String) -> [Item] + // MARK: History + + /// Where an item's earlier versions are read, or nil where the library keeps none. Reading them can fail while + /// the rest of the library works, as when the server is out of reach. + var history: (any ItemHistorySource)? { get } + // MARK: Collections /// The active collections, by title: items whose type plays Marfa's `container` role (`Catalogue.isCollection`). @@ -116,6 +122,12 @@ protocol ItemLibrary: AnyObject, Observable { /// them back. False where the set is refused before anything is sent. @discardableResult func changeType(_ ids: Set, to type: TypeID, filling: [String: PropertyValue], undoManager: UndoManager?) -> Bool + /// Puts the text and title of an earlier version (`restoreCheck`) on the item as a new edit, made on the version + /// the library holds now, so what it replaces stays in the item's history. Undo puts back what it replaced, if the + /// item still holds what the restore wrote. False where nothing is sent, and the library tells the person why + /// unless there was nothing to change. + @discardableResult + func restoreVersion(_ snapshot: ItemSnapshot, of id: ItemID, undoManager: UndoManager?) -> Bool func setFavorite(_ ids: Set, _ isFavorite: Bool, undoManager: UndoManager?) /// The tags the library sets itself, which the person neither adds nor removes by hand: the favorite tag, which /// the Favorite command owns, and any a library keeps for its own use. @@ -157,6 +169,7 @@ protocol ItemLibrary: AnyObject, Observable { extension ItemLibrary { var downloadedFiles: (any DownloadedFiles)? { nil } + var history: (any ItemHistorySource)? { nil } func createItem( typeID: String, title: String, body: String, parentID: ItemID?, in place: SidebarItem, undoManager: UndoManager? diff --git a/MAPP/Model/Libraries.swift b/MAPP/Model/Libraries.swift index a109f12..490c224 100644 --- a/MAPP/Model/Libraries.swift +++ b/MAPP/Model/Libraries.swift @@ -160,7 +160,8 @@ import Observation let host = MarfaHost( defaults: defaults, keys: MemoryKeyStore([sample: "sample"]), location: location(root: root), opener: { connection, _, _ in SampleLibrary.open(connection) }, - describer: { _ in SampleLibrary.name }) + describer: { _ in SampleLibrary.name }, + historian: { _, _ in nil }) host.presetTestServer(sample) return Libraries(host: host, defaults: defaults) } diff --git a/MAPP/Model/Marfa/AGENTS.md b/MAPP/Model/Marfa/AGENTS.md index d3261ba..9cb4a35 100644 --- a/MAPP/Model/Marfa/AGENTS.md +++ b/MAPP/Model/Marfa/AGENTS.md @@ -15,7 +15,7 @@ - Each server address has its own copy in the app's own storage (`MarfaStoreLocation`). - `MarfaHost` owns the connection: the address in defaults and the key in the Keychain (`KeychainKeyStore`). The key is never shown again once entered. Tests use `MemoryKeyStore` and a temporary store location. - `MarfaHost` makes the one `MarfaLibrary` for the copy it opens (`MarfaHost.library`). While a copy is open, how the server stands and what the copy last reported are the library's (`connection`, `syncReport`); the host reads them from it and holds its own state only while no copy is open. Never subscribe to a source's states or reports a second time to keep a copy of them. -- An address that sends the key over plain http to another machine is told once in each run, when its copy is first attached (`Server.takePlainHTTPNotice()`), in the app's own words, through the host's `notices`. `localhost`, `127.0.0.0/8` and `::1` are not. MAPP reaches the server only through the package's core, not `URLSession`, so App Transport Security does not apply and `Info.plist` has no exception. +- An address that sends the key over plain http to another machine is told once in each run, when its copy is first attached (`Server.takePlainHTTPNotice()`), in the app's own words, through the host's `notices`. `localhost`, `127.0.0.0/8` and `::1` are not. The copy reaches the server through the package's core, not `URLSession`, so App Transport Security does not apply to it. The one exception is Version History (`MarfaServerVersions`), which calls the package's generated client over `URLSession`; ATS applies to it, so a plain-http address fails there with a network error: the SDK's documentation says ATS no longer allows IP addresses by default, and allows unqualified and `.local` names only with `NSAllowsLocalNetworking`. `Info.plist` has no exception. - `MarfaHost` also reads the server's own name (`Server.instanceDescription()`, which sends no key) when a copy is attached and each time the connection returns online, off the main actor, and keeps the last name for each address in defaults. A failed read changes nothing. `LibraryDescription.name` shows the name, or the address's host until one has been read. Disconnecting or switching away forgets that address's name. ## Never lose a person's write @@ -60,6 +60,12 @@ - Undo registers the writes that put back what changed, made at the version current when Undo runs. - Where the server keeps its own text over the person's, the page shows the server's text and says the person's changes went to a copy. +## Version history + +- The copy keeps no history. Version History reads `GET /items/{id}/versions` from the server through `MarfaServerVersions` (the `ItemHistorySource` seam, injected by the host's `Historian` and held by `MarfaLibrary.history`), so it fails while the server cannot be reached. It never loads a whole history: the model pages 100 at a time, oldest first because that is the only order the endpoint has, and follows an empty page that still has a cursor. Versions are told apart by number alone, since the server thins old ones and the numbers have gaps. A snapshot is the item as it stood before the write that replaced it; the current version has none. +- A restore (`MarfaLibrary.restoreVersion`) restores only the title and the text, through the ordinary edit path: the text through `saveText` on the version the library holds, so a text another device changed is merged or kept apart, and the title through `editProperties`. What it replaces stays in the item's history. Other fields are shown to read and never written back. It is refused, with the usual notice, for an item in the bin, a file, an item the library no longer holds, and a version with no text or title the item keeps. Undo puts back what the restore replaced only while the item still holds what the restore wrote; otherwise it tells the person and writes nothing. +- The server answers 404 for an item in the bin, so the sheet says to recover it first. + ## Edges - A relationship is an edge, named as the end that holds it writes it (`child-of`), with the other end as `inverse:` and the edge type. Check links against the catalogue's types, roles, how many each end may hold, and the contract's cycle rule (`conformance/spec/edges.md`) before sending. diff --git a/MAPP/Model/Marfa/MarfaHost.swift b/MAPP/Model/Marfa/MarfaHost.swift index be3fbbc..0d88703 100644 --- a/MAPP/Model/Marfa/MarfaHost.swift +++ b/MAPP/Model/Marfa/MarfaHost.swift @@ -12,6 +12,8 @@ import Observation /// Removes the copy at a store, agreed to hold this many unsent changes. Tests stand in for the package's /// answers that a real copy can't be made to give. typealias Remover = @Sendable (URL, UInt64) async throws -> Void + /// Makes the reader of item histories for a connection, or nil where there is no server to ask, as in the sample. + typealias Historian = @Sendable (MarfaConnection, any KeyStore) -> (any ItemHistorySource)? nonisolated enum State: Hashable, Sendable { case disconnected @@ -102,6 +104,7 @@ import Observation private let opener: Opener private let describer: Describer private let remover: Remover + private let historian: Historian /// The address connected to, kept while its copy cannot be opened, so Settings can offer a key for it. private(set) var connection: MarfaConnection? @@ -129,7 +132,8 @@ import Observation defaults: UserDefaults, keys: any KeyStore, location: MarfaStoreLocation, opener: @escaping Opener = { try await MarfaSource.open($0, keys: $1, location: $2) }, describer: @escaping Describer = { try await $0.server(key: "").instanceDescription().name }, - remover: @escaping Remover = { try await WorkingCopy.remove(store: $0, unsent: $1) } + remover: @escaping Remover = { try await WorkingCopy.remove(store: $0, unsent: $1) }, + historian: @escaping Historian = { MarfaServerVersions(connection: $0, keys: $1) } ) { self.defaults = defaults self.keys = keys @@ -137,6 +141,7 @@ import Observation self.opener = opener self.describer = describer self.remover = remover + self.historian = historian instanceNames = defaults.dictionary(forKey: Self.namesKey) as? [String: String] ?? [:] } @@ -435,7 +440,7 @@ import Observation private func attach(_ source: any ItemSource, for connection: MarfaConnection) { detach() - let library = MarfaLibrary(source: source, notices: notices) + let library = MarfaLibrary(source: source, notices: notices, history: historian(connection, keys)) self.library = library if let notice = connection.server(key: "").takePlainHTTPNotice() { notices.tell(Words.plainHTTPTitle, Words.plainHTTP(origin: notice.origin)) diff --git a/MAPP/Model/Marfa/MarfaLibrary+Writing.swift b/MAPP/Model/Marfa/MarfaLibrary+Writing.swift index 5ac8916..e54bbb1 100644 --- a/MAPP/Model/Marfa/MarfaLibrary+Writing.swift +++ b/MAPP/Model/Marfa/MarfaLibrary+Writing.swift @@ -182,11 +182,11 @@ extension MarfaLibrary { } } - private func afterHandingOverEdits(_ action: @escaping () -> Void) { + private func afterHandingOverEdits(failing title: String = MarfaLibrary.cantChangeType, _ action: @escaping () -> Void) { handOverEdits { [weak self] kept in guard let self else { return } guard kept else { - return notices.tell(Self.cantChangeType, String(localized: "Finish editing the current note, then try again.")) + return notices.tell(title, String(localized: "Finish editing the current note, then try again.")) } action() } @@ -211,6 +211,83 @@ extension MarfaLibrary { } } + // MARK: Versions + + private static var cantRestore: String { String(localized: "Can’t Restore Version") } + + /// The text and title of an earlier version go on the item as new edits, the text through the same save as typing, + /// so it is made on the version the library holds and a text another device has changed since is merged or kept + /// apart, never overwritten. What it replaces stays in the item's history. Undo and Redo register their opposite + /// at once and make their writes once the open pages have handed over their last edit, as a change of type does. + @discardableResult + func restoreVersion(_ snapshot: ItemSnapshot, of id: ItemID, undoManager: UndoManager?) -> Bool { + switch restoreCheck(of: snapshot, for: id) { + case .unchanged: + return true + case .unavailable(let reason): + notices.tell(Self.cantRestore, reason) + return false + case .ready(let plan): + guard let item = item(id) else { return false } + let before = RestorePlan(text: plan.text == nil ? nil : body(of: id)?.text, title: plan.title == nil ? nil : item.title) + let change = RestoreChange(from: before, to: plan) + guard writeRestore(change, of: id) else { return false } + registerRestoreUndo(change, of: id, undoManager: undoManager) + return true + } + } + + /// A restore's two ends: what the item holds before it and what it holds after. + private struct RestoreChange { + var from: RestorePlan + var to: RestorePlan + var reversed: RestoreChange { RestoreChange(from: to, to: from) } + } + + /// Writes `change.to` over an item that still holds `change.from`; a part the item no longer holds is left alone + /// and told, since writing over it would replace what was typed after. False where nothing was written. + private func writeRestore(_ change: RestoreChange, of id: ItemID) -> Bool { + guard let item = item(id), item.isWritable else { + notices.tell(Self.cantRestore, String(localized: "This item can’t be changed right now.")) + return false + } + var wrote = false + var changedSince = false + if let text = change.to.text { + if let body = body(of: id), body.text == change.from.text { + saveText(text, of: id, readAt: body.version, failing: Self.cantRestore) + wrote = true + } else { + changedSince = true + } + } + if let title = change.to.title, case .property(let key) = item.titleRule { + if item.title == change.from.title { + submit(.editProperties(id, [key: .string(title)], base: item.version, undoing: nil), failing: Self.cantRestore) + wrote = true + } else { + changedSince = true + } + } + if changedSince { + notices.tell( + Self.cantRestore, + String(localized: "“\(item.title)” has changed since, so that can’t be put back here. Earlier versions are in Version History.")) + } + return wrote + } + + private func registerRestoreUndo(_ change: RestoreChange, of id: ItemID, undoManager: UndoManager?) { + let name = String(localized: "Restore Version") + registerUndo(undoManager, name: name) { [weak undoManager] library in + library.registerRestoreUndo(change.reversed, of: id, undoManager: undoManager) + library.afterHandingOverEdits(failing: Self.cantRestore) { + // The page has handed over what was typed, so the item is judged as it now is. + _ = library.writeRestore(change.reversed, of: id) + } + } + } + // MARK: Favorites and the bin /// Items in the bin keep their tags, since Recently Deleted shows no favorites. diff --git a/MAPP/Model/Marfa/MarfaLibrary.swift b/MAPP/Model/Marfa/MarfaLibrary.swift index ce2b918..4899d2c 100644 --- a/MAPP/Model/Marfa/MarfaLibrary.swift +++ b/MAPP/Model/Marfa/MarfaLibrary.swift @@ -42,6 +42,9 @@ import Observation private var viewRevision = 0 @ObservationIgnored let source: any ItemSource + /// Reads an item's earlier versions from the server, which the copy does not hold. Nil where there is no server + /// to ask, as in the sample library. + @ObservationIgnored let history: (any ItemHistorySource)? @ObservationIgnored private var records: [ItemID: ItemRecord] = [:] @ObservationIgnored private(set) var index = MarfaItemIndex() @ObservationIgnored private var listed: [SidebarItem: Listing] = [:] @@ -114,8 +117,9 @@ import Observation /// Searches keep the items not in the bin. static let searchScope = ItemQuery(states: [.active, .archived]) - init(source: any ItemSource, notices: Notices, now: @escaping () -> Date = Date.init) { + init(source: any ItemSource, notices: Notices, history: (any ItemHistorySource)? = nil, now: @escaping () -> Date = Date.init) { self.source = source + self.history = history self.notices = notices self.now = now syncReport = source.syncReport diff --git a/MAPP/Model/Marfa/MarfaServerVersions.swift b/MAPP/Model/Marfa/MarfaServerVersions.swift new file mode 100644 index 0000000..ad24ec3 --- /dev/null +++ b/MAPP/Model/Marfa/MarfaServerVersions.swift @@ -0,0 +1,64 @@ +import Foundation +import Marfa +import MarfaAPI + +/// Reads an item's snapshots from the Marfa server (`GET /items/{id}/versions`). The working copy keeps no history, +/// so this is the one read that asks the server and fails while it cannot be reached. The key is read from the key +/// store on each call, so a key the person has since replaced is the one used. Unlike the copy, which goes through the +/// package's core, this call goes through `URLSession`, so App Transport Security applies to a plain http address. +nonisolated struct MarfaServerVersions: ItemHistorySource { + let connection: MarfaConnection + let keys: any KeyStore + /// How long a call waits for the server to answer before it fails, so an unreachable server never leaves the + /// sheet waiting. + var timeout = Duration.seconds(20) + + @concurrent func page(of id: ItemID, after cursor: String?, limit: Int) async throws(ItemHistoryFailure) -> ItemHistoryPage { + guard let key = try? keys.key(for: connection), !key.isEmpty else { throw .keyRefused } + let client = Client(connection.server(key: key), timeout: timeout) + do { + let answer = try await client.listItemVersions(path: .init(id: id.rawValue), query: .init(limit: limit, cursor: cursor)) + let page = try answer.ok.body.json + return ItemHistoryPage(snapshots: page.data.map(Self.snapshot), next: page.nextCursor) + } catch { + throw Self.failure(error) + } + } + + static func failure(_ error: any Error) -> ItemHistoryFailure { + guard let error = error as? MarfaError else { return .failed(error.localizedDescription) } + switch error { + case .noConnection, .network: return .unreachable + case .forbidden: return .notPermitted + case .notFound: return .unavailable + case .unauthorized, .signedOut: return .keyRefused + default: return .failed(error.message) + } + } + + static func snapshot(_ version: Components.Schemas.Version) -> ItemSnapshot { + ItemSnapshot( + id: version.id, version: version.version, type: TypeID(version._type), properties: properties(version.properties), + written: ItemDate.parse(version.occurredAt)?.date, + writer: version.writer.map { writer in + ItemSnapshot.Writer(kind: kind(writer.kind), name: writer.name) + }) + } + + private static func kind(_ kind: Components.Schemas.Version.WriterPayload.KindPayload) -> ItemSnapshot.Writer.Kind { + switch kind { + case .browser: .browser + case .app: .app + case .key: .key + case .local: .local + } + } + + /// The properties through their JSON, which the package's `JSONValue` reads as the rest of the app does. + private static func properties(_ payload: Components.Schemas.Version.PropertiesPayload) -> [String: PropertyValue] { + guard let data = try? JSONEncoder().encode(payload), let json = try? JSONDecoder().decode(JSONValue.self, from: data), + case .object(let properties) = MarfaMapping.value(json) + else { return [:] } + return properties + } +} diff --git a/MAPP/Views/CommandPalette.swift b/MAPP/Views/CommandPalette.swift index 187bf7b..58b5e3a 100644 --- a/MAPP/Views/CommandPalette.swift +++ b/MAPP/Views/CommandPalette.swift @@ -279,6 +279,11 @@ struct CommandPalette: View { } } + if let details, selected.count == 1, let only = selected.first { + add(String(localized: "Version History…"), "clock.arrow.circlepath") { + details.history = VersionHistoryRequest(item: only.id, undoManager: undoManager) + } + } if !selected.isEmpty, selected.allSatisfy({ $0.state == .trashed }) { add(String(localized: "Recover"), "arrow.uturn.backward") { library.recover(ids, undoManager: undoManager) } } else if !selected.isEmpty, !selected.contains(where: { $0.state == .trashed }) { diff --git a/MAPP/Views/FocusedNotes.swift b/MAPP/Views/FocusedNotes.swift index cdb7704..4b74f7b 100644 --- a/MAPP/Views/FocusedNotes.swift +++ b/MAPP/Views/FocusedNotes.swift @@ -34,6 +34,8 @@ final class DetailsPresentation { var isPresented = false /// The items the window's Change Type sheet is open for. var typeChange: TypeChangeRequest? + /// The item the window's Version History sheet is open for. + var history: VersionHistoryRequest? /// The items the window's Add to Collection sheet is open for. var collectionPick: CollectionPickRequest? /// The items the window's Add Tag sheet is open for. diff --git a/MAPP/Views/ItemBoard.swift b/MAPP/Views/ItemBoard.swift index 753ad8d..189d775 100644 --- a/MAPP/Views/ItemBoard.swift +++ b/MAPP/Views/ItemBoard.swift @@ -149,6 +149,7 @@ struct ItemBoard: View { .disabled(!item.isEditable) AddTagButton(ids: browser.selection.contains(item.id) ? browser.selection : [item.id], details: details, undoManager: undoManager) TypeChangeButton(ids: browser.selection.contains(item.id) ? browser.selection : [item.id], details: details, undoManager: undoManager) + VersionHistoryButton(ids: [item.id], details: details, undoManager: undoManager) } .draggable(containerItemID: item.id) } diff --git a/MAPP/Views/MainView.swift b/MAPP/Views/MainView.swift index 5944736..2c83353 100644 --- a/MAPP/Views/MainView.swift +++ b/MAPP/Views/MainView.swift @@ -192,6 +192,7 @@ struct MainView: View { } .focusedSceneValue(\.detailsPresentation, details) .sheet(item: Binding { details.typeChange } set: { details.typeChange = $0 }) { TypeChangeSheet(request: $0) } + .sheet(item: Binding { details.history } set: { details.history = $0 }) { VersionHistorySheet(request: $0) } .sheet(item: Binding { details.collectionPick } set: { details.collectionPick = $0 }) { CollectionPickerSheet(request: $0) } .sheet(item: Binding { details.tagRequest } set: { details.tagRequest = $0 }) { TagSheet(request: $0) } .onChange(of: details.isPresented) { if restoresWindow { storedDetailsPresented = details.isPresented } } diff --git a/MAPP/Views/NoteActions.swift b/MAPP/Views/NoteActions.swift index d5a6eac..8fb58e6 100644 --- a/MAPP/Views/NoteActions.swift +++ b/MAPP/Views/NoteActions.swift @@ -33,7 +33,7 @@ private struct NoteActionContext { var isEmpty: Bool { notes.isEmpty && !keepsPlace } } -/// Favorites and Change Type for notes, or Recover for deleted ones. The collection actions sit beside these +/// Favorites, Change Type and Version History for notes, or Recover for deleted ones. The collection actions sit beside these /// (`CollectionActions`), since they take files too. struct NoteOrganizeActions: View { @CurrentLibrary private var library @@ -53,6 +53,7 @@ struct NoteOrganizeActions: View { EmptyView() } else if context.allDeleted { Button("Recover", systemImage: "arrow.uturn.backward") { library.recover(ids, undoManager: undoManager) } + VersionHistoryButton(ids: ids, details: details, undoManager: undoManager) } else { let allFavorites = !notes.isEmpty && notes.allSatisfy(\.isFavorite) Button(allFavorites ? "Remove from Favorites" : "Add to Favorites", systemImage: allFavorites ? "star.slash" : "star") { @@ -61,6 +62,7 @@ struct NoteOrganizeActions: View { .disabled(notes.isEmpty) AddTagButton(ids: ids, details: details, undoManager: undoManager) TypeChangeButton(ids: ids, details: details, undoManager: undoManager) + VersionHistoryButton(ids: ids, details: details, undoManager: undoManager) } } } diff --git a/MAPP/Views/NoteWindow.swift b/MAPP/Views/NoteWindow.swift index 89cabd6..89955d9 100644 --- a/MAPP/Views/NoteWindow.swift +++ b/MAPP/Views/NoteWindow.swift @@ -76,6 +76,7 @@ struct NoteWindow: View { .focusedSceneValue(\.detailsPresentation, details) .environment(details) .sheet(item: Binding { details.typeChange } set: { details.typeChange = $0 }) { TypeChangeSheet(request: $0) } + .sheet(item: Binding { details.history } set: { details.history = $0 }) { VersionHistorySheet(request: $0) } .sheet(item: Binding { details.collectionPick } set: { details.collectionPick = $0 }) { CollectionPickerSheet(request: $0) } .sheet(item: Binding { details.tagRequest } set: { details.tagRequest = $0 }) { TagSheet(request: $0) } .onChange(of: details.isPresented) { storedDetailsPresented = details.isPresented } diff --git a/MAPP/Views/VersionHistorySheet.swift b/MAPP/Views/VersionHistorySheet.swift new file mode 100644 index 0000000..ce2237f --- /dev/null +++ b/MAPP/Views/VersionHistorySheet.swift @@ -0,0 +1,274 @@ +import SwiftUI + +/// Lists an item's earlier versions as the server kept them, shows the one chosen beside the item as it is now, and +/// restores its text and title as a new edit, so what it replaces stays in the history. +struct VersionHistorySheet: View { + @CurrentLibrary private var library + @Environment(\.dismiss) private var dismiss + let request: VersionHistoryRequest + @State private var model: ItemHistoryModel? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Version History").interfaceFont(.title2.bold()) + Text(subtitle).foregroundStyle(.secondary) + if let model { + VersionHistoryBody(model: model, request: request, close: { dismiss() }) + } else { + Spacer() + } + } + .padding(20) + .frame(width: 780, height: 500) + .task { + guard model == nil else { return } + let model = ItemHistoryModel(id: request.item, source: library.history) + self.model = model + model.load() + } + .onDisappear { model?.cancel() } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("VersionHistorySheet") + } + + private var subtitle: String { + if let item = library.item(request.item) { + String(localized: "Earlier versions of “\(item.title)”.") + } else { + String(localized: "This item is no longer in the library.") + } + } +} + +private struct VersionHistoryBody: View { + @CurrentLibrary private var library + @Bindable var model: ItemHistoryModel + let request: VersionHistoryRequest + let close: () -> Void + /// Whether a restore is waiting for the open pages to hand over their last edit. + @State private var isRestoring = false + + var body: some View { + // Read on each change of the library, so an item changed or deleted elsewhere is judged as it stands now. + let check = model.selected.map { library.restoreCheck(of: $0, for: request.item) } + VStack(alignment: .leading, spacing: 12) { + content + if let reason = reason(for: check) { + Text(reason) + .interfaceFont(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("VersionHistoryReason") + } + HStack { + Spacer() + Button("Done", role: .cancel) { close() } + .keyboardShortcut(.cancelAction) + Button("Restore") { restore() } + .keyboardShortcut(.defaultAction) + .disabled(!canRestore(check)) + .accessibilityIdentifier("VersionHistoryRestore") + } + } + } + + private var isDeleted: Bool { library.item(request.item)?.state == .trashed } + + @ViewBuilder private var content: some View { + switch model.phase { + case .idle, .loading: + ProgressView("Loading versions…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let failure): + ContentUnavailableView { + Label("No Versions to Show", systemImage: "clock.arrow.circlepath") + } description: { + Text(isDeleted ? String(localized: "This item is in Recently Deleted, where its history can’t be read. Recover it to see its versions.") : failure.message) + } actions: { + if failure != .unsupported { Button("Try Again") { model.load() } } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded: + HStack(alignment: .top, spacing: 12) { + list.frame(width: 230) + preview + } + } + } + + private var list: some View { + List(selection: $model.selection) { + ForEach(model.snapshots) { snapshot in + VStack(alignment: .leading, spacing: 2) { + Text(when(snapshot)) + if let who = who(snapshot) { + Text(who).interfaceFont(.callout).foregroundStyle(.secondary) + } + } + .tag(snapshot.id) + .accessibilityIdentifier("VersionHistoryRow.\(snapshot.version)") + } + if model.hasMore || model.moreFailure != nil { + loadMore + } + } + .overlay { + if model.snapshots.isEmpty && !model.hasMore { + ContentUnavailableView("No Earlier Versions", systemImage: "clock.arrow.circlepath", + description: Text("This item hasn’t been changed since it was made, or the server has kept no earlier version of it.")) + } + } + } + + private var loadMore: some View { + VStack(alignment: .leading, spacing: 4) { + if let failure = model.moreFailure { + Text(failure.message).interfaceFont(.callout).foregroundStyle(.secondary) + } + HStack { + Button("Load More") { model.loadMore() } + .disabled(model.isLoadingMore) + if model.isLoadingMore { ProgressView().controlSize(.small) } + } + } + .selectionDisabled() + } + + @ViewBuilder private var preview: some View { + if let snapshot = model.selected { + let catalogue = library.catalogue + let current = library.body(of: request.item)?.text + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + pane(String(localized: "This Version"), title: snapshot.title(in: catalogue), text: snapshot.text(in: catalogue)) + pane(String(localized: "Now"), title: library.item(request.item)?.title, text: current) + } + fields(of: snapshot, in: catalogue) + } + } else { + ContentUnavailableView("Choose a Version", systemImage: "text.alignleft", + description: Text("Pick one on the left to read it beside the item as it is now.")) + } + } + + private func pane(_ heading: String, title: String?, text: String?) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(heading).interfaceFont(.headline) + if let title, !title.isEmpty { Text(title).interfaceFont(.callout.bold()) } + ScrollView { + if let text { + let excerpt = TextExcerpt(text) + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: excerpt.text).textSelection(.enabled) + if excerpt.omitted > 0 { + Text("\(excerpt.omitted.formatted()) more characters aren’t shown.").foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("No text").foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(8) + .background(.background.secondary, in: .rect(cornerRadius: 6)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder private func fields(of snapshot: ItemSnapshot, in catalogue: Catalogue) -> some View { + let fields = snapshot.fields(in: catalogue) + if !fields.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("Other Fields in This Version").interfaceFont(.headline) + ForEach(Array(fields.enumerated()), id: \.offset) { _, field in + HStack(alignment: .firstTextBaseline) { + Text(verbatim: field.label).foregroundStyle(.secondary) + Text(verbatim: field.value).textSelection(.enabled).lineLimit(2) + } + .interfaceFont(.callout) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func when(_ snapshot: ItemSnapshot) -> String { + guard let written = snapshot.written else { return String(localized: "Version \(snapshot.version)") } + return written.formatted(date: .abbreviated, time: .shortened) + } + + private func who(_ snapshot: ItemSnapshot) -> String? { + guard let writer = snapshot.writer else { return nil } + guard writer.name.isEmpty else { return writer.name } + return switch writer.kind { + case .browser: String(localized: "Browser") + case .app: String(localized: "App") + case .key: String(localized: "Key") + case .local: String(localized: "This device") + } + } + + /// Why Restore is dimmed, in words, once a version is chosen. + private func reason(for check: RestoreCheck?) -> String? { + switch check { + case nil, .ready?: nil + case .unchanged?: String(localized: "The item already has this version’s text and title.") + case .unavailable(let reason)?: reason + } + } + + private func canRestore(_ check: RestoreCheck?) -> Bool { + if case .ready? = check { return !isRestoring } + return false + } + + private func restore() { + guard let snapshot = model.selected else { return } + isRestoring = true + // A restore refused leaves the sheet open, with the notice the library tells. + library.restoreVersionAfterSaving(snapshot, of: request.item, undoManager: request.undoManager) { restored in + isRestoring = false + if restored { close() } + } + } +} + +/// Opens the Version History sheet for one item, in the window whose presentation it is given. It stays available +/// offline and for a deleted item, since the sheet says plainly what it can't show. +struct VersionHistoryButton: View { + @CurrentLibrary private var library + let ids: Set + let details: DetailsPresentation? + let undoManager: UndoManager? + + var body: some View { + Button("Version History…", systemImage: "clock.arrow.circlepath") { + if let id = ids.first { details?.history = VersionHistoryRequest(item: id, undoManager: undoManager) } + } + .disabled(details == nil || ids.count != 1 || ids.first.flatMap { library.item($0) } == nil) + } +} + +/// The item the Version History sheet is open for, with the undo manager of the window it was asked for in, since +/// the sheet has its own. +struct VersionHistoryRequest: Identifiable { + let id = UUID() + let item: ItemID + let undoManager: UndoManager? +} + +extension ItemLibrary { + /// Restores the version once every open page has handed over what was just typed, so the restore is based on the + /// text the person sees. `done` says whether it was sent. + func restoreVersionAfterSaving( + _ snapshot: ItemSnapshot, of id: ItemID, undoManager: UndoManager?, then done: @escaping (Bool) -> Void + ) { + EditorController.flushAll(leaving: false) { [self] kept in + guard kept else { + notices.tell(String(localized: "Can’t Restore Version"), String(localized: "Finish editing the current note, then try again.")) + return done(false) + } + done(restoreVersion(snapshot, of: id, undoManager: undoManager)) + } + } +} diff --git a/MAPPTests/FakeItemLibrary.swift b/MAPPTests/FakeItemLibrary.swift index fe6186a..5862dca 100644 --- a/MAPPTests/FakeItemLibrary.swift +++ b/MAPPTests/FakeItemLibrary.swift @@ -253,6 +253,26 @@ final class FakeItemLibrary: ItemLibrary { return true } + /// Where `history` reads versions from; nil keeps none, as a library without a server does. + var history: (any ItemHistorySource)? + /// The versions restored, in order. + private(set) var restored: [(snapshot: ItemSnapshot, id: ItemID)] = [] + + func restoreVersion(_ snapshot: ItemSnapshot, of id: ItemID, undoManager: UndoManager?) -> Bool { + switch restoreCheck(of: snapshot, for: id) { + case .unchanged: + return true + case .unavailable(let reason): + notices.tell("Can’t Restore Version", reason) + return false + case .ready(let plan): + restored.append((snapshot, id)) + if let text = plan.text, let held = body(of: id) { updateBody(of: id, to: text, readAt: held.version) } + if let title = plan.title { change([id]) { $0.title = title } } + return true + } + } + func setFavorite(_ ids: Set, _ isFavorite: Bool, undoManager: UndoManager?) { change(ids) { item in if isFavorite { item.tags.insert(ItemTag.favorite) } else { item.tags.remove(ItemTag.favorite) } diff --git a/MAPPTests/VersionHistoryTests.swift b/MAPPTests/VersionHistoryTests.swift new file mode 100644 index 0000000..f108fdd --- /dev/null +++ b/MAPPTests/VersionHistoryTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Synchronization +import Testing +@testable import MAPP + +/// A history that answers from a list the test gives it, one answer for each request, and keeps what it was asked. +nonisolated final class FakeHistory: ItemHistorySource { + private let answers: Mutex<[Result]> + let cursors = Mutex<[String?]>([]) + + init(_ answers: [Result]) { + self.answers = Mutex(answers) + } + + func set(_ next: [Result]) { + answers.withLock { $0 = next } + } + + func page(of id: ItemID, after cursor: String?, limit: Int) async throws(ItemHistoryFailure) -> ItemHistoryPage { + cursors.withLock { $0.append(cursor) } + let answer = answers.withLock { $0.isEmpty ? nil : $0.removeFirst() } + return try (answer ?? .success(ItemHistoryPage(snapshots: [], next: nil))).get() + } +} + +private func snapshot( + _ version: Int, body: String? = "Old", title: String? = nil, type: TypeID = TypeChangeFixture.note +) -> ItemSnapshot { + var properties: [String: PropertyValue] = [:] + if let body { properties["body"] = .string(body) } + if let title { properties["title"] = .string(title) } + return ItemSnapshot(id: "v\(version)", version: version, type: type, properties: properties, written: nil, writer: nil) +} + +private func page(_ versions: [Int], next: String? = nil) -> Result { + .success(ItemHistoryPage(snapshots: versions.map { snapshot($0) }, next: next)) +} + +/// What the Version History sheet lists, and when a version can be put back. +@MainActor +@Suite struct VersionHistoryTests { + private static let id = ItemID("launch") + + private func loaded(_ source: FakeHistory?) async -> ItemHistoryModel { + let model = ItemHistoryModel(id: Self.id, source: source) + model.load() + _ = await eventually { model.phase != .loading } + return model + } + + @Test func versionsWithGapsAreListedOldestFirstAndNeverRepeated() async { + let source = FakeHistory([page([7, 3], next: "a"), page([], next: "b"), page([7, 12], next: nil)]) + let model = await loaded(source) + // An empty page with a cursor is followed without the person asking again. + model.loadMore() + _ = await eventually { !model.hasMore && !model.isLoadingMore } + #expect(model.snapshots.map(\.version) == [3, 7, 12]) + #expect(source.cursors.withLock { $0 } == [nil, "a", "b"]) + } + + @Test func aLongHistoryIsReadOnePageAtATime() async { + let source = FakeHistory([page([1, 2], next: "more")]) + let model = await loaded(source) + #expect(model.hasMore) + #expect(source.cursors.withLock { $0.count } == 1) + + source.set([page([3], next: nil)]) + model.loadMore() + #expect(await eventually { !model.hasMore }) + #expect(model.snapshots.map(\.version) == [1, 2, 3]) + } + + @Test func aCursorThatLeadsBackToItselfEndsTheList() async { + let source = FakeHistory([page([1], next: "a"), page([], next: "a")]) + let model = await loaded(source) + model.loadMore() + _ = await eventually { !model.isLoadingMore && !model.hasMore } + #expect(!model.hasMore) + #expect(model.snapshots.count == 1) + } + + @Test func noServerOrNoPermissionIsSaidPlainlyAndCanBeTriedAgain() async { + let source = FakeHistory([.failure(.unreachable)]) + let model = await loaded(source) + #expect(model.phase == .failed(.unreachable)) + #expect(!ItemHistoryFailure.unreachable.message.isEmpty) + + source.set([.failure(.notPermitted)]) + model.load() + #expect(await eventually { model.phase == .failed(.notPermitted) }) + + source.set([page([4])]) + model.load() + #expect(await eventually { model.phase == .loaded }) + #expect(model.snapshots.map(\.version) == [4]) + } + + @Test func aFailedLoadMoreKeepsTheVersionsAlreadyListed() async { + let source = FakeHistory([page([1], next: "more")]) + let model = await loaded(source) + source.set([.failure(.unreachable)]) + model.loadMore() + #expect(await eventually { model.moreFailure == .unreachable }) + #expect(model.snapshots.count == 1) + #expect(model.phase == .loaded) + } + + @Test func aLibraryWithNoHistoryHasNothingToLoad() async { + let model = await loaded(nil) + #expect(model.phase == .failed(.unsupported)) + } + + @Test func aLongTextIsCutForReadingAndSaysHowMuch() { + let excerpt = TextExcerpt(String(repeating: "a", count: 25), limit: 10) + #expect(excerpt.text.count == 10) + #expect(excerpt.omitted == 15) + } + + // MARK: Whether a version can be put back + + @Test func restoreIsOfferedOnlyWhereTheVersionDiffersAndTheItemCanTakeIt() { + let library = FakeItemLibrary(items: []) + library.catalogue = TypeChangeFixture.catalogue + let note = library.add("Agenda") + let deleted = library.add("Gone", state: .trashed) + + #expect(library.restoreCheck(of: snapshot(1, body: "Agenda"), for: note.id) == .unchanged) + #expect(library.restoreCheck(of: snapshot(1, body: "Old"), for: note.id) == .ready(RestorePlan(text: "Old"))) + // Recently Deleted is for reading only. + if case .unavailable = library.restoreCheck(of: snapshot(1), for: deleted.id) {} else { Issue.record("restorable in the bin") } + // A version of a kind with no text or title MAPP edits can be read, not restored. + let unknown = snapshot(1, body: nil, type: TypeID("elsewhere.kind")) + if case .unavailable = library.restoreCheck(of: unknown, for: note.id) {} else { Issue.record("restorable without text") } + // An item deleted elsewhere while the sheet was open. + if case .unavailable = library.restoreCheck(of: snapshot(1), for: ItemID("missing")) {} else { Issue.record("restorable when gone") } + } + + @Test func restoringFromTheFakeLibraryChangesTheTextOnce() { + let library = FakeItemLibrary(items: []) + library.catalogue = TypeChangeFixture.catalogue + let note = library.add("Agenda") + #expect(library.restoreVersion(snapshot(1, body: "Old"), of: note.id, undoManager: nil)) + #expect(library.body(of: note.id)?.text == "Old") + // The version now current is not written again. + #expect(library.restoreVersion(snapshot(1, body: "Old"), of: note.id, undoManager: nil)) + #expect(library.restored.count == 1) + } +} + +/// Restoring a version through `MarfaLibrary`: the edit it sends, its undo, and what it refuses. +@MainActor +@Suite struct MarfaRestoreTests { + typealias F = TypeChangeFixture + private static let launch = ItemID("launch") + + private static func note(title: String, version: Int64 = 1) -> MAPP.Item { + let date = Date(timeIntervalSince1970: 1_000_000) + return MAPP.Item( + id: launch, type: F.note, title: title, titleRule: .property("title"), + properties: [Property(key: "title", value: .string(title))], version: ItemVersion(version), created: date, modified: date) + } + + private func loaded() async throws -> (MarfaLibrary, FakeItemSource) { + let source = FakeItemSource() + source.setBody("Agenda", of: Self.launch) + let library = MarfaLibrary(source: source, notices: Notices()) + library.handOverEdits = { $0(true) } + source.send(.loaded(SourceSnapshot(items: [Self.note(title: "Launch")], catalogue: F.catalogue))) + try #require(await eventually { library.isLoaded }) + return (library, source) + } + + private let earlier = snapshot(1, body: "Old agenda", title: "Old launch") + + @Test func restoringSendsTheTextAndTitleAsNewEditsOnTheCurrentVersion() async throws { + let (library, source) = try await loaded() + let undo = UndoManager() + #expect(library.restoreVersion(earlier, of: Self.launch, undoManager: undo)) + #expect(undo.undoActionName == String(localized: "Restore Version")) + guard case .editBody(Self.launch, "Old agenda", let base, _)? = source.writes.first(where: { if case .editBody = $0 { true } else { false } }) + else { Issue.record("no text edit"); return } + #expect(base == ItemVersion(1)) + #expect(source.writes.contains(.editProperties(Self.launch, ["title": .string("Old launch")], base: ItemVersion(1), undoing: nil))) + } + + @Test func undoPutsBackWhatWasReplacedOnlyWhileItIsUnchanged() async throws { + let (library, source) = try await loaded() + let undo = UndoManager() + library.restoreVersion(earlier, of: Self.launch, undoManager: undo) + source.setBody("Old agenda", of: Self.launch, version: 3) + source.send(.upserted([Self.note(title: "Old launch", version: 3)])) + try #require(await eventually { library.item(Self.launch)?.title == "Old launch" }) + + undo.undo() + #expect(source.writes.contains { if case .editBody(_, "Agenda", _, _) = $0 { true } else { false } }) + + // Typed over meanwhile, so Undo says so rather than replace it. + let (second, other) = try await loaded() + let again = UndoManager() + second.restoreVersion(earlier, of: Self.launch, undoManager: again) + for write in other.submitted { other.send(.answered(write.id, .accepted)) } + let before = other.writes.count + other.setBody("Typed since", of: Self.launch, version: 3) + other.send(.upserted([Self.note(title: "Old launch", version: 3)])) + try #require(await eventually { second.body(of: Self.launch)?.text == "Typed since" }) + try #require(await eventually { second.item(Self.launch)?.title == "Old launch" }) + again.undo() + // The title is still what the restore made it, so it alone is put back; the typed text is left, and told. + try #require(await eventuallyAsking { + other.writes.dropFirst(before).contains { if case .editProperties(_, let changes, _, _) = $0 { changes["title"] == .string("Launch") } else { false } } + }) + #expect(!other.writes.dropFirst(before).contains { if case .editBody = $0 { true } else { false } }) + #expect(second.notices.waiting.last?.title == String(localized: "Can’t Restore Version")) + } + + @Test func aVersionTheItemAlreadyHoldsIsNotWrittenAgain() async throws { + let (library, source) = try await loaded() + #expect(library.restoreVersion(snapshot(1, body: "Agenda", title: "Launch"), of: Self.launch, undoManager: nil)) + #expect(source.writes.isEmpty) + } + + @Test func anItemDeletedElsewhereRefusesTheRestoreWithTheNoticeAndKeepsItsText() async throws { + let (library, source) = try await loaded() + source.send(.removed([Self.launch])) + try #require(await eventually { library.item(Self.launch) == nil }) + #expect(!library.restoreVersion(earlier, of: Self.launch, undoManager: nil)) + #expect(library.notices.waiting.last?.title == String(localized: "Can’t Restore Version")) + #expect(source.writes.isEmpty) + } +}