import type { TimeRecord } from "../shared/record.ts"; import { normalizeTag } from "../shared/tag.ts"; const FOREST_HEADERS = ["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"]; export function parseCsv(text: string): string[][] { const rows: string[][] = []; let row: string[] = []; let field = ""; let quoted = false; const input = text.replace(/^\uFEFF/, ""); for (let i = 0; i < input.length; i++) { const char = input[i]!; if (quoted) { if (char === '"' && input[i + 1] === '"') { field += '"'; i++; } else if (char === '"') quoted = false; else field += char; } else if (char === '"' && field === "") { quoted = true; } else if (char === ",") { row.push(field); field = ""; } else if (char === "\n" || char === "\r") { if (char === "\r" && input[i + 1] === "\n") i++; row.push(field); if (row.some(value => value !== "")) rows.push(row); row = []; field = ""; } else { field += char; } } if (quoted) throw new Error("CSV has an unfinished quoted field"); row.push(field); if (row.some(value => value !== "")) rows.push(row); return rows; } function parseForestDate(value: string): number { const normalized = value.trim().replace(/([+-]\d{2})(\d{2})$/, "$1:$2"); const ms = Date.parse(normalized); if (!Number.isFinite(ms)) throw new Error(`Invalid Forest timestamp: ${value}`); return ms; } function hashHex(bytes: ArrayBuffer) { return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join(""); } export async function importForestCsv(text: string): Promise { const rows = parseCsv(text); if (!rows.length || FOREST_HEADERS.some((header, index) => rows[0]![index]?.trim() !== header)) { throw new Error("This is not a Forest CSV with the expected six columns"); } const records: TimeRecord[] = []; for (const [index, row] of rows.slice(1).entries()) { if (row.length !== 6) throw new Error(`Row ${index + 2} has ${row.length} columns; expected 6`); const [rawStart, rawEnd, tag, note, treeType, rawSuccess] = row as [string, string, string, string, string, string]; const startMs = parseForestDate(rawStart); const endMs = parseForestDate(rawEnd); if (endMs < startMs) throw new Error(`Row ${index + 2} ends before it starts`); if (rawSuccess !== "True" && rawSuccess !== "False") throw new Error(`Row ${index + 2} has an invalid success value`); const fingerprint = [rawStart, rawEnd, tag, note, treeType, rawSuccess].join("\u001f"); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(fingerprint)); records.push({ id: `forest_${hashHex(digest).slice(0, 32)}`, startMs, endMs, targetSeconds: null, tag: normalizeTag(tag) || "Unset", note, treeType, isSuccess: rawSuccess === "True", source: "forest", rawStart, rawEnd, deleted: false, }); } return records; } function forestDate(ms: number) { const date = new Date(ms); const pad = (n: number, width = 2) => String(n).padStart(width, "0"); const offset = -date.getTimezoneOffset(); const sign = offset >= 0 ? "+" : "-"; const offsetText = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}${pad(Math.abs(offset) % 60)}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offsetText}`; } function quote(value: string) { return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; } export function exportForestCsv(records: TimeRecord[]): string { const lines = [FOREST_HEADERS.join(",")]; for (const record of records.filter(r => !r.deleted && r.endMs !== null).sort((a, b) => a.startMs - b.startMs)) { const values = [record.rawStart || forestDate(record.startMs), record.rawEnd || forestDate(record.endMs!), record.tag, record.note, record.treeType, record.isSuccess ? "True" : "False"]; lines.push(values.map(quote).join(",")); } return lines.join("\r\n") + "\r\n"; }