Compare commits
9 Commits
4c512a5161
...
272ce1d2f0
| Author | SHA1 | Date |
|---|---|---|
|
|
272ce1d2f0 | |
|
|
feb10fdb8b | |
|
|
97e55ab6c2 | |
|
|
9f49223df3 | |
|
|
1ca21889ca | |
|
|
d3eeeb2833 | |
|
|
3d07ff6a49 | |
|
|
15ada9cb52 | |
|
|
e1f5019006 |
|
|
@ -54,3 +54,6 @@ temp/
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Harness runtime state
|
||||||
|
.harness/*.json
|
||||||
|
|
|
||||||
14
TODO.md
14
TODO.md
|
|
@ -33,17 +33,17 @@ MVP is functionally complete (core app + docs + tests).
|
||||||
### Phase 2: Import UI
|
### Phase 2: Import UI
|
||||||
- [x] Add “Import from URL” UI page/form in frontend
|
- [x] Add “Import from URL” UI page/form in frontend
|
||||||
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
- [x] Show parsed preview (title, ingredients, steps, source URL)
|
||||||
- [ ] Allow edit-before-save flow, then save to existing create recipe API
|
- [x] Allow edit-before-save flow, then save to existing create recipe API
|
||||||
- [ ] Add frontend error states (invalid URL, parse failure, timeout)
|
- [x] Add frontend error states (invalid URL, parse failure, timeout)
|
||||||
|
|
||||||
### Phase 3: Fallback Parsing + Hardening
|
### Phase 3: Fallback Parsing + Hardening
|
||||||
- [ ] Add heuristic fallback parser when Schema.org missing
|
- [x] Add heuristic fallback parser when Schema.org missing
|
||||||
- [ ] Add timeout/retry + user-friendly import failure messages
|
- [x] Add timeout/retry + user-friendly import failure messages
|
||||||
- [ ] Add logging/telemetry for import success/failure reasons
|
- [x] Add logging/telemetry for import success/failure reasons
|
||||||
|
|
||||||
### Phase 4: Browser Extension (after URL import stable)
|
### Phase 4: Browser Extension (after URL import stable)
|
||||||
- [ ] Scaffold browser extension project (Manifest v3)
|
- [x] Scaffold browser extension project (Manifest v3)
|
||||||
- [ ] Add “Send to Recipe Manager” action to call import API
|
- [x] Add “Send to Recipe Manager” action to call import API
|
||||||
- [ ] Add extension settings for Recipe Manager base URL
|
- [ ] Add extension settings for Recipe Manager base URL
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
const openOptionsButton = document.getElementById('open-options');
|
||||||
|
|
||||||
|
openOptionsButton?.addEventListener('click', () => {
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,142 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { importRecipeFromUrl } from '../services/api';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { UrlImportResult } from '../types/recipe';
|
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() {
|
export function ImportUrlPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
|
||||||
const [result, setResult] = useState<UrlImportResult | 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) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setErrorType(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setDraft(null);
|
||||||
|
setDraftError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await importRecipeFromUrl(url);
|
const imported = await importRecipeFromUrl(url);
|
||||||
setResult(imported);
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
|
||||||
|
|
@ -59,9 +170,21 @@ export function ImportUrlPage() {
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div
|
||||||
<p className="text-red-800">
|
className={`mt-4 border rounded-lg p-4 ${
|
||||||
<strong>Error:</strong> {error}
|
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>
|
</p>
|
||||||
</div>
|
</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>
|
<p className="text-sm text-gray-600">JSON-LD blocks found: {result.json_ld_blocks.length}</p>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Ingredients</h5>
|
<label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<ul className="list-disc list-inside space-y-1 text-gray-800">
|
Ingredients (one per line)
|
||||||
{result.draft_recipe.ingredients.map((ingredient, index) => (
|
</label>
|
||||||
<li key={`${ingredient}-${index}`}>{ingredient}</li>
|
<textarea
|
||||||
))}
|
id="draft-ingredients"
|
||||||
</ul>
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-sm font-semibold uppercase tracking-wide text-gray-700 mb-2">Steps</h5>
|
<label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<ol className="list-decimal list-inside space-y-1 text-gray-800">
|
Steps (one per line)
|
||||||
{result.draft_recipe.instructions.map((instruction, index) => (
|
</label>
|
||||||
<li key={`${instruction}-${index}`}>{instruction}</li>
|
<textarea
|
||||||
))}
|
id="draft-instructions"
|
||||||
</ol>
|
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>
|
||||||
</>
|
|
||||||
|
<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">
|
<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.
|
Could not parse a recipe preview from this URL.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Link } from 'react-router-dom';
|
||||||
import { useRecipes } from '../hooks/useRecipes';
|
import { useRecipes } from '../hooks/useRecipes';
|
||||||
import { useTags } from '../hooks/useTags';
|
import { useTags } from '../hooks/useTags';
|
||||||
import { RecipeCard } from '../components/RecipeCard';
|
import { RecipeCard } from '../components/RecipeCard';
|
||||||
|
import { MissionControlPanel } from '../components/MissionControlPanel';
|
||||||
|
|
||||||
export function RecipeListPage() {
|
export function RecipeListPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -46,6 +47,8 @@ export function RecipeListPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<MissionControlPanel />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* API client for Recipe Manager backend
|
* 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
|
// Use relative URL - nginx will proxy to backend in production
|
||||||
// For local development (npm run dev), configure vite.config.ts proxy
|
// 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
|
* 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`, {
|
const response = await fetch(`${API_BASE_URL}/recipes`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -253,14 +253,35 @@ export async function importRecipeFromUrl(url: string): Promise<UrlImportResult>
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result: ApiResponse<UrlImportResult> = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
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) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.error || 'Failed to import URL');
|
throw new Error(result.error || 'Failed to import URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,21 @@ export interface Recipe {
|
||||||
last_cooked_at?: number;
|
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
|
* Tag data model
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,5 +58,42 @@ export interface UrlImportResult {
|
||||||
source_url: string;
|
source_url: string;
|
||||||
html: string;
|
html: string;
|
||||||
json_ld_blocks: 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;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getDatabase, saveDatabase } from './db/database.js';
|
||||||
import { createRecipeRoutes } from './routes/recipes.js';
|
import { createRecipeRoutes } from './routes/recipes.js';
|
||||||
import { createTagRoutes } from './routes/tags.js';
|
import { createTagRoutes } from './routes/tags.js';
|
||||||
import { createImportRoutes } from './routes/import.js';
|
import { createImportRoutes } from './routes/import.js';
|
||||||
|
import { createHarnessRoutes } from './routes/harness.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
@ -43,6 +44,7 @@ async function startServer() {
|
||||||
app.use('/api/recipes', createRecipeRoutes(db));
|
app.use('/api/recipes', createRecipeRoutes(db));
|
||||||
app.use('/api/tags', createTagRoutes(db));
|
app.use('/api/tags', createTagRoutes(db));
|
||||||
app.use('/api/import', createImportRoutes());
|
app.use('/api/import', createImportRoutes());
|
||||||
|
app.use('/api/harness', createHarnessRoutes(process.cwd()));
|
||||||
|
|
||||||
// Save database periodically (every 5 seconds)
|
// Save database periodically (every 5 seconds)
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|
@ -86,6 +88,8 @@ async function startServer() {
|
||||||
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
|
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
|
||||||
console.log(` Import:`);
|
console.log(` Import:`);
|
||||||
console.log(` POST /api/import/url - Import recipe foundation data from URL`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,54 @@
|
||||||
import { Router } from 'express';
|
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 { z } from 'zod';
|
||||||
import { UrlImportService } from '../services/UrlImportService.js';
|
import { UrlImportError, UrlImportService } from '../services/UrlImportService.js';
|
||||||
import { SchemaOrgRecipeParserService } from '../services/SchemaOrgRecipeParserService.js';
|
import { SchemaOrgRecipeParserService } from '../services/SchemaOrgRecipeParserService.js';
|
||||||
|
import { HeuristicRecipeParserService } from '../services/HeuristicRecipeParserService.js';
|
||||||
|
|
||||||
const importUrlSchema = z.object({
|
const importUrlSchema = z.object({
|
||||||
url: z.string().url('A valid URL is required'),
|
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 {
|
export function createImportRoutes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const urlImportService = new UrlImportService();
|
const urlImportService = new UrlImportService();
|
||||||
const schemaOrgParser = new SchemaOrgRecipeParserService();
|
const schemaOrgParser = new SchemaOrgRecipeParserService();
|
||||||
|
const heuristicParser = new HeuristicRecipeParserService();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/import/url
|
* POST /api/import/url
|
||||||
* Fetch an external recipe page and return imported, normalized Recipe (if found)
|
* Fetch an external recipe page and return imported, normalized Recipe (if found)
|
||||||
*/
|
*/
|
||||||
router.post('/url', async (req, res) => {
|
router.post('/url', async (req, res) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let requestUrl = 'unknown';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { url } = importUrlSchema.parse(req.body);
|
const { url } = importUrlSchema.parse(req.body);
|
||||||
|
requestUrl = url;
|
||||||
const result = await urlImportService.fetchFromUrl(url);
|
const result = await urlImportService.fetchFromUrl(url);
|
||||||
|
|
||||||
// Try to parse and normalize Recipe from JSON-LD blocks
|
// Try to parse and normalize Recipe from JSON-LD blocks
|
||||||
|
|
@ -28,6 +58,23 @@ export function createImportRoutes(): Router {
|
||||||
if (draft) break;
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { ...result, draft_recipe: draft },
|
data: { ...result, draft_recipe: draft },
|
||||||
|
|
@ -35,6 +82,14 @@ export function createImportRoutes(): Router {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
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({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
data: null,
|
data: null,
|
||||||
|
|
@ -43,9 +98,16 @@ export function createImportRoutes(): Router {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof UrlImportError) {
|
||||||
const status = error.message.includes('timed out') ? 504 : 400;
|
logImportTelemetry({
|
||||||
res.status(status).json({
|
event: 'import_failure',
|
||||||
|
url: requestUrl,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
failureCode: error.code,
|
||||||
|
failureReason: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(mapImportErrorToStatus(error)).json({
|
||||||
success: false,
|
success: false,
|
||||||
data: null,
|
data: null,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
|
@ -53,6 +115,31 @@ export function createImportRoutes(): Router {
|
||||||
return;
|
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({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
data: null,
|
data: null,
|
||||||
|
|
|
||||||
|
|
@ -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(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>');
|
||||||
|
|
||||||
|
return decoded.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private uniqueNonEmpty(values: string[]): string[] {
|
||||||
|
return [...new Set(values.map((v) => v.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,14 +4,32 @@ export interface UrlImportFetchResult {
|
||||||
json_ld_blocks: string[];
|
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.
|
* Foundation service for importing recipe content from public URLs.
|
||||||
*/
|
*/
|
||||||
export class UrlImportService {
|
export class UrlImportService {
|
||||||
private static readonly DEFAULT_TIMEOUT_MS = 10000;
|
private static readonly DEFAULT_TIMEOUT_MS = 10000;
|
||||||
|
private static readonly MAX_RETRIES = 2;
|
||||||
|
|
||||||
async fetchFromUrl(url: string): Promise<UrlImportFetchResult> {
|
async fetchFromUrl(url: string): Promise<UrlImportFetchResult> {
|
||||||
const html = await this.fetchHtml(url);
|
const html = await this.fetchHtmlWithRetry(url);
|
||||||
const jsonLdBlocks = this.extractJsonLdBlocks(html);
|
const jsonLdBlocks = this.extractJsonLdBlocks(html);
|
||||||
|
|
||||||
return {
|
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 controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), UrlImportService.DEFAULT_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), UrlImportService.DEFAULT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
|
@ -36,25 +82,42 @@ export class UrlImportService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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') ?? '';
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
if (!contentType.includes('text/html')) {
|
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();
|
return await response.text();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof UrlImportError) {
|
||||||
throw new Error('Import request timed out while fetching URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error;
|
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 {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import { createImportRoutes } from '../routes/import.js';
|
||||||
|
|
||||||
describe('Import API', () => {
|
describe('Import API', () => {
|
||||||
let app: express.Application;
|
let app: express.Application;
|
||||||
|
let infoSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||||
app = express();
|
app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/api/import', createImportRoutes());
|
app.use('/api/import', createImportRoutes());
|
||||||
|
|
@ -58,6 +60,14 @@ describe('Import API', () => {
|
||||||
ingredients: ['Flour', 'Eggs'],
|
ingredients: ['Flour', 'Eggs'],
|
||||||
instructions: ['Mix', 'Cook']
|
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 () => {
|
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 () => {
|
it('should return draft_recipe as null for non-recipe JSON-LD', async () => {
|
||||||
const html = `
|
const html = `
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -147,6 +199,59 @@ describe('Import API', () => {
|
||||||
expect(response.body.data.draft_recipe).toBeNull();
|
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 () => {
|
it('should return an error for non-HTML responses', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue