~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
271 lines · 11.0 KB Raw History
import Foundation
import os

private let logger = Logger(subsystem: "social.grain.grain", category: "GalleryDraft")

/// A gallery that has been handed to the uploader but hasn't finished publishing.
///
/// Two things make a draft resumable. Every record key it will ever write is
/// assigned up front, so a retry overwrites its own records rather than
/// creating a second gallery. And its photos are stored next to it on disk as
/// already-resized JPEGs, so the work survives the app being killed mid-upload
/// and doesn't depend on the `PhotosPickerItem` still being alive.
struct GalleryDraft: Codable, Sendable, Identifiable, Equatable {
    var id: UUID = .init()
    /// DID of the author, i.e. the repo every record is written to.
    var repo: String
    var title: String
    var description: String
    var labels: [String]
    var location: Location?
    var includeExif: Bool
    var postToBluesky: Bool
    /// `createdAt` for every record in the gallery. Frozen when the draft is
    /// created so a retry two minutes later writes byte-identical records.
    var createdAt: String
    var galleryRkey: String
    /// Record key for the optional Bluesky cross-post, assigned up front for
    /// the same reason as the rest: a resumed publish must not post twice.
    var blueskyRkey: String
    var photos: [Photo]
    /// Set once the atomic commit is known to have landed. Short-circuits the
    /// commit step on any later resume.
    var committed: Bool = false
    /// Set once the Bluesky cross-post has been written (or deliberately skipped).
    var crossPosted: Bool = false

    struct Location: Codable, Sendable, Equatable {
        var h3: String
        var name: String
        var address: [String: AnyCodable]?

        static func == (lhs: Location, rhs: Location) -> Bool {
            lhs.h3 == rhs.h3 && lhs.name == rhs.name
        }
    }

    struct Photo: Codable, Sendable, Identifiable, Equatable {
        var id: UUID
        /// File name of the resized JPEG inside the draft's directory.
        var fileName: String
        var width: Int
        var height: Int
        var alt: String
        var exif: [String: AnyCodable]?
        var photoRkey: String
        var exifRkey: String
        var itemRkey: String
        /// Set once the blob is on the PDS. Blobs are content-addressed so a
        /// re-upload would return the same ref anyway — but skipping it is the
        /// entire point when the connection is the thing that's failing.
        var blob: BlobRef?

        static func == (lhs: Photo, rhs: Photo) -> Bool {
            lhs.id == rhs.id && lhs.blob?.ref?.link == rhs.blob?.ref?.link
        }
    }

    static func == (lhs: GalleryDraft, rhs: GalleryDraft) -> Bool {
        lhs.id == rhs.id && lhs.committed == rhs.committed && lhs.photos == rhs.photos
    }

    var galleryUri: String {
        "at://\(repo)/social.grain.gallery/\(galleryRkey)"
    }

    func photoUri(_ photo: Photo) -> String {
        "at://\(repo)/social.grain.photo/\(photo.photoRkey)"
    }

    var uploadedPhotoCount: Int {
        photos.count(where: { $0.blob != nil })
    }

    /// A short label for the resume banner — the title, or a fallback.
    var displayTitle: String {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? "Untitled gallery" : trimmed
    }

    // MARK: - Record construction

    /// Every write the gallery needs, in the order the appview should index
    /// them: photos first, then the gallery they belong to, then the links.
    /// They all go out in one atomic commit, so this ordering is about how the
    /// server reads the batch, not about durability.
    func writes() -> [ApplyWrite] {
        var writes: [ApplyWrite] = []

        for photo in photos {
            guard let blob = photo.blob else { continue }
            var record: [String: AnyCodable] = [
                "photo": AnyCodable(blobDict(blob)),
                "aspectRatio": AnyCodable([
                    "width": AnyCodable(photo.width),
                    "height": AnyCodable(photo.height),
                ] as [String: AnyCodable]),
                "createdAt": AnyCodable(createdAt),
            ]
            // Always sent: the PDS validates against its own compiled-in copy of
            // this schema, which requires alt even when it is empty.
            record["alt"] = AnyCodable(photo.alt.trimmingCharacters(in: .whitespacesAndNewlines))
            writes.append(.create(collection: "social.grain.photo", rkey: photo.photoRkey, value: AnyCodable(record)))

            if includeExif, var exif = photo.exif, !exif.isEmpty {
                exif["photo"] = AnyCodable(photoUri(photo))
                exif["createdAt"] = AnyCodable(createdAt)
                writes.append(.create(collection: "social.grain.photo.exif", rkey: photo.exifRkey, value: AnyCodable(exif)))
            }
        }

        var gallery: [String: AnyCodable] = [
            "title": AnyCodable(title),
            "createdAt": AnyCodable(createdAt),
        ]
        if !description.isEmpty {
            gallery["description"] = AnyCodable(description)
        }
        if !labels.isEmpty {
            let values = labels.map { ["val": AnyCodable($0)] as [String: AnyCodable] }
            gallery["labels"] = AnyCodable([
                "$type": AnyCodable("com.atproto.label.defs#selfLabels"),
                "values": AnyCodable(values as [[String: AnyCodable]]),
            ] as [String: AnyCodable])
        }
        if let location {
            gallery["location"] = AnyCodable([
                "value": AnyCodable(location.h3),
                "name": AnyCodable(location.name),
            ] as [String: AnyCodable])
            if let address = location.address {
                gallery["address"] = AnyCodable(address)
            }
        }
        writes.append(.create(collection: "social.grain.gallery", rkey: galleryRkey, value: AnyCodable(gallery)))

        var position = 0
        for photo in photos where photo.blob != nil {
            let item: [String: AnyCodable] = [
                "gallery": AnyCodable(galleryUri),
                "item": AnyCodable(photoUri(photo)),
                "position": AnyCodable(position),
                "createdAt": AnyCodable(createdAt),
            ]
            writes.append(.create(collection: "social.grain.gallery.item", rkey: photo.itemRkey, value: AnyCodable(item)))
            position += 1
        }

        return writes
    }

    private func blobDict(_ blob: BlobRef) -> [String: AnyCodable] {
        [
            "$type": AnyCodable(blob.type ?? "blob"),
            "ref": AnyCodable(["$link": AnyCodable(blob.ref?.link ?? "")] as [String: AnyCodable]),
            "mimeType": AnyCodable(blob.mimeType ?? "image/jpeg"),
            "size": AnyCodable(blob.size ?? 0),
        ]
    }
}

// MARK: - Disk store

/// On-disk home for drafts that are mid-publish.
///
/// Lives in Application Support rather than Caches: a half-uploaded gallery is
/// the user's unsaved work, and the system is free to evict Caches at any time.
/// Excluded from backup — these are short-lived and can be several megabytes of
/// JPEG each.
final class GalleryDraftStore: Sendable {
    static let shared = GalleryDraftStore()

    private let root: URL

    init(root: URL? = nil) {
        if let root {
            self.root = root
        } else {
            let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            self.root = support.appendingPathComponent("grain_pending_galleries", isDirectory: true)
        }
        try? FileManager.default.createDirectory(at: self.root, withIntermediateDirectories: true)
        var resourceValues = URLResourceValues()
        resourceValues.isExcludedFromBackup = true
        var mutableRoot = self.root
        try? mutableRoot.setResourceValues(resourceValues)
    }

    func directory(for draftID: UUID) -> URL {
        root.appendingPathComponent(draftID.uuidString, isDirectory: true)
    }

    func imageURL(draftID: UUID, fileName: String) -> URL {
        directory(for: draftID).appendingPathComponent(fileName)
    }

    func writeImage(_ data: Data, draftID: UUID, fileName: String) throws {
        let directory = directory(for: draftID)
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        try data.write(to: directory.appendingPathComponent(fileName), options: .atomic)
    }

    func readImage(draftID: UUID, fileName: String) throws -> Data {
        try Data(contentsOf: imageURL(draftID: draftID, fileName: fileName))
    }

    /// Persist the draft's progress. Called after every uploaded blob, so an
    /// upload that dies on photo 14 of 20 resumes at 14 rather than at 1.
    func save(_ draft: GalleryDraft) {
        do {
            let directory = directory(for: draft.id)
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
            let data = try JSONEncoder().encode(draft)
            try data.write(to: directory.appendingPathComponent("draft.json"), options: .atomic)
        } catch {
            logger.error("Failed to save draft \(draft.id): \(error.localizedDescription)")
        }
    }

    /// How long an unfinished draft is kept before it's written off. A gallery
    /// that hasn't managed to post in a week isn't going to, and each one can
    /// be twenty megabytes of JPEG sitting in Application Support.
    static let maxAge: TimeInterval = 7 * 24 * 60 * 60

    /// The draft as last checkpointed, which is the authoritative record of how
    /// far a publish actually got.
    func load(_ draftID: UUID) -> GalleryDraft? {
        guard let data = try? Data(contentsOf: directory(for: draftID).appendingPathComponent("draft.json")) else {
            return nil
        }
        return try? JSONDecoder().decode(GalleryDraft.self, from: data)
    }

    /// Every draft still on disk, oldest first. Expired drafts and directories
    /// with no readable `draft.json` are swept up on the way past.
    func loadAll(now: Date = Date()) -> [GalleryDraft] {
        guard let entries = try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else {
            return []
        }
        return entries.compactMap { entry -> GalleryDraft? in
            guard let data = try? Data(contentsOf: entry.appendingPathComponent("draft.json")),
                  let draft = try? JSONDecoder().decode(GalleryDraft.self, from: data)
            else {
                try? FileManager.default.removeItem(at: entry)
                return nil
            }
            if let created = DateFormatting.parse(draft.createdAt),
               now.timeIntervalSince(created) > Self.maxAge
            {
                logger.info("Discarding gallery draft \(draft.id) — unfinished for over a week")
                delete(draft.id)
                return nil
            }
            return draft
        }
        .sorted { $0.createdAt < $1.createdAt }
    }

    func delete(_ draftID: UUID) {
        try? FileManager.default.removeItem(at: directory(for: draftID))
    }
}