Skip to content

Implement "files/:file_id/versions" API #258

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/FigmaAPI/Endpoint/BaseEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ extension JSONDecoder {
internal static let `default`: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
26 changes: 26 additions & 0 deletions Sources/FigmaAPI/Endpoint/VersionEndpoint.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions Sources/FigmaAPI/Model/Version.swift
Original file line number Diff line number Diff line change
@@ -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]
}
63 changes: 63 additions & 0 deletions Sources/FigmaExport/Output/VersionManager.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}

9 changes: 7 additions & 2 deletions Sources/FigmaExport/Subcommands/ExportColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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...")

Expand Down Expand Up @@ -112,12 +116,13 @@ extension FigmaExportCommand {

logger.info("Done!")
}

versionManager.setVersionDate(lastAvailableDate, for: .colors)
}

private func exportXcodeColors(colorPairs: [AssetPair<Color>], 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?
Expand Down
12 changes: 8 additions & 4 deletions Sources/FigmaExport/Subcommands/ExportIcons.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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...")
Expand Down Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions Sources/FigmaExport/Subcommands/ExportImages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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...")
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -144,8 +148,7 @@ extension FigmaExportCommand {

private func exportAndroidSVGImages(images: [AssetPair<ImagesProcessor.AssetType>], 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
Expand Down Expand Up @@ -224,8 +227,7 @@ extension FigmaExportCommand {

private func exportAndroidRasterImages(images: [AssetPair<ImagesProcessor.AssetType>], 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
Expand Down
78 changes: 49 additions & 29 deletions Sources/FigmaExport/Subcommands/ExportTypography.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions Sources/FigmaExport/Subcommands/shouldUpdateFigmaVersion.swift
Original file line number Diff line number Diff line change
@@ -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
}
}