diff --git a/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift b/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift index 9b3c4f01..8612c0e9 100644 --- a/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift +++ b/Sources/FigmaAPI/Endpoint/BaseEndpoint.swift @@ -39,6 +39,7 @@ extension JSONDecoder { internal static let `default`: JSONDecoder = { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 return decoder }() } diff --git a/Sources/FigmaAPI/Endpoint/VersionEndpoint.swift b/Sources/FigmaAPI/Endpoint/VersionEndpoint.swift new file mode 100644 index 00000000..8d0e8989 --- /dev/null +++ b/Sources/FigmaAPI/Endpoint/VersionEndpoint.swift @@ -0,0 +1,26 @@ +import Foundation +#if os(Linux) +import FoundationNetworking +#endif + +public struct VersionEndpoint: BaseEndpoint { + public typealias Content = [Version] + + private let fileId: String + + public init(fileId: String) { + self.fileId = fileId + } + + func content(from root: VersionResponse) -> Content { + root.versions + } + + public func makeRequest(baseURL: URL) -> URLRequest { + let url = baseURL + .appendingPathComponent("files") + .appendingPathComponent(fileId) + .appendingPathComponent("versions") + return URLRequest(url: url) + } +} diff --git a/Sources/FigmaAPI/Model/Version.swift b/Sources/FigmaAPI/Model/Version.swift new file mode 100644 index 00000000..94f13b24 --- /dev/null +++ b/Sources/FigmaAPI/Model/Version.swift @@ -0,0 +1,15 @@ +import Foundation +#if os(Linux) +import FoundationNetworking +#endif + +public struct Version: Codable { + let id: String + public let createdAt: Date? + let label: String? + let description: String? +} + +public struct VersionResponse: Decodable { + public var versions: [Version] +} diff --git a/Sources/FigmaExport/Output/VersionManager.swift b/Sources/FigmaExport/Output/VersionManager.swift new file mode 100644 index 00000000..341762bc --- /dev/null +++ b/Sources/FigmaExport/Output/VersionManager.swift @@ -0,0 +1,63 @@ +import Foundation +import Logging +#if os(Linux) +import FoundationNetworking +#endif + +class VersionManager { + private let versionFileURL: URL + private let dateFormatter = ISO8601DateFormatter() + private let logger = Logger(label: "com.redmadrobot.figma-export.version-manager") + + enum AssetKey: String, CaseIterable, Codable { + case images + case icons + case typography + case colors + } + + private var versionDates: [String: String] = [:] + + init(versionFilePath: String) { + self.versionFileURL = URL(fileURLWithPath: versionFilePath) + loadVersionDates() + } + + private func loadVersionDates() { + guard FileManager.default.fileExists(atPath: versionFileURL.path) else { return } + + do { + let data = try Data(contentsOf: versionFileURL) + let rawDict = try JSONDecoder().decode([String: String].self, from: data) + + for (key, dateString) in rawDict { + if let assetKey = AssetKey(rawValue: key) { + versionDates[assetKey.rawValue] = dateString + } + } + } catch { + logger.error("Failed to load version data: \(error)") + } + } + + private func saveVersionDates() { + do { + let jsonData = try JSONSerialization.data(withJSONObject: versionDates, options: .prettyPrinted) + try jsonData.write(to: versionFileURL, options: .atomic) + } catch { + logger.error("Failed to save version data: \(error)") + } + } + + func getVersionDate(for asset: AssetKey) -> Date? { + guard let dateString = versionDates[asset.rawValue] else { return nil } + return dateFormatter.date(from: dateString) + } + + func setVersionDate(_ date: Date, for asset: AssetKey) { + let dateString = dateFormatter.string(from: date) + versionDates[asset.rawValue] = dateString + saveVersionDates() + } +} + diff --git a/Sources/FigmaExport/Subcommands/ExportColors.swift b/Sources/FigmaExport/Subcommands/ExportColors.swift index 5277e5bb..7407ea67 100644 --- a/Sources/FigmaExport/Subcommands/ExportColors.swift +++ b/Sources/FigmaExport/Subcommands/ExportColors.swift @@ -25,6 +25,10 @@ extension FigmaExportCommand { var filter: String? func run() throws { + let versionManager = VersionManager(versionFilePath: "figma-versions.json") + let lastAvailableDate = shouldUpdateFigmaVersion(for: .colors, options: options, logger: logger, versionManager: versionManager) + guard let lastAvailableDate else { return } + logger.info("Using FigmaExport \(FigmaExportCommand.version) to export colors.") logger.info("Fetching colors. Please wait...") @@ -112,12 +116,13 @@ extension FigmaExportCommand { logger.info("Done!") } + + versionManager.setVersionDate(lastAvailableDate, for: .colors) } private func exportXcodeColors(colorPairs: [AssetPair], iosParams: Params.iOS) throws { guard let colorParams = iosParams.colors else { - logger.error("Nothing to do. Add ios.colors parameters to the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. Add ios.colors parameters to the config file.") } var colorsURL: URL? diff --git a/Sources/FigmaExport/Subcommands/ExportIcons.swift b/Sources/FigmaExport/Subcommands/ExportIcons.swift index a98b064d..7dde09d5 100644 --- a/Sources/FigmaExport/Subcommands/ExportIcons.swift +++ b/Sources/FigmaExport/Subcommands/ExportIcons.swift @@ -28,6 +28,10 @@ extension FigmaExportCommand { var filter: String? func run() throws { + let versionManager = VersionManager(versionFilePath: "figma-versions.json") + let lastAvailableDate = shouldUpdateFigmaVersion(for: .icons, options: options, logger: logger, versionManager: versionManager) + guard let lastAvailableDate else { return } + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) if options.params.ios != nil { @@ -39,13 +43,14 @@ extension FigmaExportCommand { logger.info("Using FigmaExport \(FigmaExportCommand.version) to export icons to Android Studio project.") try exportAndroidIcons(client: client, params: options.params) } + + versionManager.setVersionDate(lastAvailableDate, for: .icons) } private func exportiOSIcons(client: Client, params: Params) throws { guard let ios = params.ios, let iconsParams = ios.icons else { - logger.info("Nothing to do. You haven’t specified ios.icons parameters in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified ios.icons parameter in the config file.") } logger.info("Fetching icons info from Figma. Please wait...") @@ -115,8 +120,7 @@ extension FigmaExportCommand { private func exportAndroidIcons(client: Client, params: Params) throws { guard let android = params.android, let androidIcons = android.icons else { - logger.info("Nothing to do. You haven’t specified android.icons parameter in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified android.icons parameter in the config file.") } // 1. Get Icons info diff --git a/Sources/FigmaExport/Subcommands/ExportImages.swift b/Sources/FigmaExport/Subcommands/ExportImages.swift index 8dc5ded0..0175f273 100644 --- a/Sources/FigmaExport/Subcommands/ExportImages.swift +++ b/Sources/FigmaExport/Subcommands/ExportImages.swift @@ -25,6 +25,10 @@ extension FigmaExportCommand { var filter: String? func run() throws { + let versionManager = VersionManager(versionFilePath: "figma-versions.json") + let lastAvailableDate = shouldUpdateFigmaVersion(for: .images, options: options, logger: logger, versionManager: versionManager) + guard let lastAvailableDate else { return } + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) if let _ = options.params.ios { @@ -36,13 +40,14 @@ extension FigmaExportCommand { logger.info("Using FigmaExport \(FigmaExportCommand.version) to export images to Android Studio project.") try exportAndroidImages(client: client, params: options.params) } + + versionManager.setVersionDate(lastAvailableDate, for: .images) } private func exportiOSImages(client: Client, params: Params) throws { guard let ios = params.ios, let imagesParams = ios.images else { - logger.info("Nothing to do. You haven’t specified ios.images parameters in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified ios.images parameters in the config file.") } logger.info("Fetching images info from Figma. Please wait...") @@ -110,8 +115,7 @@ extension FigmaExportCommand { private func exportAndroidImages(client: Client, params: Params) throws { guard let androidImages = params.android?.images else { - logger.info("Nothing to do. You haven’t specified android.images parameter in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified android.images parameters in the config file.") } logger.info("Fetching images info from Figma. Please wait...") @@ -144,8 +148,7 @@ extension FigmaExportCommand { private func exportAndroidSVGImages(images: [AssetPair], params: Params) throws { guard let android = params.android, let androidImages = android.images else { - logger.info("Nothing to do. You haven’t specified android.images parameter in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified android.images parameters in the config file.") } // Create empty temp directory @@ -224,8 +227,7 @@ extension FigmaExportCommand { private func exportAndroidRasterImages(images: [AssetPair], params: Params) throws { guard let android = params.android, let androidImages = android.images else { - logger.info("Nothing to do. You haven’t specified android.images parameter in the config file.") - return + throw FigmaExportError.custom(errorString: "Nothing to do. You haven’t specified android.images parameters in the config file.") } // Create empty temp directory diff --git a/Sources/FigmaExport/Subcommands/ExportTypography.swift b/Sources/FigmaExport/Subcommands/ExportTypography.swift index 3a357135..9ea68d36 100644 --- a/Sources/FigmaExport/Subcommands/ExportTypography.swift +++ b/Sources/FigmaExport/Subcommands/ExportTypography.swift @@ -18,43 +18,63 @@ extension FigmaExportCommand { var options: FigmaExportOptions func run() throws { + let versionManager = VersionManager(versionFilePath: "figma-versions.json") + let lastAvailableDate = shouldUpdateFigmaVersion(for: .typography, options: options, logger: logger, versionManager: versionManager) + guard let lastAvailableDate else { return } + let client = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma.timeout) - logger.info("Using FigmaExport \(FigmaExportCommand.version) to export typography.") - logger.info("Fetching text styles. Please wait...") let loader = TextStylesLoader(client: client, params: options.params.figma) let textStyles = try loader.load() - if let ios = options.params.ios, - let typographyParams = ios.typography { - - logger.info("Processing typography...") - let processor = TypographyProcessor( - platform: .ios, - nameValidateRegexp: options.params.common?.typography?.nameValidateRegexp, - nameReplaceRegexp: options.params.common?.typography?.nameReplaceRegexp, - nameStyle: typographyParams.nameStyle - ) - let processedTextStyles = try processor.process(assets: textStyles).get() - logger.info("Saving text styles...") - try exportXcodeTextStyles(textStyles: processedTextStyles, iosParams: ios) - logger.info("Done!") + if let _ = options.params.ios { + logger.info("Using FigmaExport \(FigmaExportCommand.version) to export typography to Xcode project.") + try exportiOSIcons(params: options.params, textStyles: textStyles) } - - if let android = options.params.android { - logger.info("Processing typography...") - let processor = TypographyProcessor( - platform: .android, - nameValidateRegexp: options.params.common?.typography?.nameValidateRegexp, - nameReplaceRegexp: options.params.common?.typography?.nameReplaceRegexp, - nameStyle: options.params.android?.typography?.nameStyle - ) - let processedTextStyles = try processor.process(assets: textStyles).get() - logger.info("Saving text styles...") - try exportAndroidTextStyles(textStyles: processedTextStyles, androidParams: android) - logger.info("Done!") + + if let _ = options.params.android { + logger.info("Using FigmaExport \(FigmaExportCommand.version) to export typography to Android Studio project.") + try exportAndroidIcons(params: options.params, textStyles: textStyles) + } + + versionManager.setVersionDate(lastAvailableDate, for: .typography) + } + + private func exportiOSIcons(params: Params, textStyles: [TextStyle]) throws { + guard let ios = options.params.ios, let typographyParams = ios.typography else { + throw FigmaExportError.custom(errorString: "Nothing to do. Add ios.typography parameters to the config file.") } + + logger.info("Processing typography...") + let iOSProcessor = TypographyProcessor( + platform: .ios, + nameValidateRegexp: params.common?.typography?.nameValidateRegexp, + nameReplaceRegexp: params.common?.typography?.nameReplaceRegexp, + nameStyle: typographyParams.nameStyle + ) + let iOSProcessedTextStyles = try iOSProcessor.process(assets: textStyles).get() + logger.info("Saving text styles...") + try exportXcodeTextStyles(textStyles: iOSProcessedTextStyles, iosParams: ios) + logger.info("Done!") + } + + private func exportAndroidIcons(params: Params, textStyles: [TextStyle]) throws { + guard let android = options.params.android else { + throw FigmaExportError.custom(errorString: "Nothing to do. Add android.typography parameters to the config file.") + } + + logger.info("Processing typography...") + let androidProcessor = TypographyProcessor( + platform: .android, + nameValidateRegexp: params.common?.typography?.nameValidateRegexp, + nameReplaceRegexp: params.common?.typography?.nameReplaceRegexp, + nameStyle: params.android?.typography?.nameStyle + ) + let androidProcessedTextStyles = try androidProcessor.process(assets: textStyles).get() + logger.info("Saving text styles...") + try exportAndroidTextStyles(textStyles: androidProcessedTextStyles, androidParams: android) + logger.info("Done!") } private func createXcodeOutput(from iosParams: Params.iOS) -> XcodeTypographyOutput { diff --git a/Sources/FigmaExport/Subcommands/shouldUpdateFigmaVersion.swift b/Sources/FigmaExport/Subcommands/shouldUpdateFigmaVersion.swift new file mode 100644 index 00000000..686330ed --- /dev/null +++ b/Sources/FigmaExport/Subcommands/shouldUpdateFigmaVersion.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import Foundation +import Logging +import FigmaAPI + +extension ParsableCommand { + + func shouldUpdateFigmaVersion( + for assetKey: VersionManager.AssetKey, + options: FigmaExportOptions, + timeout: TimeInterval? = nil, + logger: Logger, + versionManager: VersionManager + ) -> Date? { + let fileId = options.params.figma.lightFileId + let client = FigmaClient(accessToken: options.accessToken, timeout: timeout) + let endpoint = VersionEndpoint(fileId: fileId) + guard + let fileVersions = try? client.request(endpoint), + let lastVersion = fileVersions.first + else { + return nil + } + + let lastVersionDate = lastVersion.createdAt ?? Date() + let localVersionDate = versionManager.getVersionDate(for: assetKey) + if let localVersionDate, lastVersionDate >= localVersionDate { + versionManager.setVersionDate(lastVersionDate, for: assetKey) + logger.info(""" + + ---------------------------------------------------------------------------- + You are on the latest file version, nothing to download. + ---------------------------------------------------------------------------- + """) + return nil + } + + logger.info(""" + + ------------------------------------------------------------------------------------- + New version available for file: \(fileId)... downloading updates now... + ------------------------------------------------------------------------------------- + """) + return lastVersionDate + } +}