Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions InterlinedList.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
A057072E1611947A2415D4D9 /* DocumentCollaboratorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */; };
A1B1C1D1E1F10001 /* InterlinedListApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10002 /* InterlinedListApp.swift */; };
A1B1C1D1E1F10003 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10004 /* RootView.swift */; };
90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F84433332486544DA15C7BA /* APIClient+Documents.swift */; };
A1B1C1D1E1F10005 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10006 /* User.swift */; };
A1B1C1D1E1F10007 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F10008 /* Message.swift */; };
A1B1C1D1E1F10009 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C1D1E1F1000A /* KeychainService.swift */; };
Expand Down Expand Up @@ -287,6 +288,7 @@
8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMergeTests.swift; sourceTree = "<group>"; };
907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLinkMetadataTests.swift; sourceTree = "<group>"; };
90E55EFCB42C25CBC10B3942 /* DMThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMThreadView.swift; sourceTree = "<group>"; };
1F84433332486544DA15C7BA /* APIClient+Documents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Documents.swift"; sourceTree = "<group>"; };
93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = "<group>"; };
93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = "<group>"; };
956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -534,6 +536,7 @@
B1C1D1E1F1A10002 /* DataCache.swift */,
B1C1D1E1F1A10004 /* AppDataStore.swift */,
D0A1D0A1D0A10002 /* OAuthCoordinator.swift */,
1F84433332486544DA15C7BA /* APIClient+Documents.swift */,
FC5914D730B4355F02E89D81 /* ILColor.swift */,
8B79D26B822DCA1188DF37E9 /* PushService.swift */,
5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */,
Expand Down Expand Up @@ -870,6 +873,7 @@
B1C1D1E1F1A1000D /* ListItemFormView.swift in Sources */,
D0A1D0A1D0A10001 /* OAuthCoordinator.swift in Sources */,
D0A1D0A1D0A10003 /* ChangeEmailView.swift in Sources */,
90B9BB00F26A98E19FEAE318 /* APIClient+Documents.swift in Sources */,
D0A1D0A1D0A10005 /* ForgotPasswordView.swift in Sources */,
D0A1D0A1D0A10007 /* ResetPasswordView.swift in Sources */,
D0A1D0A1D0A10009 /* EmailVerificationBanner.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions InterlinedList/Models/Document.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ struct DocumentFolder: Codable, Identifiable {
}
}

extension DocumentFolder {
/// A copy under a new name, for showing a rename before the server confirms it.
func renamed(to newName: String) -> DocumentFolder {
DocumentFolder(id: id, name: newName, parentId: parentId,
updatedAt: updatedAt, deletedAt: deletedAt)
}
}

/// A starter document a subscriber can copy into a new document. `id` is the
/// source document's id (`templateDocumentId` for `POST /api/documents/from-template`).
struct DocumentTemplate: Codable, Identifiable {
Expand Down
62 changes: 62 additions & 0 deletions InterlinedList/Services/APIClient+Documents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

/// How a folder update should treat `parentId`.
///
/// `PUT /api/documents/folders/{id}` only validates and applies a move when the
/// `parentId` key is *present* in the body, and Swift's synthesized `Encodable`
/// drops a `nil` optional rather than writing `null`. Without this distinction a
/// rename would be indistinguishable from a move to root.
enum DocumentFolderParentUpdate: Equatable {
/// Leave the folder where it is — omit `parentId` entirely.
case unchanged
/// Move to the top level — send an explicit `null`.
case root
/// Move under another folder.
case folder(String)
}

extension APIClient {
/// Renames and/or moves a document folder (`PUT /api/documents/folders/{id}`).
///
/// The route reads plain camelCase keys (`name`, `parentId`), so this goes
/// through `putCamel` — the snake_case `put` would send `parent_id`, which the
/// server ignores, silently dropping the move.
///
/// A move that would make the folder its own ancestor is rejected server-side
/// with a `400` whose body carries readable text, which `checkResponse` surfaces
/// as `APIError.server(_:)`. A name that collides with a sibling comes back the
/// same way with a `409`.
func updateDocumentFolder(id: String,
name: String? = nil,
parentId: DocumentFolderParentUpdate = .unchanged) async throws -> DocumentFolder {
struct Response: Decodable { let folder: DocumentFolder? }
let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
let response: Response = try await putCamel(
"/api/documents/folders/\(encoded)",
body: UpdateDocumentFolderBody(name: name, parentId: parentId))
guard let folder = response.folder else { throw APIError.noData }
return folder
}
}

/// Encodes only the fields the caller actually wants changed, so a rename never
/// re-sends (and re-validates) the folder's parent.
private struct UpdateDocumentFolderBody: Encodable {
let name: String?
let parentId: DocumentFolderParentUpdate

private enum CodingKeys: String, CodingKey { case name, parentId }

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
switch parentId {
case .unchanged:
break
case .root:
try container.encodeNil(forKey: .parentId)
case .folder(let id):
try container.encode(id, forKey: .parentId)
}
}
}
14 changes: 14 additions & 0 deletions InterlinedList/Services/AppDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,20 @@ final class AppDataStore: ObservableObject {
}
func removeDocumentFolder(id: String) { documentFolders.removeAll { $0.id == id }; persistDocs() }

/// Mirrors a renamed/moved folder into the local tree and persists it, so the
/// change survives a cold start the way a create or delete does.
///
/// Unlike `createDocumentFolderOffline` / `deleteDocumentFolderOffline` this
/// deliberately enqueues **no** sync operation: `POST /api/documents/sync`
/// answers a folder update with `unsupported_operation` ("Folders support only
/// `create` and `delete`"), so the change reaches the server through
/// `PUT /api/documents/folders/{id}` and this only mirrors the result.
func updateDocumentFolder(_ folder: DocumentFolder) {
guard let idx = documentFolders.firstIndex(where: { $0.id == folder.id }) else { return }
documentFolders[idx] = folder
persistDocs()
}

private func persistDocs() {
if offlineDocSyncEnabled {
saveDocsSyncCache()
Expand Down
Loading
Loading