- 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]>
71 lines
2.3 KiB
JavaScript
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);
|
|
}
|