authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 16:01:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 18:06:14-07:00
log218d66206d78eb0a122f2a2ab226c0b7a232dcfd
tree8712631c145de2ee8ae3e5414bde9ee2b0a35e6d
parent90fff4d83a2857efc8507af9ae1fbaded1652925
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

10 files changed, 1212 insertions(+), 50 deletions(-)

apps/ios/Snowbound/App.swift+11
......@@ -287,6 +287,17 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro
287287 split.show(.primary)
288288 }
289289
290 /// Lets go of the section shown where it is the section or group at `path` of `notebook`
291 /// or within it, as before its file moves; its path and page, to open once moved.
292 func release(_ path: String, of notebook: Notebook) -> (section: String, page: String?)? {
293 guard let section = pages.section, section.notebook === notebook,
294 section.tab.path == path || section.tab.path.hasPrefix(path + "/")
295 else { return nil }
296 let shown = (section.tab.path, Place.saved?.page)
297 close(notebook)
298 return shown
299 }
300
290301 /// Adds a page to the open section and opens it, its title ready for typing.
291302 func newPage(subpage: Bool = false) {
292303 guard let section = pages.section else { return }
apps/ios/Snowbound/Arranging.swift created+669
......@@ -0,0 +1,669 @@
1import UIKit
2
3/// A page's place in its section's order, as `sb_section_arrange` takes it.
4struct Placed: Codable, Equatable {
5 let id: String
6 var level: Int
7}
8
9/// Where `Arranging.move(page:)` took a page, from `sb_library_move_page`.
10private struct MovedPage: Decodable {
11 let page: String
12 let added: String?
13 let before: String?
14 let level: Int
15}
16
17/// The catalog path of the folder holding the section or group at `path`; "" for the
18/// notebook's top.
19func parentFolder(of path: String) -> String {
20 path.range(of: "/", options: .backwards).map { String(path[..<$0.lowerBound]) } ?? ""
21}
22
23private func json(_ value: some Encodable) -> String {
24 (try? JSONEncoder().encode(value)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
25}
26
27/// Moves of pages, sections, section groups and notebooks, each made as the desktop makes it,
28/// as one edit through the notebook, and kept for Undo: shake, three-finger swipe or ⌘Z in
29/// either list.
30enum Arranging {
31 static let history = UndoManager()
32 private static let owner = NSObject()
33
34 /// ⌘Z and ⇧⌘Z in a list, which, unlike a text view, has none of its own.
35 static let keyCommands = [
36 UIKeyCommand(title: "Undo", action: #selector(UIResponder.undoArranging), input: "z", modifierFlags: .command),
37 UIKeyCommand(title: "Redo", action: #selector(UIResponder.redoArranging), input: "z", modifierFlags: [.command, .shift]),
38 ]
39
40 /// Keeps `undo` as Undo for `name`. Called as the change starts, so that a change Undo
41 /// makes keeps its own undo as Redo.
42 private static func done(_ name: String, undo: @escaping () -> Void) {
43 history.registerUndo(withTarget: owner) { _ in undo() }
44 history.setActionName(name)
45 UIImpactFeedbackGenerator(style: .light).impactOccurred()
46 }
47
48 /// Runs `work` off the main thread after the changes before it, then `then` with its result
49 /// on the main thread.
50 private static func later<T>(_ work: @escaping () -> T, then: @escaping (T) -> Void) {
51 queue.async {
52 let result = work()
53 DispatchQueue.main.async { then(result) }
54 }
55 }
56 private static let queue = DispatchQueue(label: "Arranging")
57
58 private static func failed(_ title: String) {
59 let window = UIApplication.shared.connectedScenes.lazy.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first
60 var top = window?.rootViewController
61 while let presented = top?.presentedViewController { top = presented }
62 top?.alert(title, "Check that the notebook can be reached, then try again.")
63 }
64
65 /// The pages `rows` lists with page `id` moved to `index` of them, at its level or under
66 /// the page now above it, as the desktop drops a dragged page.
67 static func order(_ rows: [Row], moving id: String, to index: Int) -> [Placed] {
68 var order = rows.map { Placed(id: $0.id, level: $0.level) }
69 guard let from = order.firstIndex(where: { $0.id == id }) else { return order }
70 var page = order.remove(at: from)
71 let at = min(max(index, 0), order.count)
72 page.level = min(page.level, at == 0 ? 1 : order[at - 1].level + 1)
73 order.insert(page, at: at)
74 return order
75 }
76
77 /// Puts the pages of `section` in `order`, page `id` the one moved, as one edit.
78 static func arrange(_ section: Section, _ order: [Placed], moving id: String) {
79 let before = section.rows.map { Placed(id: $0.id, level: $0.level) }
80 guard order != before, section.arrange(order, moving: id) else { return }
81 let (notebook, path) = (section.notebook, section.tab.path)
82 done("Move Page") {
83 opening(path, of: notebook) { arrange($0, before, moving: id) }
84 }
85 }
86
87 /// Gives page `id` of `section` `level` where it stands, as Make Subpage and Promote
88 /// Subpage do.
89 static func indent(_ section: Section, _ id: String, to level: Int) {
90 var order = section.rows.map { Placed(id: $0.id, level: $0.level) }
91 guard let at = order.firstIndex(where: { $0.id == id }), order[at].level != level else { return }
92 let was = order[at].level
93 order[at].level = level
94 guard section.arrange(order, moving: id) else { return }
95 let (notebook, path) = (section.notebook, section.tab.path)
96 done(level > was ? "Make Subpage" : "Promote Subpage") {
97 opening(path, of: notebook) { indent($0, id, to: was) }
98 }
99 }
100
101 /// Hands `then` the section at `path` of `notebook`, opening it where it isn't open.
102 private static func opening(_ path: String, of notebook: Notebook, then: @escaping (Section) -> Void) {
103 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
104 Section.open(tab, of: notebook) { section, _ in section.map(then) }
105 }
106
107 /// Moves page `id` of the section at `from` into the section at `to`, before `before` at
108 /// `level` (last without), as OneNote moves a page dropped on a section's tab.
109 static func move(
110 page id: String, of notebook: Notebook, from: String, to: String, before: String? = nil, level: Int = 1,
111 discard: String? = nil
112 ) {
113 guard let library = notebook.handle else { return }
114 let pointer = Int(bitPattern: library)
115 let author = Author.name ?? ""
116 let (date, time) = titleDate()
117 // Undo takes back what the move reports, once it has.
118 var reported: MovedPage?
119 done("Move Page") {
120 guard let moved = reported else { return }
121 move(
122 page: moved.page, of: notebook, from: to, to: from, before: moved.before, level: moved.level,
123 discard: moved.added)
124 }
125 later({
126 decode(
127 MovedPage.self,
128 sb_library_move_page(
129 OpaquePointer(bitPattern: pointer), from, id, to, before, UInt32(level), discard, author, date,
130 time))
131 }) { moved in
132 reported = moved
133 for section in Section.all where section.notebook === notebook && [from, to].contains(section.tab.path) {
134 section.listed()
135 }
136 if moved == nil { failed("Can’t Move Page") }
137 }
138 }
139
140 /// Puts the section or group at `path` of `notebook` into `folder`, ordered as `order`
141 /// names its entries once moved; a section shown opens again where it went.
142 static func place(_ path: String, of notebook: Notebook, in folder: String, order: [String] = []) {
143 guard let library = notebook.handle else { return }
144 let home = parentFolder(of: path)
145 let name = path.split(separator: "/").last.map(String.init) ?? path
146 let placed = home == folder ? path : folder.isEmpty ? name : folder + "/" + name
147 let undo = notebook.entries(in: home)
148 done("Move Section") { place(placed, of: notebook, in: home, order: undo) }
149 let scenes = UIApplication.shared.connectedScenes.compactMap { $0.delegate as? SceneDelegate }
150 let released = home == folder ? [] : scenes.compactMap { scene in scene.release(path, of: notebook).map { (scene, $0) } }
151 let pointer = Int(bitPattern: library)
152 let paths = json(order)
153 later({ take(sb_library_place(OpaquePointer(bitPattern: pointer), path, folder, paths)) != nil }) { moved in
154 notebook.reload {
155 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
156 for (scene, shown) in released {
157 let section = moved ? follow(shown.section, from: path, to: placed) : shown.section
158 if let tab = notebook.tabs.first(where: { $0.path == section }) {
159 scene.open(tab, of: notebook, page: shown.page)
160 }
161 }
162 if !moved { failed("Can’t Move Section") }
163 }
164 }
165 }
166
167 /// `current`, a section's path, after the section or group at `from` went to `to`.
168 private static func follow(_ current: String, from: String, to: String) -> String {
169 current == from ? to : current.hasPrefix(from + "/") ? to + current.dropFirst(from.count) : current
170 }
171
172 /// Puts `notebook` at `index` among the notebooks of its place in the list.
173 static func move(_ notebook: Notebook, to index: Int) {
174 guard let from = Notebooks.siblings(of: notebook).firstIndex(where: { $0 === notebook }), from != index else {
175 return
176 }
177 Notebooks.move(notebook, to: index)
178 NotificationCenter.default.post(name: Notebook.listed, object: notebook)
179 done("Move Notebook") { move(notebook, to: from) }
180 }
181}
182
183extension UIResponder {
184 @objc func undoArranging() { Arranging.history.undo() }
185 @objc func redoArranging() { Arranging.history.redo() }
186}
187
188extension Section {
189 /// Puts page `id` where `order` places it, as one edit; false where it isn't listed.
190 func arrange(_ order: [Placed], moving id: String) -> Bool {
191 guard sb_section_arrange(handle, json(order), json([id])) else { return false }
192 listed()
193 return true
194 }
195
196 /// Lists the pages again and tells the views.
197 func listed() {
198 reloadRows()
199 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
200 }
201}
202
203extension Notebook {
204 /// The section groups, by catalog path, in the order the list shows them.
205 var groups: [String] {
206 var groups: [String] = []
207 for tab in tabs {
208 var path = ""
209 for part in tab.group.split(separator: "/") {
210 path += (path.isEmpty ? "" : "/") + part
211 if !groups.contains(path) { groups.append(path) }
212 }
213 }
214 return groups
215 }
216
217 /// The sections and groups directly in `folder`, by catalog path, as the list shows them.
218 func entries(in folder: String) -> [String] {
219 tabs.filter { $0.group == folder }.map(\.path) + groups.filter { parentFolder(of: $0) == folder }
220 }
221
222 /// Where the section or group at `path` can move: the notebook's top and its groups, by
223 /// catalog path, leaving out its own folder, itself and its groups.
224 func destinations(of path: String) -> [String] {
225 ([""] + groups).filter { $0 != parentFolder(of: path) && $0 != path && !$0.hasPrefix(path + "/") }
226 }
227
228 /// The sections a page of the section at `path` can move to.
229 func sections(besides path: String) -> [Tab] {
230 tabs.filter { $0.path != path && $0.readable && !$0.locked }
231 }
232}
233
234/// A page dragged out of the page list.
235final class PageDrag {
236 let section: Section
237 let id: String
238
239 init(section: Section, id: String) {
240 self.section = section
241 self.id = id
242 }
243}
244
245/// A sheet offering `actions`, for VoiceOver’s Move actions, from `view`.
246private func sheet(_ title: String, _ actions: [UIAction], from view: UIView, in controller: UIViewController) {
247 let sheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
248 for action in actions {
249 sheet.addAction(UIAlertAction(title: action.title, style: .default) { _ in action.performWithSender(nil, target: nil) })
250 }
251 sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel))
252 sheet.popoverPresentationController?.sourceView = view
253 sheet.popoverPresentationController?.sourceRect = view.bounds
254 controller.present(sheet, animated: true)
255}
256
257// MARK: Pages
258
259extension PagesViewController: UITableViewDragDelegate, UITableViewDropDelegate {
260 func setUpArranging() {
261 tableView.dragDelegate = self
262 tableView.dropDelegate = self
263 tableView.dragInteractionEnabled = true
264 navigationItem.rightBarButtonItem = editButtonItem
265 }
266
267 override var canBecomeFirstResponder: Bool { true }
268 override var undoManager: UndoManager? { Arranging.history }
269 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
270
271 /// Undo in the list takes back its moves.
272 override func viewDidAppear(_ animated: Bool) {
273 super.viewDidAppear(animated)
274 becomeFirstResponder()
275 }
276
277 /// Page `id` moved to `index` of the section's pages.
278 private func move(_ id: String, to index: Int) {
279 guard let section else { return }
280 Arranging.arrange(section, Arranging.order(section.rows, moving: id, to: index), moving: id)
281 becomeFirstResponder()
282 }
283
284 /// Page `id` a level deeper or shallower, where it stays.
285 private func indent(_ id: String, by change: Int) {
286 guard let section, let row = section.rows.first(where: { $0.id == id }) else { return }
287 Arranging.indent(section, id, to: row.level + change)
288 becomeFirstResponder()
289 }
290
291 /// Whether page `at` of `rows` can become a subpage, as the desktop allows: under a page
292 /// at its level or deeper, at most three deep.
293 private func indentable(_ rows: [Row], _ at: Int) -> Bool {
294 at > 0 && rows[at].level < 3 && rows[at - 1].level >= rows[at].level
295 }
296
297 private func moveActions(_ item: Item) -> [UIAction] {
298 guard let section else { return [] }
299 return section.notebook.sections(besides: section.tab.path).map { tab in
300 UIAction(
301 title: tab.name, subtitle: tab.group.isEmpty ? nil : tab.group,
302 image: UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(tab.uiColor, renderingMode: .alwaysOriginal)
303 ) { [weak self] _ in
304 Arranging.move(page: item.row.id, of: section.notebook, from: section.tab.path, to: tab.path)
305 self?.becomeFirstResponder()
306 }
307 }
308 }
309
310 /// Make Subpage, Promote Subpage and Move to Section, for a page's menu.
311 func arrangeMenu(_ item: Item) -> [UIMenuElement] {
312 guard let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id }) else { return [] }
313 let id = item.row.id
314 var actions: [UIMenuElement] = [
315 UIAction(
316 title: "Make Subpage", image: UIImage(systemName: "increase.indent"),
317 attributes: indentable(rows, at) ? [] : .disabled
318 ) { [weak self] _ in self?.indent(id, by: 1) },
319 UIAction(
320 title: "Promote Subpage", image: UIImage(systemName: "decrease.indent"),
321 attributes: rows[at].level > 1 ? [] : .disabled
322 ) { [weak self] _ in self?.indent(id, by: -1) },
323 ]
324 let moves = moveActions(item)
325 if !moves.isEmpty {
326 actions.append(UIMenu(title: "Move to Section", image: UIImage(systemName: "arrow.right.doc.on.clipboard"), children: moves))
327 }
328 return [UIMenu(options: .displayInline, children: actions)]
329 }
330
331 /// VoiceOver's ways to move a page without dragging it.
332 func arrangeActions(_ item: Item) -> [UIAccessibilityCustomAction]? {
333 guard item.version == nil, let rows = section?.rows, let at = rows.firstIndex(where: { $0.id == item.row.id })
334 else { return nil }
335 let id = item.row.id
336 var actions: [UIAccessibilityCustomAction] = []
337 let add = { (name: String, act: @escaping () -> Void) in
338 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })
339 }
340 if at > 0 { add("Move Up") { [weak self] in self?.move(id, to: at - 1) } }
341 if at < rows.count - 1 { add("Move Down") { [weak self] in self?.move(id, to: at + 1) } }
342 if indentable(rows, at) { add("Make Subpage") { [weak self] in self?.indent(id, by: 1) } }
343 if rows[at].level > 1 { add("Promote Subpage") { [weak self] in self?.indent(id, by: -1) } }
344 if !moveActions(item).isEmpty {
345 add("Move to Section…") { [weak self] in
346 guard let self, let row = items.firstIndex(where: { $0.id == id }),
347 let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0))
348 else { return }
349 sheet("Move to Section", moveActions(item), from: cell, in: self)
350 }
351 }
352 return actions
353 }
354
355 override func tableView(
356 _ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath
357 ) -> UISwipeActionsConfiguration? {
358 // A swipe across the list changes section instead.
359 guard !Prototype.sectionStrip, let rows = section?.rows, items[indexPath.row].version == nil,
360 let at = rows.firstIndex(where: { $0.id == items[indexPath.row].row.id })
361 else { return nil }
362 let id = rows[at].id
363 var actions: [UIContextualAction] = []
364 if indentable(rows, at) {
365 let indent = UIContextualAction(style: .normal, title: "Make Subpage") { [weak self] _, _, done in
366 self?.indent(id, by: 1)
367 done(true)
368 }
369 indent.image = UIImage(systemName: "increase.indent")
370 indent.backgroundColor = .systemIndigo
371 actions.append(indent)
372 }
373 if rows[at].level > 1 {
374 let promote = UIContextualAction(style: .normal, title: "Promote Subpage") { [weak self] _, _, done in
375 self?.indent(id, by: -1)
376 done(true)
377 }
378 promote.image = UIImage(systemName: "decrease.indent")
379 promote.backgroundColor = .systemTeal
380 actions.append(promote)
381 }
382 return UISwipeActionsConfiguration(actions: actions)
383 }
384
385 override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
386 items[indexPath.row].version == nil
387 }
388
389 override func tableView(_ tableView: UITableView, moveRowAt from: IndexPath, to: IndexPath) {
390 var moved = items
391 let item = moved.remove(at: from.row)
392 moved.insert(item, at: to.row)
393 guard let index = moved.filter({ $0.version == nil }).firstIndex(where: { $0.id == item.id }) else { return }
394 // The table finishes its move before the list reloads.
395 DispatchQueue.main.async { [weak self] in self?.move(item.id, to: index) }
396 }
397
398 override func tableView(
399 _ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath
400 ) -> UITableViewCell.EditingStyle {
401 tableView.isEditing ? .none : .delete
402 }
403
404 override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
405 false
406 }
407
408 func tableView(
409 _ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
410 ) -> [UIDragItem] {
411 let item = items[indexPath.row]
412 guard let section, item.version == nil else { return [] }
413 let drag = UIDragItem(itemProvider: NSItemProvider())
414 drag.localObject = PageDrag(section: section, id: item.row.id)
415 return [drag]
416 }
417
418 func tableView(_ tableView: UITableView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession) -> Bool {
419 true
420 }
421
422 /// A wide window shows the notebooks beside the pages while a page is dragged, so it can
423 /// be dropped on a section there.
424 func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) {
425 guard let split = splitViewController, !split.isCollapsed, split.displayMode != .twoBesideSecondary else { return }
426 session.localContext = split.preferredDisplayMode
427 split.preferredDisplayMode = .twoBesideSecondary
428 }
429
430 func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
431 guard let mode = session.localContext as? UISplitViewController.DisplayMode else { return }
432 splitViewController?.preferredDisplayMode = mode
433 }
434
435 func tableView(
436 _ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath: IndexPath?
437 ) -> UITableViewDropProposal {
438 let page = session.localDragSession?.items.first?.localObject as? PageDrag
439 guard let page, page.section === section else {
440 return UITableViewDropProposal(operation: .forbidden)
441 }
442 return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
443 }
444
445 /// Drops within the list reorder through `moveRowAt`.
446 func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {}
447}
448
449// MARK: Notebooks, sections and groups
450
451extension NotebooksViewController: UICollectionViewDragDelegate, UICollectionViewDropDelegate {
452 func setUpArranging() {
453 collectionView.dragDelegate = self
454 collectionView.dropDelegate = self
455 collectionView.dragInteractionEnabled = true
456 // A sidebar's trailing edge has no room left for it.
457 if UIDevice.current.userInterfaceIdiom == .pad {
458 navigationItem.leftBarButtonItem = editButtonItem
459 } else {
460 navigationItem.rightBarButtonItems?.append(editButtonItem)
461 }
462 // Rows dragged between rows, and by Edit's reorder handles, move through the data source.
463 dataSource.reorderingHandlers.canReorderItem = { [weak self] item in self?.movable(item) == true }
464 // The data source is still applying the move when this runs.
465 dataSource.reorderingHandlers.didReorder = { [weak self] transaction in
466 DispatchQueue.main.async { self?.reordered(transaction) }
467 }
468 }
469
470 override var canBecomeFirstResponder: Bool { true }
471 override var undoManager: UndoManager? { Arranging.history }
472 override var keyCommands: [UIKeyCommand]? { Arranging.keyCommands }
473
474 /// Undo in the list takes back its moves.
475 override func viewDidAppear(_ animated: Bool) {
476 super.viewDidAppear(animated)
477 becomeFirstResponder()
478 }
479
480 override func setEditing(_ editing: Bool, animated: Bool) {
481 super.setEditing(editing, animated: animated)
482 collectionView.isEditing = editing
483 collectionView.dragInteractionEnabled = !editing
484 }
485
486 private func movable(_ item: Item) -> Bool {
487 switch item {
488 case .notebook, .group, .section: true
489 default: false
490 }
491 }
492
493 /// The notebook and catalog path of a section or group row.
494 private func entry(_ item: Item) -> (notebook: Notebook, path: String)? {
495 switch item {
496 case .group(let id, let path), .section(let id, let path): notebook(id).map { ($0, path) }
497 default: nil
498 }
499 }
500
501 /// The group or notebook top a row stands for, as a folder an entry of `notebook` can go into.
502 private func folder(_ item: Item, in notebook: Notebook) -> String? {
503 switch item {
504 case .notebook(let id) where id == notebook.id: ""
505 case .group(let id, let path) where id == notebook.id: path
506 default: nil
507 }
508 }
509
510 /// Moves the section or group at `path` within its folder by `offset`.
511 private func shift(_ path: String, of notebook: Notebook, by offset: Int) {
512 var order = notebook.entries(in: parentFolder(of: path))
513 guard let at = order.firstIndex(of: path), order.indices.contains(at + offset) else { return }
514 order.swapAt(at, at + offset)
515 Arranging.place(path, of: notebook, in: parentFolder(of: path), order: order)
516 becomeFirstResponder()
517 }
518
519 private func moveActions(_ path: String, of notebook: Notebook) -> [UIAction] {
520 notebook.destinations(of: path).map { folder in
521 UIAction(
522 title: folder.isEmpty ? notebook.name : folder.split(separator: "/").joined(separator: " › "),
523 image: UIImage(systemName: folder.isEmpty ? "book.closed" : "folder")
524 ) { [weak self] _ in
525 Arranging.place(path, of: notebook, in: folder)
526 self?.becomeFirstResponder()
527 }
528 }
529 }
530
531 /// Move, into the notebook's top or a group, for a section's or group's menu; Move Up and
532 /// Move Down for a notebook's, as the desktop's.
533 func arrangeMenu(_ item: Item) -> [UIMenuElement] {
534 if case .notebook(let id) = item, let notebook = notebook(id) {
535 let siblings = Notebooks.siblings(of: notebook)
536 guard let at = siblings.firstIndex(where: { $0 === notebook }), siblings.count > 1 else { return [] }
537 return [
538 UIMenu(options: .displayInline, children: [
539 UIAction(title: "Move Up", image: UIImage(systemName: "arrow.up"), attributes: at > 0 ? [] : .disabled) {
540 _ in Arranging.move(notebook, to: at - 1)
541 },
542 UIAction(
543 title: "Move Down", image: UIImage(systemName: "arrow.down"),
544 attributes: at < siblings.count - 1 ? [] : .disabled
545 ) { _ in Arranging.move(notebook, to: at + 1) },
546 ])
547 ]
548 }
549 guard let (notebook, path) = entry(item) else { return [] }
550 let moves = moveActions(path, of: notebook)
551 return moves.isEmpty ? [] : [UIMenu(title: "Move", image: UIImage(systemName: "folder"), children: moves)]
552 }
553
554 /// Adds Edit's reorder handle and VoiceOver's ways to move to a row.
555 func arranging(_ cell: UICollectionViewListCell, _ item: Item) {
556 if movable(item) { cell.accessories.append(.reorder(displayed: .whenEditing)) }
557 var actions: [UIAccessibilityCustomAction] = []
558 let add = { (name: String, act: @escaping () -> Void) in
559 actions.append(UIAccessibilityCustomAction(name: name) { _ in act(); return true })
560 }
561 if case .notebook(let id) = item, let notebook = notebook(id) {
562 let siblings = Notebooks.siblings(of: notebook)
563 if let at = siblings.firstIndex(where: { $0 === notebook }) {
564 if at > 0 { add("Move Up") { Arranging.move(notebook, to: at - 1) } }
565 if at < siblings.count - 1 { add("Move Down") { Arranging.move(notebook, to: at + 1) } }
566 }
567 } else if let (notebook, path) = entry(item) {
568 let order = notebook.entries(in: parentFolder(of: path))
569 if let at = order.firstIndex(of: path) {
570 if at > 0 { add("Move Up") { [weak self] in self?.shift(path, of: notebook, by: -1) } }
571 if at < order.count - 1 { add("Move Down") { [weak self] in self?.shift(path, of: notebook, by: 1) } }
572 }
573 if !notebook.destinations(of: path).isEmpty {
574 add("Move…") { [weak self, weak cell] in
575 guard let self, let cell else { return }
576 sheet("Move", moveActions(path, of: notebook), from: cell, in: self)
577 }
578 }
579 }
580 cell.accessibilityCustomActions = actions
581 }
582
583 /// Carries out a move the data source made: a notebook among its place's, or a
584 /// section or group into the folder it landed in; anywhere else it goes back.
585 private func reordered(_ transaction: NSDiffableDataSourceTransaction<Location, Item>) {
586 becomeFirstResponder()
587 let moved = transaction.difference.insertions.lazy.compactMap { change -> Item? in
588 if case .insert(_, let item, _) = change { return item }
589 return nil
590 }.first
591 guard let moved, let list = transaction.sectionTransactions.first?.finalSnapshot,
592 let parent = list.parent(of: moved)
593 else { return reload() }
594 let siblings = list.snapshot(of: parent).rootItems
595 if case .notebook(let id) = moved, let notebook = notebook(id), case .location = parent {
596 let notebooks = siblings.filter { if case .notebook = $0 { true } else { false } }
597 return Arranging.move(notebook, to: notebooks.firstIndex(of: moved) ?? 0)
598 }
599 guard let (notebook, path) = entry(moved), let folder = folder(parent, in: notebook),
600 folder != path, !folder.hasPrefix(path + "/")
601 else { return reload() }
602 let name = path.split(separator: "/").last.map(String.init) ?? path
603 let order = siblings.compactMap { sibling in
604 sibling == moved ? (folder.isEmpty ? name : folder + "/" + name) : entry(sibling)?.path
605 }
606 Arranging.place(path, of: notebook, in: folder, order: order)
607 }
608
609 func collectionView(
610 _ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath
611 ) -> [UIDragItem] {
612 guard let item = dataSource.itemIdentifier(for: indexPath), movable(item) else { return [] }
613 let drag = UIDragItem(itemProvider: NSItemProvider())
614 drag.localObject = item
615 return [drag]
616 }
617
618 func collectionView(
619 _ collectionView: UICollectionView, dragSessionIsRestrictedToDraggingApplication session: UIDragSession
620 ) -> Bool {
621 true
622 }
623
624 func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
625 session.localDragSession != nil
626 }
627
628 /// The row under `session`'s touch.
629 private func row(under session: UIDropSession) -> (IndexPath, Item, UICollectionViewCell)? {
630 let point = session.location(in: collectionView)
631 guard let indexPath = collectionView.indexPathForItem(at: point),
632 let item = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath)
633 else { return nil }
634 return (indexPath, item, cell)
635 }
636
637 /// The section a dragged page would move to, under the touch.
638 private func section(for page: PageDrag, under session: UIDropSession) -> Tab? {
639 guard let (_, item, _) = row(under: session), case .section(let id, let path) = item,
640 id == page.section.notebook.id
641 else { return nil }
642 return page.section.notebook.sections(besides: page.section.tab.path).first { $0.path == path }
643 }
644
645 func collectionView(
646 _ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession,
647 withDestinationIndexPath destinationIndexPath: IndexPath?
648 ) -> UICollectionViewDropProposal {
649 let dragged = session.localDragSession?.items.first?.localObject
650 if let page = dragged as? PageDrag {
651 guard section(for: page, under: session) != nil else { return UICollectionViewDropProposal(operation: .forbidden) }
652 return UICollectionViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
653 }
654 guard dragged is Item else { return UICollectionViewDropProposal(operation: .forbidden) }
655 // A row dropped between rows moves through the data source's reordering handlers.
656 return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
657 }
658
659 func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
660 guard let drop = coordinator.items.first else { return }
661 let session = coordinator.session
662 guard let page = drop.dragItem.localObject as? PageDrag, let tab = section(for: page, under: session),
663 let (indexPath, _, cell) = row(under: session)
664 else { return }
665 coordinator.drop(drop.dragItem, intoItemAt: indexPath, rect: cell.bounds)
666 Arranging.move(page: page.id, of: page.section.notebook, from: page.section.tab.path, to: tab.path)
667 becomeFirstResponder()
668 }
669}
apps/ios/Snowbound/Lists.swift+28-16
......@@ -4,7 +4,7 @@ import UniformTypeIdentifiers
44/// The places notebooks are kept, each listing its notebooks with their sections, as Files
55/// lists its locations and Notes each account's folders.
66final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate {
7 private enum Location: Hashable {
7 enum Location: Hashable {
88 /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on.
99 case icloud
1010 /// Snowbound's Documents, which Files shows as Snowbound's folder.
......@@ -13,7 +13,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
1313 case elsewhere
1414 }
1515
16 private enum Item: Hashable {
16 enum Item: Hashable {
1717 case location(Location)
1818 case notebook(String)
1919 /// A section group, by its `/`-separated path.
......@@ -23,7 +23,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
2323 case status(notebook: String)
2424 }
2525
26 private var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
26 var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
2727 private var collapsed: Set<Item> = []
2828 /// What each notebook's row shows of its sync status, by notebook id.
2929 private var attention: [String: Sync.Attention] = [:]
......@@ -63,6 +63,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
6363 }
6464 let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] cell, _, item in
6565 self?.configure(cell, item)
66 self?.arranging(cell, item)
6667 }
6768 dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in
6869 view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item)
......@@ -138,6 +139,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
138139 UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more),
139140 ]
140141 navigationItem.searchController = SearchViewController.controller()
142 setUpArranging()
141143 let corner = Prototype.quickNote ? [QuickNote.item()] : []
142144 if #available(iOS 26, *) {
143145 // Search sits in the bottom toolbar, as Notes and Files put it.
......@@ -151,7 +153,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
151153 }
152154 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
153155 guard let self else { return }
154 reload()
156 reload(animated: true)
155157 var shown = dataSource.snapshot()
156158 shown.reconfigureItems(shown.itemIdentifiers)
157159 dataSource.apply(shown, animatingDifferences: false)
......@@ -165,9 +167,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
165167 navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false)
166168 }
167169
168 private func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
170 func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
169171
170 func reload() {
172 func reload(animated: Bool = false) {
171173 guard isViewLoaded else { return }
172174 if Prototype.welcome {
173175 let welcome = Prototype.welcome(
......@@ -194,9 +196,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
194196 } else {
195197 contentUnavailableConfiguration = nil
196198 }
197 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
198 sections.appendSections(locations.map(\.0))
199 dataSource.apply(sections, animatingDifferences: false)
199 // Locations kept keep their rows, so a move animates.
200 if dataSource.snapshot().sectionIdentifiers != locations.map(\.0) {
201 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
202 sections.appendSections(locations.map(\.0))
203 dataSource.apply(sections, animatingDifferences: false)
204 }
200205 for (location, notebooks) in locations {
201206 var list = NSDiffableDataSourceSectionSnapshot<Item>()
202207 let header = Item.location(location)
......@@ -221,7 +226,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
221226 }
222227 }
223228 list.expand(list.items.filter { !collapsed.contains($0) })
224 dataSource.apply(list, to: location, animatingDifferences: false)
229 dataSource.apply(list, to: location, animatingDifferences: animated)
225230 }
226231 showSelection()
227232 refreshSync()
......@@ -583,10 +588,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke
583588 override func collectionView(
584589 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
585590 ) -> UIContextMenuConfiguration? {
586 guard indexPaths.count == 1, case .notebook(let id) = dataSource.itemIdentifier(for: indexPaths[0]),
587 let notebook = notebook(id)
588 else { return nil }
589 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.menu(for: notebook) })
591 guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil }
592 guard case .notebook(let id) = item, let notebook = notebook(id) else {
593 let moves = arrangeMenu(item)
594 return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) })
595 }
596 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
597 UIMenu(children: (self?.menu(for: notebook).children ?? []) + (self?.arrangeMenu(item) ?? []))
598 })
590599 }
591600
592601 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
......@@ -807,14 +816,14 @@ extension UIFont {
807816
808817/// A section's pages, subpages indented, each page's conflict pages beneath it.
809818final class PagesViewController: UITableViewController {
810 private struct Item {
819 struct Item {
811820 let row: Row
812821 let version: Row.Version?
813822 var id: String { version?.id ?? row.id }
814823 }
815824
816825 private(set) var section: Section?
817 private var items: [Item] = []
826 private(set) var items: [Item] = []
818827 private var selected: String?
819828 private lazy var search = SearchViewController.controller { [weak self] in self?.section }
820829 /// Opens a page of the section.
......@@ -847,6 +856,7 @@ final class PagesViewController: UITableViewController {
847856 }
848857 NotificationCenter.default.addObserver(
849858 self, selector: #selector(changed), name: Section.changed, object: nil)
859 setUpArranging()
850860 if Prototype.sectionStrip {
851861 for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
852862 let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped))
......@@ -981,6 +991,7 @@ final class PagesViewController: UITableViewController {
981991 }
982992 }
983993 cell.contentConfiguration = content
994 cell.accessibilityCustomActions = arrangeActions(item)
984995 return cell
985996 }
986997
......@@ -1014,6 +1025,7 @@ final class PagesViewController: UITableViewController {
10141025 actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
10151026 self?.newPage(under: item.row.id)
10161027 })
1028 actions += self?.arrangeMenu(item) ?? []
10171029 }
10181030 actions.append(
10191031 UIAction(
apps/ios/Snowbound/Notebooks.swift+38-9
......@@ -447,6 +447,8 @@ enum Notebooks {
447447
448448 private static let key = "notebooks"
449449 private static let hidden = "hidesOnDevice"
450 /// The notebooks' ids in the order the reader put them in.
451 private static let order = "notebookOrder"
450452 /// The Snowbound Guide's folder, in the app and in Documents once copied there.
451453 static let guide = "Snowbound Guide"
452454 /// Whether the list offers the guide; off until Clover has read it through.
......@@ -478,13 +480,13 @@ enum Notebooks {
478480 if let data = UserDefaults.standard.data(forKey: key),
479481 let entries = try? JSONDecoder().decode([Entry].self, from: data)
480482 {
481 elsewhere = entries.compactMap { entry in
483 elsewhere = ordered(entries.compactMap { entry in
482484 switch entry.source {
483485 case .documents, .icloud: return nil
484486 default: break
485487 }
486488 return Notebook(id: entry.id, name: entry.name, source: entry.source)
487 }
489 })
488490 } else {
489491 // The notebook the first version remembered from Files.
490492 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
......@@ -522,16 +524,43 @@ enum Notebooks {
522524 return []
523525 }
524526 var added: [Notebook] = []
525 list = notebooks(in: folder).map { [list] name in
526 if let kept = list.first(where: { $0.id == prefix + name }) { return kept }
527 let notebook = Notebook(
528 id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name))
529 added.append(notebook)
530 return notebook
531 }
527 list = ordered(
528 notebooks(in: folder).map { [list] name in
529 if let kept = list.first(where: { $0.id == prefix + name }) { return kept }
530 let notebook = Notebook(
531 id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name))
532 added.append(notebook)
533 return notebook
534 })
532535 return added
533536 }
534537
538 /// `list` in the reader's order, those it doesn't name after in their own.
539 private static func ordered(_ list: [Notebook]) -> [Notebook] {
540 let order = UserDefaults.standard.stringArray(forKey: order) ?? []
541 let rank = { (notebook: Notebook) in order.firstIndex(of: notebook.id) ?? order.count }
542 return list.enumerated().sorted { (rank($0.element), $0.offset) < (rank($1.element), $1.offset) }.map(\.element)
543 }
544
545 /// The notebooks listed in the same place as `notebook`, in order.
546 static func siblings(of notebook: Notebook) -> [Notebook] {
547 [inCloud, onDevice, elsewhere].first { $0.contains { $0 === notebook } } ?? []
548 }
549
550 /// Puts `notebook` at `index` among the notebooks of its place, and keeps that order.
551 static func move(_ notebook: Notebook, to index: Int) {
552 func move(in list: inout [Notebook]) {
553 guard let at = list.firstIndex(where: { $0 === notebook }) else { return }
554 let moved = list.remove(at: at)
555 list.insert(moved, at: min(max(index, 0), list.count))
556 }
557 move(in: &inCloud)
558 move(in: &onDevice)
559 move(in: &elsewhere)
560 guard scripted == nil else { return }
561 UserDefaults.standard.set(all.map(\.id), forKey: order)
562 }
563
535564 /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as
536565 /// when another device added or removed one.
537566 static var inCloudChanged: Bool {
crates/mobile/include/snowbound.h+4
......@@ -32,6 +32,9 @@ char *sb_library_sections(const Library *library);
3232int32_t sb_library_color(const Library *library);
3333char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
3434 const char *date, const char *time);
35char *sb_library_move_page(const Library *library, const char *from, const char *id, const char *to, const char *before,
36 uint32_t level, const char *discard, const char *author, const char *date, const char *time);
37char *sb_library_place(const Library *library, const char *path, const char *folder, const char *paths);
3538char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query);
3639char *sb_library_tagged(const Library *library);
3740bool sb_library_unlock(const Library *library, const char *path, const char *password);
......@@ -55,6 +58,7 @@ char *sb_section_pages(const Section *section);
5558uint32_t sb_section_poll(const Section *section, uint8_t *status);
5659char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);
5760bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time);
61bool sb_section_arrange(const Section *section, const char *order, const char *moved);
5862char *sb_section_themes(const Section *section, const char *id);
5963bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme);
6064bool sb_section_flush(const Section *section, double seconds);
crates/mobile/src/library.rs+239
......@@ -931,6 +931,20 @@ impl Section {
931931 })
932932 }
933933
934 /// Puts the pages `moved` where `order` lists them, at their levels, as one edit; false
935 /// where none of them is listed any more.
936 pub(crate) fn arrange(&self, order: &[(ExGuid, u32)], moved: &[ExGuid]) -> Result<bool> {
937 let edits = session::arrange(&self.shared.section.pages()?, order, moved)?;
938 if edits.is_empty() {
939 return Ok(false);
940 }
941 self.shared.apply(Edit {
942 at: filetime(),
943 ops: vec![Op::Section(SectionOp::Pages(edits))],
944 })?;
945 Ok(true)
946 }
947
934948 /// Applies the events since the last poll to the shared status.
935949 pub(crate) fn poll(&self) -> u32 {
936950 let mut flags = 0;
......@@ -1205,6 +1219,130 @@ impl Library {
12051219 Ok(())
12061220 }
12071221
1222 /// Runs `change` on the section at catalog `path`: through its session where one is open,
1223 /// else through one opened for it and closed after, its edits left to the background.
1224 fn with_section<T>(
1225 &self,
1226 path: &str,
1227 change: impl FnOnce(&session::Section) -> Result<T>,
1228 ) -> Result<T> {
1229 if let Some((_, shared)) = self
1230 .open_sections()
1231 .into_iter()
1232 .find(|(open, _)| open == path)
1233 {
1234 return change(&shared.section);
1235 }
1236 let section = self.section(path, || {})?;
1237 let result = change(&section);
1238 section.close()?;
1239 result
1240 }
1241
1242 /// Moves page `space` of the section at catalog `from` into the section at `to`, before
1243 /// `before` at `level` (last without), as OneNote moves a page dropped on a section's tab:
1244 /// it keeps its identity, title, date and content there. A section left without pages
1245 /// gains one titled `date` and `time`; `discard`, the page `to` gained when this page
1246 /// left it, goes while it holds nothing, as Undo takes the move back.
1247 #[allow(clippy::too_many_arguments)]
1248 pub(crate) fn move_page(
1249 &self,
1250 from: &str,
1251 space: ExGuid,
1252 to: &str,
1253 before: Option<ExGuid>,
1254 level: u32,
1255 discard: Option<ExGuid>,
1256 author: &str,
1257 [date, time]: [&str; 2],
1258 ) -> Result<MovedPage> {
1259 if from == to {
1260 return Err("The page is in that section already".into());
1261 }
1262 let (page, was) = self.with_section(from, |section| {
1263 let listed = section.pages()?;
1264 let at = (listed.iter().position(|(listed, ..)| *listed == space))
1265 .ok_or("The page is not listed")?;
1266 let next = listed.get(at + 1).map(|(next, ..)| *next);
1267 Ok((section.page(space)?, (next, listed[at].2)))
1268 })?;
1269 let import = session::moved(&page, author)?;
1270 let Op::Section(SectionOp::Import { creation, .. }) = &import else {
1271 unreachable!("notebook::session::moved imports");
1272 };
1273 let arrived = creation.space();
1274 self.with_section(to, |section| {
1275 let listed = section.pages()?;
1276 let has = |page: &ExGuid| listed.iter().any(|(space, ..)| space == page);
1277 let mut ops = vec![import];
1278 let before = before.filter(has);
1279 if before.is_some() || level != 1 {
1280 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
1281 arrived, before, level,
1282 )?])));
1283 }
1284 if let Some(discard) = discard.filter(has)
1285 && session::blank(&section.page(discard)?)
1286 {
1287 ops.push(Op::Section(SectionOp::Delete(vec![discard])));
1288 }
1289 Ok(section.apply(
1290 author,
1291 Edit {
1292 at: filetime(),
1293 ops,
1294 },
1295 )?)
1296 })?;
1297 let added = self.with_section(from, |section| {
1298 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
1299 let added = match section.pages()?.as_slice() {
1300 [(only, ..)] if *only == space => {
1301 let fresh = dated(author, date, time)?;
1302 let added = fresh.space();
1303 ops.push(Op::Section(SectionOp::Create(fresh)));
1304 Some(added)
1305 }
1306 _ => None,
1307 };
1308 section.apply(
1309 author,
1310 Edit {
1311 at: filetime(),
1312 ops,
1313 },
1314 )?;
1315 Ok(added)
1316 })?;
1317 let id = |space: Option<ExGuid>| space.map(|space| space.to_string());
1318 Ok(MovedPage {
1319 page: arrived.to_string(),
1320 added: id(added),
1321 before: id(was.0),
1322 level: was.1,
1323 })
1324 }
1325
1326 /// Puts the section or group at catalog `path` into the group `folder` ("" for the
1327 /// notebook's top), moving it there unless it is there, as OneNote moves one dragged
1328 /// onto a group, then orders `folder` as `paths` lists its entries, as they are named
1329 /// once moved; entries left out follow in their order. Returns its catalog path.
1330 pub(crate) fn place(&self, path: &str, folder: &str, paths: &[String]) -> Result<String> {
1331 let parent = path.rsplit_once('/').map_or("", |(parent, _)| parent);
1332 self.with_notebook(true, |notebook| {
1333 notebook.refresh()?;
1334 let placed = match parent == folder {
1335 true => path.to_owned(),
1336 false => notebook.move_entry(path, folder)?,
1337 };
1338 if !paths.is_empty() {
1339 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
1340 notebook.reorder(folder, &paths)?;
1341 }
1342 Ok(placed)
1343 })
1344 }
1345
12081346 fn open_sections(&self) -> Vec<(String, Arc<Shared>)> {
12091347 let mut open = self.open.lock().unwrap_or_else(|error| error.into_inner());
12101348 open.retain(|(_, shared)| shared.strong_count() > 0);
......@@ -1214,6 +1352,16 @@ impl Library {
12141352 }
12151353}
12161354
1355/// A page `Library::move_page` moved, by id: where it went, the page the section it left
1356/// gained, and where it was there: before `before`, at `level`.
1357#[derive(serde::Serialize)]
1358pub(crate) struct MovedPage {
1359 pub(crate) page: String,
1360 pub(crate) added: Option<String>,
1361 pub(crate) before: Option<String>,
1362 pub(crate) level: u32,
1363}
1364
12171365#[derive(serde::Serialize)]
12181366pub(crate) struct Synced {
12191367 pub(crate) path: String,
......@@ -1477,6 +1625,63 @@ pub unsafe extern "C" fn sb_library_new_section(
14771625 .map_or(std::ptr::null_mut(), owned)
14781626}
14791627
1628/// Moves page `id` of the section at catalog `from` into the section at `to` (`Library::move_page`),
1629/// before page `before` at `level`, or last with `before` null; `discard`, unless null, is
1630/// the page `to` gained when this page left it. Returns JSON with `page`, its id there,
1631/// `added`, the page titled `date` and `time` that `from` gained, or null, and `before` and
1632/// `level`, where it was in `from`; null when it cannot move. Writes to both sections, so
1633/// call it off the main thread.
1634///
1635/// # Safety
1636/// Every string is NUL-terminated UTF-8; `before` and `discard` may be null.
1637#[unsafe(no_mangle)]
1638pub unsafe extern "C" fn sb_library_move_page(
1639 library: &Library,
1640 from: *const c_char,
1641 id: *const c_char,
1642 to: *const c_char,
1643 before: *const c_char,
1644 level: u32,
1645 discard: *const c_char,
1646 author: *const c_char,
1647 date: *const c_char,
1648 time: *const c_char,
1649) -> *mut c_char {
1650 let space = |text: *const c_char| optional(text).map(|id| id.parse()).transpose();
1651 json((|| {
1652 library.move_page(
1653 &string(from),
1654 string(id).parse()?,
1655 &string(to),
1656 space(before)?,
1657 level,
1658 space(discard)?,
1659 &string(author),
1660 [&string(date), &string(time)],
1661 )
1662 })())
1663}
1664
1665/// Puts the section or group at catalog `path` into the group at catalog path `folder` (""
1666/// for the notebook's top), moving it unless it is there, then orders `folder` as `paths`, a
1667/// JSON array of catalog paths as they are named after the move; entries left out follow
1668/// in their order. Returns its catalog path, or null.
1669///
1670/// # Safety
1671/// Every string is NUL-terminated UTF-8.
1672#[unsafe(no_mangle)]
1673pub unsafe extern "C" fn sb_library_place(
1674 library: &Library,
1675 path: *const c_char,
1676 folder: *const c_char,
1677 paths: *const c_char,
1678) -> *mut c_char {
1679 let placed = serde_json::from_str::<Vec<String>>(&string(paths))
1680 .map_err(Into::into)
1681 .and_then(|paths| library.place(&string(path), &string(folder), &paths));
1682 report(placed).map_or(std::ptr::null_mut(), owned)
1683}
1684
14801685/// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title
14811686/// matches first and then the most recently changed, as JSON: each with `notebook` (its
14821687/// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`,
......@@ -1858,6 +2063,40 @@ pub unsafe extern "C" fn sb_section_delete_page(
18582063 report(deleted).is_some()
18592064}
18602065
2066/// Puts the pages `moved`, a JSON array of ids, where `order`, a JSON array of `{id, level}`
2067/// listing the section's pages, places them, at their levels, as one edit: a page dragged
2068/// to a new place, made a subpage or promoted, or put back as Undo does. False when none of
2069/// them is listed any more.
2070///
2071/// # Safety
2072/// `order` and `moved` are NUL-terminated UTF-8.
2073#[unsafe(no_mangle)]
2074pub unsafe extern "C" fn sb_section_arrange(
2075 section: &Section,
2076 order: *const c_char,
2077 moved: *const c_char,
2078) -> bool {
2079 #[derive(serde::Deserialize)]
2080 struct Placed {
2081 id: String,
2082 level: u32,
2083 }
2084 let arranged = (|| {
2085 let order: Vec<Placed> = serde_json::from_str(&string(order))?;
2086 let order = order
2087 .into_iter()
2088 .map(|placed| Ok((placed.id.parse()?, placed.level)))
2089 .collect::<Result<Vec<_>>>()?;
2090 let moved: Vec<String> = serde_json::from_str(&string(moved))?;
2091 let moved = moved
2092 .iter()
2093 .map(|id| Ok(id.parse()?))
2094 .collect::<Result<Vec<_>>>()?;
2095 section.arrange(&order, &moved)
2096 })();
2097 report(arranged).unwrap_or(false)
2098}
2099
18612100/// Waits for every edit to be stored in the cache, then up to `seconds` for them to be
18622101/// published; true when nothing waits. For the host's background time.
18632102#[unsafe(no_mangle)]
crates/mobile/src/tests.rs+181
......@@ -1274,3 +1274,184 @@ fn a_movie_from_the_camera_becomes_the_desktop_s_avi() {
12741274 assert_eq!((parsed.frames.len(), parsed.duration_ms()), (15, 1000));
12751275 assert!(parsed.sound.is_some());
12761276}
1277
1278fn levels(section: &Section) -> Vec<(ExGuid, u32)> {
1279 let pages = section.shared.section.pages().unwrap();
1280 pages
1281 .into_iter()
1282 .map(|(space, _, level)| (space, level))
1283 .collect()
1284}
1285
1286#[test]
1287fn pages_drag_indent_and_undo_as_one_edit_each() {
1288 let (_directory, _root, _library, section) = notebook_open("Features.one");
1289 let before = levels(&section);
1290 assert!(before.len() > 2);
1291 let last = before.last().unwrap().0;
1292 let mut dragged = before.clone();
1293 let moved = dragged.pop().unwrap();
1294 dragged.insert(0, moved);
1295 let revisions = section.shared.section.pending().unwrap().len();
1296 assert!(section.arrange(&dragged, &[last]).unwrap());
1297 assert_eq!(levels(&section), dragged);
1298 assert_eq!(
1299 section.shared.section.pending().unwrap().len(),
1300 revisions + 1
1301 );
1302 let second = dragged[1].0;
1303 let mut indented = dragged.clone();
1304 indented[1].1 = 2;
1305 assert!(section.arrange(&indented, &[second]).unwrap());
1306 assert_eq!(levels(&section), indented);
1307 assert!(section.arrange(&dragged, &[second]).unwrap());
1308 assert!(section.arrange(&before, &[last]).unwrap());
1309 assert_eq!(levels(&section), before);
1310 assert!(!section.arrange(&before, &[ExGuid::default()]).unwrap());
1311}
1312
1313#[test]
1314fn a_page_moves_to_another_section_and_back_where_it_was() {
1315 let (_directory, root, library, features) = notebook_open("Features.one");
1316 let before = levels(&features);
1317 let (space, level) = before[1];
1318 let title = features.shared.section.page(space).unwrap().title;
1319 let date = ["Thursday, October 1, 2026", "9:00 AM"];
1320 let moved = library
1321 .move_page(
1322 "Features.one",
1323 space,
1324 "Empty.one",
1325 None,
1326 1,
1327 None,
1328 "Clover Test",
1329 date,
1330 )
1331 .unwrap();
1332 let arrived: ExGuid = moved.page.parse().unwrap();
1333 assert_eq!(moved.added, None);
1334 assert_eq!(moved.before, Some(before[2].0.to_string()));
1335 assert_eq!(moved.level, level);
1336 assert!(levels(&features).iter().all(|(page, _)| *page != space));
1337 let empty = open(&library, "Empty.one");
1338 let listed = empty.shared.section.pages().unwrap();
1339 assert_eq!(listed.last().unwrap(), &(arrived, title.clone(), 1));
1340 drop(empty);
1341 let back = library
1342 .move_page(
1343 "Empty.one",
1344 arrived,
1345 "Features.one",
1346 moved.before.map(|before| before.parse().unwrap()),
1347 moved.level,
1348 None,
1349 "Clover Test",
1350 date,
1351 )
1352 .unwrap()
1353 .page
1354 .parse()
1355 .unwrap();
1356 let mut returned = before.clone();
1357 returned[1].0 = back;
1358 assert_eq!(levels(&features), returned);
1359 assert_eq!(features.shared.section.page(back).unwrap().title, title);
1360 drop(features);
1361 library.close(Duration::from_secs(20)).unwrap();
1362 let arena = onestore::Arena::default();
1363 let file = onestore::read_file(root.join("Features.one")).unwrap();
1364 let stored = onestore::Section::open(&arena, file).unwrap();
1365 assert_eq!(stored.page(back).unwrap().title, title);
1366}
1367
1368#[test]
1369fn a_sections_last_page_moving_leaves_a_page_that_undo_takes_out() {
1370 let (_directory, _root, library, features) = notebook_open("Features.one");
1371 let date = ["Thursday, October 1, 2026", "9:02 AM"];
1372 let page = features.new_page(None, date[0], date[1]).unwrap();
1373 let arrived: ExGuid = library
1374 .move_page(
1375 "Features.one",
1376 page,
1377 "Empty.one",
1378 None,
1379 1,
1380 None,
1381 "Clover Test",
1382 date,
1383 )
1384 .unwrap()
1385 .page
1386 .parse()
1387 .unwrap();
1388 assert_eq!(levels(&open(&library, "Empty.one")), [(arrived, 1)]);
1389 let returned = library
1390 .move_page(
1391 "Empty.one",
1392 arrived,
1393 "Features.one",
1394 None,
1395 1,
1396 None,
1397 "Clover Test",
1398 date,
1399 )
1400 .unwrap();
1401 let back = returned.page.parse().unwrap();
1402 let added: ExGuid = returned.added.unwrap().parse().unwrap();
1403 assert_eq!(levels(&open(&library, "Empty.one")), [(added, 1)]);
1404 library
1405 .move_page(
1406 "Features.one",
1407 back,
1408 "Empty.one",
1409 None,
1410 1,
1411 Some(added),
1412 "Clover Test",
1413 date,
1414 )
1415 .unwrap();
1416 assert_eq!(levels(&open(&library, "Empty.one")).len(), 1);
1417}
1418
1419#[test]
1420fn sections_reorder_and_move_between_groups_and_back() {
1421 let (_directory, root, library, features) = notebook_open("Features.one");
1422 drop(features);
1423 let top = |library: &Library| -> Vec<String> {
1424 let tabs = library.tabs().unwrap();
1425 tabs.into_iter()
1426 .filter(|tab| tab.group.is_empty())
1427 .map(|tab| tab.path)
1428 .collect()
1429 };
1430 let before = top(&library);
1431 let mut reversed = before.clone();
1432 reversed.reverse();
1433 assert_eq!(
1434 library.place("Empty.one", "", &reversed).unwrap(),
1435 "Empty.one"
1436 );
1437 assert_eq!(top(&library), reversed);
1438 assert_eq!(
1439 library.place("Empty.one", "Group A", &[]).unwrap(),
1440 "Group A/Empty.one"
1441 );
1442 assert!(root.join("Group A/Empty.one").exists());
1443 let tabs = library.tabs().unwrap();
1444 let grouped: Vec<&str> = tabs
1445 .iter()
1446 .filter(|tab| tab.group == "Group A")
1447 .map(|tab| tab.path.as_str())
1448 .collect();
1449 assert_eq!(grouped.last(), Some(&"Group A/Empty.one"));
1450 assert_eq!(
1451 library.place("Group A/Empty.one", "", &before).unwrap(),
1452 "Empty.one"
1453 );
1454 assert_eq!(top(&library), before);
1455 assert!(root.join("Empty.one").exists());
1456 open(&library, "Empty.one");
1457}
crates/notebook/src/session.rs+34-1
......@@ -6,7 +6,7 @@ use crate::{
66 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs,
77};
88use onestore::{
9 CommitError, ExGuid, PageCreation, RevisionIndex, Stamp, Store, Transaction,
9 CommitError, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, Transaction,
1010 document::Document,
1111 op::{Edit, Op, SectionOp},
1212 page::Page,
......@@ -1724,6 +1724,39 @@ pub fn moved(page: &Page, author: &str) -> Result<Op> {
17241724 }))
17251725}
17261726
1727/// The edits putting the pages `moved` where `order` lists them, with their levels, in a
1728/// section now listing `listed`: each goes before the next page of `order` that stays put
1729/// and is still listed. Pages gone from the section are left out.
1730pub fn arrange(
1731 listed: &[(ExGuid, String, u32)],
1732 order: &[(ExGuid, u32)],
1733 moved: &[ExGuid],
1734) -> Result<Vec<PageEdit>> {
1735 let present = |space: &ExGuid| listed.iter().any(|(listed, ..)| listed == space);
1736 let mut edits = Vec::new();
1737 for (index, (space, level)) in order.iter().enumerate() {
1738 if !moved.contains(space) || !present(space) {
1739 continue;
1740 }
1741 let before = order[index + 1..]
1742 .iter()
1743 .map(|(space, _)| *space)
1744 .find(|space| !moved.contains(space) && present(space));
1745 edits.push(PageEdit::move_to(*space, before, *level)?);
1746 }
1747 Ok(edits)
1748}
1749
1750/// Whether `page` holds nothing but an empty title, as the page a section left without
1751/// pages gains does.
1752pub fn blank(page: &Page) -> bool {
1753 page.title.trim().is_empty()
1754 && page
1755 .objects
1756 .iter()
1757 .all(|object| matches!(object, onestore::page::PageObject::Title(_)))
1758}
1759
17271760fn component(name: &str) -> bool {
17281761 !name.is_empty() && !name.contains(['/', '\\', '\0']) && name != "." && name != ".."
17291762}
crates/snowbound/src/print.rs+1-1
......@@ -446,7 +446,7 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> {
446446fn printed(pages: Vec<Page>) -> Vec<Page> {
447447 pages
448448 .into_iter()
449 .filter(|page| !crate::undo::blank(page))
449 .filter(|page| !notebook::session::blank(page))
450450 .collect()
451451}
452452
crates/snowbound/src/undo.rs+7-23
......@@ -6,11 +6,15 @@
66use crate::library::{entry_name, folders};
77use crate::{Command, Library, Loaded, State, UserEvent};
88use canvas::editor::CanvasEditor;
9use notebook::{Replica, discover::Folder};
9use notebook::{
10 Replica,
11 discover::Folder,
12 session::{arrange, blank},
13};
1014use onestore::{
1115 ExGuid, PageCreation, PageEdit,
1216 op::{Edit, Op, SectionOp},
13 page::{Page, PageObject},
17 page::Page,
1418};
1519use std::{error::Error, sync::Arc};
1620
......@@ -285,15 +289,6 @@ fn drop_newest(steps: &mut Vec<Step>, page: ExGuid, count: usize) {
285289 }
286290}
287291
288/// Whether `page` holds nothing but its title, untitled.
289pub(crate) fn blank(page: &Page) -> bool {
290 page.title.trim().is_empty()
291 && page
292 .objects
293 .iter()
294 .all(|object| matches!(object, PageObject::Title(_)))
295}
296
297292/// The page shown once `gone` leave the section listing `listed`: `prefer` if it stays,
298293/// else the page after the first one gone, or before it, as OneNote 2010 shows.
299294fn neighbor(
......@@ -502,18 +497,7 @@ impl Site {
502497 }
503498 }
504499 Change::Arrange { order, moved } => {
505 let moving = |space: &ExGuid| moved.contains(space) && at(*space).is_some();
506 let mut edits = Vec::new();
507 for (index, (space, level)) in order.iter().enumerate() {
508 if !moving(space) {
509 continue;
510 }
511 let before = order[index + 1..]
512 .iter()
513 .map(|(space, _)| *space)
514 .find(|space| !moved.contains(space) && at(*space).is_some());
515 edits.push(PageEdit::move_to(*space, before, *level)?);
516 }
500 let edits = arrange(&listed, &order, &moved)?;
517501 let Some(show) = edits.first().map(PageEdit::space) else {
518502 return Ok(None);
519503 };