Files
knur-app/lib/strong/parser.ts
Dominik Klarkowski 36407f534b init
2026-06-16 09:43:48 +02:00

98 lines
2.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}));
}