This commit is contained in:
Dominik Klarkowski
2026-06-16 09:43:48 +02:00
parent f0e87d8d11
commit 36407f534b
52 changed files with 3211 additions and 100 deletions

97
lib/strong/parser.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createHash } from "crypto";
import { parse } from "date-fns";
import { enUS } from "date-fns/locale";
import type { StrengthWorkoutInput } from "@/lib/models/strength";
const DATE_FORMAT = "EEEE, d MMMM yyyy 'at' HH:mm";
const HEADER_DATE_RE = /^[A-Za-z]+,\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+at\s+\d{1,2}:\d{2}$/;
const SET_RE = /^Set\s+\d+:\s*(?:([\d.,]+)\s*kg\s*[×x]\s*)?(\d+)$/i;
const SOURCE_URL_RE = /^https:\/\/link\.strong\.app\/\S+$/;
type ParsedBlock = string[];
function splitBlocks(text: string): ParsedBlock[] {
return text
.replace(/\r\n/g, "\n")
.split(/\n\s*\n+/)
.map((block) =>
block
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
)
.filter((block) => block.length > 0);
}
function parseWeight(raw: string | undefined): number | undefined {
if (!raw) return undefined;
const value = Number.parseFloat(raw.replace(",", "."));
return Number.isFinite(value) ? value : undefined;
}
function makeSourceKey(workout: Omit<StrengthWorkoutInput, "sourceKey">): string {
if (workout.sourceUrl) return workout.sourceUrl;
return createHash("sha256")
.update(`${workout.date.toISOString()}|${workout.name}`)
.digest("hex");
}
export function parseStrongShareText(text: string): StrengthWorkoutInput[] {
const blocks = splitBlocks(text);
const workouts: Omit<StrengthWorkoutInput, "sourceKey">[] = [];
for (const block of blocks) {
const isHeader = block.length === 2 && HEADER_DATE_RE.test(block[1]);
if (isHeader) {
const date = parse(block[1], DATE_FORMAT, new Date(), { locale: enUS });
workouts.push({ date, name: block[0], exercises: [] });
continue;
}
const current = workouts[workouts.length - 1];
if (!current) {
throw new Error(`Nieoczekiwany blok przed nagłówkiem treningu: "${block[0]}"`);
}
const lines = [...block];
const lastLine = lines[lines.length - 1];
if (SOURCE_URL_RE.test(lastLine)) {
current.sourceUrl = lastLine;
lines.pop();
}
if (lines.length === 0) continue;
if (/^Notes:/i.test(lines[0])) {
const note = lines.join(" ").replace(/^Notes:\s*/i, "");
const lastExercise = current.exercises[current.exercises.length - 1];
if (lastExercise) {
lastExercise.notes = note;
} else {
current.notes = note;
}
continue;
}
const [exerciseName, ...setLines] = lines;
const sets = setLines
.map((line, index) => {
const match = SET_RE.exec(line);
if (!match) return null;
return {
order: index + 1,
weightKg: parseWeight(match[1]),
reps: Number.parseInt(match[2], 10),
};
})
.filter((set): set is NonNullable<typeof set> => set !== null);
current.exercises.push({ name: exerciseName, sets });
}
return workouts.map((workout) => ({
...workout,
sourceKey: makeSourceKey(workout),
}));
}