Pick any exercise from the full library while a workout is running, not just the ones in its split. The new exercise's plan is seeded from the most recent log of that exercise, else the library's authored Defaults line, else a plain 3x10. Adds a searchable two-section picker sheet and a **Defaults:** bullet to all 47 library reference pages. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
195 lines
7.6 KiB
Swift
195 lines
7.6 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]
|
||
/// 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 {
|
||
/// 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 `<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)
|
||
}
|
||
}
|