Compare commits

...

9 Commits

20 changed files with 1070 additions and 48 deletions

3
.gitignore vendored
View File

@ -54,3 +54,6 @@ temp/
# Backup files
*.bak
*~
# Harness runtime state
.harness/*.json

14
TODO.md
View File

@ -33,17 +33,17 @@ MVP is functionally complete (core app + docs + tests).
### Phase 2: Import UI
- [x] Add “Import from URL” UI page/form in frontend
- [x] Show parsed preview (title, ingredients, steps, source URL)
- [ ] Allow edit-before-save flow, then save to existing create recipe API
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
- [x] Allow edit-before-save flow, then save to existing create recipe API
- [x] Add frontend error states (invalid URL, parse failure, timeout)
### Phase 3: Fallback Parsing + Hardening
- [ ] Add heuristic fallback parser when Schema.org missing
- [ ] Add timeout/retry + user-friendly import failure messages
- [ ] Add logging/telemetry for import success/failure reasons
- [x] Add heuristic fallback parser when Schema.org missing
- [x] Add timeout/retry + user-friendly import failure messages
- [x] Add logging/telemetry for import success/failure reasons
### Phase 4: Browser Extension (after URL import stable)
- [ ] Scaffold browser extension project (Manifest v3)
- [ ] Add “Send to Recipe Manager” action to call import API
- [x] Scaffold browser extension project (Manifest v3)
- [x] Add “Send to Recipe Manager” action to call import API
- [ ] Add extension settings for Recipe Manager base URL
---

View File

@ -0,0 +1,20 @@
# Browser Extension (Manifest v3) Scaffold
This folder contains the initial Manifest v3 scaffold for the Recipe Manager browser extension.
## Files
- `manifest.json` — extension manifest
- `background.js` — service worker and context menu registration
- `popup.html` / `popup.js` — basic action popup
- `options.html` — placeholder settings page
- `styles.css` — shared minimal styles
## Load locally (Chrome)
1. Open `chrome://extensions`
2. Enable **Developer mode**
3. Click **Load unpacked**
4. Select this folder: `browser-extension/`
Future tasks will wire the context menu action to the import API and implement settings persistence for base URL.

View File

@ -0,0 +1,65 @@
const DEFAULT_BASE_URL = 'http://localhost:3000';
function normalizeBaseUrl(rawBaseUrl) {
if (typeof rawBaseUrl !== 'string' || rawBaseUrl.trim().length === 0) {
return DEFAULT_BASE_URL;
}
return rawBaseUrl.trim().replace(/\/+$/, '');
}
async function getRecipeManagerBaseUrl() {
const { recipeManagerBaseUrl } = await chrome.storage.sync.get({
recipeManagerBaseUrl: DEFAULT_BASE_URL,
});
return normalizeBaseUrl(recipeManagerBaseUrl);
}
async function sendUrlToRecipeManager(pageUrl) {
const baseUrl = await getRecipeManagerBaseUrl();
const importUrl = `${baseUrl}/api/import/url`;
const response = await fetch(importUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: pageUrl }),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(`Import request failed (${response.status}): ${responseText}`);
}
return response.json();
}
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'send-to-recipe-manager',
title: 'Send to Recipe Manager',
contexts: ['page'],
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId !== 'send-to-recipe-manager' || !tab?.url) {
return;
}
sendUrlToRecipeManager(tab.url)
.then((payload) => {
console.info('[Recipe Manager Extension] Import request sent successfully', {
sourceUrl: tab.url,
success: payload?.success ?? true,
});
})
.catch((error) => {
console.error('[Recipe Manager Extension] Failed to import URL', {
sourceUrl: tab.url,
error: error instanceof Error ? error.message : String(error),
});
});
});

View File

@ -0,0 +1,17 @@
{
"manifest_version": 3,
"name": "Recipe Manager Import",
"version": "0.1.0",
"description": "Send recipe page URLs to Recipe Manager for import.",
"permissions": ["storage", "contextMenus"],
"host_permissions": ["http://*/*", "https://*/*"],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_title": "Recipe Manager",
"default_popup": "popup.html"
},
"options_page": "options.html"
}

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recipe Manager Settings</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main>
<h1>Recipe Manager Settings</h1>
<p>Settings UI scaffold. Base URL wiring is a follow-up task.</p>
</main>
</body>
</html>

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recipe Manager</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main>
<h1>Recipe Manager</h1>
<p>Extension scaffold ready.</p>
<button id="open-options" type="button">Open Settings</button>
</main>
<script type="module" src="popup.js"></script>
</body>
</html>

View File

@ -0,0 +1,5 @@
const openOptionsButton = document.getElementById('open-options');
openOptionsButton?.addEventListener('click', () => {
chrome.runtime.openOptionsPage();
});

View File

@ -0,0 +1,28 @@
body {
margin: 0;
min-width: 280px;
font-family: Arial, sans-serif;
color: #1f2937;
}
main {
padding: 16px;
}
h1 {
font-size: 16px;
margin: 0 0 8px;
}
p {
margin: 0 0 12px;
font-size: 13px;
}
button {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 10px;
background: #f9fafb;
cursor: pointer;
}

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { fetchHarnessStatus } from '../services/api';
import type { HarnessStatus } from '../types/recipe';
function getStatusPillClass(status: string | undefined): string {
switch (status) {
case 'HEALTHY':
return 'bg-green-100 text-green-800 border-green-200';
case 'IDLE':
return 'bg-gray-100 text-gray-700 border-gray-200';
case 'STALE':
case 'MISSING':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
}
}
export function MissionControlPanel() {
const [status, setStatus] = useState<HarnessStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await fetchHarnessStatus();
if (!cancelled) {
setStatus(data);
setError(null);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load mission control status');
setLoading(false);
}
}
};
load();
const interval = setInterval(load, 15000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (loading) {
return (
<div className="mb-4 rounded-lg border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500">Mission Control: loading status</p>
</div>
);
}
if (error || !status) {
return (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">Mission Control unavailable: {error ?? 'unknown error'}</p>
</div>
);
}
return (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-blue-900">Mission Control Harness Progress</h3>
<span className={`rounded-full border px-2 py-0.5 text-xs font-medium ${getStatusPillClass(status.keepalive.status)}`}>
{status.keepalive.status ?? 'UNKNOWN'}
</span>
</div>
<div className="grid gap-2 text-sm text-gray-800 md:grid-cols-2">
<p>
<span className="font-medium">Last commit:</span>{' '}
{status.commit ? `${status.commit.hash} (${status.commit.relative})` : 'N/A'}
</p>
<p>
<span className="font-medium">Iteration:</span>{' '}
{status.keepalive.activeSessionLabel ?? 'none'}
</p>
<p>
<span className="font-medium">v1 tasks:</span>{' '}
{status.todo.checked} done / {status.todo.unchecked} remaining
</p>
<p>
<span className="font-medium">Heartbeat age:</span>{' '}
{status.keepalive.heartbeatAgeSeconds != null ? `${status.keepalive.heartbeatAgeSeconds}s` : 'n/a'}
</p>
</div>
<p className="mt-2 text-sm text-gray-700">
<span className="font-medium">Next task:</span>{' '}
{status.todo.nextTask ?? 'No unchecked v1 tasks'}
</p>
{status.workerHeartbeatHistory.length > 0 && (
<div className="mt-3">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-gray-600">Last 5 heartbeats</p>
<ul className="space-y-1 text-xs text-gray-700">
{status.workerHeartbeatHistory.map((entry, index) => (
<li key={`${entry.timestamp ?? 'heartbeat'}-${index}`}>
{entry.timestamp ?? 'unknown time'} {entry.step ?? 'step n/a'} ({entry.status ?? 'status n/a'})
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -1,31 +1,142 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { importRecipeFromUrl } from '../services/api';
import type { UrlImportResult } from '../types/recipe';
import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe';
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
function toTextBlock(items: string[]): string {
return items.join('\n');
}
function toList(text: string): string[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
const normalized = message.toLowerCase();
if (normalized.includes('valid url')) {
return {
type: 'invalid-url',
message: 'Please enter a valid URL (including https://).',
};
}
if (normalized.includes('timed out')) {
return {
type: 'timeout',
message: 'The import request timed out. Please try again in a moment.',
};
}
if (normalized.includes('network error') || normalized.includes('could not fetch the page')) {
return {
type: 'generic',
message: 'We could not reach that recipe page right now. Please try again in a moment.',
};
}
if (normalized.includes('did not return an html page')) {
return {
type: 'generic',
message: 'That link did not point to an HTML recipe page. Try the direct recipe URL.',
};
}
return {
type: 'generic',
message,
};
}
export function ImportUrlPage() {
const navigate = useNavigate();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
const [result, setResult] = useState<UrlImportResult | null>(null);
const [draft, setDraft] = useState<RecipeDraft | null>(null);
const [draftError, setDraftError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setLoading(true);
setError(null);
setErrorType(null);
setResult(null);
setDraft(null);
setDraftError(null);
try {
const imported = await importRecipeFromUrl(url);
setResult(imported);
setDraft(imported.draft_recipe);
if (!imported.draft_recipe) {
setErrorType('parse-failure');
setError('We could fetch this page, but could not find recipe fields to import.');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
setError(message);
const details = getImportErrorDetails(message);
setErrorType(details.type);
setError(details.message);
} finally {
setLoading(false);
}
};
const handleSave = async (event: FormEvent) => {
event.preventDefault();
if (!draft) {
setDraftError('No draft recipe to save.');
return;
}
const title = draft.title.trim();
const ingredients = draft.ingredients.map((item) => item.trim()).filter(Boolean);
const instructions = draft.instructions.map((item) => item.trim()).filter(Boolean);
if (!title) {
setDraftError('Title is required.');
return;
}
if (ingredients.length === 0) {
setDraftError('At least one ingredient is required.');
return;
}
if (instructions.length === 0) {
setDraftError('At least one instruction step is required.');
return;
}
setIsSaving(true);
setDraftError(null);
try {
const created = await createRecipe({
...draft,
title,
ingredients,
instructions,
});
navigate(`/recipe/${created.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save recipe';
setDraftError(message);
setIsSaving(false);
}
};
return (
<div className="max-w-3xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
@ -59,9 +170,21 @@ export function ImportUrlPage() {
</form>
{error && (
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800">
<strong>Error:</strong> {error}
<div
className={`mt-4 border rounded-lg p-4 ${
errorType === 'parse-failure'
? 'bg-amber-50 border-amber-200'
: 'bg-red-50 border-red-200'
}`}
>
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
<strong>
{errorType === 'invalid-url' && 'Invalid URL:'}
{errorType === 'timeout' && 'Import timed out:'}
{errorType === 'parse-failure' && 'Parse failed:'}
{errorType === 'generic' && 'Error:'}
</strong>{' '}
{error}
</p>
</div>
)}
@ -74,30 +197,90 @@ export function ImportUrlPage() {
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
</div>
{result.draft_recipe ? (
<>
{draft ? (
<form onSubmit={handleSave} className="space-y-4">
<p className="text-sm text-gray-600">Review and edit before saving.</p>
{draftError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm">
{draftError}
</div>
)}
<div>
<h4 className="text-lg font-semibold text-gray-900">{result.draft_recipe.title}</h4>
<label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
id="draft-title"
type="text"
required
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Ingredients</h5>
<ul className="list-disc list-inside space-y-1 text-gray-800">
{result.draft_recipe.ingredients.map((ingredient, index) => (
<li key={`${ingredient}-${index}`}>{ingredient}</li>
))}
</ul>
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">
Ingredients (one per line)
</label>
<textarea
id="draft-ingredients"
rows={8}
value={toTextBlock(draft.ingredients)}
onChange={(event) => setDraft({ ...draft, ingredients: toList(event.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Steps</h5>
<ol className="list-decimal list-inside space-y-1 text-gray-800">
{result.draft_recipe.instructions.map((instruction, index) => (
<li key={`${instruction}-${index}`}>{instruction}</li>
))}
</ol>
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">
Steps (one per line)
</label>
<textarea
id="draft-instructions"
rows={10}
value={toTextBlock(draft.instructions)}
onChange={(event) => setDraft({ ...draft, instructions: toList(event.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</>
<div>
<label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">
Source URL
</label>
<input
id="draft-source-url"
type="url"
value={draft.source_url ?? ''}
onChange={(event) =>
setDraft({
...draft,
source_url: event.target.value.trim() ? event.target.value : undefined,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={isSaving}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Saving…' : 'Save Recipe'}
</button>
<Link
to="/recipe/new"
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
>
Open full editor
</Link>
</div>
</form>
) : (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">
Could not parse a recipe preview from this URL.

View File

@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard';
import { MissionControlPanel } from '../components/MissionControlPanel';
export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState('');
@ -46,6 +47,8 @@ export function RecipeListPage() {
return (
<div>
<MissionControlPanel />
{/* Header */}
<div className="mb-6">
<div className="flex justify-between items-center mb-4">

View File

@ -2,7 +2,7 @@
* API client for Recipe Manager backend
*/
import type { Recipe, Tag, ApiResponse, UrlImportResult } from '../types/recipe';
import type { Recipe, RecipeDraft, Tag, ApiResponse, UrlImportResult, HarnessStatus } from '../types/recipe';
// Use relative URL - nginx will proxy to backend in production
// For local development (npm run dev), configure vite.config.ts proxy
@ -61,7 +61,7 @@ export async function fetchRecipe(id: number): Promise<Recipe> {
/**
* Create a new recipe
*/
export async function createRecipe(recipe: Omit<Recipe, 'id' | 'created_at' | 'updated_at' | 'last_cooked_at'>): Promise<Recipe> {
export async function createRecipe(recipe: RecipeDraft): Promise<Recipe> {
const response = await fetch(`${API_BASE_URL}/recipes`, {
method: 'POST',
headers: {
@ -253,14 +253,35 @@ export async function importRecipeFromUrl(url: string): Promise<UrlImportResult>
body: JSON.stringify({ url }),
});
const result: ApiResponse<UrlImportResult> = await response.json();
if (!response.ok) {
throw new Error(`Failed to import URL: ${response.statusText}`);
const errorMessage = typeof result.error === 'string'
? result.error
: JSON.stringify(result.error ?? 'Failed to import URL');
throw new Error(errorMessage);
}
const result: ApiResponse<UrlImportResult> = await response.json();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to import URL');
}
return result.data;
}
/**
* Fetch harness mission-control status for progress visibility
*/
export async function fetchHarnessStatus(): Promise<HarnessStatus> {
const response = await fetch(`${API_BASE_URL}/harness/status`);
if (!response.ok) {
throw new Error(`Failed to fetch harness status: ${response.statusText}`);
}
const result: ApiResponse<HarnessStatus> = await response.json();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch harness status');
}
return result.data;
}

View File

@ -17,6 +17,21 @@ export interface Recipe {
last_cooked_at?: number;
}
/**
* Recipe payload used for create/import/edit-before-save flows
*/
export interface RecipeDraft {
title: string;
description?: string;
ingredients: string[];
instructions: string[];
source_url?: string;
notes?: string;
servings?: number;
prep_time_minutes?: number;
cook_time_minutes?: number;
}
/**
* Tag data model
*/
@ -43,5 +58,42 @@ export interface UrlImportResult {
source_url: string;
html: string;
json_ld_blocks: string[];
draft_recipe: Recipe | null;
draft_recipe: RecipeDraft | null;
}
export interface HarnessStatus {
projectRoot: string;
commit: {
hash: string;
message: string;
timestamp: string;
relative: string;
} | null;
todo: {
checked: number;
unchecked: number;
nextTask: string | null;
};
keepalive: {
checkedAt?: string;
status?: string;
heartbeatAgeSeconds?: number | null;
lastStep?: string | null;
historyCount?: number;
shouldRecover?: boolean;
activeSessionLabel?: string | null;
reason?: string;
};
workerHeartbeat: {
timestamp?: string;
step?: string;
status?: string;
note?: string;
} | null;
workerHeartbeatHistory: Array<{
timestamp?: string;
step?: string;
status?: string;
note?: string;
}>;
}

View File

@ -3,6 +3,7 @@ import { getDatabase, saveDatabase } from './db/database.js';
import { createRecipeRoutes } from './routes/recipes.js';
import { createTagRoutes } from './routes/tags.js';
import { createImportRoutes } from './routes/import.js';
import { createHarnessRoutes } from './routes/harness.js';
const app = express();
const port = 3000;
@ -43,6 +44,7 @@ async function startServer() {
app.use('/api/recipes', createRecipeRoutes(db));
app.use('/api/tags', createTagRoutes(db));
app.use('/api/import', createImportRoutes());
app.use('/api/harness', createHarnessRoutes(process.cwd()));
// Save database periodically (every 5 seconds)
setInterval(() => {
@ -86,6 +88,8 @@ async function startServer() {
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
console.log(` Import:`);
console.log(` POST /api/import/url - Import recipe foundation data from URL`);
console.log(` Harness:`);
console.log(` GET /api/harness/status - Mission Control progress/status feed`);
});
} catch (error) {
console.error('Failed to start server:', error);

View File

@ -0,0 +1,112 @@
import { Router } from 'express';
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
type KeepaliveStatus = {
checkedAt?: string;
status?: 'IDLE' | 'HEALTHY' | 'STALE' | 'MISSING' | string;
heartbeatAgeSeconds?: number | null;
lastStep?: string | null;
historyCount?: number;
shouldRecover?: boolean;
activeSessionLabel?: string | null;
reason?: string;
};
type WorkerHeartbeat = {
timestamp?: string;
step?: string;
status?: string;
note?: string;
};
type GitCommitSummary = {
hash: string;
message: string;
timestamp: string;
relative: string;
};
function safeReadJson<T>(path: string): T | null {
try {
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, 'utf8')) as T;
} catch {
return null;
}
}
function parseActiveV1Todo(todoText: string): { checked: number; unchecked: number; nextTask: string | null } {
const activeStart = todoText.indexOf('## 🎯 Active Tasks — v1.0 Recipe Import');
const backlogStart = todoText.indexOf('## 📋 Backlog (Post-v1)');
if (activeStart < 0 || backlogStart < 0 || backlogStart <= activeStart) {
return { checked: 0, unchecked: 0, nextTask: null };
}
const activeSection = todoText.slice(activeStart, backlogStart);
const lines = activeSection.split('\n').map((line) => line.trim());
const checked = lines.filter((line) => line.startsWith('- [x]')).length;
const uncheckedLines = lines.filter((line) => line.startsWith('- [ ]'));
return {
checked,
unchecked: uncheckedLines.length,
nextTask: uncheckedLines[0]?.replace(/^- \[ \] /, '') ?? null,
};
}
function getLastCommit(projectRoot: string): GitCommitSummary | null {
try {
const format = '%H|%s|%cI|%cr';
const raw = execSync(`git -C "${projectRoot}" log -1 --pretty=format:${format}`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const [hash, message, timestamp, relative] = raw.split('|');
if (!hash || !message || !timestamp || !relative) return null;
return { hash: hash.slice(0, 8), message, timestamp, relative };
} catch {
return null;
}
}
export function createHarnessRoutes(projectRoot = process.cwd()): Router {
const router = Router();
router.get('/status', (_req, res) => {
try {
const todoPath = join(projectRoot, 'TODO.md');
const keepalivePath = join(projectRoot, '.harness', 'keepalive.json');
const workerHeartbeatPath = join(projectRoot, '.harness', 'worker-heartbeat.json');
const workerHistoryPath = join(projectRoot, '.harness', 'worker-heartbeat-history.json');
const todoText = existsSync(todoPath) ? readFileSync(todoPath, 'utf8') : '';
const todo = parseActiveV1Todo(todoText);
const keepalive = safeReadJson<KeepaliveStatus>(keepalivePath) ?? { status: 'MISSING' };
const workerHeartbeat = safeReadJson<WorkerHeartbeat>(workerHeartbeatPath);
const history = safeReadJson<WorkerHeartbeat[]>(workerHistoryPath) ?? [];
const data = {
projectRoot,
commit: getLastCommit(projectRoot),
todo,
keepalive,
workerHeartbeat,
workerHeartbeatHistory: Array.isArray(history) ? history.slice(-5) : [],
};
res.status(200).json({ success: true, data, error: null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
res.status(500).json({ success: false, data: null, error: message });
}
});
return router;
}

View File

@ -1,24 +1,54 @@
import { Router } from 'express';
type ImportTelemetryEvent = {
event: 'import_success' | 'import_failure';
url: string;
parser?: 'schema_org' | 'heuristic' | 'none';
jsonLdBlockCount?: number;
durationMs: number;
failureCode?: string;
failureReason?: string;
};
function logImportTelemetry(event: ImportTelemetryEvent): void {
console.info('[import.telemetry]', JSON.stringify(event));
}
import { z } from 'zod';
import { UrlImportService } from '../services/UrlImportService.js';
import { UrlImportError, UrlImportService } from '../services/UrlImportService.js';
import { SchemaOrgRecipeParserService } from '../services/SchemaOrgRecipeParserService.js';
import { HeuristicRecipeParserService } from '../services/HeuristicRecipeParserService.js';
const importUrlSchema = z.object({
url: z.string().url('A valid URL is required'),
});
function mapImportErrorToStatus(error: UrlImportError): number {
if (error.code === 'IMPORT_TIMEOUT') return 504;
if (error.code === 'IMPORT_NETWORK') return 502;
if (error.code === 'IMPORT_FETCH_FAILED') {
if (error.status !== undefined && error.status >= 500) return 502;
return 400;
}
return 400;
}
export function createImportRoutes(): Router {
const router = Router();
const urlImportService = new UrlImportService();
const schemaOrgParser = new SchemaOrgRecipeParserService();
const heuristicParser = new HeuristicRecipeParserService();
/**
* POST /api/import/url
* Fetch an external recipe page and return imported, normalized Recipe (if found)
*/
router.post('/url', async (req, res) => {
const startedAt = Date.now();
let requestUrl = 'unknown';
try {
const { url } = importUrlSchema.parse(req.body);
requestUrl = url;
const result = await urlImportService.fetchFromUrl(url);
// Try to parse and normalize Recipe from JSON-LD blocks
@ -28,6 +58,23 @@ export function createImportRoutes(): Router {
if (draft) break;
}
// Fallback: heuristic HTML parser when Schema.org data is missing/invalid
let parserUsed: 'schema_org' | 'heuristic' | 'none' = 'none';
if (draft) {
parserUsed = 'schema_org';
} else {
draft = heuristicParser.parseHtml(result.html, result.source_url);
parserUsed = draft ? 'heuristic' : 'none';
}
logImportTelemetry({
event: 'import_success',
url: requestUrl,
parser: parserUsed,
jsonLdBlockCount: result.json_ld_blocks.length,
durationMs: Date.now() - startedAt,
});
res.status(200).json({
success: true,
data: { ...result, draft_recipe: draft },
@ -35,6 +82,14 @@ export function createImportRoutes(): Router {
});
} catch (error) {
if (error instanceof z.ZodError) {
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: 'VALIDATION_ERROR',
failureReason: error.issues[0]?.message ?? 'Request validation failed',
});
res.status(400).json({
success: false,
data: null,
@ -43,9 +98,16 @@ export function createImportRoutes(): Router {
return;
}
if (error instanceof Error) {
const status = error.message.includes('timed out') ? 504 : 400;
res.status(status).json({
if (error instanceof UrlImportError) {
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: error.code,
failureReason: error.message,
});
res.status(mapImportErrorToStatus(error)).json({
success: false,
data: null,
error: error.message,
@ -53,6 +115,31 @@ export function createImportRoutes(): Router {
return;
}
if (error instanceof Error) {
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: 'UNHANDLED_ERROR',
failureReason: error.message,
});
res.status(500).json({
success: false,
data: null,
error: error.message,
});
return;
}
logImportTelemetry({
event: 'import_failure',
url: requestUrl,
durationMs: Date.now() - startedAt,
failureCode: 'UNKNOWN_ERROR',
failureReason: 'Internal server error',
});
res.status(500).json({
success: false,
data: null,

View File

@ -0,0 +1,107 @@
import type { CreateRecipeInput } from '../types/recipe.js';
/**
* Lightweight fallback parser for pages without usable Schema.org Recipe JSON-LD.
*/
export class HeuristicRecipeParserService {
parseHtml(html: string, sourceUrl?: string): CreateRecipeInput | null {
const title = this.extractTitle(html);
const ingredients = this.extractSectionList(html, 'ingredients');
const instructions = this.extractSectionList(html, 'instructions')
.concat(this.extractSectionList(html, 'directions'));
const mergedInstructions = this.uniqueNonEmpty(instructions);
if (!title && ingredients.length === 0 && mergedInstructions.length === 0) {
return null;
}
if (ingredients.length === 0 && mergedInstructions.length === 0) {
return null;
}
return {
title: title ?? 'Imported Recipe',
ingredients,
instructions: mergedInstructions,
source_url: sourceUrl,
};
}
private extractTitle(html: string): string | undefined {
const h1Match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (h1Match?.[1]) {
return this.normalizeText(h1Match[1]);
}
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!titleMatch?.[1]) return undefined;
const raw = this.normalizeText(titleMatch[1]);
if (!raw) return undefined;
// Common site title separators (e.g., "Recipe Name | Site")
const split = raw.split(/\s[\-||:]\s/);
return split[0]?.trim() || raw;
}
private extractSectionList(html: string, sectionName: 'ingredients' | 'instructions' | 'directions'): string[] {
const headingPattern = new RegExp(
`<h[1-6][^>]*>\\s*${sectionName}\\s*<\\/h[1-6]>\\s*<(ul|ol)[^>]*>([\\s\\S]*?)<\\/\\1>`,
'i',
);
const headingMatch = html.match(headingPattern);
if (headingMatch?.[2]) {
return this.extractListItems(headingMatch[2]);
}
const classPattern = new RegExp(
`<(ul|ol|div)[^>]*(class|id)=["'][^"']*${sectionName.slice(0, -1)}[^"']*["'][^>]*>([\\s\\S]*?)<\\/\\1>`,
'gi',
);
const candidates: string[] = [];
let match = classPattern.exec(html);
while (match) {
const content = match[3] ?? '';
candidates.push(...this.extractListItems(content));
match = classPattern.exec(html);
}
return this.uniqueNonEmpty(candidates);
}
private extractListItems(sectionHtml: string): string[] {
const listItemRegex = /<li[^>]*>([\s\S]*?)<\/li>/gi;
const items: string[] = [];
let match = listItemRegex.exec(sectionHtml);
while (match) {
const normalized = this.normalizeText(match[1] ?? '');
if (normalized) {
items.push(normalized);
}
match = listItemRegex.exec(sectionHtml);
}
return this.uniqueNonEmpty(items);
}
private normalizeText(text: string): string {
const withoutTags = text.replace(/<[^>]+>/g, ' ');
const decoded = withoutTags
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>');
return decoded.replace(/\s+/g, ' ').trim();
}
private uniqueNonEmpty(values: string[]): string[] {
return [...new Set(values.map((v) => v.trim()).filter(Boolean))];
}
}

View File

@ -4,14 +4,32 @@ export interface UrlImportFetchResult {
json_ld_blocks: string[];
}
export type UrlImportErrorCode =
| 'IMPORT_TIMEOUT'
| 'IMPORT_NETWORK'
| 'IMPORT_FETCH_FAILED'
| 'IMPORT_UNSUPPORTED_CONTENT';
export class UrlImportError extends Error {
constructor(
public readonly code: UrlImportErrorCode,
message: string,
public readonly status?: number,
) {
super(message);
this.name = 'UrlImportError';
}
}
/**
* Foundation service for importing recipe content from public URLs.
*/
export class UrlImportService {
private static readonly DEFAULT_TIMEOUT_MS = 10000;
private static readonly MAX_RETRIES = 2;
async fetchFromUrl(url: string): Promise<UrlImportFetchResult> {
const html = await this.fetchHtml(url);
const html = await this.fetchHtmlWithRetry(url);
const jsonLdBlocks = this.extractJsonLdBlocks(html);
return {
@ -21,7 +39,35 @@ export class UrlImportService {
};
}
private async fetchHtml(url: string): Promise<string> {
private async fetchHtmlWithRetry(url: string): Promise<string> {
let lastError: UrlImportError | null = null;
for (let attempt = 0; attempt <= UrlImportService.MAX_RETRIES; attempt += 1) {
try {
return await this.fetchHtmlOnce(url);
} catch (error) {
if (error instanceof UrlImportError) {
lastError = error;
// Retry only on transient failures
if (
(error.code === 'IMPORT_TIMEOUT' || error.code === 'IMPORT_NETWORK' || (error.status !== undefined && error.status >= 500)) &&
attempt < UrlImportService.MAX_RETRIES
) {
continue;
}
throw error;
}
throw error;
}
}
throw lastError ?? new UrlImportError('IMPORT_FETCH_FAILED', 'Unable to import this URL right now. Please try again.');
}
private async fetchHtmlOnce(url: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), UrlImportService.DEFAULT_TIMEOUT_MS);
@ -36,25 +82,42 @@ export class UrlImportService {
});
if (!response.ok) {
throw new Error(`Failed to fetch URL: HTTP ${response.status}`);
throw new UrlImportError(
'IMPORT_FETCH_FAILED',
`Could not fetch the page (HTTP ${response.status}).`,
response.status,
);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('text/html')) {
throw new Error('URL did not return an HTML document');
throw new UrlImportError(
'IMPORT_UNSUPPORTED_CONTENT',
'The URL did not return an HTML page. Please use a direct recipe page URL.',
);
}
return await response.text();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Import request timed out while fetching URL');
}
if (error instanceof Error) {
if (error instanceof UrlImportError) {
throw error;
}
throw new Error('Unknown error while fetching URL');
if (error instanceof Error && error.name === 'AbortError') {
throw new UrlImportError(
'IMPORT_TIMEOUT',
'Import timed out while contacting the recipe page. Please try again.',
);
}
if (error instanceof Error) {
throw new UrlImportError(
'IMPORT_NETWORK',
'Network error while fetching recipe URL. Please try again.',
);
}
throw new UrlImportError('IMPORT_FETCH_FAILED', 'Unknown error while fetching URL');
} finally {
clearTimeout(timeout);
}

View File

@ -5,8 +5,10 @@ import { createImportRoutes } from '../routes/import.js';
describe('Import API', () => {
let app: express.Application;
let infoSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
app = express();
app.use(express.json());
app.use('/api/import', createImportRoutes());
@ -58,6 +60,14 @@ describe('Import API', () => {
ingredients: ['Flour', 'Eggs'],
instructions: ['Mix', 'Cook']
});
expect(infoSpy).toHaveBeenCalledWith(
'[import.telemetry]',
expect.stringContaining('"event":"import_success"')
);
expect(infoSpy).toHaveBeenCalledWith(
'[import.telemetry]',
expect.stringContaining('"parser":"schema_org"')
);
});
it('should normalize whitespace and HowToStep instructions into draft format', async () => {
@ -91,6 +101,48 @@ describe('Import API', () => {
});
});
it('should use heuristic fallback parser when Schema.org data is missing', async () => {
const html = `
<html>
<head><title>Easy Banana Bread | Example</title></head>
<body>
<h1>Easy Banana Bread</h1>
<h2>Ingredients</h2>
<ul>
<li>3 ripe bananas</li>
<li>2 cups flour</li>
</ul>
<h2>Instructions</h2>
<ol>
<li>Mash bananas.</li>
<li>Bake at 350°F for 50 minutes.</li>
</ol>
</body>
</html>
`;
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
text: async () => html,
} as Response);
const response = await request(app)
.post('/api/import/url')
.send({ url: 'https://example.com/banana-bread' })
.expect(200);
expect(response.body.success).toBe(true);
expect(response.body.data.json_ld_blocks).toEqual([]);
expect(response.body.data.draft_recipe).toEqual({
title: 'Easy Banana Bread',
ingredients: ['3 ripe bananas', '2 cups flour'],
instructions: ['Mash bananas.', 'Bake at 350°F for 50 minutes.'],
source_url: 'https://example.com/banana-bread'
});
});
it('should return draft_recipe as null for non-recipe JSON-LD', async () => {
const html = `
<html>
@ -147,6 +199,59 @@ describe('Import API', () => {
expect(response.body.data.draft_recipe).toBeNull();
});
it('should retry transient fetch failures and eventually succeed', async () => {
const html = '<html><body><h1>Retry Recipe</h1><h2>Ingredients</h2><ul><li>1 egg</li></ul><h2>Instructions</h2><ol><li>Cook it.</li></ol></body></html>';
let callCount = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
callCount += 1;
if (callCount < 3) {
throw new Error('temporary network issue');
}
return {
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
text: async () => html,
} as Response;
});
const response = await request(app)
.post('/api/import/url')
.send({ url: 'https://example.com/retry-recipe' })
.expect(200);
expect(callCount).toBe(3);
expect(response.body.success).toBe(true);
expect(response.body.data.draft_recipe).toMatchObject({
title: 'Retry Recipe',
ingredients: ['1 egg'],
instructions: ['Cook it.'],
});
});
it('should return timeout-friendly message after retries are exhausted', async () => {
const timeoutError = new Error('aborted');
timeoutError.name = 'AbortError';
vi.spyOn(globalThis, 'fetch').mockRejectedValue(timeoutError);
const response = await request(app)
.post('/api/import/url')
.send({ url: 'https://example.com/slow-recipe' })
.expect(504);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain('timed out');
expect(infoSpy).toHaveBeenCalledWith(
'[import.telemetry]',
expect.stringContaining('"failureCode":"IMPORT_TIMEOUT"')
);
});
it('should return an error for non-HTML responses', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,