| 1 | /**
|
| 2 | * Cooking Notes Helper Script
|
| 3 | * Allows Claude to create, edit, and view cooking notes from PocketBase
|
| 4 | *
|
| 5 | * Usage:
|
| 6 | * npx ts-node scripts/cooking-helper.ts list
|
| 7 | * npx ts-node scripts/cooking-helper.ts view <slug>
|
| 8 | * npx ts-node scripts/cooking-helper.ts fetch-recipe <url>
|
| 9 | * npx ts-node scripts/cooking-helper.ts create <json>
|
| 10 | * npx ts-node scripts/cooking-helper.ts update <id> <json>
|
| 11 | */
|
| 12 |
|
| 13 | import PocketBase from "pocketbase";
|
| 14 | import * as fs from "fs";
|
| 15 | import * as path from "path";
|
| 16 |
|
| 17 | // Load env from .env file
|
| 18 | function loadEnv() {
|
| 19 | const envPath = path.join(process.cwd(), ".env");
|
| 20 | const envContent = fs.readFileSync(envPath, "utf-8");
|
| 21 | const env: Record<string, string> = {};
|
| 22 |
|
| 23 | envContent.split("\n").forEach((line) => {
|
| 24 | const trimmed = line.trim();
|
| 25 | if (trimmed && !trimmed.startsWith("#")) {
|
| 26 | const [key, ...valueParts] = trimmed.split("=");
|
| 27 | const value = valueParts.join("=").replace(/^"(.*)"$/, "$1");
|
| 28 | env[key] = value;
|
| 29 | }
|
| 30 | });
|
| 31 |
|
| 32 | return env;
|
| 33 | }
|
| 34 |
|
| 35 | const env = loadEnv();
|
| 36 |
|
| 37 | const pb = new PocketBase(env.POCKET_BASE_HOST);
|
| 38 |
|
| 39 | // Authenticate with PocketBase
|
| 40 | async function authenticate() {
|
| 41 | try {
|
| 42 | const userName = "personal_site_astro";
|
| 43 | const pw = env.POCKET_BASE_PASSWORD;
|
| 44 |
|
| 45 | await pb.collection("users").authWithPassword(userName, pw);
|
| 46 | } catch (e: any) {
|
| 47 | console.error("Auth failed:", e.message);
|
| 48 | throw new Error("Failed to authenticate with PocketBase");
|
| 49 | }
|
| 50 | }
|
| 51 |
|
| 52 | type CookingNote = {
|
| 53 | id: string;
|
| 54 | content: string;
|
| 55 | slug: string;
|
| 56 | title: string;
|
| 57 | description: string;
|
| 58 | updated: string;
|
| 59 | expand?: {
|
| 60 | tags: Array<{ id: string; tag: string }>;
|
| 61 | };
|
| 62 | };
|
| 63 |
|
| 64 | async function listNotes() {
|
| 65 | await authenticate();
|
| 66 | const notes = await pb
|
| 67 | .collection<
|
| 68 | Pick<CookingNote, "slug" | "title" | "updated">
|
| 69 | >("cooking_notes")
|
| 70 | .getList(1, 100, {
|
| 71 | sort: "-updated",
|
| 72 | fields: "slug,title,updated",
|
| 73 | });
|
| 74 |
|
| 75 | console.log(
|
| 76 | JSON.stringify(
|
| 77 | {
|
| 78 | success: true,
|
| 79 | count: notes.items.length,
|
| 80 | notes: notes.items,
|
| 81 | },
|
| 82 | null,
|
| 83 | 2,
|
| 84 | ),
|
| 85 | );
|
| 86 | }
|
| 87 |
|
| 88 | async function viewNote(slug: string) {
|
| 89 | await authenticate();
|
| 90 | try {
|
| 91 | const note = await pb
|
| 92 | .collection<CookingNote>("cooking_notes")
|
| 93 | .getFirstListItem(`slug = '${slug}'`, {
|
| 94 | expand: "tags",
|
| 95 | });
|
| 96 |
|
| 97 | console.log(
|
| 98 | JSON.stringify(
|
| 99 | {
|
| 100 | success: true,
|
| 101 | note,
|
| 102 | },
|
| 103 | null,
|
| 104 | 2,
|
| 105 | ),
|
| 106 | );
|
| 107 | } catch (e: any) {
|
| 108 | if (e.status === 404) {
|
| 109 | console.log(
|
| 110 | JSON.stringify(
|
| 111 | {
|
| 112 | success: false,
|
| 113 | error: "Note not found",
|
| 114 | },
|
| 115 | null,
|
| 116 | 2,
|
| 117 | ),
|
| 118 | );
|
| 119 | } else {
|
| 120 | throw e;
|
| 121 | }
|
| 122 | }
|
| 123 | }
|
| 124 |
|
| 125 | async function fetchRecipe(url: string) {
|
| 126 | try {
|
| 127 | const response = await fetch(url);
|
| 128 | const html = await response.text();
|
| 129 |
|
| 130 | // Extract recipe content using marked and some basic parsing
|
| 131 | const titleMatch = html.match(/<title>([^<]+)<\/title>/);
|
| 132 | const title = titleMatch ? titleMatch[1].trim() : "Recipe from " + url;
|
| 133 |
|
| 134 | // Extract main content (very basic - you might need to customize per recipe site)
|
| 135 | const contentMatch = html.match(
|
| 136 | /<(article|main|div[^>]*class="[^"]*recipe[^"]*"[^>]*)>([\s\S]*?)<\/(article|main|div)>/i,
|
| 137 | );
|
| 138 | let content = contentMatch ? contentMatch[2] : html;
|
| 139 |
|
| 140 | // Basic HTML to text conversion
|
| 141 | content = content
|
| 142 | .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
| 143 | .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
| 144 | .replace(/<[^>]+>/g, "\n")
|
| 145 | .replace(/\n\n+/g, "\n")
|
| 146 | .trim();
|
| 147 |
|
| 148 | console.log(
|
| 149 | JSON.stringify(
|
| 150 | {
|
| 151 | success: true,
|
| 152 | title,
|
| 153 | content: content.substring(0, 2000), // First 2000 chars
|
| 154 | sourceUrl: url,
|
| 155 | instructions: [
|
| 156 | "Use the above content as a reference to write a cooking note",
|
| 157 | "Match the style of your other cooking notes",
|
| 158 | "Format: include ingredients, steps, timing, difficulty",
|
| 159 | "Use tags for categories (e.g., 'vegetarian', 'quick', 'pasta')",
|
| 160 | ],
|
| 161 | },
|
| 162 | null,
|
| 163 | 2,
|
| 164 | ),
|
| 165 | );
|
| 166 | } catch (e: any) {
|
| 167 | console.log(
|
| 168 | JSON.stringify(
|
| 169 | {
|
| 170 | success: false,
|
| 171 | error: "Failed to fetch recipe: " + e.message,
|
| 172 | },
|
| 173 | null,
|
| 174 | 2,
|
| 175 | ),
|
| 176 | );
|
| 177 | }
|
| 178 | }
|
| 179 |
|
| 180 | async function createNote(dataStr: string) {
|
| 181 | await authenticate();
|
| 182 |
|
| 183 | try {
|
| 184 | const data = JSON.parse(dataStr);
|
| 185 |
|
| 186 | // Validate required fields
|
| 187 | if (!data.title || !data.slug || !data.content) {
|
| 188 | throw new Error("Missing required fields: title, slug, content");
|
| 189 | }
|
| 190 |
|
| 191 | // Get or create tags
|
| 192 | const tagIds: string[] = [];
|
| 193 | if (data.tags && Array.isArray(data.tags)) {
|
| 194 | for (const tag of data.tags) {
|
| 195 | try {
|
| 196 | const existingTag = await pb
|
| 197 | .collection("cooking_tags")
|
| 198 | .getFirstListItem(`tag = '${tag}'`);
|
| 199 | tagIds.push(existingTag.id);
|
| 200 | } catch (e: any) {
|
| 201 | if (e.status === 404) {
|
| 202 | const newTag = await pb.collection("cooking_tags").create({ tag });
|
| 203 | tagIds.push(newTag.id);
|
| 204 | } else {
|
| 205 | throw e;
|
| 206 | }
|
| 207 | }
|
| 208 | }
|
| 209 | }
|
| 210 |
|
| 211 | const note = await pb.collection<CookingNote>("cooking_notes").create({
|
| 212 | title: data.title,
|
| 213 | slug: data.slug,
|
| 214 | content: data.content,
|
| 215 | description: data.description || "",
|
| 216 | tags: tagIds,
|
| 217 | });
|
| 218 |
|
| 219 | console.log(
|
| 220 | JSON.stringify(
|
| 221 | {
|
| 222 | success: true,
|
| 223 | message: "Note created successfully",
|
| 224 | note,
|
| 225 | },
|
| 226 | null,
|
| 227 | 2,
|
| 228 | ),
|
| 229 | );
|
| 230 | } catch (e: any) {
|
| 231 | console.log(
|
| 232 | JSON.stringify(
|
| 233 | {
|
| 234 | success: false,
|
| 235 | error: e.message,
|
| 236 | },
|
| 237 | null,
|
| 238 | 2,
|
| 239 | ),
|
| 240 | );
|
| 241 | }
|
| 242 | }
|
| 243 |
|
| 244 | async function updateNote(id: string, dataStr: string) {
|
| 245 | await authenticate();
|
| 246 |
|
| 247 | try {
|
| 248 | const data = JSON.parse(dataStr);
|
| 249 |
|
| 250 | // Get or create tags
|
| 251 | let tagIds: string[] = [];
|
| 252 | if (data.tags && Array.isArray(data.tags)) {
|
| 253 | for (const tag of data.tags) {
|
| 254 | try {
|
| 255 | const existingTag = await pb
|
| 256 | .collection("cooking_tags")
|
| 257 | .getFirstListItem(`tag = '${tag}'`);
|
| 258 | tagIds.push(existingTag.id);
|
| 259 | } catch (e: any) {
|
| 260 | if (e.status === 404) {
|
| 261 | const newTag = await pb.collection("cooking_tags").create({ tag });
|
| 262 | tagIds.push(newTag.id);
|
| 263 | } else {
|
| 264 | throw e;
|
| 265 | }
|
| 266 | }
|
| 267 | }
|
| 268 | }
|
| 269 |
|
| 270 | const updateData: Record<string, any> = {};
|
| 271 | if (data.title) updateData.title = data.title;
|
| 272 | if (data.slug) updateData.slug = data.slug;
|
| 273 | if (data.content) updateData.content = data.content;
|
| 274 | if (data.description) updateData.description = data.description;
|
| 275 | if (tagIds.length > 0) updateData.tags = tagIds;
|
| 276 |
|
| 277 | const note = await pb
|
| 278 | .collection<CookingNote>("cooking_notes")
|
| 279 | .update(id, updateData);
|
| 280 |
|
| 281 | console.log(
|
| 282 | JSON.stringify(
|
| 283 | {
|
| 284 | success: true,
|
| 285 | message: "Note updated successfully",
|
| 286 | note,
|
| 287 | },
|
| 288 | null,
|
| 289 | 2,
|
| 290 | ),
|
| 291 | );
|
| 292 | } catch (e: any) {
|
| 293 | console.log(
|
| 294 | JSON.stringify(
|
| 295 | {
|
| 296 | success: false,
|
| 297 | error: e.message,
|
| 298 | },
|
| 299 | null,
|
| 300 | 2,
|
| 301 | ),
|
| 302 | );
|
| 303 | }
|
| 304 | }
|
| 305 |
|
| 306 | async function deleteNote(id: string) {
|
| 307 | await authenticate();
|
| 308 |
|
| 309 | try {
|
| 310 | await pb.collection("cooking_notes").delete(id);
|
| 311 |
|
| 312 | console.log(
|
| 313 | JSON.stringify(
|
| 314 | {
|
| 315 | success: true,
|
| 316 | message: "Note deleted successfully",
|
| 317 | id,
|
| 318 | },
|
| 319 | null,
|
| 320 | 2,
|
| 321 | ),
|
| 322 | );
|
| 323 | } catch (e: any) {
|
| 324 | console.log(
|
| 325 | JSON.stringify(
|
| 326 | {
|
| 327 | success: false,
|
| 328 | error: e.message,
|
| 329 | },
|
| 330 | null,
|
| 331 | 2,
|
| 332 | ),
|
| 333 | );
|
| 334 | }
|
| 335 | }
|
| 336 |
|
| 337 | // Main
|
| 338 | const command = process.argv[2];
|
| 339 | const arg1 = process.argv[3];
|
| 340 | const arg2 = process.argv[4];
|
| 341 |
|
| 342 | (async () => {
|
| 343 | try {
|
| 344 | switch (command) {
|
| 345 | case "list":
|
| 346 | await listNotes();
|
| 347 | break;
|
| 348 | case "view":
|
| 349 | if (!arg1) throw new Error("Missing slug argument");
|
| 350 | await viewNote(arg1);
|
| 351 | break;
|
| 352 | case "fetch-recipe":
|
| 353 | if (!arg1) throw new Error("Missing URL argument");
|
| 354 | await fetchRecipe(arg1);
|
| 355 | break;
|
| 356 | case "create":
|
| 357 | if (!arg1) throw new Error("Missing JSON argument");
|
| 358 | await createNote(arg1);
|
| 359 | break;
|
| 360 | case "update":
|
| 361 | if (!arg1 || !arg2) throw new Error("Missing id or JSON argument");
|
| 362 | await updateNote(arg1, arg2);
|
| 363 | break;
|
| 364 | case "delete":
|
| 365 | if (!arg1) throw new Error("Missing id argument");
|
| 366 | await deleteNote(arg1);
|
| 367 | break;
|
| 368 | default:
|
| 369 | console.error(`Unknown command: ${command}`);
|
| 370 | console.error(`Available commands:
|
| 371 | list - List all cooking notes
|
| 372 | view <slug> - View a specific note
|
| 373 | fetch-recipe <url> - Fetch and parse recipe from URL
|
| 374 | create <json> - Create new note from JSON
|
| 375 | update <id> <json> - Update existing note
|
| 376 | delete <id> - Delete a note
|
| 377 |
|
| 378 | Example create JSON:
|
| 379 | {
|
| 380 | "title": "Pasta Carbonara",
|
| 381 | "slug": "pasta-carbonara",
|
| 382 | "description": "Classic Italian pasta",
|
| 383 | "content": "...",
|
| 384 | "tags": ["pasta", "italian", "quick"]
|
| 385 | }`);
|
| 386 | process.exit(1);
|
| 387 | }
|
| 388 | } catch (e: any) {
|
| 389 | console.log(
|
| 390 | JSON.stringify(
|
| 391 | {
|
| 392 | success: false,
|
| 393 | error: e.message,
|
| 394 | },
|
| 395 | null,
|
| 396 | 2,
|
| 397 | ),
|
| 398 | );
|
| 399 | process.exit(1);
|
| 400 | }
|
| 401 | })();
|
| 402 |
|