init
This commit is contained in:
97
lib/strong/parser.ts
Normal file
97
lib/strong/parser.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user