98 lines
2.9 KiB
TypeScript
98 lines
2.9 KiB
TypeScript
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),
|
||
}));
|
||
}
|