From 3f6b767468190093c86920d26a94378cd57b3455 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 5 Sep 2026 23:59:32 -0700 Subject: [PATCH 1/2] feat(create-from,github): materialize sheet + GitHub label/assignee pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread C: closes G16 ("Create from…") and G18 (GitHub metadata depth) from work-consolidation.md. Stacked on refactor/apiclient-transport-seam — the new endpoints live in `APIClient+*.swift` files, which that refactor made possible. G16 — POST /api/materialize - Models/Materialize.swift: the camelCase wire contract. `sourceKey` encodes as an explicit null for user-added columns (the contract documents `string|null`, and omission is a different thing). - Services/MaterializePlanner.swift + MarkdownBlocks.swift: pure ports of the backend's build-list.ts / markdown-blocks.ts, so the sheet's seeded columns and preview match what the server will build. Optionless select/multiselect columns are downgraded to text — the server's DSL validator rejects them outright, and GitHub-backed lists hit exactly that with labels/assignees. - Views/CreateFromSheet.swift + CreateFromColumnEditor.swift: destination picker (List / Doc / Both), column rename/retype/remove/add, live preview, and the created-result summary. Only ids go over the wire. - Entry points: a message (detail), multi-select messages (feed), a whole list, list rows (single via context menu, many via selection mode), and a document. - Subscriber-gated: every entry point is hidden for free users, and a 403 maps to neutral copy — the raw body carries "Subscribe…" text that must not appear in-app (Guideline 3.1.1). G18 — GitHub repo metadata - APIClient+GitHub.swift: githubLabels / githubAssignees / githubOrgs / githubNextIssueNumber. Orgs decode from either a bare array or an { orgs: [] } envelope — production and the backend source currently disagree. - ListItemFormView gains a `multiselect` case backed by real repo options; ListDetailView fetches them and now shows labels/assignees instead of hiding them. Falls back to free text when options can't load, and the write path is unchanged — still the full-row `updateItem` proxy, never GitHub's issue routes (a partial PUT renames the issue to "Untitled"). - CreateListView repo picker gains an org filter; list detail shows the next issue number. Shared: SelectionActionBar.swift extracts the selection row + bottom bar that the feed and list detail both needed. Verified: build green; 902 unit tests, 0 failures (823 baseline + 79 new); app launches clean in the simulator with no runtime errors. Not yet driven by hand against a live account — tapping Create writes real data to the shared test account, so that pass is deliberately left to a human. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WFg1zPdWuasXD9ab6HJGHk --- InterlinedList.xcodeproj/project.pbxproj | 60 +++ InterlinedList/Models/GitHubMetadata.swift | 59 +++ InterlinedList/Models/Materialize.swift | 185 +++++++++ .../Services/APIClient+GitHub.swift | 50 +++ .../Services/APIClient+Materialize.swift | 32 ++ InterlinedList/Services/MarkdownBlocks.swift | 170 ++++++++ .../Services/MaterializePlanner.swift | 294 +++++++++++++ .../Views/CreateFromColumnEditor.swift | 126 ++++++ InterlinedList/Views/CreateFromSheet.swift | 390 ++++++++++++++++++ InterlinedList/Views/CreateListView.swift | 34 +- InterlinedList/Views/DocumentsView.swift | 20 + InterlinedList/Views/FeedView.swift | 127 ++++-- InterlinedList/Views/ListItemFormView.swift | 35 +- InterlinedList/Views/ListsView.swift | 173 +++++++- InterlinedList/Views/MessageDetailView.swift | 31 +- InterlinedList/Views/MultiSelectField.swift | 99 +++++ InterlinedList/Views/SelectionActionBar.swift | 83 ++++ .../APIClientGitHubMetadataTests.swift | 126 ++++++ .../APIClientMaterializeTests.swift | 225 ++++++++++ .../ModelTests/CreateFromColumnTests.swift | 65 +++ .../ServiceTests/MarkdownBlocksTests.swift | 122 ++++++ .../MaterializePlannerTests.swift | 245 +++++++++++ 22 files changed, 2705 insertions(+), 46 deletions(-) create mode 100644 InterlinedList/Models/GitHubMetadata.swift create mode 100644 InterlinedList/Models/Materialize.swift create mode 100644 InterlinedList/Services/APIClient+GitHub.swift create mode 100644 InterlinedList/Services/APIClient+Materialize.swift create mode 100644 InterlinedList/Services/MarkdownBlocks.swift create mode 100644 InterlinedList/Services/MaterializePlanner.swift create mode 100644 InterlinedList/Views/CreateFromColumnEditor.swift create mode 100644 InterlinedList/Views/CreateFromSheet.swift create mode 100644 InterlinedList/Views/MultiSelectField.swift create mode 100644 InterlinedList/Views/SelectionActionBar.swift create mode 100644 InterlinedListTests/APIClientTests/APIClientGitHubMetadataTests.swift create mode 100644 InterlinedListTests/APIClientTests/APIClientMaterializeTests.swift create mode 100644 InterlinedListTests/ModelTests/CreateFromColumnTests.swift create mode 100644 InterlinedListTests/ServiceTests/MarkdownBlocksTests.swift create mode 100644 InterlinedListTests/ServiceTests/MaterializePlannerTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index f72b2e3..6883ba6 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -19,7 +19,9 @@ 0C2A6EE423332702FD29DEBA /* Manrope.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 229B4152945DABCD6D88A59A /* Manrope.ttf */; }; 10A357B7D843EDB843C1B640 /* AIArtifact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F919661375CA53C1864025A /* AIArtifact.swift */; }; 1102DD971BF4AC03027058B9 /* BlockedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */; }; + 11847BEEA578F150FDDC0A10 /* MarkdownBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA1C9FBEDFA45ECC85E9515 /* MarkdownBlocks.swift */; }; 153437B4818A068C9ADE32DE /* APIClientLinkMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907AC4BA9A6A5DF132AEF075 /* APIClientLinkMetadataTests.swift */; }; + 16DF0EED05294D58BD783473 /* APIClient+GitHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0973B73544CBC37CA793B70 /* APIClient+GitHub.swift */; }; 1775C9B4C2CF08267AF3B138 /* WatchersListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5828367E38948582EF1DF2A /* WatchersListView.swift */; }; 17CD6CF35900DC95AA284E3C /* PushService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B79D26B822DCA1188DF37E9 /* PushService.swift */; }; 1C47F69DF0848EF0C45C51EE /* DocumentSyncModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */; }; @@ -29,6 +31,7 @@ 23E11F4CE0298A685F13AA21 /* APIClientGapPhasesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */; }; 264F2B22B11DA2FF8D2AD68A /* ShareLinksSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03C47A3ECFD7F4D25F91E3BE /* ShareLinksSheet.swift */; }; 29AE2CB98C2CF115F997F74A /* FindPeopleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D812B42F75DBEA52611BE0 /* FindPeopleView.swift */; }; + 29E56B202B1B0FCD63C90664 /* CreateFromSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4D57788950D4B94BB40AE0 /* CreateFromSheet.swift */; }; 2B99F4DE40ABB197360D9650 /* TagDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */; }; 2BD179B7617ABFD7271B9FB8 /* GitHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5802D2FF0552D510BB51D636 /* GitHub.swift */; }; 362DEA03EA2CDD9E135B1411 /* APIClientDirectMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */; }; @@ -39,6 +42,7 @@ 4AD79062F63869F2EA8B24E9 /* APIClientSharedListsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC0E81A45922BB9A42CDDB6 /* APIClientSharedListsTests.swift */; }; 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; }; 54CB167C520E0D951820214F /* AIWritingAssistantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */; }; + 536A88A4EA05DD9D0866F2AC /* MaterializePlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5EDB36FBED0F9134DD2BD9 /* MaterializePlannerTests.swift */; }; 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; 13BB05CD901E0E4960D6CDD2 /* APIClientListContributorsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2935CF30CE3CCA8210774CE5 /* APIClientListContributorsTests.swift */; }; 46DB96578FB6794A1BEE36F0 /* APIClient+ListContributors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */; }; @@ -49,9 +53,11 @@ 58151C239F0FDD01BD1BAEBB /* LinkedInPostingTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */; }; 5AC90042DEF661C6A1550A0B /* AIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A0129BD986243B7BECC74B /* AIRequest.swift */; }; 5B19E1BF3FA99B70C1F0153A /* APIClientSharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */; }; + 5F02C865EE007AFFA08E9D60 /* MultiSelectField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 257A7DE02422311CD8209E6D /* MultiSelectField.swift */; }; 5F19AD96D4278D5B8D87154E /* MessagesInboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471B0516327616EC36DE917 /* MessagesInboxView.swift */; }; 5F81C584710958DCBF85F60E /* AIServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC10FAF5FB93F8317D2D91A /* AIServiceError.swift */; }; 61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */; }; + 5F39C031ED69D47574ACF17F /* APIClientGitHubMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94499C7F9007F4B2CE90CA91 /* APIClientGitHubMetadataTests.swift */; }; 64F5804ECC25725FD1E58E84 /* APIClientModerationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A569D8DB8DFD3CC59072FDB /* APIClientModerationTests.swift */; }; 6749119D27FA93BE00D5A27F /* FeedTruncationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A695D62B746B092CF51AFA /* FeedTruncationTests.swift */; }; 6A89622E299B0D172D5B5556 /* GitHubModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337406C10876F331B1888E3 /* GitHubModelTests.swift */; }; @@ -72,9 +78,13 @@ 945250FBC238FFDDC4E526B7 /* ShareInvite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236670B73F2B603E5EB318D7 /* ShareInvite.swift */; }; 94E60D26B6EA1C38B91D9247 /* ServerLimitsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */; }; 98CC00030F4340A4BD821FC4 /* MarkdownBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B096C366108083D174366 /* MarkdownBlockTests.swift */; }; + 9922A874E8EBACB8FBB83976 /* CreateFromColumnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6BCCA562BFD5B8ABB63333 /* CreateFromColumnTests.swift */; }; 99E688DB379BD4A824487604 /* ComposeImageUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */; }; + 9B647B54BF1F121446C58857 /* Materialize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82EC3EB1E17182C51042BA83 /* Materialize.swift */; }; 9CA0B53AE87AA79D8BAC81FF /* FollowListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF29F5DC946D80A63000209 /* FollowListView.swift */; }; A057072E1611947A2415D4D9 /* DocumentCollaboratorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */; }; + A06A40D471C18810E4DA0FBE /* CreateFromColumnEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C36D72E3B993A1C09981BA /* CreateFromColumnEditor.swift */; }; + A0FBE78E04FAA7E6052DDECE /* GitHubMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.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 */; }; @@ -122,6 +132,8 @@ B270170D3EEF336A45464D01 /* CrossPostLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164718CC67967AC147E30465 /* CrossPostLinksView.swift */; }; BDCCBC7CA67219EA0998AE91 /* APIClientIdentityStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */; }; C184FAF9652A6EB2F4C00CEA /* APIClient+Limits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A026223AD393B5E1A339E045 /* APIClient+Limits.swift */; }; + BC5F98582B7D3B15655BBBED /* SelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553E5BC3D991EA12A81ED755 /* SelectionActionBar.swift */; }; + BCED4A30A06C0B2521E5BF71 /* MarkdownBlocksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E878BF21B7951D1F6D5012 /* MarkdownBlocksTests.swift */; }; C18C73C8A93572993F29D559 /* ImageUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */; }; C1D1E1F1A1B10001 /* Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D1E1F1A1B10002 /* Organization.swift */; }; C3D82E611C8006A59B3FC5CD /* APIClient+AI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7318B0E3D687653B06626F64 /* APIClient+AI.swift */; }; @@ -134,6 +146,7 @@ C818B9571C62180579743014 /* DocumentSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C2C3577AEE0C73D51308DA /* DocumentSync.swift */; }; C83E43066F7C0ABF783444CA /* SharedResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C470172F5310397C2E6195BC /* SharedResolution.swift */; }; C841BDE9BCCCB4D2517BBFEE /* ShareInvitesSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CAA8B0033D6DD27ED089CD /* ShareInvitesSheet.swift */; }; + C88B42C1A395552E00B94387 /* APIClientMaterializeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF7B8DA796C27F4200ABA1 /* APIClientMaterializeTests.swift */; }; C8A35B3D8DDE085B8D445014 /* DirectMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C2904D262C0859FD38A830 /* DirectMessage.swift */; }; C8AFF749A154E5F96FB77DE2 /* APIClientSessionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8D880C1D849E2E67C36116 /* APIClientSessionsTests.swift */; }; CB45081F767583424D54523C /* APIClientPushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09470364B5A0A559FDE3215F /* APIClientPushTests.swift */; }; @@ -163,6 +176,7 @@ E21121B51C15922CFBF92C43 /* DocumentSyncOutboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */; }; E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */; }; E51C9FF84350C46C947AC953 /* ServerLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0221B233F89B966D044C37FA /* ServerLimits.swift */; }; + E25BDBC0302C8A9152AE7429 /* MaterializePlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = E581F7084CA5B54C804C96E8 /* MaterializePlanner.swift */; }; E64747D9845D6726CC1A1485 /* NetworkReachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */; }; EA48487E20A6BECF67D3C060 /* ILWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B06F2D2448FDFA3D1FCDC4A /* ILWebURL.swift */; }; EC636FE56A7CC64D7D01F64B /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5569B56DBD6747BB9587870 /* SessionsView.swift */; }; @@ -170,6 +184,7 @@ EF9A053E1DFC0D99766D0C5D /* AISeriesLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D154417430173A77A358118 /* AISeriesLoading.swift */; }; F026A514BDE07AF64725D508 /* AIJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE452BC01A7461AF5199FFE6 /* AIJSON.swift */; }; F187F186189A7C7D39EBC2FF /* AIStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F77792CB1F50F6A8AC6111 /* AIStatus.swift */; }; + EDFA766E9CED7FB477856299 /* APIClient+Materialize.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE97DA0960589AD7409730C3 /* APIClient+Materialize.swift */; }; F211AD1D4EB93B9258F88CAC /* APIClientLinkedInTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC17A8A26D35CC755EE5988 /* APIClientLinkedInTargetsTests.swift */; }; F4B74219CE0CB90DAE6B7516 /* ShareInviteModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */; }; F6CDA845B608759F92890B8E /* AIJSONTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE615A2103F89BAFD24418B /* AIJSONTests.swift */; }; @@ -254,6 +269,7 @@ 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIServiceTests.swift; sourceTree = ""; }; + 257A7DE02422311CD8209E6D /* MultiSelectField.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MultiSelectField.swift; sourceTree = ""; }; 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInPostingTarget.swift; sourceTree = ""; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; @@ -261,6 +277,7 @@ 32C2904D262C0859FD38A830 /* DirectMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessage.swift; sourceTree = ""; }; 38E7411CA219F3E8EB351BAB /* SharedListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedListView.swift; sourceTree = ""; }; 3CD5DCAF4C97D6B577F52527 /* DocumentSyncMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncMerge.swift; sourceTree = ""; }; + 3EA1C9FBEDFA45ECC85E9515 /* MarkdownBlocks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlocks.swift; sourceTree = ""; }; 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NetworkReachability.swift; sourceTree = ""; }; 44825D4863E64D2D9C026645 /* APIClientShareInvitesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientShareInvitesTests.swift; sourceTree = ""; }; 468BAE51289ECDF42B84CE8B /* APIClientDocumentSyncTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentSyncTests.swift; sourceTree = ""; }; @@ -277,6 +294,7 @@ 4EE76647702E9C83B941FA2C /* AppDataStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDataStoreTests.swift; sourceTree = ""; }; 5110757D7694EC9FBF4366F7 /* DocumentCollaboratorsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentCollaboratorsView.swift; sourceTree = ""; }; 544F68F263670763D5E23C59 /* MessageDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MessageDetailView.swift; sourceTree = ""; }; + 553E5BC3D991EA12A81ED755 /* SelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SelectionActionBar.swift; sourceTree = ""; }; 5802D2FF0552D510BB51D636 /* GitHub.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHub.swift; path = GitHub.swift; sourceTree = ""; }; 5A04FF25F89FC0E5253896FD /* ImageUploadProcessor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessor.swift; sourceTree = ""; }; 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredTemplateView.swift; sourceTree = ""; }; @@ -292,10 +310,12 @@ 7800699C927CE3E133944E62 /* ReportSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReportSheet.swift; sourceTree = ""; }; 78BDFABB1D991EB98BFEC2AA /* ImageUploadProcessorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ImageUploadProcessorTests.swift; sourceTree = ""; }; 7A01238CA201A950E76845AE /* SpaceGrotesk.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/SpaceGrotesk.ttf; sourceTree = SOURCE_ROOT; }; + 7B4D57788950D4B94BB40AE0 /* CreateFromSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateFromSheet.swift; sourceTree = ""; }; 7EE93E83AA6CBE672E89C719 /* APIClientDocumentTemplatesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientDocumentTemplatesTests.swift; sourceTree = ""; }; 7F7063C59228E7D97A16B17D /* ComposeImageUploader.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploader.swift; sourceTree = ""; }; 7F919661375CA53C1864025A /* AIArtifact.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifact.swift; sourceTree = ""; }; 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IdentityHealth.swift; sourceTree = ""; }; + 82EC3EB1E17182C51042BA83 /* Materialize.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Materialize.swift; sourceTree = ""; }; 83DB813EA089E4807AF07110 /* MarkdownEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownEditor.swift; sourceTree = ""; }; 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientIdentityStatusTests.swift; sourceTree = ""; }; 8471B0516327616EC36DE917 /* MessagesInboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesInboxView.swift; sourceTree = ""; }; @@ -307,6 +327,7 @@ 1F84433332486544DA15C7BA /* APIClient+Documents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Documents.swift"; sourceTree = ""; }; 93642F79C3049C4A2ECC8AFF /* GapModelsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GapModelsTests.swift; sourceTree = ""; }; 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSearchUsersTests.swift; sourceTree = ""; }; + 94499C7F9007F4B2CE90CA91 /* APIClientGitHubMetadataTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientGitHubMetadataTests.swift; sourceTree = ""; }; 956BD8A48B0F2C66C2C6362A /* AppDeepLinkParseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDeepLinkParseTests.swift; sourceTree = ""; }; 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientLimitsTests.swift; sourceTree = ""; }; 98DB21A23AF902D811F28C38 /* PublicDocumentsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicDocumentsView.swift; sourceTree = ""; }; @@ -314,6 +335,8 @@ 9DBBE05B636285F9CA9A2431 /* IdentityHealthTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IdentityHealthTests.swift; sourceTree = ""; }; A017C8D6CDA816772A8D4424 /* AIWritingAssistantButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIWritingAssistantButton.swift; sourceTree = ""; }; A026223AD393B5E1A339E045 /* APIClient+Limits.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Limits.swift"; sourceTree = ""; }; + 95C36D72E3B993A1C09981BA /* CreateFromColumnEditor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateFromColumnEditor.swift; sourceTree = ""; }; + 9A5EDB36FBED0F9134DD2BD9 /* MaterializePlannerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MaterializePlannerTests.swift; sourceTree = ""; }; A1B1C1D1E1F10000 /* InterlinedList.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = InterlinedList.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B1C1D1E1F10002 /* InterlinedListApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListApp.swift; sourceTree = ""; }; A1B1C1D1E1F10004 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; @@ -360,6 +383,7 @@ B1C1D1E1F1A1000A /* ListSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListSkeletonView.swift; sourceTree = ""; }; B1C1D1E1F1A1000C /* DocumentSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentSkeletonView.swift; sourceTree = ""; }; B1C1D1E1F1A1000E /* ListItemFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemFormView.swift; sourceTree = ""; }; + B1E878BF21B7951D1F6D5012 /* MarkdownBlocksTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlocksTests.swift; sourceTree = ""; }; B3A38EE80BD5D25C7D290EF5 /* APIClientGapPhasesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientGapPhasesTests.swift; sourceTree = ""; }; B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredDocumentSheet.swift; sourceTree = ""; }; B6A7982C7C11D3E9002E51A0 /* TagDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TagDiscovery.swift; sourceTree = ""; }; @@ -368,12 +392,14 @@ BD05B653363E3A794B1B1EE7 /* ShareLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareLink.swift; sourceTree = ""; }; BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncConflict.swift; sourceTree = ""; }; C0041BEAB46F9186FB5BF907 /* OrganizationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OrganizationsView.swift; sourceTree = ""; }; + C0973B73544CBC37CA793B70 /* APIClient+GitHub.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+GitHub.swift"; sourceTree = ""; }; C1D1E1F1A1B10002 /* Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organization.swift; sourceTree = ""; }; C2A39A4CB7CD21E155C1AD77 /* AIService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIService.swift; sourceTree = ""; }; C337406C10876F331B1888E3 /* GitHubModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = GitHubModelTests.swift; path = GitHubModelTests.swift; sourceTree = ""; }; C470172F5310397C2E6195BC /* SharedResolution.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedResolution.swift; sourceTree = ""; }; C5C861AD11BBD66C75EB3EEE /* DocumentSyncModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncModelTests.swift; sourceTree = ""; }; C8BE2B897589E1AEA55ACA75 /* APIClientSharingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSharingTests.swift; sourceTree = ""; }; + CD6BCCA562BFD5B8ABB63333 /* CreateFromColumnTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreateFromColumnTests.swift; sourceTree = ""; }; D0A1D0A1D0A10002 /* OAuthCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthCoordinator.swift; sourceTree = ""; }; D0A1D0A1D0A10004 /* ChangeEmailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeEmailView.swift; sourceTree = ""; }; 0B0E633AE3DAB1E526CCB0EB /* APIClientSingleListTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientSingleListTests.swift; sourceTree = ""; }; @@ -388,24 +414,28 @@ D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DocumentSyncOutboxTests.swift; sourceTree = ""; }; D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageUploaderTests.swift; sourceTree = ""; }; D664CCA80BB084AF101113F8 /* APIClient+SharedLists.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+SharedLists.swift"; sourceTree = ""; }; + D7DF7B8DA796C27F4200ABA1 /* APIClientMaterializeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientMaterializeTests.swift; sourceTree = ""; }; DCDCA7EDAA58C66663ACC010 /* ShareInviteModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInviteModelTests.swift; sourceTree = ""; }; DCE615A2103F89BAFD24418B /* AIJSONTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSONTests.swift; sourceTree = ""; }; DE18C95CBB9F759B7D44112B /* AISeriesSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISeriesSheet.swift; sourceTree = ""; }; DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIPoweredTemplate.swift; sourceTree = ""; }; E16DC6255396818F5BB523DE /* AISuggestionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AISuggestionSheet.swift; sourceTree = ""; }; E32E0E59381D02A8A4E0A247 /* BlockedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BlockedUsersView.swift; sourceTree = ""; }; + E581F7084CA5B54C804C96E8 /* MaterializePlanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MaterializePlanner.swift; sourceTree = ""; }; E5828367E38948582EF1DF2A /* WatchersListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchersListView.swift; sourceTree = ""; }; E6A3F2C860C6B16F84CFFE12 /* APIClientDirectMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientDirectMessagesTests.swift; sourceTree = ""; }; ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; EE6BD1CB3C5C5C494971E0B4 /* DirectMessageModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DirectMessageModelTests.swift; sourceTree = ""; }; EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientAITests.swift; sourceTree = ""; }; EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIComposerLogicTests.swift; sourceTree = ""; }; + EE97DA0960589AD7409730C3 /* APIClient+Materialize.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "APIClient+Materialize.swift"; sourceTree = ""; }; F0470C00F1A0E215224120A1 /* ListWatcher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ListWatcher.swift; sourceTree = ""; }; F379C02B2FCEB9440069B81C /* InterlinedList.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = InterlinedList.xctestplan; sourceTree = ""; }; F3E6216F2FFB4666000D153A /* InterlinedList.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = InterlinedList.entitlements; sourceTree = ""; }; F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIArtifactTests.swift; sourceTree = ""; }; F5569B56DBD6747BB9587870 /* SessionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; F78B096C366108083D174366 /* MarkdownBlockTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MarkdownBlockTests.swift; sourceTree = ""; }; + F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GitHubMetadata.swift; sourceTree = ""; }; FC5914D730B4355F02E89D81 /* ILColor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InterlinedList/Services/ILColor.swift; sourceTree = SOURCE_ROOT; }; T1E5T1E5T1E50000 /* InterlinedListTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InterlinedListTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; T1E5T1E5T1E50002 /* InterlinedListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterlinedListTests.swift; sourceTree = ""; }; @@ -547,6 +577,8 @@ 5D154417430173A77A358118 /* AISeriesLoading.swift */, DE1B944CA1DA2CC8C8EECFC0 /* AIPoweredTemplate.swift */, 80D1D19E4DB2D914AD6551D7 /* IdentityHealth.swift */, + 82EC3EB1E17182C51042BA83 /* Materialize.swift */, + F9CCBC371A0DEF4F2F42A941 /* GitHubMetadata.swift */, ); path = Models; sourceTree = ""; @@ -582,6 +614,10 @@ 7318B0E3D687653B06626F64 /* APIClient+AI.swift */, C2A39A4CB7CD21E155C1AD77 /* AIService.swift */, 1DC9510D4092B9D8DE46F152 /* APIClient+Identities.swift */, + 3EA1C9FBEDFA45ECC85E9515 /* MarkdownBlocks.swift */, + E581F7084CA5B54C804C96E8 /* MaterializePlanner.swift */, + EE97DA0960589AD7409730C3 /* APIClient+Materialize.swift */, + C0973B73544CBC37CA793B70 /* APIClient+GitHub.swift */, ); path = Services; sourceTree = ""; @@ -652,6 +688,10 @@ 77237EF9E0A0D7E3D2B321CF /* AISeriesGeneratorSection.swift */, 5A2C29076D46F83C22850E30 /* AIPoweredTemplateView.swift */, B5F2DB07B553A8326D7B3114 /* AIPoweredDocumentSheet.swift */, + 7B4D57788950D4B94BB40AE0 /* CreateFromSheet.swift */, + 95C36D72E3B993A1C09981BA /* CreateFromColumnEditor.swift */, + 257A7DE02422311CD8209E6D /* MultiSelectField.swift */, + 553E5BC3D991EA12A81ED755 /* SelectionActionBar.swift */, ); path = Views; sourceTree = ""; @@ -718,6 +758,8 @@ 96C9F8430F081186DD98ABEB /* APIClientLimitsTests.swift */, EEB1570DDE0B0F44F205DC71 /* APIClientAITests.swift */, 8430183A960BC207B50FC939 /* APIClientIdentityStatusTests.swift */, + D7DF7B8DA796C27F4200ABA1 /* APIClientMaterializeTests.swift */, + 94499C7F9007F4B2CE90CA91 /* APIClientGitHubMetadataTests.swift */, ); path = APIClientTests; sourceTree = ""; @@ -750,6 +792,7 @@ F54C1776AD4A8F204FA8A2BC /* AIArtifactTests.swift */, EF9EA6C9C5C613591038EA9A /* AIComposerLogicTests.swift */, 9DBBE05B636285F9CA9A2431 /* IdentityHealthTests.swift */, + CD6BCCA562BFD5B8ABB63333 /* CreateFromColumnTests.swift */, ); path = ModelTests; sourceTree = ""; @@ -767,6 +810,8 @@ 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */, 281721EB23841D6DD2F6EE58 /* AIServiceTests.swift */, + B1E878BF21B7951D1F6D5012 /* MarkdownBlocksTests.swift */, + 9A5EDB36FBED0F9134DD2BD9 /* MaterializePlannerTests.swift */, ); path = ServiceTests; sourceTree = ""; @@ -994,6 +1039,16 @@ E3626ADC322104C3A3BC8760 /* AIPoweredDocumentSheet.swift in Sources */, 7B9B1453EE6A8560D94D357A /* APIClient+Identities.swift in Sources */, 61621697746E8CF1769E9C80 /* IdentityHealth.swift in Sources */, + 9B647B54BF1F121446C58857 /* Materialize.swift in Sources */, + A0FBE78E04FAA7E6052DDECE /* GitHubMetadata.swift in Sources */, + 11847BEEA578F150FDDC0A10 /* MarkdownBlocks.swift in Sources */, + E25BDBC0302C8A9152AE7429 /* MaterializePlanner.swift in Sources */, + EDFA766E9CED7FB477856299 /* APIClient+Materialize.swift in Sources */, + 16DF0EED05294D58BD783473 /* APIClient+GitHub.swift in Sources */, + 29E56B202B1B0FCD63C90664 /* CreateFromSheet.swift in Sources */, + A06A40D471C18810E4DA0FBE /* CreateFromColumnEditor.swift in Sources */, + 5F02C865EE007AFFA08E9D60 /* MultiSelectField.swift in Sources */, + BC5F98582B7D3B15655BBBED /* SelectionActionBar.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1086,6 +1141,11 @@ C4C3B533A4C5F76394318FAB /* AIServiceTests.swift in Sources */, BDCCBC7CA67219EA0998AE91 /* APIClientIdentityStatusTests.swift in Sources */, C77AB91280416826FCC6A6D3 /* IdentityHealthTests.swift in Sources */, + C88B42C1A395552E00B94387 /* APIClientMaterializeTests.swift in Sources */, + 5F39C031ED69D47574ACF17F /* APIClientGitHubMetadataTests.swift in Sources */, + BCED4A30A06C0B2521E5BF71 /* MarkdownBlocksTests.swift in Sources */, + 536A88A4EA05DD9D0866F2AC /* MaterializePlannerTests.swift in Sources */, + 9922A874E8EBACB8FBB83976 /* CreateFromColumnTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Models/GitHubMetadata.swift b/InterlinedList/Models/GitHubMetadata.swift new file mode 100644 index 0000000..18260da --- /dev/null +++ b/InterlinedList/Models/GitHubMetadata.swift @@ -0,0 +1,59 @@ +// +// GitHubMetadata.swift +// InterlinedList +// + +import Foundation + +/// Repo metadata used to fill in GitHub-backed list rows: the repo's real labels +/// and assignable users, the orgs the linked account belongs to, and the next +/// issue number. +/// +/// `/api/github/repos/:owner/:repo/{labels,assignees}` forward the **raw GitHub +/// REST arrays**, so these decode through `APIClient`'s `convertFromSnakeCase` +/// decoder (`avatar_url` → `avatarUrl`). Decode defensively: only the identifying +/// field is required. + +struct GitHubLabel: Decodable, Identifiable, Hashable { + let name: String + let color: String? + let description: String? + + var id: String { name } +} + +struct GitHubAssignee: Decodable, Identifiable, Hashable { + let login: String + let avatarUrl: String? + + var id: String { login } +} + +struct GitHubOrg: Decodable, Identifiable, Hashable { + let login: String + let avatarUrl: String? + + var id: String { login } +} + +/// `GET /api/github/orgs` has been observed returning **both** a bare array and +/// an `{ orgs: [...] }` envelope depending on deployment, so accept either. +struct GitHubOrgsResponse: Decodable { + let orgs: [GitHubOrg] + + init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), + let list = try? container.decode([GitHubOrg].self) { + orgs = list + return + } + let keyed = try decoder.container(keyedBy: CodingKeys.self) + orgs = try keyed.decodeIfPresent([GitHubOrg].self, forKey: .orgs) ?? [] + } + + private enum CodingKeys: String, CodingKey { case orgs } +} + +struct GitHubNextIssueNumber: Decodable { + let nextNumber: Int +} diff --git a/InterlinedList/Models/Materialize.swift b/InterlinedList/Models/Materialize.swift new file mode 100644 index 0000000..312afd9 --- /dev/null +++ b/InterlinedList/Models/Materialize.swift @@ -0,0 +1,185 @@ +// +// Materialize.swift +// InterlinedList +// + +import Foundation + +/// Wire types for "Create from…" (`POST /api/materialize`), which turns an +/// existing object — message(s), list(s), list rows, or a document — into a new +/// List, a new Document, or both. +/// +/// The request carries **id-only references**. The server re-fetches and +/// authorizes every referenced id under the calling user and re-derives each +/// column's values from that authoritative data via `sourceKey`; client-supplied +/// cell values are never trusted. So the preview this app renders is advisory — +/// the server's output is the real thing. +/// +/// The body is camelCase, so it must go out through `postCamel`, not `post`. + +enum MaterializeTarget: String, Codable, CaseIterable, Identifiable { + case list + case doc + case both + + var id: String { rawValue } + + var label: String { + switch self { + case .list: return "To List" + case .doc: return "To Doc" + case .both: return "To List & Doc" + } + } + + var createsList: Bool { self == .list || self == .both } + var createsDocument: Bool { self == .doc || self == .both } +} + +/// The id-only source reference sent over the wire. +/// +/// The server also accepts a `docElements` kind (a document id plus selected +/// markdown), used by the web editor's text-selection menu. iOS has no +/// equivalent selection affordance yet, so it is deliberately not modeled here. +enum MaterializeSourceRef: Equatable { + case messages(messageIds: [String]) + case lists(listIds: [String]) + case rows(listId: String, rowIds: [String]) + case document(documentId: String) +} + +extension MaterializeSourceRef: Encodable { + private enum CodingKeys: String, CodingKey { + case kind, messageIds, listIds, listId, rowIds, documentId + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .messages(let ids): + try c.encode("messages", forKey: .kind) + try c.encode(ids, forKey: .messageIds) + case .lists(let ids): + try c.encode("lists", forKey: .kind) + try c.encode(ids, forKey: .listIds) + case .rows(let listId, let rowIds): + try c.encode("rows", forKey: .kind) + try c.encode(listId, forKey: .listId) + try c.encode(rowIds, forKey: .rowIds) + case .document(let documentId): + try c.encode("document", forKey: .kind) + try c.encode(documentId, forKey: .documentId) + } + } +} + +/// One column in the finalize sheet's schema editor. +/// +/// `sourceKey` maps the column back to a source attribute so the **server** can +/// re-derive its values. `nil` means a user-added empty column and is encoded as +/// an explicit `null` (not omitted) to match the documented contract. +struct MaterializeFieldConfig: Identifiable, Equatable { + var propertyKey: String + var propertyName: String + var propertyType: String + var isRequired: Bool? + var options: [String]? + var sourceKey: String? + + var id: String { propertyKey } + + /// Column types offered by the editor. `textarea` is included because the + /// server's inferred schemas use it for long-form columns (message content, + /// document text). + static let supportedTypes: [String] = [ + "text", "textarea", "number", "boolean", "date", "url", "email", + ] + + init(propertyKey: String, + propertyName: String, + propertyType: String, + isRequired: Bool? = nil, + options: [String]? = nil, + sourceKey: String?) { + self.propertyKey = propertyKey + self.propertyName = propertyName + self.propertyType = propertyType + self.isRequired = isRequired + self.options = options + self.sourceKey = sourceKey + } +} + +extension MaterializeFieldConfig: Encodable { + private enum CodingKeys: String, CodingKey { + case propertyKey, propertyName, propertyType, isRequired, options, sourceKey + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(propertyKey, forKey: .propertyKey) + try c.encode(propertyName, forKey: .propertyName) + try c.encode(propertyType, forKey: .propertyType) + try c.encodeIfPresent(isRequired, forKey: .isRequired) + try c.encodeIfPresent(options, forKey: .options) + if let sourceKey { + try c.encode(sourceKey, forKey: .sourceKey) + } else { + try c.encodeNil(forKey: .sourceKey) + } + } +} + +struct MaterializeListConfig: Encodable, Equatable { + var title: String + var description: String? + var isPublic: Bool? + var fields: [MaterializeFieldConfig] + /// Defaults to `true` server-side; only an explicit `false` creates an + /// empty, columns-only list. + var includeData: Bool? +} + +enum MaterializeDocListStyle: String, Codable, CaseIterable, Identifiable { + case numbered + case bulleted + + var id: String { rawValue } + var label: String { rawValue.capitalized } +} + +enum MaterializeDocRowDataStyle: String, Codable, CaseIterable, Identifiable { + case inline + case subItems = "sub-items" + + var id: String { rawValue } + var label: String { self == .inline ? "Inline" : "Sub-items" } +} + +struct MaterializeDocConfig: Encodable, Equatable { + var title: String + /// Omitted so the server derives its canonical path (it appends `.md` and + /// slugs from the source). Set only when the user supplies one. + var relativePath: String? + var isPublic: Bool? + /// Only meaningful for list / row sources. + var listStyle: MaterializeDocListStyle? + var rowDataStyle: MaterializeDocRowDataStyle? +} + +struct MaterializeRequest: Encodable { + let target: MaterializeTarget + let source: MaterializeSourceRef + let listConfig: MaterializeListConfig? + let docConfig: MaterializeDocConfig? +} + +struct MaterializeCreatedRef: Decodable, Equatable { + let id: String + let title: String +} + +struct MaterializeResult: Decodable, Equatable { + let list: MaterializeCreatedRef? + let document: MaterializeCreatedRef? +} diff --git a/InterlinedList/Services/APIClient+GitHub.swift b/InterlinedList/Services/APIClient+GitHub.swift new file mode 100644 index 0000000..1069b55 --- /dev/null +++ b/InterlinedList/Services/APIClient+GitHub.swift @@ -0,0 +1,50 @@ +// +// APIClient+GitHub.swift +// InterlinedList +// + +import Foundation + +/// GitHub repo metadata (`/api/github/*`, Bearer). These routes resolve the user +/// through `getGitHubIssuesContext`, which accepts a Bearer sync token, then call +/// GitHub with the linked account's token. They return **400 "GitHub account not +/// linked"** — not 401 — when no GitHub identity is linked, so callers should +/// degrade to free-text entry rather than treat it as an auth failure. +/// +/// Issue rows themselves are still written through the standard +/// `/api/lists/:id/data` proxy (`updateItem`, full row) — never through GitHub's +/// issue routes directly — because the backend rebuilds the issue from the row it +/// receives and defaults a missing `title` to "Untitled". +extension APIClient { + /// Labels defined on a repo (`GET /api/github/repos/:owner/:repo/labels`). + func githubLabels(owner: String, repo: String) async throws -> [GitHubLabel] { + try await get("/api/github/repos/\(escape(owner))/\(escape(repo))/labels") + } + + /// Users assignable to the repo's issues + /// (`GET /api/github/repos/:owner/:repo/assignees`). + func githubAssignees(owner: String, repo: String) async throws -> [GitHubAssignee] { + try await get("/api/github/repos/\(escape(owner))/\(escape(repo))/assignees") + } + + /// Organizations the linked GitHub account belongs to (`GET /api/github/orgs`). + /// Best-effort on the server: private memberships appear only when the + /// connection carried the `read:org` scope. + func githubOrgs() async throws -> [GitHubOrg] { + let response: GitHubOrgsResponse = try await get("/api/github/orgs") + return response.orgs + } + + /// The number GitHub will assign to the next issue in a repo + /// (`GET /api/github/repos/:owner/:repo/next-issue-number`). + func githubNextIssueNumber(owner: String, repo: String) async throws -> Int { + let response: GitHubNextIssueNumber = try await get( + "/api/github/repos/\(escape(owner))/\(escape(repo))/next-issue-number" + ) + return response.nextNumber + } + + private func escape(_ component: String) -> String { + component.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? component + } +} diff --git a/InterlinedList/Services/APIClient+Materialize.swift b/InterlinedList/Services/APIClient+Materialize.swift new file mode 100644 index 0000000..6c1d650 --- /dev/null +++ b/InterlinedList/Services/APIClient+Materialize.swift @@ -0,0 +1,32 @@ +// +// APIClient+Materialize.swift +// InterlinedList +// + +import Foundation + +extension APIClient { + /// Creates a List, a Document, or both from an existing object + /// (`POST /api/materialize`, Bearer, camelCase body). + /// + /// **Subscriber-only** — a free account gets `403` ("Subscribe to create + /// lists and documents."), so callers must hide the affordance rather than + /// surface a paywall. `404` means a referenced id isn't owned by the caller; + /// `400` means a bad config (no title, no columns, unsupported source). + /// + /// `listConfig` is **required** whenever `target` creates a list — the server + /// rejects a missing title or an empty column set. `docConfig` is optional; + /// the server derives defaults for anything omitted. + func materialize(target: MaterializeTarget, + source: MaterializeSourceRef, + listConfig: MaterializeListConfig? = nil, + docConfig: MaterializeDocConfig? = nil) async throws -> MaterializeResult { + let body = MaterializeRequest( + target: target, + source: source, + listConfig: target.createsList ? listConfig : nil, + docConfig: target.createsDocument ? docConfig : nil + ) + return try await postCamel("/api/materialize", body: body) + } +} diff --git a/InterlinedList/Services/MarkdownBlocks.swift b/InterlinedList/Services/MarkdownBlocks.swift new file mode 100644 index 0000000..10392a3 --- /dev/null +++ b/InterlinedList/Services/MarkdownBlocks.swift @@ -0,0 +1,170 @@ +// +// MarkdownBlocks.swift +// InterlinedList +// + +import Foundation + +/// A lightweight markdown block splitter, ported from the backend's +/// `lib/materialize/markdown-blocks.ts` so the "Create from a document" preview +/// shows the same rows the server will build. +/// +/// Deliberately not a full CommonMark parser — it handles only the constructs +/// that matter for row extraction: ATX headings, unordered/ordered list items, +/// blockquotes, fenced code, and paragraphs. Keep in sync with the backend if +/// that splitter changes. +enum MarkdownBlocks { + + /// Nested so it doesn't collide with `MarkdownView`'s own rendering-side + /// `MarkdownBlock`, which is a different concern (display, not extraction). + enum BlockType: String, Equatable { + case heading + case listItem = "list-item" + case paragraph + case code + case quote + } + + struct Block: Equatable { + let type: BlockType + /// Heading level (1–6) or list nesting depth (1-based); 0 when not applicable. + let level: Int + /// Block text with the leading marker (`#`, `-`, `>`) stripped. + let text: String + /// Nearest preceding heading text; empty before the first heading. + let section: String + } + + static func split(_ markdown: String) -> [Block] { + var blocks: [Block] = [] + let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + + var section = "" + var paragraph: [String] = [] + + func flushParagraph() { + guard !paragraph.isEmpty else { return } + let text = paragraph.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + blocks.append(Block(type: .paragraph, level: 0, text: text, section: section)) + } + paragraph = [] + } + + var i = 0 + while i < lines.count { + let line = lines[i] + + if let fence = fenceMarker(line) { + flushParagraph() + var code: [String] = [] + i += 1 + while i < lines.count, !isClosingFence(lines[i], marker: fence) { + code.append(lines[i]) + i += 1 + } + i += 1 // skip the closing fence (or run off the end) + blocks.append(Block(type: .code, level: 0, text: code.joined(separator: "\n"), section: section)) + continue + } + + if line.trimmingCharacters(in: .whitespaces).isEmpty { + flushParagraph() + i += 1 + continue + } + + if let heading = parseHeading(line) { + flushParagraph() + section = heading.text + blocks.append(Block(type: .heading, level: heading.level, text: heading.text, section: heading.text)) + i += 1 + continue + } + + if let item = parseListItem(line) { + flushParagraph() + blocks.append(Block(type: .listItem, level: item.level, text: item.text, section: section)) + i += 1 + continue + } + + if let quote = parseQuote(line) { + flushParagraph() + blocks.append(Block(type: .quote, level: 0, text: quote, section: section)) + i += 1 + continue + } + + paragraph.append(line.trimmingCharacters(in: .whitespaces)) + i += 1 + } + + flushParagraph() + return blocks + } + + /// Blocks worth turning into rows: headings + list items, else every block. + static func rowBlocks(_ markdown: String) -> [Block] { + let all = split(markdown) + let primary = all.filter { $0.type == .heading || $0.type == .listItem } + return primary.isEmpty ? all : primary + } + + // MARK: - Line parsing + + /// `` ``` `` or `~~~` (3+), optionally indented. Returns the fence character. + private static func fenceMarker(_ line: String) -> Character? { + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + guard let first = trimmed.first, first == "`" || first == "~" else { return nil } + let run = trimmed.prefix(while: { $0 == first }).count + return run >= 3 ? first : nil + } + + private static func isClosingFence(_ line: String, marker: Character) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.allSatisfy({ $0 == marker }) else { return false } + return trimmed.count >= 3 + } + + private static func parseHeading(_ line: String) -> (level: Int, text: String)? { + guard line.first == "#" else { return nil } + let hashes = line.prefix(while: { $0 == "#" }).count + guard (1...6).contains(hashes) else { return nil } + let rest = line.dropFirst(hashes) + // The backend regex requires at least one space after the hashes. + guard let separator = rest.first, separator == " " || separator == "\t" else { return nil } + return (hashes, rest.trimmingCharacters(in: .whitespaces)) + } + + private static func parseListItem(_ line: String) -> (level: Int, text: String)? { + let indentChars = line.prefix(while: { $0 == " " || $0 == "\t" }) + // Tabs count as two spaces, matching the backend's `replace(/\t/g, " ")`. + let indent = indentChars.reduce(0) { $0 + ($1 == "\t" ? 2 : 1) } + var rest = Substring(line.dropFirst(indentChars.count)) + guard let marker = rest.first else { return nil } + + if marker == "-" || marker == "*" || marker == "+" { + rest = rest.dropFirst() + } else if marker.isNumber { + let digits = rest.prefix(while: { $0.isNumber }) + let afterDigits = rest.dropFirst(digits.count) + guard let delimiter = afterDigits.first, delimiter == "." || delimiter == ")" else { return nil } + rest = afterDigits.dropFirst() + } else { + return nil + } + + guard let separator = rest.first, separator == " " || separator == "\t" else { return nil } + return (indent / 2 + 1, rest.trimmingCharacters(in: .whitespaces)) + } + + private static func parseQuote(_ line: String) -> String? { + var rest = Substring(line.drop(while: { $0 == " " || $0 == "\t" })) + guard rest.first == ">" else { return nil } + rest = rest.dropFirst() + // The backend's `>\s?` consumes at most one space after the marker; the + // captured text is then trimmed, so a plain trim matches. + return rest.trimmingCharacters(in: .whitespaces) + } +} diff --git a/InterlinedList/Services/MaterializePlanner.swift b/InterlinedList/Services/MaterializePlanner.swift new file mode 100644 index 0000000..d942a1c --- /dev/null +++ b/InterlinedList/Services/MaterializePlanner.swift @@ -0,0 +1,294 @@ +// +// MaterializePlanner.swift +// InterlinedList +// + +import Foundation + +/// One source list, hydrated with its schema and rows, for a `lists` source. +struct MaterializeListSummary { + let listId: String + let listTitle: String + let description: String? + let isPublic: Bool + let fields: [ListPropertyDef] + let rows: [ListItem] + let totalRows: Int + + init(listId: String, + listTitle: String, + description: String? = nil, + isPublic: Bool = false, + fields: [ListPropertyDef] = [], + rows: [ListItem] = [], + totalRows: Int? = nil) { + self.listId = listId + self.listTitle = listTitle + self.description = description + self.isPublic = isPublic + self.fields = fields + self.rows = rows + self.totalRows = totalRows ?? rows.count + } +} + +struct MaterializeDocumentSummary { + let documentId: String + let title: String + let content: String +} + +/// An in-memory source for the "Create from…" preview. The wire request carries +/// only the ids from `sourceRef`; these full objects exist solely so the sheet +/// can seed its column editor and render a preview. +enum MaterializeLocalSource { + case messages([Message]) + case lists([MaterializeListSummary]) + case rows(listId: String, listTitle: String, fields: [ListPropertyDef], rows: [ListItem]) + case document(MaterializeDocumentSummary) +} + +/// Pure planning for "Create from…": the default columns, the default titles, +/// and the preview rows. Ported from the backend's `lib/materialize/build-list.ts` +/// so the sheet shows what the server will build. +/// +/// The server is authoritative — it re-derives every cell from re-fetched data +/// via each column's `sourceKey`. Divergence here only affects the preview. +enum MaterializePlanner { + + // MARK: - Default schemas + + private static func field(_ key: String, _ name: String, _ type: String, _ sourceKey: String?) -> MaterializeFieldConfig { + MaterializeFieldConfig(propertyKey: key, propertyName: name, propertyType: type, sourceKey: sourceKey) + } + + private static var messageFields: [MaterializeFieldConfig] { + [ + field("content", "Content", "textarea", "content"), + field("author", "Author", "text", "author"), + field("posted", "Posted", "text", "posted"), + field("links", "Links", "textarea", "links"), + field("tags", "Tags", "text", "tags"), + ] + } + + private static var listSummaryFields: [MaterializeFieldConfig] { + [ + field("title", "Title", "text", "title"), + field("description", "Description", "textarea", "description"), + field("rowCount", "Rows", "number", "rowCount"), + field("isPublic", "Public", "boolean", "isPublic"), + ] + } + + private static var documentFields: [MaterializeFieldConfig] { + [ + field("section", "Section", "text", "section"), + field("text", "Text", "textarea", "text"), + field("type", "Type", "text", "type"), + ] + } + + /// Clone a source list's schema into editable column configs (identity mapping). + /// + /// A `select`/`multiselect` column is downgraded to `text` when the source + /// carries no options: the server's DSL validator rejects an optionless + /// select outright, and GitHub-backed lists hit exactly that — their + /// synthetic `labels`/`assignees` columns are multiselect with no options. + private static func fields(from props: [ListPropertyDef]) -> [MaterializeFieldConfig] { + props + .sorted { $0.displayOrder < $1.displayOrder } + .map { prop in + let options = prop.selectOptions.isEmpty ? nil : prop.selectOptions + let needsOptions = prop.propertyType == "select" || prop.propertyType == "multiselect" + return MaterializeFieldConfig( + propertyKey: prop.propertyKey, + propertyName: prop.propertyName, + propertyType: (needsOptions && options == nil) ? "text" : prop.propertyType, + isRequired: prop.isRequired ? true : nil, + options: options, + sourceKey: prop.propertyKey + ) + } + } + + static func inferSchema(for source: MaterializeLocalSource) -> [MaterializeFieldConfig] { + switch source { + case .messages: + return messageFields + case .rows(_, _, let fields, _): + return self.fields(from: fields) + case .lists(let lists): + return lists.count == 1 ? fields(from: lists[0].fields) : listSummaryFields + case .document: + return documentFields + } + } + + // MARK: - Default titles + + static func defaultListTitle(for source: MaterializeLocalSource) -> String { + switch source { + case .messages(let messages): + if messages.count == 1 { + return "Message by @\(messages[0].user?.username ?? "unknown")" + } + return "\(messages.count) messages" + case .rows(_, let listTitle, _, _): + return listTitle + case .lists(let lists): + return lists.count == 1 ? lists[0].listTitle : "\(lists.count) lists" + case .document(let doc): + return doc.title + } + } + + /// The document title seeded into the sheet. + /// + /// The backend derives its own default when `docConfig.title` is omitted + /// (`defaultDocPaths`), using helpers iOS does not mirror. Rather than guess + /// at those, the sheet shows this simpler default and **sends it**, so what + /// the user sees is what gets created. `relativePath` is still left to the + /// server so the canonical path stays server-owned. + static func defaultDocumentTitle(for source: MaterializeLocalSource) -> String { + switch source { + case .messages(let messages): + if messages.count == 1, let first = messages.first { + return firstLine(of: first.content, fallback: "Message by @\(first.user?.username ?? "unknown")") + } + return "\(messages.count) messages" + case .rows(_, let listTitle, _, let rows): + return rows.count == 1 ? "\(listTitle) (row)" : listTitle + case .lists(let lists): + return lists.count == 1 ? lists[0].listTitle : "\(lists.count) lists" + case .document(let doc): + return "Copy of \(doc.title)" + } + } + + private static func firstLine(of content: String, fallback: String) -> String { + let line = content + .components(separatedBy: .newlines) + .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })? + .trimmingCharacters(in: .whitespaces) ?? "" + guard !line.isEmpty else { return fallback } + return line.count > 120 ? String(line.prefix(117)) + "…" : line + } + + // MARK: - Source reference (what actually goes over the wire) + + static func sourceRef(for source: MaterializeLocalSource) -> MaterializeSourceRef { + switch source { + case .messages(let messages): + return .messages(messageIds: messages.map(\.id)) + case .lists(let lists): + return .lists(listIds: lists.map(\.listId)) + case .rows(let listId, _, _, let rows): + return .rows(listId: listId, rowIds: rows.map(\.id)) + case .document(let doc): + return .document(documentId: doc.documentId) + } + } + + // MARK: - Preview projection + + /// Preview rows as display strings, keyed by `propertyKey`. Advisory only — + /// the server re-derives the real values from authoritative data. + static func previewRows(for source: MaterializeLocalSource, + fields: [MaterializeFieldConfig], + limit: Int = 25) -> [[String: String]] { + let rows: [[String: String]] + switch source { + case .messages(let messages): + rows = messages.map { message in + project(fields) { messageValue(message, key: $0) } + } + case .rows(_, _, _, let items): + rows = items.map { item in + project(fields) { item.rowData[$0]?.displayString ?? "" } + } + case .lists(let lists): + if lists.count == 1 { + rows = lists[0].rows.map { item in + project(fields) { item.rowData[$0]?.displayString ?? "" } + } + } else { + rows = lists.map { list in + project(fields) { listSummaryValue(list, key: $0) } + } + } + case .document(let doc): + rows = MarkdownBlocks.rowBlocks(doc.content).map { block in + project(fields) { blockValue(block, key: $0) } + } + } + return Array(rows.prefix(limit)) + } + + private static func project(_ fields: [MaterializeFieldConfig], + _ value: (String) -> String) -> [String: String] { + var row: [String: String] = [:] + for field in fields { + row[field.propertyKey] = field.sourceKey.map(value) ?? "" + } + return row + } + + private static func messageValue(_ message: Message, key: String) -> String { + switch key { + case "content": + return message.content + case "author": + guard let user = message.user else { return "" } + if let displayName = user.displayName, !displayName.isEmpty { + return "\(displayName) (@\(user.username))" + } + return "@\(user.username)" + case "posted": + return postedFormatter.string(from: parseISO(message.createdAt) ?? Date()) + case "links": + return (message.linkMetadata?.links ?? []).map(\.url).joined(separator: "\n") + case "tags": + return (message.tags ?? []).joined(separator: ", ") + case "images": + return (message.imageUrls ?? []).joined(separator: "\n") + default: + return "" + } + } + + private static func listSummaryValue(_ list: MaterializeListSummary, key: String) -> String { + switch key { + case "title": return list.listTitle + case "description": return list.description ?? "" + case "rowCount": return String(list.totalRows) + case "isPublic": return list.isPublic ? "true" : "false" + default: return "" + } + } + + private static func blockValue(_ block: MarkdownBlocks.Block, key: String) -> String { + switch key { + case "section": return block.section + case "text": return block.text + case "type": return block.type.rawValue + case "level": return block.level == 0 ? "" : String(block.level) + default: return "" + } + } + + private static let postedFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + private static func parseISO(_ string: String) -> Date? { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: string) { return date } + iso.formatOptions = [.withInternetDateTime] + return iso.date(from: string) + } +} diff --git a/InterlinedList/Views/CreateFromColumnEditor.swift b/InterlinedList/Views/CreateFromColumnEditor.swift new file mode 100644 index 0000000..b75e779 --- /dev/null +++ b/InterlinedList/Views/CreateFromColumnEditor.swift @@ -0,0 +1,126 @@ +// +// CreateFromColumnEditor.swift +// InterlinedList +// + +import SwiftUI + +/// One editable column in the "Create from…" sheet. +/// +/// Carries its own stable `id` so renaming a column doesn't churn SwiftUI's list +/// identity — `MaterializeFieldConfig.propertyKey` is the wire identity and can +/// change as the user types a new column's name. +struct CreateFromColumn: Identifiable { + let id = UUID() + var name: String + var type: String + /// The source attribute this column reads from. `nil` = a user-added empty + /// column, which the server fills with "". + var sourceKey: String? + var isRequired: Bool? + var options: [String]? + /// The key a source-mapped column arrived with; preserved so the mapping + /// survives a rename. + let originalKey: String? + + init(from config: MaterializeFieldConfig) { + name = config.propertyName + type = config.propertyType + sourceKey = config.sourceKey + isRequired = config.isRequired + options = config.options + originalKey = config.propertyKey + } + + init(name: String = "", type: String = "text") { + self.name = name + self.type = type + sourceKey = nil + isRequired = nil + options = nil + originalKey = nil + } + + /// Wire config. A source-mapped column keeps its original key; a user-added + /// one derives a key from its name so the server's duplicate-key check and + /// row projection both line up. + func config(fallbackIndex: Int) -> MaterializeFieldConfig { + let key = originalKey ?? Self.slug(name, fallback: "column_\(fallbackIndex + 1)") + return MaterializeFieldConfig( + propertyKey: key, + propertyName: name.trimmingCharacters(in: .whitespacesAndNewlines), + propertyType: type, + isRequired: isRequired, + options: options, + sourceKey: sourceKey + ) + } + + static func slug(_ value: String, fallback: String) -> String { + let lowered = value.lowercased() + var out = "" + var lastWasSeparator = false + for char in lowered { + if char.isLetter || char.isNumber { + out.append(char) + lastWasSeparator = false + } else if !out.isEmpty && !lastWasSeparator { + out.append("_") + lastWasSeparator = true + } + } + while out.hasSuffix("_") { out.removeLast() } + return out.isEmpty ? fallback : out + } +} + +/// The rename / retype / remove row for one column. +struct CreateFromColumnRow: View { + @Binding var column: CreateFromColumn + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + TextField("Column name", text: $column.name) + .font(.ilBody()) + .accessibilityLabel("Column name") + HStack { + Picker("Type", selection: $column.type) { + ForEach(MaterializeFieldConfig.supportedTypes, id: \.self) { type in + Text(Self.typeLabel(type)).tag(type) + } + } + .pickerStyle(.menu) + .accessibilityLabel("Column type for \(column.name.isEmpty ? "new column" : column.name)") + Spacer() + if column.sourceKey == nil { + Text("empty") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .accessibilityLabel("Empty column, not filled from the source") + } + } + } + .padding(.vertical, 2) + } + + static func typeLabel(_ type: String) -> String { + switch type { + case "textarea": return "Long text" + case "boolean": return "Yes / No" + case "url": return "Link" + default: return type.capitalized + } + } +} + +#Preview("Source-mapped column") { + @Previewable @State var column = CreateFromColumn( + from: MaterializeFieldConfig(propertyKey: "content", propertyName: "Content", propertyType: "textarea", sourceKey: "content") + ) + Form { CreateFromColumnRow(column: $column) } +} + +#Preview("User-added empty column") { + @Previewable @State var column = CreateFromColumn(name: "Notes") + Form { CreateFromColumnRow(column: $column) } +} diff --git a/InterlinedList/Views/CreateFromSheet.swift b/InterlinedList/Views/CreateFromSheet.swift new file mode 100644 index 0000000..679c81b --- /dev/null +++ b/InterlinedList/Views/CreateFromSheet.swift @@ -0,0 +1,390 @@ +// +// CreateFromSheet.swift +// InterlinedList +// + +import SwiftUI + +/// "Create from…" — turns a message, a list, list rows, or a document into a new +/// List, a new Document, or both (`POST /api/materialize`). +/// +/// Shows exactly what will be created before anything is saved: the destination, +/// the list's columns (renameable, retypeable, removable, extendable), and a +/// preview. Only **ids** go over the wire — the server re-fetches and authorizes +/// every referenced object and re-derives each cell from that authoritative data, +/// so the preview here is advisory. +/// +/// Creating lists and documents is subscriber-gated on the backend; callers must +/// hide the entry point for free users rather than present a paywall. +/// Identifiable wrapper so a `MaterializeLocalSource` can drive `.sheet(item:)`. +/// The source enum holds model values that aren't meaningfully identifiable on +/// their own, and presenting by item (rather than a bool) guarantees the sheet +/// reads the source that was current when it opened. +struct CreateFromSourceBox: Identifiable { + let id = UUID() + let source: MaterializeLocalSource +} + +struct CreateFromSheet: View { + let source: MaterializeLocalSource + var onCreated: ((MaterializeResult) -> Void)? + + @EnvironmentObject private var authState: AuthState + @Environment(\.dismiss) private var dismiss + + @State private var target: MaterializeTarget = .list + @State private var listTitle: String = "" + @State private var listDescription: String = "" + @State private var listIsPublic = false + @State private var includeData = true + @State private var columns: [CreateFromColumn] = [] + @State private var docTitle: String = "" + @State private var docIsPublic = false + @State private var listStyle: MaterializeDocListStyle = .bulleted + @State private var rowDataStyle: MaterializeDocRowDataStyle = .inline + @State private var isCreating = false + @State private var errorMessage: String? + @State private var result: MaterializeResult? + + /// The server caps a materialized list at 100 columns and 1000 rows. + private static let maxColumns = 100 + + private var trimmedListTitle: String { + listTitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var namedColumns: [CreateFromColumn] { + columns.filter { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + private var wireColumns: [MaterializeFieldConfig] { + namedColumns.enumerated().map { $0.element.config(fallbackIndex: $0.offset) } + } + + private var duplicateKeys: [String] { + let keys = wireColumns.map(\.propertyKey) + return Array(Set(keys.filter { key in keys.filter { $0 == key }.count > 1 })).sorted() + } + + private var validationProblem: String? { + guard target.createsList else { return nil } + if trimmedListTitle.isEmpty { return "A list title is required." } + if namedColumns.isEmpty { return "The list needs at least one column." } + if namedColumns.count > Self.maxColumns { return "A list can have at most \(Self.maxColumns) columns." } + if let duplicate = duplicateKeys.first { + return "Two columns map to the same key (\(duplicate)). Rename one." + } + return nil + } + + private var canCreate: Bool { + guard !isCreating, validationProblem == nil else { return false } + if target.createsDocument && docTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return false + } + return true + } + + /// The doc-style pickers only affect list-shaped sources. + private var sourceHasRows: Bool { + switch source { + case .lists, .rows: return true + case .messages, .document: return false + } + } + + private var previewRows: [[String: String]] { + MaterializePlanner.previewRows(for: source, fields: wireColumns) + } + + var body: some View { + NavigationStack { + Form { + destinationSection + if let result { + createdSection(result) + } else { + if target.createsList { + listDetailsSection + columnsSection + listPreviewSection + } + if target.createsDocument { + documentDetailsSection + documentPreviewSection + } + if let problem = validationProblem { + Section { Text(problem).font(.ilMono(12)).foregroundStyle(.red) } + } + if let errorMessage { + Section { Text(errorMessage).font(.ilMono(12)).foregroundStyle(.red) } + } + } + } + .navigationTitle("Create from…") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(result == nil ? "Cancel" : "Done") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + if result == nil { + Button("Create") { Task { await create() } } + .disabled(!canCreate) + } + } + } + .overlay { + if isCreating { + ProgressView("Creating…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + .onAppear(perform: seed) + } + + // MARK: - Sections + + private var destinationSection: some View { + Section { + Picker("Destination", selection: $target) { + ForEach(MaterializeTarget.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .disabled(result != nil) + .accessibilityLabel("What to create") + } footer: { + Text(sourceSummary) + } + } + + private var listDetailsSection: some View { + Section("List") { + TextField("List title", text: $listTitle) + .accessibilityLabel("List title") + TextField("Description (optional)", text: $listDescription, axis: .vertical) + .lineLimit(1...3) + .accessibilityLabel("List description") + Toggle("Public list", isOn: $listIsPublic) + Toggle("Include rows", isOn: $includeData) + .accessibilityHint("Off creates the list with its columns only") + } + } + + private var columnsSection: some View { + Section { + ForEach($columns) { $column in + CreateFromColumnRow(column: $column) + } + .onDelete { columns.remove(atOffsets: $0) } + Button { + columns.append(CreateFromColumn()) + } label: { + Label("Add column", systemImage: "plus") + } + .disabled(columns.count >= Self.maxColumns) + } header: { + Text("Columns") + } footer: { + Text("Swipe a column to remove it. Added columns start empty — the server only fills columns mapped to the source.") + } + } + + private var listPreviewSection: some View { + Section { + if !includeData { + Text("No rows — the list will be created with its columns only.") + .font(.ilMono(12)) + .foregroundStyle(.secondary) + } else if previewRows.isEmpty { + Text("No rows to preview.") + .font(.ilMono(12)) + .foregroundStyle(.secondary) + } else { + ScrollView(.horizontal, showsIndicators: true) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 12) { + ForEach(wireColumns) { column in + Text(column.propertyName) + .font(.ilMono(11)) + .fontWeight(.semibold) + .frame(width: 120, alignment: .leading) + } + } + Divider() + ForEach(Array(previewRows.enumerated()), id: \.offset) { _, row in + HStack(alignment: .top, spacing: 12) { + ForEach(wireColumns) { column in + Text(row[column.propertyKey] ?? "") + .font(.ilMono(11)) + .lineLimit(3) + .frame(width: 120, alignment: .leading) + } + } + } + } + .padding(.vertical, 4) + } + } + } header: { + Text("Preview") + } footer: { + Text("Approximate. The server rebuilds every value from your original data when it creates the list.") + } + } + + private var documentDetailsSection: some View { + Section("Document") { + TextField("Document title", text: $docTitle) + .accessibilityLabel("Document title") + Toggle("Public document", isOn: $docIsPublic) + if sourceHasRows { + Picker("List style", selection: $listStyle) { + ForEach(MaterializeDocListStyle.allCases) { style in + Text(style.label).tag(style) + } + } + Picker("Row data", selection: $rowDataStyle) { + ForEach(MaterializeDocRowDataStyle.allCases) { style in + Text(style.label).tag(style) + } + } + } + } + } + + private var documentPreviewSection: some View { + Section { + Text(documentPreviewText) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } header: { + Text("Document preview") + } footer: { + Text("Approximate. The server writes the final markdown, and picks the file path.") + } + } + + private func createdSection(_ result: MaterializeResult) -> some View { + Section("Created") { + if let list = result.list { + Label(list.title, systemImage: "list.bullet") + .accessibilityLabel("Created list \(list.title)") + } + if let document = result.document { + Label(document.title, systemImage: "doc.text") + .accessibilityLabel("Created document \(document.title)") + } + } + } + + // MARK: - Derived copy + + private var sourceSummary: String { + switch source { + case .messages(let messages): + return messages.count == 1 ? "From 1 message" : "From \(messages.count) messages" + case .lists(let lists): + return lists.count == 1 ? "From the list “\(lists[0].listTitle)”" : "From \(lists.count) lists" + case .rows(_, let listTitle, _, let rows): + return rows.count == 1 ? "From 1 row of “\(listTitle)”" : "From \(rows.count) rows of “\(listTitle)”" + case .document(let doc): + return "From the document “\(doc.title)”" + } + } + + private var documentPreviewText: String { + let rows = MaterializePlanner.previewRows(for: source, fields: wireColumns, limit: 8) + guard !rows.isEmpty else { return "An empty document." } + let marker = listStyle == .numbered ? "1." : "-" + let headline = wireColumns.first?.propertyKey + let lines = rows.prefix(8).map { row -> String in + let text = headline.flatMap { row[$0] } ?? "" + let first = text.components(separatedBy: .newlines).first ?? text + return "\(marker) \(first.isEmpty ? "(empty)" : first)" + } + return lines.joined(separator: "\n") + } + + // MARK: - Actions + + private func seed() { + guard columns.isEmpty else { return } + columns = MaterializePlanner.inferSchema(for: source).map(CreateFromColumn.init(from:)) + listTitle = MaterializePlanner.defaultListTitle(for: source) + docTitle = MaterializePlanner.defaultDocumentTitle(for: source) + } + + private func create() async { + guard canCreate else { return } + isCreating = true + errorMessage = nil + defer { isCreating = false } + + let description = listDescription.trimmingCharacters(in: .whitespacesAndNewlines) + let listConfig = MaterializeListConfig( + title: trimmedListTitle, + description: description.isEmpty ? nil : description, + isPublic: listIsPublic, + fields: wireColumns, + includeData: includeData + ) + let docConfig = MaterializeDocConfig( + title: docTitle.trimmingCharacters(in: .whitespacesAndNewlines), + relativePath: nil, + isPublic: docIsPublic, + listStyle: sourceHasRows ? listStyle : nil, + rowDataStyle: sourceHasRows ? rowDataStyle : nil + ) + + do { + let created = try await APIClient.shared.materialize( + target: target, + source: MaterializePlanner.sourceRef(for: source), + listConfig: listConfig, + docConfig: docConfig + ) + result = created + onCreated?(created) + } catch APIError.status(401) { + authState.handleUnauthorized() + errorMessage = "Your session expired. Try again." + } catch APIError.status(403), APIError.forbidden { + // Never surface the raw 403 body — it carries "Subscribe…" upsell copy + // that must not appear in-app (Guideline 3.1.1). + errorMessage = "Creating lists and documents is a subscriber feature." + } catch APIError.status(404) { + errorMessage = "Something this would be built from is no longer available." + } catch APIError.server(let text) { + errorMessage = text + } catch { + errorMessage = "Couldn't create that. Try again." + } + } +} + +#Preview("From a message") { + CreateFromSheet(source: .messages([ + Message( + id: "m1", content: "Tacos in Seattle — where?\nAsking for a friend.", + publiclyVisible: true, userId: "u1", createdAt: "2026-09-01T12:00:00Z", updatedAt: nil, + user: MessageUser(id: "u1", username: "adron", displayName: "Adron", avatar: nil), + imageUrls: nil, videoUrls: nil, linkMetadata: nil, parentId: nil, scheduledAt: nil, + tags: ["food", "seattle"], digCount: 3, dugByMe: false, crossPostUrls: nil + ) + ])) + .environmentObject(AuthState()) +} + +#Preview("From a document") { + CreateFromSheet(source: .document(MaterializeDocumentSummary( + documentId: "d1", + title: "Reading list", + content: "# Books\n\n- Dune\n- Neuromancer\n\n## Later\n\n- Snow Crash" + ))) + .environmentObject(AuthState()) +} diff --git a/InterlinedList/Views/CreateListView.swift b/InterlinedList/Views/CreateListView.swift index e7a9575..3cf2acf 100644 --- a/InterlinedList/Views/CreateListView.swift +++ b/InterlinedList/Views/CreateListView.swift @@ -40,6 +40,8 @@ struct CreateListView: View { @State private var githubBacked = false @State private var repos: [GitHubRepo] = [] + @State private var orgs: [GitHubOrg] = [] + @State private var selectedOwner: String? @State private var selectedRepo: String? @State private var reposLoading = false @State private var reposError: String? @@ -138,6 +140,14 @@ struct CreateListView: View { // MARK: - GitHub section + /// Repos narrowed to the selected org. An org with no approved OAuth-App + /// access simply yields no repos — the backend can list the membership + /// without being able to list its repositories. + private var filteredRepos: [GitHubRepo] { + guard let selectedOwner else { return repos } + return repos.filter { $0.owner == selectedOwner } + } + @ViewBuilder private var githubSection: some View { if canOfferGitHub { @@ -156,9 +166,18 @@ struct CreateListView: View { .foregroundStyle(.secondary) .font(.ilMono()) } else { + if !orgs.isEmpty { + Picker("Owner", selection: $selectedOwner) { + Text("All").tag(String?.none) + ForEach(orgs) { org in + Text(org.login).tag(String?.some(org.login)) + } + } + .accessibilityLabel("Filter repositories by organization") + } Picker("Repository", selection: $selectedRepo) { Text("Select a repository").tag(String?.none) - ForEach(repos) { repo in + ForEach(filteredRepos) { repo in Text(repo.fullName).tag(String?.some(repo.fullName)) } } @@ -175,6 +194,12 @@ struct CreateListView: View { .onChange(of: githubBacked) { _, on in if on { Task { await loadReposIfNeeded() } } } + .onChange(of: selectedOwner) { _, _ in + if let current = selectedRepo, + !filteredRepos.contains(where: { $0.fullName == current }) { + selectedRepo = nil + } + } } else if isSubscriber && !hasGitHubIdentity { Section { Text("Connect GitHub on the web to create GitHub-backed lists.") @@ -223,8 +248,13 @@ struct CreateListView: View { reposError = nil defer { reposLoading = false } do { - let fetched = try await APIClient.shared.githubRepos() + async let reposTask = APIClient.shared.githubRepos() + // Best-effort: org listing needs the `read:org` scope, and the picker + // works fine without it — the filter just doesn't appear. + async let orgsTask = APIClient.shared.githubOrgs() + let fetched = try await reposTask repos = fetched + orgs = (try? await orgsTask) ?? [] reposLoaded = true if selectedRepo == nil, let preferred = authState.user?.githubDefaultRepo, diff --git a/InterlinedList/Views/DocumentsView.swift b/InterlinedList/Views/DocumentsView.swift index 6068e1f..79e6a32 100644 --- a/InterlinedList/Views/DocumentsView.swift +++ b/InterlinedList/Views/DocumentsView.swift @@ -767,6 +767,11 @@ private struct DocumentDetailView: View { @State private var showEdit = false @State private var showDeleteConfirm = false @State private var showSharing = false + @State private var showCreateFrom = false + + /// "Create from…" writes a list/document, both subscriber-gated on the + /// backend — hide it for free users rather than surface a 403. + private var canCreateFrom: Bool { authState.user?.isSubscriber == true } init(document: Document, onUpdate: @escaping (Document) -> Void, onDelete: @escaping (String) -> Void) { self.document = document @@ -795,6 +800,13 @@ private struct DocumentDetailView: View { Menu { Button("Edit") { showEdit = true } Button("Share") { showSharing = true } + if canCreateFrom { + Button { + showCreateFrom = true + } label: { + Label("Create from…", systemImage: "plus.square.on.square") + } + } if let url = ILWebURL.document(current.id) { SwiftUI.ShareLink(item: url) { Label("Share link", systemImage: "square.and.arrow.up") @@ -820,6 +832,14 @@ private struct DocumentDetailView: View { onUpdate(updated) } } + .sheet(isPresented: $showCreateFrom) { + CreateFromSheet(source: .document(MaterializeDocumentSummary( + documentId: current.id, + title: current.title, + content: current.content ?? "" + ))) + .environmentObject(authState) + } .sheet(isPresented: $showSharing) { SharingView(kind: .documents, resourceId: current.id, title: current.title, isPublic: current.isPublic ?? false) { newValue in diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 7ead5ba..292e04e 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -38,6 +38,9 @@ struct FeedView: View { @State private var detailMessage: Message? @State private var trendingTags: [TrendingTag] = [] @State private var tagSuggestions: [TagSuggestion] = [] + @State private var isSelectingMessages = false + @State private var selectedMessageIds: Set = [] + @State private var createFromSource: CreateFromSourceBox? /// Blocked and muted authors are both hidden from the feed; mute is the softer /// control but the row has to disappear either way for the action to read as @@ -191,33 +194,37 @@ struct FeedView: View { } } } - ForEach(messages.filter { isVisibleAuthor($0.userId) }) { message in - MessageRow( - message: message, - currentUserId: authState.user?.id, - showPreviews: showPreviews, - digState: digStates[message.id], - onReply: { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - threadMessage = message - }, - onDelete: { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - messageToDelete = message - }, - onEdit: { messageToEdit = message }, - onDig: { Task { await toggleDig(for: message) } }, - onRepost: { messageToRepost = message }, - onTapAuthor: { username in profileUsername = username }, - onReport: { reportTarget = .message(id: message.id) }, - onBlock: { Task { await blockUser(userId: message.userId, username: message.user?.username ?? "") } }, - onMute: { muteTarget = MuteTarget(message: message) }, - onOpenDetail: { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - detailMessage = message - }, - truncateContent: true - ) + ForEach(visibleMessages) { message in + if isSelectingMessages { + messageSelectionRow(message) + } else { + MessageRow( + message: message, + currentUserId: authState.user?.id, + showPreviews: showPreviews, + digState: digStates[message.id], + onReply: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + threadMessage = message + }, + onDelete: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + messageToDelete = message + }, + onEdit: { messageToEdit = message }, + onDig: { Task { await toggleDig(for: message) } }, + onRepost: { messageToRepost = message }, + onTapAuthor: { username in profileUsername = username }, + onReport: { reportTarget = .message(id: message.id) }, + onBlock: { Task { await blockUser(userId: message.userId, username: message.user?.username ?? "") } }, + onMute: { muteTarget = MuteTarget(message: message) }, + onOpenDetail: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + detailMessage = message + }, + truncateContent: true + ) + } } if let pagination = pagination, pagination.hasMore, !isLoading { HStack { @@ -239,8 +246,57 @@ struct FeedView: View { } } + private var visibleMessages: [Message] { + messages.filter { isVisibleAuthor($0.userId) } + } + + /// "Create from…" writes a list/document, both subscriber-gated on the + /// backend — hide it for free users rather than surface a 403. + private var canCreateFrom: Bool { authState.user?.isSubscriber == true } + + private func messageSelectionRow(_ message: Message) -> some View { + SelectableSummaryRow( + title: message.content, + caption: message.authorDisplay, + isSelected: selectedMessageIds.contains(message.id) + ) { + if selectedMessageIds.contains(message.id) { + selectedMessageIds.remove(message.id) + } else { + selectedMessageIds.insert(message.id) + } + } + } + + /// Bottom bar shown while picking messages to create a list/document from. + private var messageSelectionBar: some View { + SelectionActionBar( + countLabel: selectedMessageIds.count == 1 ? "1 post" : "\(selectedMessageIds.count) posts", + actionTitle: "Create from…", + isActionEnabled: !selectedMessageIds.isEmpty, + onCancel: { + isSelectingMessages = false + selectedMessageIds = [] + }, + onAction: { + let selected = visibleMessages.filter { selectedMessageIds.contains($0.id) } + createFromSource = CreateFromSourceBox(source: .messages(selected)) + } + ) + } + var body: some View { navigationContent + .safeAreaInset(edge: .bottom) { + if isSelectingMessages { messageSelectionBar } + } + .sheet(item: $createFromSource) { box in + CreateFromSheet(source: box.source) { _ in + isSelectingMessages = false + selectedMessageIds = [] + } + .environmentObject(authState) + } .sheet(item: $threadMessage) { message in MessageThreadView(rootMessage: message, currentUserId: authState.user?.id) .environmentObject(authState) @@ -380,6 +436,23 @@ struct FeedView: View { .accessibilityLabel("Scheduled posts") } } + if canCreateFrom { + ToolbarItem(placement: .topBarTrailing) { + Button { + selectedMessageIds = [] + isSelectingMessages.toggle() + } label: { + Image(systemName: isSelectingMessages ? "checklist.checked" : "checklist") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.15)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isSelectingMessages ? "Done selecting posts" : "Select posts") + } + } ToolbarItem(placement: .topBarTrailing) { Button { showCompose = true } label: { Image(systemName: "square.and.pencil") diff --git a/InterlinedList/Views/ListItemFormView.swift b/InterlinedList/Views/ListItemFormView.swift index c5235b1..68e0f0d 100644 --- a/InterlinedList/Views/ListItemFormView.swift +++ b/InterlinedList/Views/ListItemFormView.swift @@ -8,6 +8,11 @@ import SwiftUI struct ListItemFormView: View { let schema: [ListPropertyDef] let existingItem: ListItem? + /// Runtime options for `multiselect` columns, keyed by `propertyKey` — a + /// GitHub-backed list supplies its repo's real labels and assignees here. + /// A column with no entry falls back to free-text comma-separated entry, + /// which is what the backend parses either way. + let multiSelectOptions: [String: [String]] let onSave: ([String: JSONValue]) -> Void @Environment(\.dismiss) private var dismiss @@ -36,9 +41,13 @@ struct ListItemFormView: View { } } - init(schema: [ListPropertyDef], existingItem: ListItem?, onSave: @escaping ([String: JSONValue]) -> Void) { + init(schema: [ListPropertyDef], + existingItem: ListItem?, + multiSelectOptions: [String: [String]] = [:], + onSave: @escaping ([String: JSONValue]) -> Void) { self.schema = schema self.existingItem = existingItem + self.multiSelectOptions = multiSelectOptions self.onSave = onSave var fields: [String: String] = [:] @@ -161,6 +170,24 @@ struct ListItemFormView: View { ) .labelsHidden() + case "multiselect": + let options = multiSelectOptions[prop.propertyKey] ?? [] + if options.isEmpty { + TextField(prop.placeholder ?? prop.propertyName, text: Binding( + get: { fieldValues[prop.propertyKey] ?? "" }, + set: { fieldValues[prop.propertyKey] = $0 } + )) + } else { + MultiSelectField( + label: prop.propertyName, + options: options, + value: Binding( + get: { fieldValues[prop.propertyKey] ?? "" }, + set: { fieldValues[prop.propertyKey] = $0 } + ) + ) + } + case "select": Picker(prop.propertyName, selection: Binding( get: { selectValue(for: prop) }, @@ -215,6 +242,12 @@ struct ListItemFormView: View { case "select": let val = selectValue(for: prop) if !val.isEmpty { rowData[prop.propertyKey] = .string(val) } + case "multiselect": + // Sent even when empty so clearing every value is expressed + // rather than silently omitted. (GitHub-backed lists still + // won't clear server-side — `rowDataToIssuePayload` drops an + // empty label/assignee set from the issue payload.) + rowData[prop.propertyKey] = .string(fieldValues[prop.propertyKey] ?? "") default: let str = fieldValues[prop.propertyKey] ?? "" if !str.isEmpty { diff --git a/InterlinedList/Views/ListsView.swift b/InterlinedList/Views/ListsView.swift index 681ec64..336325e 100644 --- a/InterlinedList/Views/ListsView.swift +++ b/InterlinedList/Views/ListsView.swift @@ -401,6 +401,12 @@ struct ListDetailView: View { @State private var gitHubStateFilter: GitHubStateFilter = .open @State private var contributors = ListContributorsResult.empty @State private var showContributors = false + @State private var createFromSource: CreateFromSourceBox? + @State private var isSelectingRows = false + @State private var selectedRowIds: Set = [] + @State private var gitHubLabels: [String] = [] + @State private var gitHubAssignees: [String] = [] + @State private var gitHubNextIssueNumber: Int? /// Open/closed filter for GitHub-backed lists. GitHub issues carry a `state` /// of `open`/`closed`; the list defaults to showing open issues only. @@ -415,12 +421,72 @@ struct ListDetailView: View { /// management controls hidden should a shared-in list ever reach this view. private var isOwner: Bool { list.isOwned(by: authState.user?.id) } - /// Fields shown in the add/edit form. GitHub-backed lists edit only - /// title/body/state for v1 — read-only columns (#, url, timestamps) and the - /// multiselect labels/assignees are excluded. Local lists use the full schema. + /// "Create from…" writes a list/document, both subscriber-gated on the + /// backend — hide it for free users rather than surface a 403. + private var canCreateFrom: Bool { authState.user?.isSubscriber == true } + + private var listSourceSummary: MaterializeListSummary { + MaterializeListSummary( + listId: list.id, + listTitle: list.name, + description: list.description, + isPublic: list.isPublic ?? false, + fields: schema, + rows: items, + totalRows: list.itemCount ?? items.count + ) + } + + /// A one-line label for a row while picking rows to create from. + private func rowSummary(_ item: ListItem) -> String { + guard let field = ListPropertyDef.primaryDisplayField(from: schema), + let value = item.rowData[field.propertyKey]?.displayString, + !value.isEmpty else { + return "Row \(item.rowNumber.map(String.init) ?? item.id.prefix(8).description)" + } + return value + } + + /// Bottom bar shown while picking rows to create a list/document from. + private var rowSelectionBar: some View { + SelectionActionBar( + countLabel: selectedRowIds.count == 1 ? "1 row" : "\(selectedRowIds.count) rows", + actionTitle: "Create from…", + isActionEnabled: !selectedRowIds.isEmpty, + onCancel: { + isSelectingRows = false + selectedRowIds = [] + }, + onAction: { + let rows = displayedItems.filter { selectedRowIds.contains($0.id) } + createFromSource = CreateFromSourceBox(source: .rows( + listId: list.id, listTitle: list.name, fields: schema, rows: rows + )) + } + ) + } + + /// Fields shown in the add/edit form. GitHub-backed lists hide read-only + /// columns (#, url, timestamps). Their multiselect `labels`/`assignees` are + /// shown only once the repo's real options have loaded — without options the + /// field degrades to raw comma-separated text, which is worse than hiding it. + /// Local lists use the full schema. private var formSchema: [ListPropertyDef] { guard list.isGitHubBacked else { return schema } - return schema.filter { !$0.isReadOnly && $0.propertyType != "multiselect" } + return schema.filter { prop in + guard !prop.isReadOnly else { return false } + guard prop.propertyType == "multiselect" else { return true } + return !(gitHubMultiSelectOptions[prop.propertyKey] ?? []).isEmpty + } + } + + /// Repo-backed options for the GitHub schema's two multiselect columns. + private var gitHubMultiSelectOptions: [String: [String]] { + guard list.isGitHubBacked else { return [:] } + var options: [String: [String]] = [:] + if !gitHubLabels.isEmpty { options["labels"] = gitHubLabels } + if !gitHubAssignees.isEmpty { options["assignees"] = gitHubAssignees } + return options } /// Rows to render. Local lists show everything; GitHub-backed lists filter by @@ -487,6 +553,19 @@ struct ListDetailView: View { } description: { Text(emptyStateMessage) } + } else if isSelectingRows { + ForEach(displayedItems) { item in + SelectableSummaryRow( + title: rowSummary(item), + isSelected: selectedRowIds.contains(item.id) + ) { + if selectedRowIds.contains(item.id) { + selectedRowIds.remove(item.id) + } else { + selectedRowIds.insert(item.id) + } + } + } } else { ForEach(displayedItems) { item in DynamicItemRow( @@ -508,6 +587,17 @@ struct ListDetailView: View { Task { await setGitHubState(item: item, to: newState) } } : nil ) + .contextMenu { + if canCreateFrom { + Button { + createFromSource = CreateFromSourceBox(source: .rows( + listId: list.id, listTitle: list.name, fields: schema, rows: [item] + )) + } label: { + Label("Create from this row…", systemImage: "plus.square.on.square") + } + } + } } } Section { @@ -608,14 +698,43 @@ struct ListDetailView: View { } } ToolbarItem(placement: .topBarTrailing) { - if let url = ILWebURL.list(list.id) { - SwiftUI.ShareLink(item: url) { - Image(systemName: "square.and.arrow.up") + Menu { + if let url = ILWebURL.list(list.id) { + SwiftUI.ShareLink(item: url) { + Label("Share link", systemImage: "square.and.arrow.up") + } } - .accessibilityLabel("Share link") + if canCreateFrom { + Button { + createFromSource = CreateFromSourceBox(source: .lists([listSourceSummary])) + } label: { + Label("Create from list…", systemImage: "plus.square.on.square") + } + if !displayedItems.isEmpty { + Button { + selectedRowIds = [] + isSelectingRows = true + } label: { + Label("Select rows…", systemImage: "checklist") + } + } + } + } label: { + Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("List actions") } } + .safeAreaInset(edge: .bottom) { + if isSelectingRows { rowSelectionBar } + } + .sheet(item: $createFromSource) { box in + CreateFromSheet(source: box.source) { _ in + isSelectingRows = false + selectedRowIds = [] + } + .environmentObject(authState) + } .task { await loadData() } @@ -667,12 +786,14 @@ struct ListDetailView: View { } } .sheet(isPresented: $showAddItem) { - ListItemFormView(schema: formSchema, existingItem: nil) { rowData in + ListItemFormView(schema: formSchema, existingItem: nil, + multiSelectOptions: gitHubMultiSelectOptions) { rowData in Task { await addItem(rowData: rowData) } } } .sheet(item: $editingItem) { item in - ListItemFormView(schema: formSchema, existingItem: item) { rowData in + ListItemFormView(schema: formSchema, existingItem: item, + multiSelectOptions: gitHubMultiSelectOptions) { rowData in Task { await saveEdit(item: item, rowData: rowData) } } } @@ -731,6 +852,12 @@ struct ListDetailView: View { Text(gitHubStatusText) .font(.ilBody(13)) .foregroundStyle(.secondary) + if let next = gitHubNextIssueNumber { + Text("Next issue: #\(next)") + .font(.ilMono(11)) + .foregroundStyle(.secondary) + .accessibilityLabel("The next issue created here will be number \(next)") + } } Spacer() if isRefreshingGitHub { @@ -880,6 +1007,31 @@ struct ListDetailView: View { } } + /// Fetches the repo's labels and assignable users so the add/edit form can + /// offer real pickers instead of free-text. Best-effort: these routes 400 + /// (not 401) when no GitHub account is linked, and can fail for a repo whose + /// org hasn't approved the OAuth App — in either case the columns simply + /// stay hidden rather than degrading to comma-separated text entry. + private func loadGitHubRepoMetadata() async { + guard list.isGitHubBacked, + let repo = list.githubRepo, + let slash = repo.firstIndex(of: "/") else { + gitHubLabels = [] + gitHubAssignees = [] + return + } + let owner = String(repo[repo.startIndex.. Void + + var body: some View { + Button(action: onToggle) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? ILColor.primary : Color.secondary) + VStack(alignment: .leading, spacing: 2) { + if let caption { + Text(caption) + .font(.ilMono(11)) + .foregroundStyle(.secondary) + } + Text(title) + .font(.ilBody(15)) + .lineLimit(3) + .foregroundStyle(.primary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(caption.map { "\($0): \(title)" } ?? title) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} + +/// The bottom bar of a multi-select picking mode: cancel, a live count, and the +/// action the selection feeds. +struct SelectionActionBar: View { + let countLabel: String + let actionTitle: String + let isActionEnabled: Bool + let onCancel: () -> Void + let onAction: () -> Void + + var body: some View { + HStack { + Button("Cancel", action: onCancel) + Spacer() + Text(countLabel) + .font(.ilMono(12)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(countLabel) selected") + Spacer() + Button(actionTitle, action: onAction) + .disabled(!isActionEnabled) + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(.regularMaterial) + } +} + +#Preview("Rows and bar") { + VStack(spacing: 0) { + List { + SelectableSummaryRow(title: "Dune", caption: nil, isSelected: true) {} + SelectableSummaryRow(title: "Tacos in Seattle — where?", caption: "Adron", isSelected: false) {} + } + SelectionActionBar(countLabel: "1 row", actionTitle: "Create from…", + isActionEnabled: true, onCancel: {}, onAction: {}) + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientGitHubMetadataTests.swift b/InterlinedListTests/APIClientTests/APIClientGitHubMetadataTests.swift new file mode 100644 index 0000000..339480f --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientGitHubMetadataTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import InterlinedList + +/// `githubLabels` / `githubAssignees` / `githubOrgs` / `githubNextIssueNumber` — +/// the repo-metadata routes that back the label and assignee pickers. +final class APIClientGitHubMetadataTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + // MARK: - Labels + + private let labelsJSON = """ + [ + {"id":1,"node_id":"n1","name":"bug","color":"d73a4a","description":"Something is broken","default":true}, + {"id":2,"node_id":"n2","name":"good first issue","color":"7057ff"} + ] + """ + + func test_githubLabels_sendsGetToCorrectPath() async throws { + session.stub(json: labelsJSON) + _ = try await sut.githubLabels(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(session.lastRequest?.httpMethod, "GET") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/repos/octocat/Hello-World/labels") + } + + func test_githubLabels_sendsBearerToken() async throws { + session.stub(json: labelsJSON) + _ = try await sut.githubLabels(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + /// Raw GitHub JSON, so the response is a bare array with snake_case keys and + /// extra fields the client doesn't model. + func test_githubLabels_decodesBareArrayAndIgnoresUnknownFields() async throws { + session.stub(json: labelsJSON) + let labels = try await sut.githubLabels(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(labels.map(\.name), ["bug", "good first issue"]) + XCTAssertEqual(labels[0].color, "d73a4a") + XCTAssertEqual(labels[0].description, "Something is broken") + XCTAssertNil(labels[1].description) + } + + // MARK: - Assignees + + private let assigneesJSON = """ + [ + {"login":"adron","id":7,"avatar_url":"https://example.com/a.png","type":"User"}, + {"login":"octocat","id":8} + ] + """ + + func test_githubAssignees_sendsGetToCorrectPath() async throws { + session.stub(json: assigneesJSON) + _ = try await sut.githubAssignees(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/repos/octocat/Hello-World/assignees") + } + + /// `avatar_url` must arrive as `avatarUrl` via `convertFromSnakeCase`. + func test_githubAssignees_decodesLoginAndAvatar() async throws { + session.stub(json: assigneesJSON) + let assignees = try await sut.githubAssignees(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(assignees.map(\.login), ["adron", "octocat"]) + XCTAssertEqual(assignees[0].avatarUrl, "https://example.com/a.png") + XCTAssertNil(assignees[1].avatarUrl) + } + + // MARK: - Orgs (two observed response shapes) + + func test_githubOrgs_decodesBareArray() async throws { + session.stub(json: #"[{"login":"acme","avatar_url":"https://example.com/o.png"},{"login":"widgets"}]"#) + let orgs = try await sut.githubOrgs() + XCTAssertEqual(orgs.map(\.login), ["acme", "widgets"]) + XCTAssertEqual(orgs[0].avatarUrl, "https://example.com/o.png") + } + + func test_githubOrgs_decodesEnvelope() async throws { + session.stub(json: #"{"orgs":[{"login":"acme","avatar_url":null}]}"#) + let orgs = try await sut.githubOrgs() + XCTAssertEqual(orgs.map(\.login), ["acme"]) + XCTAssertNil(orgs[0].avatarUrl) + } + + func test_githubOrgs_emptyArrayIsNotAnError() async throws { + session.stub(json: "[]") + let orgs = try await sut.githubOrgs() + XCTAssertTrue(orgs.isEmpty) + } + + func test_githubOrgs_sendsGetToCorrectPath() async throws { + session.stub(json: "[]") + _ = try await sut.githubOrgs() + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/orgs") + } + + // MARK: - Next issue number + + func test_githubNextIssueNumber_decodesNextNumber() async throws { + session.stub(json: #"{"nextNumber":42}"#) + let next = try await sut.githubNextIssueNumber(owner: "octocat", repo: "Hello-World") + XCTAssertEqual(next, 42) + XCTAssertEqual(session.lastRequest?.url?.path, "/api/github/repos/octocat/Hello-World/next-issue-number") + } + + // MARK: - Not-linked path + + /// These routes answer **400** — not 401 — when no GitHub identity is linked, + /// so callers must not treat it as a session failure. + func test_githubLabels_400NotLinked_throwsWithoutUnauthorized() async throws { + session.stub(json: #"{"error":"GitHub account not linked"}"#, statusCode: 400) + do { + _ = try await sut.githubLabels(owner: "octocat", repo: "Hello-World") + XCTFail("expected a throw") + } catch APIError.status(401) { + XCTFail("400 must not surface as a 401") + } catch { + // expected + } + } +} diff --git a/InterlinedListTests/APIClientTests/APIClientMaterializeTests.swift b/InterlinedListTests/APIClientTests/APIClientMaterializeTests.swift new file mode 100644 index 0000000..50720d0 --- /dev/null +++ b/InterlinedListTests/APIClientTests/APIClientMaterializeTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import InterlinedList + +final class APIClientMaterializeTests: XCTestCase { + var sut: APIClient! + var session: MockURLSession! + + override func setUp() { + super.setUp() + session = MockURLSession() + sut = APIClient(session: session) + sut.setBearerToken("tok") + } + + private let createdJSON = """ + {"list":{"id":"l1","title":"3 messages"},"document":{"id":"d1","title":"3 messages"}} + """ + + private func body() throws -> [String: Any] { + let data = try XCTUnwrap(session.lastRequest?.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private var sampleFields: [MaterializeFieldConfig] { + [MaterializeFieldConfig(propertyKey: "content", propertyName: "Content", propertyType: "textarea", sourceKey: "content")] + } + + private var sampleListConfig: MaterializeListConfig { + MaterializeListConfig(title: "From posts", description: nil, isPublic: false, + fields: sampleFields, includeData: true) + } + + // MARK: - Request shape + + func test_materialize_postsToCorrectPath() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig) + XCTAssertEqual(session.lastRequest?.httpMethod, "POST") + XCTAssertEqual(session.lastRequest?.url?.path, "/api/materialize") + } + + func test_materialize_sendsBearerToken() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig) + XCTAssertEqual(session.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + } + + /// The whole request is camelCase — a snake_case encoder would send + /// `list_config` / `message_ids` and the server would reject it. + func test_materialize_bodyIsCamelCase() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .both, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig, + docConfig: MaterializeDocConfig(title: "Doc", relativePath: nil, isPublic: nil, + listStyle: nil, rowDataStyle: nil)) + let json = try body() + XCTAssertNotNil(json["listConfig"]) + XCTAssertNotNil(json["docConfig"]) + XCTAssertNil(json["list_config"]) + let source = try XCTUnwrap(json["source"] as? [String: Any]) + XCTAssertNotNil(source["messageIds"]) + XCTAssertNil(source["message_ids"]) + } + + // MARK: - Source encoding (one shape per kind) + + func test_materialize_messagesSourceEncodesKindAndIds() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1", "m2"]), + listConfig: sampleListConfig) + let source = try XCTUnwrap(try body()["source"] as? [String: Any]) + XCTAssertEqual(source["kind"] as? String, "messages") + XCTAssertEqual(source["messageIds"] as? [String], ["m1", "m2"]) + } + + func test_materialize_listsSourceEncodesListIds() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .lists(listIds: ["l1"]), + listConfig: sampleListConfig) + let source = try XCTUnwrap(try body()["source"] as? [String: Any]) + XCTAssertEqual(source["kind"] as? String, "lists") + XCTAssertEqual(source["listIds"] as? [String], ["l1"]) + } + + func test_materialize_rowsSourceEncodesListIdAndRowIds() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .rows(listId: "l1", rowIds: ["r1", "r2"]), + listConfig: sampleListConfig) + let source = try XCTUnwrap(try body()["source"] as? [String: Any]) + XCTAssertEqual(source["kind"] as? String, "rows") + XCTAssertEqual(source["listId"] as? String, "l1") + XCTAssertEqual(source["rowIds"] as? [String], ["r1", "r2"]) + } + + func test_materialize_documentSourceEncodesDocumentId() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .doc, source: .document(documentId: "d1"), + docConfig: MaterializeDocConfig(title: "Doc", relativePath: nil, isPublic: nil, + listStyle: nil, rowDataStyle: nil)) + let source = try XCTUnwrap(try body()["source"] as? [String: Any]) + XCTAssertEqual(source["kind"] as? String, "document") + XCTAssertEqual(source["documentId"] as? String, "d1") + } + + /// Only ids go over the wire — never cell values. The server re-fetches and + /// re-derives everything, so a request that carried content would be both + /// wasteful and ignored. + func test_materialize_sourceCarriesOnlyIds() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig) + let source = try XCTUnwrap(try body()["source"] as? [String: Any]) + XCTAssertEqual(Set(source.keys), ["kind", "messageIds"]) + } + + // MARK: - Config encoding + + /// A user-added column must send `sourceKey: null`, not omit the key — the + /// contract documents `string | null` for the mapping. + func test_materialize_userAddedColumnSendsExplicitNullSourceKey() async throws { + session.stub(json: createdJSON, statusCode: 201) + let config = MaterializeListConfig( + title: "T", description: nil, isPublic: nil, + fields: [MaterializeFieldConfig(propertyKey: "notes", propertyName: "Notes", + propertyType: "text", sourceKey: nil)], + includeData: nil + ) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), listConfig: config) + let listConfig = try XCTUnwrap(try body()["listConfig"] as? [String: Any]) + let fields = try XCTUnwrap(listConfig["fields"] as? [[String: Any]]) + XCTAssertTrue(fields[0].keys.contains("sourceKey")) + XCTAssertTrue(fields[0]["sourceKey"] is NSNull) + } + + func test_materialize_omitsListConfigForDocOnlyTarget() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .doc, source: .document(documentId: "d1"), + listConfig: sampleListConfig, + docConfig: MaterializeDocConfig(title: "Doc", relativePath: nil, isPublic: nil, + listStyle: nil, rowDataStyle: nil)) + XCTAssertNil(try body()["listConfig"]) + } + + func test_materialize_omitsDocConfigForListOnlyTarget() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig, + docConfig: MaterializeDocConfig(title: "Doc", relativePath: nil, isPublic: nil, + listStyle: nil, rowDataStyle: nil)) + XCTAssertNil(try body()["docConfig"]) + } + + func test_materialize_docConfigEncodesHyphenatedRowDataStyle() async throws { + session.stub(json: createdJSON, statusCode: 201) + _ = try await sut.materialize( + target: .doc, source: .lists(listIds: ["l1"]), + docConfig: MaterializeDocConfig(title: "Doc", relativePath: nil, isPublic: true, + listStyle: .numbered, rowDataStyle: .subItems) + ) + let docConfig = try XCTUnwrap(try body()["docConfig"] as? [String: Any]) + XCTAssertEqual(docConfig["listStyle"] as? String, "numbered") + XCTAssertEqual(docConfig["rowDataStyle"] as? String, "sub-items") + XCTAssertEqual(docConfig["isPublic"] as? Bool, true) + XCTAssertNil(docConfig["relativePath"], "path is left to the server") + } + + func test_materialize_includeDataFalseIsSent() async throws { + session.stub(json: createdJSON, statusCode: 201) + let config = MaterializeListConfig(title: "T", description: nil, isPublic: nil, + fields: sampleFields, includeData: false) + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), listConfig: config) + let listConfig = try XCTUnwrap(try body()["listConfig"] as? [String: Any]) + XCTAssertEqual(listConfig["includeData"] as? Bool, false) + } + + // MARK: - Response + + func test_materialize_decodesBothCreatedRefs() async throws { + session.stub(json: createdJSON, statusCode: 201) + let result = try await sut.materialize(target: .both, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig, + docConfig: MaterializeDocConfig(title: "d", relativePath: nil, + isPublic: nil, listStyle: nil, + rowDataStyle: nil)) + XCTAssertEqual(result.list?.id, "l1") + XCTAssertEqual(result.list?.title, "3 messages") + XCTAssertEqual(result.document?.id, "d1") + } + + func test_materialize_decodesListOnlyResponse() async throws { + session.stub(json: #"{"list":{"id":"l1","title":"Only a list"}}"#, statusCode: 201) + let result = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig) + XCTAssertEqual(result.list?.title, "Only a list") + XCTAssertNil(result.document) + } + + // MARK: - Errors + + func test_materialize_403_throwsForbidden() async throws { + session.stub(json: #"{"error":"Subscribe to create lists and documents."}"#, statusCode: 403) + do { + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["m1"]), + listConfig: sampleListConfig) + XCTFail("expected a throw") + } catch APIError.forbidden { + // expected — callers must map this to neutral copy, never the raw text + } catch APIError.status(403) { + // acceptable for a bodyless 403 + } + } + + func test_materialize_404_throwsStatus() async throws { + session.stub(json: #"{"error":"Not found"}"#, statusCode: 404) + do { + _ = try await sut.materialize(target: .list, source: .messages(messageIds: ["gone"]), + listConfig: sampleListConfig) + XCTFail("expected a throw") + } catch { + // any throw is fine; the point is it doesn't silently succeed + } + } +} diff --git a/InterlinedListTests/ModelTests/CreateFromColumnTests.swift b/InterlinedListTests/ModelTests/CreateFromColumnTests.swift new file mode 100644 index 0000000..19a2dab --- /dev/null +++ b/InterlinedListTests/ModelTests/CreateFromColumnTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import InterlinedList + +/// The editor-to-wire mapping for a "Create from…" column. +final class CreateFromColumnTests: XCTestCase { + + func test_sourceMappedColumn_keepsItsOriginalKeyThroughARename() { + var column = CreateFromColumn(from: MaterializeFieldConfig( + propertyKey: "content", propertyName: "Content", propertyType: "textarea", sourceKey: "content" + )) + column.name = "The Post" + let config = column.config(fallbackIndex: 0) + XCTAssertEqual(config.propertyKey, "content", "renaming must not break the source mapping") + XCTAssertEqual(config.propertyName, "The Post") + XCTAssertEqual(config.sourceKey, "content") + } + + func test_userAddedColumn_derivesItsKeyFromTheName() { + let column = CreateFromColumn(name: "My Notes") + XCTAssertEqual(column.config(fallbackIndex: 0).propertyKey, "my_notes") + } + + func test_userAddedColumn_hasNoSourceKey() { + XCTAssertNil(CreateFromColumn(name: "Notes").config(fallbackIndex: 0).sourceKey) + } + + func test_userAddedColumn_unnamedFallsBackToAPositionalKey() { + XCTAssertEqual(CreateFromColumn(name: "").config(fallbackIndex: 2).propertyKey, "column_3") + } + + func test_config_trimsTheDisplayName() { + let column = CreateFromColumn(name: " Padded ") + XCTAssertEqual(column.config(fallbackIndex: 0).propertyName, "Padded") + } + + func test_slug_collapsesPunctuationAndSpaces() { + XCTAssertEqual(CreateFromColumn.slug("Due Date!!", fallback: "x"), "due_date") + XCTAssertEqual(CreateFromColumn.slug("a/b-c", fallback: "x"), "a_b_c") + } + + func test_slug_stripsTrailingSeparators() { + XCTAssertEqual(CreateFromColumn.slug("Name…", fallback: "x"), "name") + } + + func test_slug_allPunctuationFallsBack() { + XCTAssertEqual(CreateFromColumn.slug("!!!", fallback: "column_1"), "column_1") + } + + func test_supportedTypes_excludeSelectAndMultiselect() { + // Both require an `options` array the editor has no way to collect, and + // the server's DSL validator rejects them without one. + XCTAssertFalse(MaterializeFieldConfig.supportedTypes.contains("select")) + XCTAssertFalse(MaterializeFieldConfig.supportedTypes.contains("multiselect")) + XCTAssertTrue(MaterializeFieldConfig.supportedTypes.contains("textarea")) + } + + func test_targetFlags() { + XCTAssertTrue(MaterializeTarget.list.createsList) + XCTAssertFalse(MaterializeTarget.list.createsDocument) + XCTAssertFalse(MaterializeTarget.doc.createsList) + XCTAssertTrue(MaterializeTarget.doc.createsDocument) + XCTAssertTrue(MaterializeTarget.both.createsList) + XCTAssertTrue(MaterializeTarget.both.createsDocument) + } +} diff --git a/InterlinedListTests/ServiceTests/MarkdownBlocksTests.swift b/InterlinedListTests/ServiceTests/MarkdownBlocksTests.swift new file mode 100644 index 0000000..5825e5a --- /dev/null +++ b/InterlinedListTests/ServiceTests/MarkdownBlocksTests.swift @@ -0,0 +1,122 @@ +import XCTest +@testable import InterlinedList + +/// `MarkdownBlocks` is a port of the backend's `lib/materialize/markdown-blocks.ts`. +/// These cases mirror its behavior — if the two ever diverge, the "Create from a +/// document" preview stops matching what the server actually builds. +final class MarkdownBlocksTests: XCTestCase { + + func test_split_emptyStringProducesNoBlocks() { + XCTAssertTrue(MarkdownBlocks.split("").isEmpty) + } + + func test_split_headingCapturesLevelAndText() { + let blocks = MarkdownBlocks.split("### Chapter One") + XCTAssertEqual(blocks.count, 1) + XCTAssertEqual(blocks[0].type, .heading) + XCTAssertEqual(blocks[0].level, 3) + XCTAssertEqual(blocks[0].text, "Chapter One") + XCTAssertEqual(blocks[0].section, "Chapter One", "a heading is its own section") + } + + func test_split_hashWithoutSpaceIsNotAHeading() { + let blocks = MarkdownBlocks.split("#hashtag not a heading") + XCTAssertEqual(blocks[0].type, .paragraph) + } + + func test_split_sevenHashesIsNotAHeading() { + let blocks = MarkdownBlocks.split("####### too deep") + XCTAssertEqual(blocks[0].type, .paragraph) + } + + func test_split_blocksInheritTheNearestPrecedingHeading() throws { + let blocks = MarkdownBlocks.split("Intro line\n\n# Books\n\n- Dune\n\n## Later\n\n- Snow Crash") + let dune = try XCTUnwrap(blocks.first { $0.text == "Dune" }) + let snowCrash = try XCTUnwrap(blocks.first { $0.text == "Snow Crash" }) + XCTAssertEqual(blocks[0].section, "", "text before any heading has no section") + XCTAssertEqual(dune.section, "Books") + XCTAssertEqual(snowCrash.section, "Later") + } + + func test_split_unorderedListMarkers() { + for marker in ["-", "*", "+"] { + let blocks = MarkdownBlocks.split("\(marker) item") + XCTAssertEqual(blocks.first?.type, .listItem, "marker \(marker)") + XCTAssertEqual(blocks.first?.text, "item") + } + } + + func test_split_orderedListMarkers() { + for marker in ["1.", "2)"] { + let blocks = MarkdownBlocks.split("\(marker) item") + XCTAssertEqual(blocks.first?.type, .listItem, "marker \(marker)") + XCTAssertEqual(blocks.first?.text, "item") + } + } + + func test_split_listItemNestingLevelFromIndent() { + let blocks = MarkdownBlocks.split("- top\n - nested\n - deeper") + XCTAssertEqual(blocks.map(\.level), [1, 2, 3]) + } + + func test_split_tabIndentCountsAsTwoSpaces() { + let blocks = MarkdownBlocks.split("- top\n\t- nested") + XCTAssertEqual(blocks.map(\.level), [1, 2]) + } + + func test_split_consecutiveLinesJoinIntoOneParagraph() { + let blocks = MarkdownBlocks.split("first line\nsecond line\n\nnew paragraph") + XCTAssertEqual(blocks.count, 2) + XCTAssertEqual(blocks[0].text, "first line second line") + XCTAssertEqual(blocks[1].text, "new paragraph") + } + + func test_split_fencedCodeIsOneBlockAndKeepsItsLines() throws { + let blocks = MarkdownBlocks.split("before\n\n```swift\nlet a = 1\nlet b = 2\n```\n\nafter") + let code = try XCTUnwrap(blocks.first { $0.type == .code }) + XCTAssertEqual(code.text, "let a = 1\nlet b = 2") + XCTAssertEqual(blocks.last?.text, "after", "content after the closing fence still parses") + } + + func test_split_tildeFenceAlsoWorks() { + let blocks = MarkdownBlocks.split("~~~\nraw\n~~~") + XCTAssertEqual(blocks.first?.type, .code) + XCTAssertEqual(blocks.first?.text, "raw") + } + + func test_split_unterminatedFenceConsumesToEnd() { + let blocks = MarkdownBlocks.split("```\nnever closed\nstill code") + XCTAssertEqual(blocks.count, 1) + XCTAssertEqual(blocks[0].type, .code) + XCTAssertEqual(blocks[0].text, "never closed\nstill code") + } + + func test_split_blockquoteStripsMarker() { + let blocks = MarkdownBlocks.split("> quoted text") + XCTAssertEqual(blocks.first?.type, .quote) + XCTAssertEqual(blocks.first?.text, "quoted text") + } + + func test_split_crlfNormalizes() { + let blocks = MarkdownBlocks.split("# Title\r\n\r\n- one") + XCTAssertEqual(blocks.map(\.type), [.heading, .listItem]) + XCTAssertEqual(blocks[1].text, "one") + } + + // MARK: - rowBlocks + + func test_rowBlocks_prefersHeadingsAndListItems() { + let blocks = MarkdownBlocks.rowBlocks("# Books\n\nsome prose\n\n- Dune\n- Neuromancer") + XCTAssertEqual(blocks.map(\.text), ["Books", "Dune", "Neuromancer"]) + XCTAssertFalse(blocks.contains { $0.text == "some prose" }) + } + + func test_rowBlocks_fallsBackToEveryBlockWhenThereAreNoHeadingsOrItems() { + let blocks = MarkdownBlocks.rowBlocks("just a paragraph\n\nand another") + XCTAssertEqual(blocks.map(\.text), ["just a paragraph", "and another"]) + } + + func test_rowBlocks_emptyDocumentProducesNoRows() { + XCTAssertTrue(MarkdownBlocks.rowBlocks(" \n\n ").isEmpty) + } +} diff --git a/InterlinedListTests/ServiceTests/MaterializePlannerTests.swift b/InterlinedListTests/ServiceTests/MaterializePlannerTests.swift new file mode 100644 index 0000000..8eef408 --- /dev/null +++ b/InterlinedListTests/ServiceTests/MaterializePlannerTests.swift @@ -0,0 +1,245 @@ +import XCTest +@testable import InterlinedList + +/// The pure planning side of "Create from…": which columns get seeded, what the +/// titles default to, which ids go over the wire, and what the preview shows. +final class MaterializePlannerTests: XCTestCase { + + // MARK: - Fixtures + + private func message(id: String, + content: String = "hello", + username: String = "adron", + displayName: String? = "Adron", + tags: [String]? = nil) -> Message { + Message( + id: id, content: content, publiclyVisible: true, userId: "u1", + createdAt: "2026-09-01T12:00:00Z", updatedAt: nil, + user: MessageUser(id: "u1", username: username, displayName: displayName, avatar: nil), + imageUrls: nil, videoUrls: nil, linkMetadata: nil, parentId: nil, scheduledAt: nil, + tags: tags, digCount: nil, dugByMe: nil, crossPostUrls: nil + ) + } + + private func prop(_ key: String, + _ name: String, + _ type: String, + order: Int, + required: Bool = false, + options: [String] = []) -> ListPropertyDef { + ListPropertyDef(id: key, propertyKey: key, propertyName: name, propertyType: type, + displayOrder: order, isVisible: true, isRequired: required, + defaultValue: nil, helpText: nil, placeholder: nil, + selectOptions: options) + } + + private func row(_ id: String, _ data: [String: JSONValue]) -> ListItem { + ListItem(id: id, rowData: data, rowNumber: nil, createdAt: nil) + } + + // MARK: - Inferred schema + + func test_inferSchema_messagesUsesTheFiveMessageColumns() { + let fields = MaterializePlanner.inferSchema(for: .messages([message(id: "m1")])) + XCTAssertEqual(fields.map(\.propertyKey), ["content", "author", "posted", "links", "tags"]) + XCTAssertEqual(fields.map(\.sourceKey), ["content", "author", "posted", "links", "tags"]) + XCTAssertEqual(fields[0].propertyType, "textarea") + } + + func test_inferSchema_documentUsesSectionTextType() { + let source = MaterializeLocalSource.document( + MaterializeDocumentSummary(documentId: "d1", title: "Doc", content: "# A") + ) + XCTAssertEqual(MaterializePlanner.inferSchema(for: source).map(\.propertyKey), ["section", "text", "type"]) + } + + func test_inferSchema_singleListClonesItsSchemaInDisplayOrder() { + let list = MaterializeListSummary( + listId: "l1", listTitle: "Books", + fields: [prop("author", "Author", "text", order: 1), prop("title", "Title", "text", order: 0)] + ) + let fields = MaterializePlanner.inferSchema(for: .lists([list])) + XCTAssertEqual(fields.map(\.propertyKey), ["title", "author"], "sorted by displayOrder") + XCTAssertEqual(fields.map(\.sourceKey), ["title", "author"], "identity mapping") + } + + func test_inferSchema_multipleListsUsesTheSummaryColumns() { + let source = MaterializeLocalSource.lists([ + MaterializeListSummary(listId: "l1", listTitle: "A"), + MaterializeListSummary(listId: "l2", listTitle: "B"), + ]) + XCTAssertEqual(MaterializePlanner.inferSchema(for: source).map(\.propertyKey), + ["title", "description", "rowCount", "isPublic"]) + } + + func test_inferSchema_rowsClonesTheSourceListSchema() { + let source = MaterializeLocalSource.rows( + listId: "l1", listTitle: "Books", + fields: [prop("title", "Title", "text", order: 0, required: true)], + rows: [row("r1", ["title": .string("Dune")])] + ) + let fields = MaterializePlanner.inferSchema(for: source) + XCTAssertEqual(fields.map(\.propertyKey), ["title"]) + XCTAssertEqual(fields[0].isRequired, true) + } + + func test_inferSchema_keepsOptionsForASelectColumn() { + let source = MaterializeLocalSource.rows( + listId: "l1", listTitle: "Tasks", + fields: [prop("state", "State", "select", order: 0, options: ["open", "closed"])], + rows: [] + ) + let field = MaterializePlanner.inferSchema(for: source)[0] + XCTAssertEqual(field.propertyType, "select") + XCTAssertEqual(field.options, ["open", "closed"]) + } + + /// The server's DSL validator rejects a select/multiselect with no options. + /// GitHub-backed lists hit this exactly — their `labels`/`assignees` columns + /// are multiselect with none — so the seed downgrades them to text rather + /// than building a request that is guaranteed to 400. + func test_inferSchema_downgradesOptionlessMultiselectToText() { + let source = MaterializeLocalSource.rows( + listId: "l1", listTitle: "Issues", + fields: [prop("labels", "Labels", "multiselect", order: 0)], + rows: [] + ) + let field = MaterializePlanner.inferSchema(for: source)[0] + XCTAssertEqual(field.propertyType, "text") + XCTAssertNil(field.options) + } + + // MARK: - Default titles + + func test_defaultListTitle_singleMessageNamesTheAuthor() { + XCTAssertEqual(MaterializePlanner.defaultListTitle(for: .messages([message(id: "m1")])), + "Message by @adron") + } + + func test_defaultListTitle_multipleMessagesCounts() { + let messages = [message(id: "m1"), message(id: "m2"), message(id: "m3")] + XCTAssertEqual(MaterializePlanner.defaultListTitle(for: .messages(messages)), "3 messages") + } + + func test_defaultListTitle_singleListUsesItsTitle() { + let source = MaterializeLocalSource.lists([MaterializeListSummary(listId: "l1", listTitle: "Books")]) + XCTAssertEqual(MaterializePlanner.defaultListTitle(for: source), "Books") + } + + func test_defaultDocumentTitle_documentIsACopy() { + let source = MaterializeLocalSource.document( + MaterializeDocumentSummary(documentId: "d1", title: "Notes", content: "") + ) + XCTAssertEqual(MaterializePlanner.defaultDocumentTitle(for: source), "Copy of Notes") + } + + func test_defaultDocumentTitle_singleMessageUsesItsFirstNonEmptyLine() { + let source = MaterializeLocalSource.messages([message(id: "m1", content: "\n\nTacos in Seattle\nmore text")]) + XCTAssertEqual(MaterializePlanner.defaultDocumentTitle(for: source), "Tacos in Seattle") + } + + func test_defaultDocumentTitle_emptyMessageFallsBackToTheAuthor() { + let source = MaterializeLocalSource.messages([message(id: "m1", content: " ")]) + XCTAssertEqual(MaterializePlanner.defaultDocumentTitle(for: source), "Message by @adron") + } + + // MARK: - Source references (ids only) + + func test_sourceRef_messagesCarriesOnlyIds() { + let ref = MaterializePlanner.sourceRef(for: .messages([message(id: "m1"), message(id: "m2")])) + XCTAssertEqual(ref, .messages(messageIds: ["m1", "m2"])) + } + + func test_sourceRef_rowsCarriesListIdAndRowIds() { + let source = MaterializeLocalSource.rows( + listId: "l1", listTitle: "Books", fields: [], + rows: [row("r1", [:]), row("r2", [:])] + ) + XCTAssertEqual(MaterializePlanner.sourceRef(for: source), .rows(listId: "l1", rowIds: ["r1", "r2"])) + } + + func test_sourceRef_documentCarriesDocumentId() { + let source = MaterializeLocalSource.document( + MaterializeDocumentSummary(documentId: "d1", title: "Doc", content: "") + ) + XCTAssertEqual(MaterializePlanner.sourceRef(for: source), .document(documentId: "d1")) + } + + // MARK: - Preview projection + + func test_previewRows_messageColumnsProjectTheExpectedValues() { + let source = MaterializeLocalSource.messages([ + message(id: "m1", content: "Tacos", tags: ["food", "seattle"]) + ]) + let fields = MaterializePlanner.inferSchema(for: source) + let rows = MaterializePlanner.previewRows(for: source, fields: fields) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0]["content"], "Tacos") + XCTAssertEqual(rows[0]["author"], "Adron (@adron)") + XCTAssertEqual(rows[0]["tags"], "food, seattle") + } + + func test_previewRows_authorFallsBackToHandleWhenThereIsNoDisplayName() { + let source = MaterializeLocalSource.messages([message(id: "m1", displayName: nil)]) + let fields = MaterializePlanner.inferSchema(for: source) + XCTAssertEqual(MaterializePlanner.previewRows(for: source, fields: fields)[0]["author"], "@adron") + } + + /// A user-added column has no `sourceKey`, so the server fills it with "" — + /// the preview must say the same thing rather than inventing a value. + func test_previewRows_userAddedColumnIsEmpty() { + let source = MaterializeLocalSource.messages([message(id: "m1", content: "Tacos")]) + let fields = [ + MaterializeFieldConfig(propertyKey: "content", propertyName: "Content", propertyType: "text", sourceKey: "content"), + MaterializeFieldConfig(propertyKey: "notes", propertyName: "Notes", propertyType: "text", sourceKey: nil), + ] + let rows = MaterializePlanner.previewRows(for: source, fields: fields) + XCTAssertEqual(rows[0]["content"], "Tacos") + XCTAssertEqual(rows[0]["notes"], "") + } + + func test_previewRows_rowsReadThroughTheColumnSourceKey() { + let source = MaterializeLocalSource.rows( + listId: "l1", listTitle: "Books", + fields: [prop("title", "Title", "text", order: 0)], + rows: [row("r1", ["title": .string("Dune")]), row("r2", ["title": .string("Neuromancer")])] + ) + let fields = MaterializePlanner.inferSchema(for: source) + XCTAssertEqual(MaterializePlanner.previewRows(for: source, fields: fields).map { $0["title"] }, + ["Dune", "Neuromancer"]) + } + + func test_previewRows_multipleListsSummarizeOnePerRow() { + let source = MaterializeLocalSource.lists([ + MaterializeListSummary(listId: "l1", listTitle: "Books", description: "to read", isPublic: true, + rows: [row("r1", [:]), row("r2", [:])]), + MaterializeListSummary(listId: "l2", listTitle: "Films"), + ]) + let fields = MaterializePlanner.inferSchema(for: source) + let rows = MaterializePlanner.previewRows(for: source, fields: fields) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[0]["title"], "Books") + XCTAssertEqual(rows[0]["description"], "to read") + XCTAssertEqual(rows[0]["rowCount"], "2") + XCTAssertEqual(rows[0]["isPublic"], "true") + XCTAssertEqual(rows[1]["isPublic"], "false") + } + + func test_previewRows_documentTurnsHeadingsAndItemsIntoRows() { + let source = MaterializeLocalSource.document(MaterializeDocumentSummary( + documentId: "d1", title: "Reading", content: "# Books\n\n- Dune\n- Neuromancer" + )) + let fields = MaterializePlanner.inferSchema(for: source) + let rows = MaterializePlanner.previewRows(for: source, fields: fields) + XCTAssertEqual(rows.map { $0["text"] }, ["Books", "Dune", "Neuromancer"]) + XCTAssertEqual(rows[1]["section"], "Books") + XCTAssertEqual(rows[1]["type"], "list-item") + } + + func test_previewRows_respectsTheLimit() { + let messages = (1...40).map { message(id: "m\($0)") } + let source = MaterializeLocalSource.messages(messages) + let fields = MaterializePlanner.inferSchema(for: source) + XCTAssertEqual(MaterializePlanner.previewRows(for: source, fields: fields, limit: 5).count, 5) + } +} From 9bbbc16d897febeb7ff8f58106fa0d42c41d78c4 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Mon, 14 Sep 2026 09:37:18 -0700 Subject: [PATCH 2/2] docs: mark G15/G16/G18 shipped and point open work at epic #44 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit work-consolidation.md still listed G16 and G18 as open and G18 as partial. G15 landed in #43, G16/G18 in #74, G17 in #40. The file is superseded for open work by epic #44 (#45–#66), so this records status and the pointer rather than restating the epic here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- work-consolidation.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/work-consolidation.md b/work-consolidation.md index 7fd0763..147b9a2 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -10,6 +10,9 @@ see [§7 Corrections to `the-gaps.md`](#7--corrections-to-the-gapsmd). **Prepared:** 2026-09-05 · **Branch:** `dev` (= `main` = origin, clean tree) +> **Superseded for open work by [epic #44](https://github.com/CompositeCode/interlinedlist-ios/issues/44)** (issues #45–#66), which also corrects three +> claims in this file (§5 there). G15 ✅ #43 · G16 ✅ #74 · G17 ✅ #40 · G18 ✅ #74. + --- ## 1 · Method & verification (what backs every claim below) @@ -120,14 +123,14 @@ found exactly one mismatch across all 104 iOS call sites, and the suite is green | ID | Gap | Backend | Effort | |----|-----|---------|--------| | **G17** ✅ | **Lists shared with me** — [#40](https://github.com/CompositeCode/interlinedlist-ios/pull/40). `listsWatching()` in `APIClient+SharedLists.swift`; `AppDataStore.watchedLists` (kept out of `userLists` — `buildTree` nests within the *owner's* hierarchy); a *"Shared with me"* section in `ListsView` with no owner-only row actions. `UserList.role` decodes the viewer's access level; `isOwned(by:)` already gates `ListDetailView`. | 🟢 Bearer, free | **S** | -| **G15** ❌ | **AI writing assistance** — 5 features: composer *Writing Assistant* (rewrite/tighten/expand/grammar/thread/suggest-tags), *Message Series*, *Article Series*, *Powered Templates* (list create), *Powered Document* (4 modes). | 🟢 `/api/ai/{status,suggest,generate}` · 💲 **subscriber-included** (app's server-side key — no user key, no provider picker) · 50/day quota | **L** | -| **G16** ❌ | **"Create from…" (Materialize)** — message / list / rows / document → new List, Document, or both, with a preview-and-edit confirm step. | 🟢 `POST /api/materialize` · 💲 subscriber | **M** | +| **G15** ✅ | **AI writing assistance** — [#43](https://github.com/CompositeCode/interlinedlist-ios/pull/43). 5 features: composer *Writing Assistant* (rewrite/tighten/expand/grammar/thread/suggest-tags), *Message Series*, *Article Series*, *Powered Templates* (list create), *Powered Document* (4 modes). | 🟢 `/api/ai/{status,suggest,generate}` · 💲 **subscriber-included** (app's server-side key — no user key, no provider picker) · 50/day quota | **L** | +| **G16** ✅ | **"Create from…" (Materialize)** — [#74](https://github.com/CompositeCode/interlinedlist-ios/pull/74). message / list / rows / document → new List, Document, or both, with a preview-and-edit confirm step. | 🟢 `POST /api/materialize` · 💲 subscriber | **M** | ### Tier 2 — smaller systems / lower urgency | ID | Gap | Backend | Effort | |----|-----|---------|--------| -| **G18** ◑ | **GitHub metadata depth** — iOS ships only `githubRepos()` + `githubIssues()`. Unconsumed: `/api/github/orgs`, `/repos/:owner/:repo/{labels,assignees,next-issue-number}`, `PATCH /api/github/issues/:owner/:repo/:number`, `POST …/comments`. Label/assignee pickers on GitHub-backed rows are the visible win. | 🟢 (GitHub-context auth, not the standard helper) | **M** | +| **G18** ✅ | **GitHub metadata depth** — [#74](https://github.com/CompositeCode/interlinedlist-ios/pull/74). Previously iOS shipped only `githubRepos()` + `githubIssues()`. Unconsumed: `/api/github/orgs`, `/repos/:owner/:repo/{labels,assignees,next-issue-number}`, `PATCH /api/github/issues/:owner/:repo/:number`, `POST …/comments`. Label/assignee pickers on GitHub-backed rows are the visible win. | 🟢 (GitHub-context auth, not the standard helper) | **M** | | **G19** ❌ | **Organization LinkedIn management** — web has `/organizations/[slug]/linkedin`; iOS has no equivalent. `status` / `assignments` / `credential` / `sync-pages`. Behind the backend flag `LINKEDIN_ORG_SCOPES_ENABLED` — **re-verify the flag before building.** | 🟢 Bearer (owner-role gated) | **M** | | **G21** ❌ | **Cross-device app-settings sync** — `/api/user/app-settings/{appKey}/…` (account doc + per-device docs + `bootstrap` provenance + CAS `baseVersion`, 64 KiB cap). Explicitly built for native clients (`platform: "ios"`, deviceId "kept in Keychain"). **Not a web-parity gap** — infrastructure iOS could adopt for settings continuity. | 🟢 Bearer, free | **M** | | **G11** ❌ | **Live document presence** (collaborative cursors) — carried forward unchanged from `the-gaps.md`. Heartbeat + poll. | 🟢 `POST/DELETE /api/documents/:id/presence` | **M** |