diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index 5a3f12581274e1c45c1c4c80923398f4f73d22cf..55abfc2e39be7e6339f7bff1a13422bd18d7d168 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -287,6 +287,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro split.show(.primary) } + /// Lets go of the section shown where it is the section or group at `path` of `notebook` + /// or within it, as before its file moves; its path and page, to open once moved. + func release(_ path: String, of notebook: Notebook) -> (section: String, page: String?)? { + guard let section = pages.section, section.notebook === notebook, + section.tab.path == path || section.tab.path.hasPrefix(path + "/") + else { return nil } + let shown = (section.tab.path, Place.saved?.page) + close(notebook) + return shown + } + /// Adds a page to the open section and opens it, its title ready for typing. func newPage(subpage: Bool = false) { guard let section = pages.section else { return } diff --git a/apps/ios/Snowbound/Arranging.swift b/apps/ios/Snowbound/Arranging.swift new file mode 100644 index 0000000000000000000000000000000000000000..fa9fef9c03df21713e2424a0882dd7935bdcb547 --- /dev/null +++ b/apps/ios/Snowbound/Arranging.swift @@ -0,0 +1,669 @@ +import UIKit + +/// A page's place in its section's order, as `sb_section_arrange` takes it. +struct Placed: Codable, Equatable { + let id: String + var level: Int +} + +/// Where `Arranging.move(page:)` took a page, from `sb_library_move_page`. +private struct MovedPage: Decodable { + let page: String + let added: String? + let before: String? + let level: Int +} + +/// The catalog path of the folder holding the section or group at `path`; "" for the +/// notebook's top. +func parentFolder(of path: String) -> String { + path.range(of: "/", options: .backwards).map { String(path[..<$0.lowerBound]) } ?? "" +} + +private func json(_ value: some Encodable) -> String { + (try? JSONEncoder().encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? "" +} + +/// Moves of pages, sections, section groups and notebooks, each made as the desktop makes it, +/// as one edit through the notebook, and kept for Undo: shake, three-finger swipe or ⌘Z in +/// either list. +enum Arranging { + static let history = UndoManager() + private static let owner = NSObject() + + /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own. + static let keyCommands = [ + UIKeyCommand(title: "Undo", action: #selector(UIResponder.undoArranging), input: "z", modifierFlags: .command), + UIKeyCommand(title: "Redo", action: #selector(UIResponder.redoArranging), input: "z", modifierFlags: [.command, .shift]), + ] + + /// Keeps `undo` as Undo for `name`. Called as the change starts, so that a change Undo + /// makes keeps its own undo as Redo. + private static func done(_ name: String, undo: @escaping () -> Void) { + history.registerUndo(withTarget: owner) { _ in undo() } + history.setActionName(name) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + /// Runs `work` off the main thread after the changes before it, then `then` with its result + /// on the main thread. + private static func later(_ work: @escaping () -> T, then: @escaping (T) -> Void) { + queue.async { + let result = work() + DispatchQueue.main.async { then(result) } + } + } + private static let queue = DispatchQueue(label: "Arranging") + + private static func failed(_ title: String) { + let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first + var top = window?.rootViewController + while let presented = top?.presentedViewController { top = presented } + top?.alert(title, "Check that the notebook can be reached, then try again.") + } + + /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under + /// the page now above it, as the desktop drops a dragged page. + static func order(_ rows: [Row], moving id: String, to index: Int) -> [Placed] { + var order = rows.map { Placed(id: $0.id, level: $0.level) } + guard let from = order.firstIndex(where: { $0.id == id }) else { return order } + var page = order.remove(at: from) + let at = min(max(index, 0), order.count) + page.level = min(page.level, at == 0 ? 1 : order[at - 1].level + 1) + order.insert(page, at: at) + return order + } + + /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit. + static func arrange(_ section: Section, _ order: [Placed], moving id: String) { + let before = section.rows.map { Placed(id: $0.id, level: $0.level) } + guard order != before, section.arrange(order, moving: id) else { return } + let (notebook, path) = (section.notebook, section.tab.path) + done("Move Page") { + opening(path, of: notebook) { arrange($0, before, moving: id) } + } + } + + /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote + /// Subpage do. + static func indent(_ section: Section, _ id: String, to level: Int) { + var order = section.rows.map { Placed(id: $0.id, level: $0.level) } + guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return } + let was = order[at].level + order[at].level = level + guard section.arrange(order, moving: id) else { return } + let (notebook, path) = (section.notebook, section.tab.path) + done(level > was ? "Make Subpage" : "Promote Subpage") { + opening(path, of: notebook) { indent($0, id, to: was) } + } + } + + /// Hands `then` the section at `path` of `notebook`, opening it where it isn't open. + private static func opening(_ path: String, of notebook: Notebook, then: @escaping (Section) -> Void) { + guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return } + Section.open(tab, of: notebook) { section, _ in section.map(then) } + } + + /// Moves page `id` of the section at `from` into the section at `to`, before `before` at + /// `level` (last without), as OneNote moves a page dropped on a section's tab. + static func move( + page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1, + discard: String? = nil + ) { + guard let library = notebook.handle else { return } + let pointer = Int(bitPattern: library) + let author = Author.name ?? "" + let (date, time) = titleDate() + // Undo takes back what the move reports, once it has. + var reported: MovedPage? + done("Move Page") { + guard let moved = reported else { return } + move( + page: moved.page, of: notebook, from: to, to: from, before: moved.before, level: moved.level, + discard: moved.added) + } + later({ + decode( + MovedPage.self, + sb_library_move_page( + OpaquePointer(bitPattern: pointer), from, id, to, before, UInt32(level), discard, author, date, + time)) + }) { moved in + reported = moved + for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) { + section.listed() + } + if moved == nil { failed("Can’t Move Page") } + } + } + + /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order` + /// names its entries once moved; a section shown opens again where it went. + static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = []) { + guard let library = notebook.handle else { return } + let home = parentFolder(of: path) + let name = path.split(separator: "/").last.map(String.init) ?? path + let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name + let undo = notebook.entries(in: home) + done("Move Section") { place(placed, of: notebook, in: home, order: undo) } + let scenes = UIApplication.shared.connectedScenes.compactMap { $0.delegate as? SceneDelegate } + let released = home == folder ? [] : scenes.compactMap { scene in scene.release(path, of: notebook).map { (scene, $0) } } + let pointer = Int(bitPattern: library) + let paths = json(order) + later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in + notebook.reload { + NotificationCenter.default.post(name: Notebook.listed, object: notebook) + for (scene, shown) in released { + let section = moved ? follow(shown.section, from: path, to: placed) : shown.section + if let tab = notebook.tabs.first(where: { $0.path == section }) { + scene.open(tab, of: notebook, page: shown.page) + } + } + if !moved { failed("Can’t Move Section") } + } + } + } + + /// `current`, a section's path, after the section or group at `from` went to `to`. + private static func follow(_ current: String, from: String, to: String) -> String { + current == from ? to : current.hasPrefix(from + "/") ? to + current.dropFirst(from.count) : current + } + + /// Puts `notebook` at `index` among the notebooks of its place in the list. + static func move(_ notebook: Notebook, to index: Int) { + guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else { + return + } + Notebooks.move(notebook, to: index) + NotificationCenter.default.post(name: Notebook.listed, object: notebook) + done("Move Notebook") { move(notebook, to: from) } + } +} + +extension UIResponder { + @objc func undoArranging() { Arranging.history.undo() } + @objc func redoArranging() { Arranging.history.redo() } +} + +extension Section { + /// Puts page `id` where `order` places it, as one edit; false where it isn't listed. + func arrange(_ order: [Placed], moving id: String) -> Bool { + guard sb_section_arrange(handle, json(order), json([id])) else { return false } + listed() + return true + } + + /// Lists the pages again and tells the views. + func listed() { + reloadRows() + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + } +} + +extension Notebook { + /// The section groups, by catalog path, in the order the list shows them. + var groups: [String] { + var groups: [String] = [] + for tab in tabs { + var path = "" + for part in tab.group.split(separator: "/") { + path += (path.isEmpty ? "" : "/") + part + if !groups.contains(path) { groups.append(path) } + } + } + return groups + } + + /// The sections and groups directly in `folder`, by catalog path, as the list shows them. + func entries(in folder: String) -> [String] { + tabs.filter { $0.group == folder }.map(\.path) + groups.filter { parentFolder(of: $0) == folder } + } + + /// Where the section or group at `path` can move: the notebook's top and its groups, by + /// catalog path, leaving out its own folder, itself and its groups. + func destinations(of path: String) -> [String] { + ([""] + groups).filter { $0 != parentFolder(of: path) && $0 != path && !$0.hasPrefix(path + "/") } + } + + /// The sections a page of the section at `path` can move to. + func sections(besides path: String) -> [Tab] { + tabs.filter { $0.path != path && $0.readable && !$0.locked } + } +} + +/// A page dragged out of the page list. +final class PageDrag { + let section: Section + let id: String + + init(section: Section, id: String) { + self.section = section + self.id = id + } +} + +/// A sheet offering `actions`, for VoiceOver’s Move actions, from `view`. +private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) { + let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + for action in actions { + sheet.addAction(UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) }) + } + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + sheet.popoverPresentationController?.sourceView = view + sheet.popoverPresentationController?.sourceRect = view.bounds + controller.present(sheet, animated: true) +} + +// MARK: Pages + +extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate { + func setUpArranging() { + tableView.dragDelegate = self + tableView.dropDelegate = self + tableView.dragInteractionEnabled = true + navigationItem.rightBarButtonItem = editButtonItem + } + + override var canBecomeFirstResponder: Bool { true } + override var undoManager: UndoManager? { Arranging.history } + override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands } + + /// Undo in the list takes back its moves. + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder() + } + + /// Page `id` moved to `index` of the section's pages. + private func move(_ id: String, to index: Int) { + guard let section else { return } + Arranging.arrange(section, Arranging.order(section.rows, moving: id, to: index), moving: id) + becomeFirstResponder() + } + + /// Page `id` a level deeper or shallower, where it stays. + private func indent(_ id: String, by change: Int) { + guard let section, let row = section.rows.first(where: { $0.id == id }) else { return } + Arranging.indent(section, id, to: row.level + change) + becomeFirstResponder() + } + + /// Whether page `at` of `rows` can become a subpage, as the desktop allows: under a page + /// at its level or deeper, at most three deep. + private func indentable(_ rows: [Row], _ at: Int) -> Bool { + at > 0 && rows[at].level < 3 && rows[at - 1].level >= rows[at].level + } + + private func moveActions(_ item: Item) -> [UIAction] { + guard let section else { return [] } + return section.notebook.sections(besides: section.tab.path).map { tab in + UIAction( + title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group, + image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal) + ) { [weak self] _ in + Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path) + self?.becomeFirstResponder() + } + } + } + + /// Make Subpage, Promote Subpage and Move to Section, for a page's menu. + func arrangeMenu(_ item: Item) -> [UIMenuElement] { + guard let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) else { return [] } + let id = item.row.id + var actions: [UIMenuElement] = [ + UIAction( + title: "Make Subpage", image: UIImage(systemName: "increase.indent"), + attributes: indentable(rows, at) ? [] : .disabled + ) { [weak self] _ in self?.indent(id, by: 1) }, + UIAction( + title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"), + attributes: rows[at].level > 1 ? [] : .disabled + ) { [weak self] _ in self?.indent(id, by: -1) }, + ] + let moves = moveActions(item) + if !moves.isEmpty { + actions.append(UIMenu(title: "Move to Section", image: UIImage(systemName: "arrow.right.doc.on.clipboard"), children: moves)) + } + return [UIMenu(options: .displayInline, children: actions)] + } + + /// VoiceOver's ways to move a page without dragging it. + func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? { + guard item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) + else { return nil } + let id = item.row.id + var actions: [UIAccessibilityCustomAction] = [] + let add = { (name: String, act: @escaping () -> Void) in + actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true }) + } + if at > 0 { add("Move Up") { [weak self] in self?.move(id, to: at - 1) } } + if at < rows.count - 1 { add("Move Down") { [weak self] in self?.move(id, to: at + 1) } } + if indentable(rows, at) { add("Make Subpage") { [weak self] in self?.indent(id, by: 1) } } + if rows[at].level > 1 { add("Promote Subpage") { [weak self] in self?.indent(id, by: -1) } } + if !moveActions(item).isEmpty { + add("Move to Section…") { [weak self] in + guard let self, let row = items.firstIndex(where: { $0.id == id }), + let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) + else { return } + sheet("Move to Section", moveActions(item), from: cell, in: self) + } + } + return actions + } + + override func tableView( + _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + // A swipe across the list changes section instead. + guard !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil, + let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id }) + else { return nil } + let id = rows[at].id + var actions: [UIContextualAction] = [] + if indentable(rows, at) { + let indent = UIContextualAction(style: .normal, title: "Make Subpage") { [weak self] _, _, done in + self?.indent(id, by: 1) + done(true) + } + indent.image = UIImage(systemName: "increase.indent") + indent.backgroundColor = .systemIndigo + actions.append(indent) + } + if rows[at].level > 1 { + let promote = UIContextualAction(style: .normal, title: "Promote Subpage") { [weak self] _, _, done in + self?.indent(id, by: -1) + done(true) + } + promote.image = UIImage(systemName: "decrease.indent") + promote.backgroundColor = .systemTeal + actions.append(promote) + } + return UISwipeActionsConfiguration(actions: actions) + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + items[indexPath.row].version == nil + } + + override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) { + var moved = items + let item = moved.remove(at: from.row) + moved.insert(item, at: to.row) + guard let index = moved.filter({ $0.version == nil }).firstIndex(where: { $0.id == item.id }) else { return } + // The table finishes its move before the list reloads. + DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) } + } + + override func tableView( + _ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath + ) -> UITableViewCell.EditingStyle { + tableView.isEditing ? .none : .delete + } + + override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + false + } + + func tableView( + _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath + ) -> [UIDragItem] { + let item = items[indexPath.row] + guard let section, item.version == nil else { return [] } + let drag = UIDragItem(itemProvider: NSItemProvider()) + drag.localObject = PageDrag(section: section, id: item.row.id) + return [drag] + } + + func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool { + true + } + + /// A wide window shows the notebooks beside the pages while a page is dragged, so it can + /// be dropped on a section there. + func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) { + guard let split = splitViewController, !split.isCollapsed, split.displayMode != .twoBesideSecondary else { return } + session.localContext = split.preferredDisplayMode + split.preferredDisplayMode = .twoBesideSecondary + } + + func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) { + guard let mode = session.localContext as? UISplitViewController.DisplayMode else { return } + splitViewController?.preferredDisplayMode = mode + } + + func tableView( + _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath? + ) -> UITableViewDropProposal { + let page = session.localDragSession?.items.first?.localObject as? PageDrag + guard let page, page.section === section else { + return UITableViewDropProposal(operation: .forbidden) + } + return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + + /// Drops within the list reorder through `moveRowAt`. + func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {} +} + +// MARK: Notebooks, sections and groups + +extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionViewDropDelegate { + func setUpArranging() { + collectionView.dragDelegate = self + collectionView.dropDelegate = self + collectionView.dragInteractionEnabled = true + // A sidebar's trailing edge has no room left for it. + if UIDevice.current.userInterfaceIdiom == .pad { + navigationItem.leftBarButtonItem = editButtonItem + } else { + navigationItem.rightBarButtonItems?.append(editButtonItem) + } + // Rows dragged between rows, and by Edit's reorder handles, move through the data source. + dataSource.reorderingHandlers.canReorderItem = { [weak self] item in self?.movable(item) == true } + // The data source is still applying the move when this runs. + dataSource.reorderingHandlers.didReorder = { [weak self] transaction in + DispatchQueue.main.async { self?.reordered(transaction) } + } + } + + override var canBecomeFirstResponder: Bool { true } + override var undoManager: UndoManager? { Arranging.history } + override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands } + + /// Undo in the list takes back its moves. + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder() + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + collectionView.isEditing = editing + collectionView.dragInteractionEnabled = !editing + } + + private func movable(_ item: Item) -> Bool { + switch item { + case .notebook, .group, .section: true + default: false + } + } + + /// The notebook and catalog path of a section or group row. + private func entry(_ item: Item) -> (notebook: Notebook, path: String)? { + switch item { + case .group(let id, let path), .section(let id, let path): notebook(id).map { ($0, path) } + default: nil + } + } + + /// The group or notebook top a row stands for, as a folder an entry of `notebook` can go into. + private func folder(_ item: Item, in notebook: Notebook) -> String? { + switch item { + case .notebook(let id) where id == notebook.id: "" + case .group(let id, let path) where id == notebook.id: path + default: nil + } + } + + /// Moves the section or group at `path` within its folder by `offset`. + private func shift(_ path: String, of notebook: Notebook, by offset: Int) { + var order = notebook.entries(in: parentFolder(of: path)) + guard let at = order.firstIndex(of: path), order.indices.contains(at + offset) else { return } + order.swapAt(at, at + offset) + Arranging.place(path, of: notebook, in: parentFolder(of: path), order: order) + becomeFirstResponder() + } + + private func moveActions(_ path: String, of notebook: Notebook) -> [UIAction] { + notebook.destinations(of: path).map { folder in + UIAction( + title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "), + image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder") + ) { [weak self] _ in + Arranging.place(path, of: notebook, in: folder) + self?.becomeFirstResponder() + } + } + } + + /// Move, into the notebook's top or a group, for a section's or group's menu; Move Up and + /// Move Down for a notebook's, as the desktop's. + func arrangeMenu(_ item: Item) -> [UIMenuElement] { + if case .notebook(let id) = item, let notebook = notebook(id) { + let siblings = Notebooks.siblings(of: notebook) + guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] } + return [ + UIMenu(options: .displayInline, children: [ + UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: at > 0 ? [] : .disabled) { + _ in Arranging.move(notebook, to: at - 1) + }, + UIAction( + title: "Move Down", image: UIImage(systemName: "arrow.down"), + attributes: at < siblings.count - 1 ? [] : .disabled + ) { _ in Arranging.move(notebook, to: at + 1) }, + ]) + ] + } + guard let (notebook, path) = entry(item) else { return [] } + let moves = moveActions(path, of: notebook) + return moves.isEmpty ? [] : [UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: moves)] + } + + /// Adds Edit's reorder handle and VoiceOver's ways to move to a row. + func arranging(_ cell: UICollectionViewListCell, _ item: Item) { + if movable(item) { cell.accessories.append(.reorder(displayed: .whenEditing)) } + var actions: [UIAccessibilityCustomAction] = [] + let add = { (name: String, act: @escaping () -> Void) in + actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true }) + } + if case .notebook(let id) = item, let notebook = notebook(id) { + let siblings = Notebooks.siblings(of: notebook) + if let at = siblings.firstIndex(where: { $0 === notebook }) { + if at > 0 { add("Move Up") { Arranging.move(notebook, to: at - 1) } } + if at < siblings.count - 1 { add("Move Down") { Arranging.move(notebook, to: at + 1) } } + } + } else if let (notebook, path) = entry(item) { + let order = notebook.entries(in: parentFolder(of: path)) + if let at = order.firstIndex(of: path) { + if at > 0 { add("Move Up") { [weak self] in self?.shift(path, of: notebook, by: -1) } } + if at < order.count - 1 { add("Move Down") { [weak self] in self?.shift(path, of: notebook, by: 1) } } + } + if !notebook.destinations(of: path).isEmpty { + add("Move…") { [weak self, weak cell] in + guard let self, let cell else { return } + sheet("Move", moveActions(path, of: notebook), from: cell, in: self) + } + } + } + cell.accessibilityCustomActions = actions + } + + /// Carries out a move the data source made: a notebook among its place's, or a + /// section or group into the folder it landed in; anywhere else it goes back. + private func reordered(_ transaction: NSDiffableDataSourceTransaction) { + becomeFirstResponder() + let moved = transaction.difference.insertions.lazy.compactMap { change -> Item? in + if case .insert(_, let item, _) = change { return item } + return nil + }.first + guard let moved, let list = transaction.sectionTransactions.first?.finalSnapshot, + let parent = list.parent(of: moved) + else { return reload() } + let siblings = list.snapshot(of: parent).rootItems + if case .notebook(let id) = moved, let notebook = notebook(id), case .location = parent { + let notebooks = siblings.filter { if case .notebook = $0 { true } else { false } } + return Arranging.move(notebook, to: notebooks.firstIndex(of: moved) ?? 0) + } + guard let (notebook, path) = entry(moved), let folder = folder(parent, in: notebook), + folder != path, !folder.hasPrefix(path + "/") + else { return reload() } + let name = path.split(separator: "/").last.map(String.init) ?? path + let order = siblings.compactMap { sibling in + sibling == moved ? (folder.isEmpty ? name : folder + "/" + name) : entry(sibling)?.path + } + Arranging.place(path, of: notebook, in: folder, order: order) + } + + func collectionView( + _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath + ) -> [UIDragItem] { + guard let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] } + let drag = UIDragItem(itemProvider: NSItemProvider()) + drag.localObject = item + return [drag] + } + + func collectionView( + _ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession + ) -> Bool { + true + } + + func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool { + session.localDragSession != nil + } + + /// The row under `session`'s touch. + private func row(under session: UIDropSession) -> (IndexPath, Item, UICollectionViewCell)? { + let point = session.location(in: collectionView) + guard let indexPath = collectionView.indexPathForItem(at: point), + let item = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) + else { return nil } + return (indexPath, item, cell) + } + + /// The section a dragged page would move to, under the touch. + private func section(for page: PageDrag, under session: UIDropSession) -> Tab? { + guard let (_, item, _) = row(under: session), case .section(let id, let path) = item, + id == page.section.notebook.id + else { return nil } + return page.section.notebook.sections(besides: page.section.tab.path).first { $0.path == path } + } + + func collectionView( + _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, + withDestinationIndexPath destinationIndexPath: IndexPath? + ) -> UICollectionViewDropProposal { + let dragged = session.localDragSession?.items.first?.localObject + if let page = dragged as? PageDrag { + guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) } + return UICollectionViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath) + } + guard dragged is Item else { return UICollectionViewDropProposal(operation: .forbidden) } + // A row dropped between rows moves through the data source's reordering handlers. + return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + + func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { + guard let drop = coordinator.items.first else { return } + let session = coordinator.session + guard let page = drop.dragItem.localObject as? PageDrag, let tab = section(for: page, under: session), + let (indexPath, _, cell) = row(under: session) + else { return } + coordinator.drop(drop.dragItem, intoItemAt: indexPath, rect: cell.bounds) + Arranging.move(page: page.id, of: page.section.notebook, from: page.section.tab.path, to: tab.path) + becomeFirstResponder() + } +} diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index f21abbe15f369c6b5a66f60203fe30484c30fd7f..c59281f0994379bed71c292b5a499054e07e88ac 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -4,7 +4,7 @@ import UniformTypeIdentifiers /// The places notebooks are kept, each listing its notebooks with their sections, as Files /// lists its locations and Notes each account's folders. final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate { - private enum Location: Hashable { + enum Location: Hashable { /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on. case icloud /// Snowbound's Documents, which Files shows as Snowbound's folder. @@ -13,7 +13,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke case elsewhere } - private enum Item: Hashable { + enum Item: Hashable { case location(Location) case notebook(String) /// A section group, by its `/`-separated path. @@ -23,7 +23,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke case status(notebook: String) } - private var dataSource: UICollectionViewDiffableDataSource! + var dataSource: UICollectionViewDiffableDataSource! private var collapsed: Set = [] /// What each notebook's row shows of its sync status, by notebook id. private var attention: [String: Sync.Attention] = [:] @@ -63,6 +63,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } let cell = UICollectionView.CellRegistration { [weak self] cell, _, item in self?.configure(cell, item) + self?.arranging(cell, item) } dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item) @@ -138,6 +139,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more), ] navigationItem.searchController = SearchViewController.controller() + setUpArranging() let corner = Prototype.quickNote ? [QuickNote.item()] : [] if #available(iOS 26, *) { // Search sits in the bottom toolbar, as Notes and Files put it. @@ -151,7 +153,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in guard let self else { return } - reload() + reload(animated: true) var shown = dataSource.snapshot() shown.reconfigureItems(shown.itemIdentifiers) dataSource.apply(shown, animatingDifferences: false) @@ -165,9 +167,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false) } - private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } + func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } } - func reload() { + func reload(animated: Bool = false) { guard isViewLoaded else { return } if Prototype.welcome { let welcome = Prototype.welcome( @@ -194,9 +196,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } else { contentUnavailableConfiguration = nil } - var sections = NSDiffableDataSourceSnapshot() - sections.appendSections(locations.map(\.0)) - dataSource.apply(sections, animatingDifferences: false) + // Locations kept keep their rows, so a move animates. + if dataSource.snapshot().sectionIdentifiers != locations.map(\.0) { + var sections = NSDiffableDataSourceSnapshot() + sections.appendSections(locations.map(\.0)) + dataSource.apply(sections, animatingDifferences: false) + } for (location, notebooks) in locations { var list = NSDiffableDataSourceSectionSnapshot() let header = Item.location(location) @@ -221,7 +226,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke } } list.expand(list.items.filter { !collapsed.contains($0) }) - dataSource.apply(list, to: location, animatingDifferences: false) + dataSource.apply(list, to: location, animatingDifferences: animated) } showSelection() refreshSync() @@ -583,10 +588,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke override func collectionView( _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint ) -> UIContextMenuConfiguration? { - guard indexPaths.count == 1, case .notebook(let id) = dataSource.itemIdentifier(for: indexPaths[0]), - let notebook = notebook(id) - else { return nil } - return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.menu(for: notebook) }) + guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil } + guard case .notebook(let id) = item, let notebook = notebook(id) else { + let moves = arrangeMenu(item) + return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) }) + } + return UIContextMenuConfiguration(actionProvider: { [weak self] _ in + UIMenu(children: (self?.menu(for: notebook).children ?? []) + (self?.arrangeMenu(item) ?? [])) + }) } override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { @@ -807,14 +816,14 @@ extension UIFont { /// A section's pages, subpages indented, each page's conflict pages beneath it. final class PagesViewController: UITableViewController { - private struct Item { + struct Item { let row: Row let version: Row.Version? var id: String { version?.id ?? row.id } } private(set) var section: Section? - private var items: [Item] = [] + private(set) var items: [Item] = [] private var selected: String? private lazy var search = SearchViewController.controller { [weak self] in self?.section } /// Opens a page of the section. @@ -847,6 +856,7 @@ final class PagesViewController: UITableViewController { } NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) + setUpArranging() if Prototype.sectionStrip { for direction in [UISwipeGestureRecognizer.Direction.left, .right] { let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped)) @@ -981,6 +991,7 @@ final class PagesViewController: UITableViewController { } } cell.contentConfiguration = content + cell.accessibilityCustomActions = arrangeActions(item) return cell } @@ -1014,6 +1025,7 @@ final class PagesViewController: UITableViewController { actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in self?.newPage(under: item.row.id) }) + actions += self?.arrangeMenu(item) ?? [] } actions.append( UIAction( diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index 88f959e34e99093a551b56921eb91a92d7b38982..0474147c78e2e58412ea1fe8ec14217bfdffc191 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -447,6 +447,8 @@ enum Notebooks { private static let key = "notebooks" private static let hidden = "hidesOnDevice" + /// The notebooks' ids in the order the reader put them in. + private static let order = "notebookOrder" /// The Snowbound Guide's folder, in the app and in Documents once copied there. static let guide = "Snowbound Guide" /// Whether the list offers the guide; off until Clover has read it through. @@ -478,13 +480,13 @@ enum Notebooks { if let data = UserDefaults.standard.data(forKey: key), let entries = try? JSONDecoder().decode([Entry].self, from: data) { - elsewhere = entries.compactMap { entry in + elsewhere = ordered(entries.compactMap { entry in switch entry.source { case .documents, .icloud: return nil default: break } return Notebook(id: entry.id, name: entry.name, source: entry.source) - } + }) } else { // The notebook the first version remembered from Files. if let bookmark = UserDefaults.standard.data(forKey: "notebook") { @@ -522,16 +524,43 @@ enum Notebooks { return [] } var added: [Notebook] = [] - list = notebooks(in: folder).map { [list] name in - if let kept = list.first(where: { $0.id == prefix + name }) { return kept } - let notebook = Notebook( - id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) - added.append(notebook) - return notebook - } + list = ordered( + notebooks(in: folder).map { [list] name in + if let kept = list.first(where: { $0.id == prefix + name }) { return kept } + let notebook = Notebook( + id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) + added.append(notebook) + return notebook + }) return added } + /// `list` in the reader's order, those it doesn't name after in their own. + private static func ordered(_ list: [Notebook]) -> [Notebook] { + let order = UserDefaults.standard.stringArray(forKey: order) ?? [] + let rank = { (notebook: Notebook) in order.firstIndex(of: notebook.id) ?? order.count } + return list.enumerated().sorted { (rank($0.element), $0.offset) < (rank($1.element), $1.offset) }.map(\.element) + } + + /// The notebooks listed in the same place as `notebook`, in order. + static func siblings(of notebook: Notebook) -> [Notebook] { + [inCloud, onDevice, elsewhere].first { $0.contains { $0 === notebook } } ?? [] + } + + /// Puts `notebook` at `index` among the notebooks of its place, and keeps that order. + static func move(_ notebook: Notebook, to index: Int) { + func move(in list: inout [Notebook]) { + guard let at = list.firstIndex(where: { $0 === notebook }) else { return } + let moved = list.remove(at: at) + list.insert(moved, at: min(max(index, 0), list.count)) + } + move(in: &inCloud) + move(in: &onDevice) + move(in: &elsewhere) + guard scripted == nil else { return } + UserDefaults.standard.set(all.map(\.id), forKey: order) + } + /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as /// when another device added or removed one. static var inCloudChanged: Bool { diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index d73295f43992e8a5fcbc3b4efc11a4816e64b86e..50e3a07c7c1c72554b887cc0188fd10881a7b9fd 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -32,6 +32,9 @@ char *sb_library_sections(const Library *library); int32_t sb_library_color(const Library *library); char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, const char *date, const char *time); +char *sb_library_move_page(const Library *library, const char *from, const char *id, const char *to, const char *before, + uint32_t level, const char *discard, const char *author, const char *date, const char *time); +char *sb_library_place(const Library *library, const char *path, const char *folder, const char *paths); char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query); char *sb_library_tagged(const Library *library); bool sb_library_unlock(const Library *library, const char *path, const char *password); @@ -55,6 +58,7 @@ char *sb_section_pages(const Section *section); uint32_t sb_section_poll(const Section *section, uint8_t *status); char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); +bool sb_section_arrange(const Section *section, const char *order, const char *moved); char *sb_section_themes(const Section *section, const char *id); bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); bool sb_section_flush(const Section *section, double seconds); diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 6c7a0d6dc40246ae5041ceebac633b6dbf76b5d3..f8bc42ed49e51bf95232a199f49cfc07a9859f50 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -931,6 +931,20 @@ impl Section { }) } + /// Puts the pages `moved` where `order` lists them, at their levels, as one edit; false + /// where none of them is listed any more. + pub(crate) fn arrange(&self, order: &[(ExGuid, u32)], moved: &[ExGuid]) -> Result { + let edits = session::arrange(&self.shared.section.pages()?, order, moved)?; + if edits.is_empty() { + return Ok(false); + } + self.shared.apply(Edit { + at: filetime(), + ops: vec![Op::Section(SectionOp::Pages(edits))], + })?; + Ok(true) + } + /// Applies the events since the last poll to the shared status. pub(crate) fn poll(&self) -> u32 { let mut flags = 0; @@ -1205,6 +1219,130 @@ impl Library { Ok(()) } + /// Runs `change` on the section at catalog `path`: through its session where one is open, + /// else through one opened for it and closed after, its edits left to the background. + fn with_section( + &self, + path: &str, + change: impl FnOnce(&session::Section) -> Result, + ) -> Result { + if let Some((_, shared)) = self + .open_sections() + .into_iter() + .find(|(open, _)| open == path) + { + return change(&shared.section); + } + let section = self.section(path, || {})?; + let result = change(§ion); + section.close()?; + result + } + + /// Moves page `space` of the section at catalog `from` into the section at `to`, before + /// `before` at `level` (last without), as OneNote moves a page dropped on a section's tab: + /// it keeps its identity, title, date and content there. A section left without pages + /// gains one titled `date` and `time`; `discard`, the page `to` gained when this page + /// left it, goes while it holds nothing, as Undo takes the move back. + #[allow(clippy::too_many_arguments)] + pub(crate) fn move_page( + &self, + from: &str, + space: ExGuid, + to: &str, + before: Option, + level: u32, + discard: Option, + author: &str, + [date, time]: [&str; 2], + ) -> Result { + if from == to { + return Err("The page is in that section already".into()); + } + let (page, was) = self.with_section(from, |section| { + let listed = section.pages()?; + let at = (listed.iter().position(|(listed, ..)| *listed == space)) + .ok_or("The page is not listed")?; + let next = listed.get(at + 1).map(|(next, ..)| *next); + Ok((section.page(space)?, (next, listed[at].2))) + })?; + let import = session::moved(&page, author)?; + let Op::Section(SectionOp::Import { creation, .. }) = &import else { + unreachable!("notebook::session::moved imports"); + }; + let arrived = creation.space(); + self.with_section(to, |section| { + let listed = section.pages()?; + let has = |page: &ExGuid| listed.iter().any(|(space, ..)| space == page); + let mut ops = vec![import]; + let before = before.filter(has); + if before.is_some() || level != 1 { + ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( + arrived, before, level, + )?]))); + } + if let Some(discard) = discard.filter(has) + && session::blank(§ion.page(discard)?) + { + ops.push(Op::Section(SectionOp::Delete(vec![discard]))); + } + Ok(section.apply( + author, + Edit { + at: filetime(), + ops, + }, + )?) + })?; + let added = self.with_section(from, |section| { + let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; + let added = match section.pages()?.as_slice() { + [(only, ..)] if *only == space => { + let fresh = dated(author, date, time)?; + let added = fresh.space(); + ops.push(Op::Section(SectionOp::Create(fresh))); + Some(added) + } + _ => None, + }; + section.apply( + author, + Edit { + at: filetime(), + ops, + }, + )?; + Ok(added) + })?; + let id = |space: Option| space.map(|space| space.to_string()); + Ok(MovedPage { + page: arrived.to_string(), + added: id(added), + before: id(was.0), + level: was.1, + }) + } + + /// Puts the section or group at catalog `path` into the group `folder` ("" for the + /// notebook's top), moving it there unless it is there, as OneNote moves one dragged + /// onto a group, then orders `folder` as `paths` lists its entries, as they are named + /// once moved; entries left out follow in their order. Returns its catalog path. + pub(crate) fn place(&self, path: &str, folder: &str, paths: &[String]) -> Result { + let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent); + self.with_notebook(true, |notebook| { + notebook.refresh()?; + let placed = match parent == folder { + true => path.to_owned(), + false => notebook.move_entry(path, folder)?, + }; + if !paths.is_empty() { + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + notebook.reorder(folder, &paths)?; + } + Ok(placed) + }) + } + fn open_sections(&self) -> Vec<(String, Arc)> { let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner()); open.retain(|(_, shared)| shared.strong_count() > 0); @@ -1214,6 +1352,16 @@ impl Library { } } +/// A page `Library::move_page` moved, by id: where it went, the page the section it left +/// gained, and where it was there: before `before`, at `level`. +#[derive(serde::Serialize)] +pub(crate) struct MovedPage { + pub(crate) page: String, + pub(crate) added: Option, + pub(crate) before: Option, + pub(crate) level: u32, +} + #[derive(serde::Serialize)] pub(crate) struct Synced { pub(crate) path: String, @@ -1477,6 +1625,63 @@ pub unsafe extern "C" fn sb_library_new_section( .map_or(std::ptr::null_mut(), owned) } +/// Moves page `id` of the section at catalog `from` into the section at `to` (`Library::move_page`), +/// before page `before` at `level`, or last with `before` null; `discard`, unless null, is +/// the page `to` gained when this page left it. Returns JSON with `page`, its id there, +/// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and +/// `level`, where it was in `from`; null when it cannot move. Writes to both sections, so +/// call it off the main thread. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8; `before` and `discard` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_move_page( + library: &Library, + from: *const c_char, + id: *const c_char, + to: *const c_char, + before: *const c_char, + level: u32, + discard: *const c_char, + author: *const c_char, + date: *const c_char, + time: *const c_char, +) -> *mut c_char { + let space = |text: *const c_char| optional(text).map(|id| id.parse()).transpose(); + json((|| { + library.move_page( + &string(from), + string(id).parse()?, + &string(to), + space(before)?, + level, + space(discard)?, + &string(author), + [&string(date), &string(time)], + ) + })()) +} + +/// Puts the section or group at catalog `path` into the group at catalog path `folder` ("" +/// for the notebook's top), moving it unless it is there, then orders `folder` as `paths`, a +/// JSON array of catalog paths as they are named after the move; entries left out follow +/// in their order. Returns its catalog path, or null. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_place( + library: &Library, + path: *const c_char, + folder: *const c_char, + paths: *const c_char, +) -> *mut c_char { + let placed = serde_json::from_str::>(&string(paths)) + .map_err(Into::into) + .and_then(|paths| library.place(&string(path), &string(folder), &paths)); + report(placed).map_or(std::ptr::null_mut(), owned) +} + /// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title /// matches first and then the most recently changed, as JSON: each with `notebook` (its /// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`, @@ -1858,6 +2063,40 @@ pub unsafe extern "C" fn sb_section_delete_page( report(deleted).is_some() } +/// Puts the pages `moved`, a JSON array of ids, where `order`, a JSON array of `{id, level}` +/// listing the section's pages, places them, at their levels, as one edit: a page dragged +/// to a new place, made a subpage or promoted, or put back as Undo does. False when none of +/// them is listed any more. +/// +/// # Safety +/// `order` and `moved` are NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_arrange( + section: &Section, + order: *const c_char, + moved: *const c_char, +) -> bool { + #[derive(serde::Deserialize)] + struct Placed { + id: String, + level: u32, + } + let arranged = (|| { + let order: Vec = serde_json::from_str(&string(order))?; + let order = order + .into_iter() + .map(|placed| Ok((placed.id.parse()?, placed.level))) + .collect::>>()?; + let moved: Vec = serde_json::from_str(&string(moved))?; + let moved = moved + .iter() + .map(|id| Ok(id.parse()?)) + .collect::>>()?; + section.arrange(&order, &moved) + })(); + report(arranged).unwrap_or(false) +} + /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be /// published; true when nothing waits. For the host's background time. #[unsafe(no_mangle)] diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 416c587a748b67a384069e2895be49db420de0ad..35691504ef6061826a7a746a0f2753b3a5f38558 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1274,3 +1274,184 @@ fn a_movie_from_the_camera_becomes_the_desktop_s_avi() { assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000)); assert!(parsed.sound.is_some()); } + +fn levels(section: &Section) -> Vec<(ExGuid, u32)> { + let pages = section.shared.section.pages().unwrap(); + pages + .into_iter() + .map(|(space, _, level)| (space, level)) + .collect() +} + +#[test] +fn pages_drag_indent_and_undo_as_one_edit_each() { + let (_directory, _root, _library, section) = notebook_open("Features.one"); + let before = levels(§ion); + assert!(before.len() > 2); + let last = before.last().unwrap().0; + let mut dragged = before.clone(); + let moved = dragged.pop().unwrap(); + dragged.insert(0, moved); + let revisions = section.shared.section.pending().unwrap().len(); + assert!(section.arrange(&dragged, &[last]).unwrap()); + assert_eq!(levels(§ion), dragged); + assert_eq!( + section.shared.section.pending().unwrap().len(), + revisions + 1 + ); + let second = dragged[1].0; + let mut indented = dragged.clone(); + indented[1].1 = 2; + assert!(section.arrange(&indented, &[second]).unwrap()); + assert_eq!(levels(§ion), indented); + assert!(section.arrange(&dragged, &[second]).unwrap()); + assert!(section.arrange(&before, &[last]).unwrap()); + assert_eq!(levels(§ion), before); + assert!(!section.arrange(&before, &[ExGuid::default()]).unwrap()); +} + +#[test] +fn a_page_moves_to_another_section_and_back_where_it_was() { + let (_directory, root, library, features) = notebook_open("Features.one"); + let before = levels(&features); + let (space, level) = before[1]; + let title = features.shared.section.page(space).unwrap().title; + let date = ["Thursday, October 1, 2026", "9:00 AM"]; + let moved = library + .move_page( + "Features.one", + space, + "Empty.one", + None, + 1, + None, + "Clover Test", + date, + ) + .unwrap(); + let arrived: ExGuid = moved.page.parse().unwrap(); + assert_eq!(moved.added, None); + assert_eq!(moved.before, Some(before[2].0.to_string())); + assert_eq!(moved.level, level); + assert!(levels(&features).iter().all(|(page, _)| *page != space)); + let empty = open(&library, "Empty.one"); + let listed = empty.shared.section.pages().unwrap(); + assert_eq!(listed.last().unwrap(), &(arrived, title.clone(), 1)); + drop(empty); + let back = library + .move_page( + "Empty.one", + arrived, + "Features.one", + moved.before.map(|before| before.parse().unwrap()), + moved.level, + None, + "Clover Test", + date, + ) + .unwrap() + .page + .parse() + .unwrap(); + let mut returned = before.clone(); + returned[1].0 = back; + assert_eq!(levels(&features), returned); + assert_eq!(features.shared.section.page(back).unwrap().title, title); + drop(features); + library.close(Duration::from_secs(20)).unwrap(); + let arena = onestore::Arena::default(); + let file = onestore::read_file(root.join("Features.one")).unwrap(); + let stored = onestore::Section::open(&arena, file).unwrap(); + assert_eq!(stored.page(back).unwrap().title, title); +} + +#[test] +fn a_sections_last_page_moving_leaves_a_page_that_undo_takes_out() { + let (_directory, _root, library, features) = notebook_open("Features.one"); + let date = ["Thursday, October 1, 2026", "9:02 AM"]; + let page = features.new_page(None, date[0], date[1]).unwrap(); + let arrived: ExGuid = library + .move_page( + "Features.one", + page, + "Empty.one", + None, + 1, + None, + "Clover Test", + date, + ) + .unwrap() + .page + .parse() + .unwrap(); + assert_eq!(levels(&open(&library, "Empty.one")), [(arrived, 1)]); + let returned = library + .move_page( + "Empty.one", + arrived, + "Features.one", + None, + 1, + None, + "Clover Test", + date, + ) + .unwrap(); + let back = returned.page.parse().unwrap(); + let added: ExGuid = returned.added.unwrap().parse().unwrap(); + assert_eq!(levels(&open(&library, "Empty.one")), [(added, 1)]); + library + .move_page( + "Features.one", + back, + "Empty.one", + None, + 1, + Some(added), + "Clover Test", + date, + ) + .unwrap(); + assert_eq!(levels(&open(&library, "Empty.one")).len(), 1); +} + +#[test] +fn sections_reorder_and_move_between_groups_and_back() { + let (_directory, root, library, features) = notebook_open("Features.one"); + drop(features); + let top = |library: &Library| -> Vec { + let tabs = library.tabs().unwrap(); + tabs.into_iter() + .filter(|tab| tab.group.is_empty()) + .map(|tab| tab.path) + .collect() + }; + let before = top(&library); + let mut reversed = before.clone(); + reversed.reverse(); + assert_eq!( + library.place("Empty.one", "", &reversed).unwrap(), + "Empty.one" + ); + assert_eq!(top(&library), reversed); + assert_eq!( + library.place("Empty.one", "Group A", &[]).unwrap(), + "Group A/Empty.one" + ); + assert!(root.join("Group A/Empty.one").exists()); + let tabs = library.tabs().unwrap(); + let grouped: Vec<&str> = tabs + .iter() + .filter(|tab| tab.group == "Group A") + .map(|tab| tab.path.as_str()) + .collect(); + assert_eq!(grouped.last(), Some(&"Group A/Empty.one")); + assert_eq!( + library.place("Group A/Empty.one", "", &before).unwrap(), + "Empty.one" + ); + assert_eq!(top(&library), before); + assert!(root.join("Empty.one").exists()); + open(&library, "Empty.one"); +} diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 0886810517e89deb59c3f592018397696073fc4c..0bab77c9ff88a48f446e184b3488b0f4ca3f313f 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -6,7 +6,7 @@ use crate::{ EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, }; use onestore::{ - CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction, + CommitError, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, Transaction, document::Document, op::{Edit, Op, SectionOp}, page::Page, @@ -1724,6 +1724,39 @@ pub fn moved(page: &Page, author: &str) -> Result { })) } +/// The edits putting the pages `moved` where `order` lists them, with their levels, in a +/// section now listing `listed`: each goes before the next page of `order` that stays put +/// and is still listed. Pages gone from the section are left out. +pub fn arrange( + listed: &[(ExGuid, String, u32)], + order: &[(ExGuid, u32)], + moved: &[ExGuid], +) -> Result> { + let present = |space: &ExGuid| listed.iter().any(|(listed, ..)| listed == space); + let mut edits = Vec::new(); + for (index, (space, level)) in order.iter().enumerate() { + if !moved.contains(space) || !present(space) { + continue; + } + let before = order[index + 1..] + .iter() + .map(|(space, _)| *space) + .find(|space| !moved.contains(space) && present(space)); + edits.push(PageEdit::move_to(*space, before, *level)?); + } + Ok(edits) +} + +/// Whether `page` holds nothing but an empty title, as the page a section left without +/// pages gains does. +pub fn blank(page: &Page) -> bool { + page.title.trim().is_empty() + && page + .objects + .iter() + .all(|object| matches!(object, onestore::page::PageObject::Title(_))) +} + fn component(name: &str) -> bool { !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".." } diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index 1011863a6bd8d45f424830e6f58dc11f129c63ff..4ad85d94d792d2cda716c191dd89b58eff3855b2 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -446,7 +446,7 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { fn printed(pages: Vec) -> Vec { pages .into_iter() - .filter(|page| !crate::undo::blank(page)) + .filter(|page| !notebook::session::blank(page)) .collect() } diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index 1fd73ba1ce755ea367e080ea153c1ce79c7a742d..131e16bbe3ccdc29a968ba85572de4ad59df6865 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -6,11 +6,15 @@ use crate::library::{entry_name, folders}; use crate::{Command, Library, Loaded, State, UserEvent}; use canvas::editor::CanvasEditor; -use notebook::{Replica, discover::Folder}; +use notebook::{ + Replica, + discover::Folder, + session::{arrange, blank}, +}; use onestore::{ ExGuid, PageCreation, PageEdit, op::{Edit, Op, SectionOp}, - page::{Page, PageObject}, + page::Page, }; use std::{error::Error, sync::Arc}; @@ -285,15 +289,6 @@ fn drop_newest(steps: &mut Vec, page: ExGuid, count: usize) { } } -/// Whether `page` holds nothing but its title, untitled. -pub(crate) fn blank(page: &Page) -> bool { - page.title.trim().is_empty() - && page - .objects - .iter() - .all(|object| matches!(object, PageObject::Title(_))) -} - /// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays, /// else the page after the first one gone, or before it, as OneNote 2010 shows. fn neighbor( @@ -502,18 +497,7 @@ impl Site { } } Change::Arrange { order, moved } => { - let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some(); - let mut edits = Vec::new(); - for (index, (space, level)) in order.iter().enumerate() { - if !moving(space) { - continue; - } - let before = order[index + 1..] - .iter() - .map(|(space, _)| *space) - .find(|space| !moved.contains(space) && at(*space).is_some()); - edits.push(PageEdit::move_to(*space, before, *level)?); - } + let edits = arrange(&listed, &order, &moved)?; let Some(show) = edits.first().map(PageEdit::space) else { return Ok(None); };