diff --git a/Inkwell.xcodeproj/project.xcworkspace/xcuserdata/ewan.xcuserdatad/UserInterfaceState.xcuserstate b/Inkwell.xcodeproj/project.xcworkspace/xcuserdata/ewan.xcuserdatad/UserInterfaceState.xcuserstate index 1988fb771c1487b0039241a4bc9eef64ab908509..4a014b640b80220d33e2b43854e4ea09aba5ba53 100644 GIT binary patch delta 5982 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 X7#Lj285mr;7#Li7w}08jn6erGW=P%0 diff --git a/Inkwell/Authentication/LoginStateManager.swift b/Inkwell/Authentication/LoginStateManager.swift index 698ab3d..b039968 100644 --- a/Inkwell/Authentication/LoginStateManager.swift +++ b/Inkwell/Authentication/LoginStateManager.swift @@ -783,11 +783,13 @@ final class LoginStateManager { path: String?, site: String, markdown: String, - provider: ContentProvider + provider: ContentProvider, + previousContent: UnknownType? = nil ) async throws -> ComAtprotoLexicon.Repository.StrongReference { guard currentDID != nil else { throw LoginError.notAuthenticated } - guard let contentRecord = provider.fromMarkdown(markdown) else { + let ctx = WriteContext(previousContent: previousContent) + guard let contentRecord = provider.fromMarkdown(markdown, ctx: ctx) else { throw LoginError.contentConversionFailed } diff --git a/Inkwell/Features/Writer/WriteView.swift b/Inkwell/Features/Writer/WriteView.swift index 3c42dfd..a83b4eb 100644 --- a/Inkwell/Features/Writer/WriteView.swift +++ b/Inkwell/Features/Writer/WriteView.swift @@ -244,7 +244,8 @@ struct WriteView: View { path: path.isEmpty ? nil : path, site: pub.uri, markdown: markdown, - provider: provider + provider: provider, + previousContent: nil // new posts have no previous content ) let linkTag = SiteStandardLexicon.Verification.discoveryLinkTag( forRecordURI: reference.recordURI, diff --git a/Inkwell/Rendering/ContentProvider.swift b/Inkwell/Rendering/ContentProvider.swift index 5c8509b..ecbb1c5 100644 --- a/Inkwell/Rendering/ContentProvider.swift +++ b/Inkwell/Rendering/ContentProvider.swift @@ -15,6 +15,13 @@ import ATProtoKit // MARK: - ContentProvider Protocol +/// Context passed to `fromMarkdown` so providers can round-trip image blobs. +/// Matching standard.horse's `WriteCtx`. +struct WriteContext { + /// The content object being replaced, so existing image blobs round-trip. + let previousContent: UnknownType? +} + /// A content-format provider that reads and writes one standard.site `content` /// member ($type), converting it to/from the markdown the editor speaks. protocol ContentProvider { @@ -30,8 +37,9 @@ protocol ContentProvider { func matches(_ content: UnknownType?) -> Bool /// Read stored content into editable markdown. func toMarkdown(_ content: UnknownType?) -> ConvertResult - /// Build a fresh content object from edited markdown. - func fromMarkdown(_ markdown: String) -> UnknownType? + /// Build a fresh content object from edited markdown, using the write + /// context to round-trip existing image blobs from the previous content. + func fromMarkdown(_ markdown: String, ctx: WriteContext) -> UnknownType? } /// Result of converting stored content to markdown. @@ -41,6 +49,35 @@ struct ConvertResult { let lost: [String] } +// MARK: - Image Blob Harvesting + +/// Walks a content tree (recursively) collecting every image blob referenced by +/// CID, keyed by CID string. Matching standard.horse's `harvestImages`. +func harvestImageBlobs(from content: UnknownType?) -> [String: ComAtprotoLexicon.Repository.UploadBlobOutput] { + guard let content else { return [:] } + var out: [String: ComAtprotoLexicon.Repository.UploadBlobOutput] = [:] + harvestImageBlobs(from: content, into: &out) + return out +} + +private func harvestImageBlobs(from value: Any, into out: inout [String: ComAtprotoLexicon.Repository.UploadBlobOutput]) { + let mirror = Mirror(reflecting: value) + // Walk the struct/class properties looking for blobs. + for child in mirror.children { + if let blob = child.value as? ComAtprotoLexicon.Repository.UploadBlobOutput, + blob.mimeType?.hasPrefix("image/") ?? true { + let cid = blob.reference.link + if !cid.isEmpty { out[cid] = blob } + } else if let dict = child.value as? [String: Any] { + for (_, v) in dict { harvestImageBlobs(from: v, into: &out) } + } else if let array = child.value as? [Any] { + for v in array { harvestImageBlobs(from: v, into: &out) } + } else if child.value is CustomReflectable || Mirror(reflecting: child.value).children.count > 0 { + harvestImageBlobs(from: child.value, into: &out) + } + } +} + // MARK: - Facet Schema /// Maps a format's facet `$type` strings to the markdown marks we support. @@ -762,12 +799,17 @@ struct LeafletProvider: ContentProvider { return MarkdownListItem(text: text, checked: item.checked, children: mdChildren) } - func fromMarkdown(_ markdown: String) -> UnknownType? { + func fromMarkdown(_ markdown: String, ctx: WriteContext) -> UnknownType? { + // Harvest existing image blobs from the previous content so CIDs in + // markdown (e.g. ![](bafy...)) can be matched and reattached verbatim + // without re-uploading — matching standard.horse's round-trip pattern. + let previousBlobs = harvestImageBlobs(from: ctx.previousContent) + let blocks = MarkdownParser.parse(markdown) var leafletBlocks: [LeafletBlockContainer] = [] for block in blocks { - if let lb = markdownToLeafletBlock(block) { + if let lb = markdownToLeafletBlock(block, previousBlobs: previousBlobs) { leafletBlocks.append(LeafletBlockContainer(block: lb)) } } @@ -777,7 +819,7 @@ struct LeafletProvider: ContentProvider { return UnknownType.record(content) } - private func markdownToLeafletBlock(_ block: MarkdownBlock) -> LeafletBlock? { + private func markdownToLeafletBlock(_ block: MarkdownBlock, previousBlobs: [String: ComAtprotoLexicon.Repository.UploadBlobOutput]) -> LeafletBlock? { switch block { case .heading(let level, let text): let (plaintext, facets) = FacetConverter.markdownToFacets(text, schema: schema) @@ -814,21 +856,28 @@ struct LeafletProvider: ContentProvider { case .horizontalRule: return LeafletBlock(type: b("horizontalRule")) - case .image: - // Images must be PDS blobs; markdown-only images can't be stored - // in leaflet format without a prior upload step. + case .image(let alt, let url): + // Match CID against previous content's blobs (standard.horse pattern). + // A blob CID is a base32 CIDv1 (`bafy…`) or base58 CIDv0 (`Qm…`). + let isCID = url.hasPrefix("baf") || url.hasPrefix("Qm") + if isCID, let existingBlob = previousBlobs[url] { + return LeafletBlock( + type: b("image"), image: existingBlob, alt: alt.isEmpty ? nil : alt + ) + } + // External URLs can't be stored in leaflet format. return nil case .unorderedList(let items): - let listItems = items.map { markdownToLeafletListItem($0, ordered: false) } + let listItems = items.map { markdownToLeafletListItem($0, ordered: false, previousBlobs: previousBlobs) } return LeafletBlock(type: b("unorderedList"), children: listItems) case .orderedList(let start, let items): - let listItems = items.map { markdownToLeafletListItem($0, ordered: true) } + let listItems = items.map { markdownToLeafletListItem($0, ordered: true, previousBlobs: previousBlobs) } return LeafletBlock(type: b("orderedList"), children: listItems, startIndex: start) case .taskList(let items): - let listItems = items.map { markdownToLeafletListItem($0, ordered: false) } + let listItems = items.map { markdownToLeafletListItem($0, ordered: false, previousBlobs: previousBlobs) } return LeafletBlock(type: b("unorderedList"), children: listItems) } } @@ -837,7 +886,7 @@ struct LeafletProvider: ContentProvider { /// Nested ordered lists become `orderedListChildren`, unordered become /// `children`, and task items carry their `checked` flag. The item content /// defaults to an empty text block when no text is provided. - private func markdownToLeafletListItem(_ item: MarkdownListItem, ordered: Bool) -> LeafletListItem { + private func markdownToLeafletListItem(_ item: MarkdownListItem, ordered: Bool, previousBlobs: [String: ComAtprotoLexicon.Repository.UploadBlobOutput]) -> LeafletListItem { let itemType = ordered ? "pub.leaflet.blocks.orderedList#listItem" : "pub.leaflet.blocks.unorderedList#listItem" @@ -846,9 +895,7 @@ struct LeafletProvider: ContentProvider { type: b("text"), plaintext: plaintext, facets: facets.isEmpty ? nil : facets ) - // Nested lists: ordered children go into `orderedListChildren`, - // unordered into `children` (matching standard.horse's convention). - let children = item.children?.map { markdownToLeafletListItem($0, ordered: false) } + let children = item.children?.map { markdownToLeafletListItem($0, ordered: false, previousBlobs: previousBlobs) } return LeafletListItem( type: itemType, content: content, checked: item.checked, children: children @@ -878,7 +925,7 @@ struct MarkpubProvider: ContentProvider { return ConvertResult(markdown: markpub.text.markdown ?? "", lost: []) } - func fromMarkdown(_ markdown: String) -> UnknownType? { + func fromMarkdown(_ markdown: String, ctx: WriteContext) -> UnknownType? { let text = MarkpubText(type: "at.markpub.text", markdown: markdown) let content = MarkpubContent(text: text) return UnknownType.record(content) @@ -1013,7 +1060,7 @@ struct PcktProvider: ContentProvider { return MarkdownListItem(text: text, checked: item.checked, children: children) } - func fromMarkdown(_ markdown: String) -> UnknownType? { + func fromMarkdown(_ markdown: String, ctx: WriteContext) -> UnknownType? { let blocks = MarkdownParser.parse(markdown) var items: [PcktBlock] = [] @@ -1225,7 +1272,7 @@ struct OffprintProvider: ContentProvider { return MarkdownListItem(text: text, checked: item.checked, children: children) } - func fromMarkdown(_ markdown: String) -> UnknownType? { + func fromMarkdown(_ markdown: String, ctx: WriteContext) -> UnknownType? { let blocks = MarkdownParser.parse(markdown) var items: [OffprintBlock] = []