// // 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] /// Parsed `**Defaults:**` bullet — the library's suggested starting plan for an /// exercise added outside a split (no prior log, no split entry to copy from). /// `nil` when the bullet is absent (not yet authored) or unparseable. var defaults: Defaults? /// 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] } /// The library's suggested starting plan, parsed from the `**Defaults:**` bullet. struct Defaults { var sets: Int var reps: Int var loadType: LoadType var durationSeconds: Int } /// 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 { /// Sections read aloud in the brief hands-free cue (the workout-flow announcement). /// The library's Speak button reads everything; a mid-workout cue skips the reference /// sections (Common Mistakes, Progression) you don't need with the bar in your hands. private static let briefSectionTitles: Set = ["Setup", "Execution", "Cues"] /// Render this exercise as a plain-text script for `AVSpeechSynthesizer`: the name, /// the summary, then each section's title and items as sentences. `brief` keeps only /// the hands-free sections (`briefSectionTitles`), falling back to every section when /// none of them are present, so an exercise with only unusual headings still speaks. func spokenScript(name: String, brief: Bool) -> String { var parts: [String] = [name] let summaryLine = summary.replacingOccurrences(of: "\n", with: " ") if !summaryLine.trimmingCharacters(in: .whitespaces).isEmpty { parts.append(summaryLine) } var chosen = brief ? sections.filter { Self.briefSectionTitles.contains($0.title) } : sections if chosen.isEmpty { chosen = sections } for section in chosen { parts.append(section.title) parts.append(contentsOf: section.items.map(\.text)) } return parts .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .map { $0.hasSuffix(".") || $0.hasSuffix("!") || $0.hasSuffix("?") ? $0 : $0 + "." } .joined(separator: " ") } /// 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 defaults: Defaults? 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 currentSection == nil, let value = metadataValue(line, key: "Defaults") { flushItem() defaults = parseDefaults(value) } 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, defaults: defaults ) } /// Parse a `**Defaults:**` value — "4 × 10 weighted", "3 × 12 bodyweight", or /// "3 × 30 s" — into sets/reps/loadType. The trailing unit picks the `LoadType` /// and which of reps/duration the middle number fills; anything else (missing /// bullet, unrecognized unit) yields `nil` rather than guessing. private static func parseDefaults(_ value: String) -> Defaults? { let parts = value.components(separatedBy: " × ") guard parts.count == 2, let sets = Int(parts[0]) else { return nil } let rest = parts[1].components(separatedBy: " ") guard rest.count == 2, let n = Int(rest[0]) else { return nil } switch rest[1] { case "weighted": return Defaults(sets: sets, reps: n, loadType: .weight, durationSeconds: 0) case "bodyweight": return Defaults(sets: sets, reps: n, loadType: .none, durationSeconds: 0) case "s": return Defaults(sets: sets, reps: 0, loadType: .duration, durationSeconds: n) default: return nil } } } /// Finds and parses the bundled `.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) } }