Files
snowspeederandClaude Sonnet 5 fedaf29847 Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve
- shared/ ports the worksheet's ledger math, time parsing, and reference tables
  (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported
  by both the server and the browser so the arithmetic can't drift between them.
- server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts
  from a bean product URL, then derives worksheet field IDs deterministically from
  reference-data.js — the model never invents a first-crack anchor or a modifier.
- server/alog.js ports the Python alog_parser.py's format handling, including the
  tokenizer-based Python-dict-literal-to-JSON conversion the real files need.
- public/ is the worksheet reproduced as a live HTML form: worksheet.css is a
  verbatim copy of the paper worksheet's print CSS, print.js bakes values into
  the print layout so the same DOM renders both on screen and on paper.
- Ledger math verified against both of the paper worksheet's worked examples;
  alog parser verified against all 14 real logs in ref/roasts/.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-29 15:41:35 -04:00

71 lines
2.3 KiB
JavaScript

// Optional convenience: list/read .alog files from a local directory the roastetta skill
// (or the operator) already populated, without this webapp owning the Cloudflare/login
// problem itself. Path-traversal guarded: only a bare basename ending in .alog, resolved
// and asserted to stay inside ALOG_DIR.
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { parseAlog } from "./alog.js";
function resolveAlogDir() {
if (process.env.ALOG_DIR) return process.env.ALOG_DIR;
return path.join(os.homedir(), "Roastetta");
}
async function firstExistingDir(candidates) {
for (const dir of candidates) {
try {
const stat = await fs.stat(dir);
if (stat.isDirectory()) return dir;
} catch {
/* try next */
}
}
return null;
}
export async function listAlogLibrary() {
const dir = await firstExistingDir([resolveAlogDir(), "/Users/shane/dev/hope_roaster/ref/roasts"]);
if (!dir) return [];
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".alog")) continue;
const full = path.join(dir, entry.name);
const stat = await fs.stat(full);
files.push({ filename: entry.name, sizeBytes: stat.size, mtime: stat.mtime.toISOString() });
}
return files.sort((a, b) => b.mtime.localeCompare(a.mtime));
}
export async function readAlogFromLibrary(filenameParam) {
const base = path.basename(String(filenameParam ?? ""));
if (!base || !base.toLowerCase().endsWith(".alog")) {
const err = new Error("Filename must end in .alog");
err.code = "bad_filename";
throw err;
}
const dir = await firstExistingDir([resolveAlogDir(), "/Users/shane/dev/hope_roaster/ref/roasts"]);
if (!dir) {
const err = new Error("No .alog library directory found.");
err.code = "not_found";
throw err;
}
const full = path.resolve(dir, base);
if (!full.startsWith(path.resolve(dir) + path.sep)) {
const err = new Error("Invalid filename.");
err.code = "bad_filename";
throw err;
}
let content;
try {
content = await fs.readFile(full, "utf8");
} catch {
const err = new Error(`${base} not found in the .alog library.`);
err.code = "not_found";
throw err;
}
return parseAlog(content, base);
}