From 218d66206d78eb0a122f2a2ab226c0b7a232dcfd Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 16:01:57 -0700 Subject: [PATCH] feat: iOS reorders pages, sections, groups and notebooks Pages drag to a new place in their section, and swipe or the context menu makes one a subpage or promotes it; Move to Section, or a drop on a section in the sidebar, moves one to another section, as the desktop moves a page dropped on a section tab. Sections and groups drag within their notebook or into a group, or go through Move; notebooks drag within their place in the list, which keeps the order. Edit shows reorder handles, and VoiceOver offers Move Up, Move Down, Make Subpage, Promote Subpage and Move as actions. Each move is the desktop's edit through crates/mobile: sb_section_arrange applies notebook::session::arrange, now shared with the desktop's Undo, as one revision; sb_library_move_page imports the page into the other section and deletes it from its own; sb_library_place moves and orders TOC entries with Notebook::move_entry and reorder. Undo and Redo (shake, three-finger swipe, Command-Z in a list) take each move back the same way. Assisted-by: claude-opus-5.5 --- apps/ios/Snowbound/App.swift | 11 + apps/ios/Snowbound/Arranging.swift | 669 +++++++++++++++++++++++++++++ apps/ios/Snowbound/Lists.swift | 44 +- apps/ios/Snowbound/Notebooks.swift | 47 +- crates/mobile/include/snowbound.h | 4 + crates/mobile/src/library.rs | 239 +++++++++++ crates/mobile/src/tests.rs | 181 ++++++++ crates/notebook/src/session.rs | 35 +- crates/snowbound/src/print.rs | 2 +- crates/snowbound/src/undo.rs | 30 +- 10 files changed, 1212 insertions(+), 50 deletions(-) create mode 100644 apps/ios/Snowbound/Arranging.swift 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); }; -- 2.54.0