world-manager/World Manager for Minecraft/Services/ContentPackageExporter.swift

170 lines
6.2 KiB
Swift

//
// ContentPackageExporter.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum ContentPackageExporter {
enum ExportError: LocalizedError {
case failedToCreateArchive(String)
var errorDescription: String? {
switch self {
case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the archive file." : output
}
}
}
nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws {
let fileManager = FileManager.default
let archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL)
let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager)
defer {
try? fileManager.removeItem(at: temporaryArchiveURL)
}
try createArchive(for: item, at: temporaryArchiveURL)
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL)
}
nonisolated static func prepareShareFile(for item: MinecraftContentItem) throws -> URL {
let fileManager = FileManager.default
let shareDirectoryURL = fileManager.temporaryDirectory
.appendingPathComponent("MinecraftContentShares", isDirectory: true)
try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true)
let archiveURL = uniqueArchiveURL(
in: shareDirectoryURL,
baseName: suggestedBaseFilename(for: item),
pathExtension: item.contentType.archiveExtension,
fileManager: fileManager
)
try createArchive(for: item, at: archiveURL)
return archiveURL
}
nonisolated static func suggestedBaseFilename(for item: MinecraftContentItem) -> String {
sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName)
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
"\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)"
}
nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
normalizedArchiveURL(for: item, destinationURL: destinationURL)
}
nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = item.folderURL
process.arguments = [
"-c",
"-k",
"--norsrc",
".",
archiveURL.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
throw ExportError.failedToCreateArchive(output)
}
}
nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL {
let normalizedDestinationURL = destinationURL.standardizedFileURL
let requiredExtension = item.contentType.archiveExtension
if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension {
return normalizedDestinationURL
}
return normalizedDestinationURL.appendingPathExtension(requiredExtension)
}
nonisolated private static func temporaryArchiveURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL {
fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(item.contentType.archiveExtension)
}
nonisolated private static func uniqueArchiveURL(
in directoryURL: URL,
baseName: String,
pathExtension: String,
fileManager: FileManager
) -> URL {
var candidateURL = directoryURL
.appendingPathComponent(baseName)
.appendingPathExtension(pathExtension)
var suffix = 2
while fileManager.fileExists(atPath: candidateURL.path) {
candidateURL = directoryURL
.appendingPathComponent("\(baseName) \(suffix)")
.appendingPathExtension(pathExtension)
suffix += 1
}
return candidateURL
}
nonisolated private static func sanitizedFilename(_ value: String) -> String {
let transliterated = portableASCIIString(from: value)
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = transliterated.components(separatedBy: invalidCharacters)
let collapsed = components.joined(separator: " ")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_().,'&+"))
let filteredScalars = collapsed.unicodeScalars.map { scalar in
allowedCharacters.contains(scalar) ? Character(scalar) : " "
}
let filtered = String(filteredScalars)
let normalizedWhitespace = filtered.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_"))
return trimmedPunctuation.isEmpty ? "Minecraft Content" : trimmedPunctuation
}
nonisolated private static func portableASCIIString(from value: String) -> String {
let mutable = NSMutableString(string: value) as CFMutableString
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
return mutable as String
}
}