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),
|
|||
|
|
}));
|
|||
|
|
}
|