Files
workouts/Workouts/ExerciseFigure/ExerciseInfo.swift
T
rzen 669ecf1259 Surface machine settings in the exercise library and refine machine rigs
Library detail screens now lead with the user's recorded machine settings
(per-split when they disagree, empty-state card for machine-based entries)
and append the weight progression chart. Starter seeds mark machine
exercises with an empty machineSettings list so the settings UI lights up
before first use. The figure rig gains a frontal body profile for face-on
machines, props that can ride mid joints (knees/elbows), and an
alternating four-frame Bird Dog loop.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 18:35:15 -04:00

156 lines
5.8 KiB
Swift

//
// ExerciseInfo.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Parsed form of an exercise's authored `info.md` (see `Exercise Library/SYSTEM.md`):
/// a one-paragraph summary, the Category/Type/Targets metadata bullets, then the
/// instructional sections (Setup, Execution, Cues, Common Mistakes, Progression…).
/// The library's markdown is uniform, so this parses the known shape rather than
/// general Markdown — SwiftUI's inline-Markdown `Text` can't render headers/lists,
/// and the app wants the structure (target chips, numbered steps) anyway.
struct ExerciseInfo {
/// The intro paragraph(s) between the `# Title` and the metadata bullets.
var summary: String
var category: String?
var type: String?
/// The comma-separated `**Targets:**` values, split for chip rendering.
var targets: [String]
var sections: [Section]
/// Whether the authored `Type:` identifies this as a machine exercise — the
/// library convention is that every machine entry's type starts "Machine-based".
/// Drives the machine-settings sections' visibility before any settings exist.
var isMachineBased: Bool {
type?.localizedCaseInsensitiveContains("machine") == true
}
struct Section {
var title: String
var items: [Item]
}
/// One block within a section, preserving the authored list flavor: numbered
/// steps render with their ordinal, bullets with a dot, paragraphs plain.
enum Item {
case step(String)
case bullet(String)
case paragraph(String)
var text: String {
switch self {
case .step(let s), .bullet(let s), .paragraph(let s): s
}
}
}
}
extension ExerciseInfo {
/// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line
/// that doesn't start a new block (heading, list marker, blank) continues the
/// previous one, matching how Markdown renders soft breaks.
static func parse(markdown: String) -> ExerciseInfo {
var summaryLines: [String] = []
var category: String?
var type: String?
var targets: [String] = []
var sections: [Section] = []
var currentSection: Section?
var currentItem: Item?
func metadataValue(_ line: String, key: String) -> String? {
let prefix = "- **\(key):**"
guard line.hasPrefix(prefix) else { return nil }
return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
}
func flushItem() {
guard let item = currentItem else { return }
currentItem = nil
if currentSection != nil {
currentSection?.items.append(item)
} else if case .paragraph(let text) = item {
summaryLines.append(text)
}
}
func flushSection() {
flushItem()
if let section = currentSection { sections.append(section) }
currentSection = nil
}
func append(to item: Item?, _ text: String) -> Item? {
switch item {
case .step(let s): .step(s + " " + text)
case .bullet(let s): .bullet(s + " " + text)
case .paragraph(let s): .paragraph(s + " " + text)
case nil: .paragraph(text)
}
}
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty {
flushItem()
} else if line.hasPrefix("## ") {
flushSection()
currentSection = Section(title: String(line.dropFirst(3)), items: [])
} else if line.hasPrefix("# ") {
continue // the document title duplicates the exercise name
} else if currentSection == nil,
let value = metadataValue(line, key: "Category") {
flushItem()
category = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Type") {
flushItem()
type = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Targets") {
flushItem()
targets = value.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
} else if let range = line.range(of: #"^\d+\.\s+"#, options: .regularExpression) {
flushItem()
currentItem = .step(String(line[range.upperBound...]))
} else if line.hasPrefix("- ") {
flushItem()
currentItem = .bullet(String(line.dropFirst(2)))
} else {
// Continuation of a hard-wrapped block (or the start of a paragraph).
currentItem = append(to: currentItem, line)
}
}
flushSection()
return ExerciseInfo(
summary: summaryLines.joined(separator: "\n\n"),
category: category,
type: type,
targets: targets,
sections: sections
)
}
}
/// Finds and parses the bundled `<Exercise Name>.info.md` exported next to the
/// motion rigs by `render.py --export`.
enum ExerciseInfoLibrary {
/// The parsed info for `exerciseName`, or `nil` when none is bundled.
static func info(for exerciseName: String) -> ExerciseInfo? {
guard
let url = Bundle.main.url(forResource: exerciseName, withExtension: "info.md"),
let markdown = try? String(contentsOf: url, encoding: .utf8)
else { return nil }
return ExerciseInfo.parse(markdown: markdown)
}
}