@sebbo2002/to-paprika 1.0.0-develop.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +44 -0
- package/dist/bin/cli.cjs +6 -0
- package/dist/bin/cli.cjs.map +1 -0
- package/dist/bin/cli.d.cts +1 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +3 -0
- package/dist/bin/cli.js.map +1 -0
- package/dist/bin/start.cjs +6 -0
- package/dist/bin/start.cjs.map +1 -0
- package/dist/bin/start.d.cts +1 -0
- package/dist/bin/start.d.ts +1 -0
- package/dist/bin/start.js +3 -0
- package/dist/bin/start.js.map +1 -0
- package/dist/chunk-KARYAMZ2.js +5 -0
- package/dist/chunk-KARYAMZ2.js.map +1 -0
- package/dist/lib/index.cjs +5 -0
- package/dist/lib/index.cjs.map +1 -0
- package/dist/lib/index.d.cts +78 -0
- package/dist/lib/index.d.ts +78 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sebastian Pekarek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 🫑 to-paprika
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
Simple script that feeds a photo or PDF of a recipe into an OpenAPI-compatible API and generates a
|
|
6
|
+
[Paprika Recipes](https://www.paprikaapp.com/)-compatible export. The CLI supports multiple files at
|
|
7
|
+
once. PDFs are automatically split into individual pages, and each page is processed separately
|
|
8
|
+
(corresponding to one recipe per page). HEIC images are also supported. There is also a small web
|
|
9
|
+
server that provides a simple HTTP API that does the same thing.
|
|
10
|
+
|
|
11
|
+
## ⚡️ Quick Start
|
|
12
|
+
|
|
13
|
+
```shell
|
|
14
|
+
# Install the CLI globally
|
|
15
|
+
npm i -g @sebbo2002/to-paprika
|
|
16
|
+
|
|
17
|
+
# Run Setup Wizard
|
|
18
|
+
to-paprika setup
|
|
19
|
+
|
|
20
|
+
# Convert file to Paprika Recipe format
|
|
21
|
+
to-paprika convert ./path/to/your/file.jpg
|
|
22
|
+
|
|
23
|
+
# Run simple HTTP server
|
|
24
|
+
to-paprika server
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
There's also a container image available on [Docker Hub](https://hub.docker.com/r/sebbo2002/to-paprika):
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
docker run --rm \
|
|
31
|
+
-v $(pwd):/data \
|
|
32
|
+
-e TO_PAPRIKA_CONFIG_PATH=/data/to-paprika-config.json \
|
|
33
|
+
sebbo2002/to-paprika \
|
|
34
|
+
cli convert /data/path/to/your/file.jpg
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 📱 Apple Shortcut
|
|
38
|
+
|
|
39
|
+
You can use this [Apple Shortcut](https://www.icloud.com/shortcuts/c1fd034864b34f60a75821811bc1696c)
|
|
40
|
+
to easily use the HTTP API from your iPhone or iPad. Ensure to change the URL and add your Auth Token.
|
|
41
|
+
|
|
42
|
+
## 🙆🏼♂️ Copyright and license
|
|
43
|
+
|
|
44
|
+
Copyright (c) Sebastian Pekarek under the [MIT license](LICENSE).
|
package/dist/bin/cli.cjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";var _=Object.create;var $=Object.defineProperty;var J=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var G=Object.getPrototypeOf,q=Object.prototype.hasOwnProperty;var g=(o,e)=>()=>(o&&(e=o(o=0)),e);var D=(o,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of H(e))!q.call(o,i)&&i!==t&&$(o,i,{get:()=>e[i],enumerable:!(r=J(e,i))||r.enumerable});return o};var c=(o,e,t)=>(t=o!=null?_(G(o)):{},D(e||!o||!o.__esModule?$(t,"default",{value:o,enumerable:!0}):t,o));var k,y,h,u,w,a,d=g(()=>{"use strict";k=require("crypto"),y=require("fs"),h=require("fs/promises"),u=require("path"),w=c(require("prompts"),1),a=class o{constructor(e){this.config=e}static get path(){return process.env.TO_PAPRIKA_CONFIG_PATH?(0,u.resolve)(process.env.TO_PAPRIKA_CONFIG_PATH):(0,u.join)(this.homePath,".to-paprika.config.json")}static get homePath(){if(process.platform==="win32"&&process.env.USERPROFILE)return process.env.USERPROFILE;if(process.platform!=="win32"&&process.env.HOME)return process.env.HOME;throw new Error("Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.")}get llm(){return{...this.config.llm,prompt:process.env.TO_PAPRIKA_CONFIG_PROMPT||"You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate."}}get output(){return this.config.output}get server(){return this.config.server}static async getJson(){if(!(0,y.existsSync)(this.path))throw new Error(`Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`);let e;try{e=await(0,h.readFile)(this.path,"utf8")}catch(r){throw new Error(`Failed to read config file at ${this.path}: ${r.message}`)}let t;try{t=JSON.parse(e)}catch(r){throw new Error(`Failed to parse config file at ${this.path}: ${r.message}`)}return t}static async setup(){console.log(`
|
|
3
|
+
\u{1F44B}\u{1F3FC} Hi there`),console.log("");let e;try{e=await this.getJson()}catch{}console.log("\u{1F325}\uFE0F to-paprica requires an OpenAI-compatible LLM provider to function."),console.log(" Please provide the following provider details:"),console.log("");let t=await(0,w.default)([{initial:e?.llm.baseUrl,message:" LLM Provider Base URL:",name:"baseUrl",type:"text",validate:n=>{try{return new URL(n),!0}catch{return"Invalid Base URL"}}},{initial:e?.llm.apiKey,message:" LLM Provider API Key:",name:"apiKey",type:"password",validate:n=>n.length>0?!0:"API Key cannot be empty"},{initial:e?.llm.model,message:" LLM Model to use:",name:"model",type:"text",validate:n=>n.length>0?!0:"Model cannot be empty"}]);console.log(""),console.log("\u{1F4C2} Next, please specify the output directory for your Paprika recipes."),console.log(" This is where all converted recipes will be saved."),console.log("");let r=await(0,w.default)({initial:e?.output||(0,u.resolve)(process.cwd()),message:" Output Directory:",name:"output",type:"text",validate:n=>(0,y.existsSync)(n)}),i={llm:t,output:r.output,server:{authToken:e?.server?.authToken}};i.server.authToken||(i.server.authToken=(0,k.randomBytes)(32).toString("hex")),console.log(""),console.log("\u{1F512} Great. Just in case you would like to use the build-in server,"),console.log(` please use this API Token to authenticate your requests:
|
|
4
|
+
`),console.log(` ${i.server.authToken}`),console.log("");try{await(0,h.writeFile)(this.path,JSON.stringify(i,null,2))}catch(n){throw console.log("\u{1F6A8} Failed to save config file"),console.log(""),new Error(`Failed to write config file at ${this.path}: ${n.message}`)}console.log(""),console.log("\u{1F389} Config file saved successfully"),console.log("")}static async use(){let e=await this.getJson();return new o(e)}}});async function B(o){let e=(0,I.default)("zip",{zlib:{level:9}}),t=new x.PassThrough,r=[];t.on("data",i=>r.push(i)),t.on("end",()=>{}),e.pipe(t);for(let i of o){let n=Buffer.from(JSON.stringify({...i,servings:W(i.servings)}));e.append(n,{name:`${i.name}.paprikarecipe`})}return new Promise((i,n)=>{e.on("error",n),e.on("end",()=>{i(Buffer.concat(r))}),e.finalize()})}function W(o){if(!o)return null;if(/^[0-9]+ ?[mlg]{1,2}$/i.test(o.trim()))return o;let e=parseInt(o,10);return isNaN(e)?o:e.toString()}var I,x,s,b,E=g(()=>{"use strict";I=c(require("archiver"),1),x=require("stream"),s=c(require("zod"),1),b=s.object({cook_time:s.string().optional().nullable().describe("Cooking time required, e.g. '30 min' or '1 h'. Keep it short."),description:s.string().optional().nullable().describe("A brief description of the recipe. Use newline characters for formatting."),directions:s.string().describe("Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list."),ingredients:s.string('Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g "1 cup milk" or "3 apples". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.'),name:s.string(),notes:s.string().describe("An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields."),prep_time:s.string().optional().nullable().describe('Preparation time required before cooking, e.g. "15 min" or "30 min". Keep it short.'),servings:s.string().optional().nullable().describe('Number of servings, e.g. "2" or "4-6 servings".'),source:s.string().optional().nullable().describe("Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL."),total_time:s.string().optional().nullable().describe('Total time required to prepare the dish, e.g. "45 min" or "1.5 h". Keep it short.')})});var L,v,F,m,z,S,A,Y,l,C=g(()=>{"use strict";L=c(require("heic-convert"),1),v=require("fs"),F=require("fs/promises"),m=require("path"),z=c(require("openai"),1),S=require("openai/helpers/zod"),A=require("pdf-to-png-converter");d();E();Y=new Map([[".heic","image/heic"],[".jpeg","image/jpeg"],[".jpg","image/jpeg"],[".pdf","application/pdf"],[".png","image/png"],[".webp","image/webp"]]),l=class o{client;config;options;constructor(e,t={}){this.config=e,this.options=t,this.client=new z.default({apiKey:e.llm.apiKey,baseURL:e.llm.baseUrl})}static async convert(e,t,r){let i=await a.use(),n=[];if(typeof e=="string"&&(typeof t=="object"||t===void 0))n=await new o(i,t).convertFiles([e]);else if(Array.isArray(e)&&(typeof t=="object"||t===void 0))n=await new o(i,t).convertFiles(e);else if(e instanceof Buffer&&typeof t=="string")n=await new o(i,r).convertBuffer(`Buffer (${e.byteLength} bytes)`,e,t);else throw new Error("Invalid arguments for convert function.");return await B(n)}static async generateOutputFilePath(e){let t=`${Date.now().toString()}`;e.length===1&&(t=(0,m.basename)(e[0]).replace(/\.[a-z]{3,4}$/g,""));let r=await a.use(),i=(0,m.join)(r.output,t+".paprikarecipes");if((0,v.existsSync)(i)){let n=1;do i=(0,m.join)(r.output,`${t}-${n}.paprikarecipes`),n++;while((0,v.existsSync)(i))}return i}async convertBuffer(e,t,r){if(r==="image/heic"){this.log(e,"\u{1F500}");let f=Buffer.from(await(0,L.default)({buffer:t,format:"JPEG",quality:1}));return this.convertBuffer(e,f,"image/jpeg")}if(r==="application/pdf"){this.log(e,"\u{1F500}");let f=await(0,A.pdfToPng)(t);this.log(e,!0);let T=[];for(let O of f)T.push(...await this.convertBuffer(`${e} (p. ${O.pageNumber})`,O.content,"image/png"));return T}this.log(e,"\u{1FA84}");let n=(await this.client.chat.completions.create({messages:[{content:[{text:this.config.llm.prompt,type:"text"}],role:"developer"},{content:[{image_url:{url:`data:${r};base64,${t.toString("base64")}`},type:"image_url"}],role:"user"}],model:this.config.llm.model,response_format:(0,S.zodResponseFormat)(b,"recipe")})).choices[0];if(!n.message||!n.message.content)throw new Error(`Invalid response from LLM for file: ${e} (mime = ${r}, length = ${t.length})`);let p;try{p=JSON.parse(n.message.content)}catch(f){throw new Error(`Failed to parse LLM response for file: ${e} (mime = ${r}, length = ${t.length}): ${f.message}`)}let M=b.parse(p);return this.log(e,!0),[M]}async convertFile(e){let t=e.slice(e.lastIndexOf(".")).toLowerCase(),r=Y.get(t);if(r)return this.convertBuffer(e,await(0,F.readFile)(e),r);throw new Error(`Unsupported file format for file: ${e}`)}async convertFiles(e=[]){if(!e.length)throw new Error("No input files specified for conversion.");for(let r of e)if(!(0,v.existsSync)(r))throw new Error(`Unable to find file: ${r}`);let t=[];for(let r of e)t.push(...await this.convertFile(r));return t}log(e,t){let r=t===!0?"\u2705":t;this.options.stdout?.write(`\r${r} ${e}${t===!0?`
|
|
5
|
+
`:""}`)}}});var Q={};var P,R,N=g(()=>{"use strict";P=c(require("express"),1);d();C();R=class o{app;server;constructor(){this.app=(0,P.default)(),this.app.use(P.default.raw({limit:"50mb",type:["image/jpeg","image/png","image/webp","image/heic"]})),this.setupRoutes(),this.server=this.app.listen(process.env.PORT||8080),process.on("SIGINT",()=>this.stop()),process.on("SIGTERM",()=>this.stop())}static run(){new o}setupRoutes(){this.app.get("/ping",(t,r)=>{r.send("pong")});let e=null;a.use().then(t=>{e=t.server.authToken||null}),this.app.post("/convert",(t,r)=>{let i=t.headers.authorization||"";if(e&&i!==`Bearer ${e}`){r.sendStatus(401);return}let n=t.headers["content-type"]||"";l.convert(Buffer.from(t.body),n).then(p=>{r.setHeader("Content-Type","application/octet-stream"),r.setHeader("Content-Length",p.byteLength),r.setHeader("Content-Disposition",'attachment; filename="recipes.paprikarecipes"'),r.send(p)}).catch(p=>{console.error(p),r.sendStatus(500)})})}async stop(){await new Promise(e=>this.server.close(e)),process.exit()}};R.run()});var U=require("fs/promises"),j=c(require("yargs"),1),K=require("yargs/helpers");d();C();(0,j.default)((0,K.hideBin)(process.argv)).usage("$0 <cmd> [args]").command("setup","Create or update the configuration file",()=>{},()=>a.setup()).command("convert [files..]","Convert supported files to a .paprikarecipes file",o=>{o.positional("files",{array:!0,demandOption:!0,describe:"Input files to convert",type:"string"})},async o=>{let e=o.files,t=await l.convert(e,{stdout:process.stdout}),r=await l.generateOutputFilePath(e);await(0,U.writeFile)(r,t)}).command("server","Launch the server",()=>{},()=>{Promise.resolve().then(()=>(N(),Q)).catch(o=>{console.error("Failed to start the server:",o),process.exit(1)})}).demandCommand(1,1).strict().help("h").parse();
|
|
6
|
+
//# sourceMappingURL=cli.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/config.ts","../../src/lib/recipe.ts","../../src/lib/convert.ts","../../src/bin/start.ts","../../src/bin/cli.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport { existsSync } from 'node:fs';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\nimport prompts from 'prompts';\n\nexport interface ConfigContent {\n llm: {\n apiKey: string;\n baseUrl: string;\n model: string;\n };\n output: string;\n server: {\n authToken?: string;\n };\n}\n\nexport class Config {\n static get path() {\n if (process.env.TO_PAPRIKA_CONFIG_PATH) {\n return resolve(process.env.TO_PAPRIKA_CONFIG_PATH);\n }\n\n return join(this.homePath, '.to-paprika.config.json');\n }\n protected static get homePath() {\n if (process.platform === 'win32' && process.env.USERPROFILE) {\n return process.env.USERPROFILE;\n }\n if (process.platform !== 'win32' && process.env.HOME) {\n return process.env.HOME;\n }\n\n throw new Error(\n 'Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.',\n );\n }\n\n get llm(): ConfigContent['llm'] & { prompt: string } {\n return {\n ...this.config.llm,\n prompt:\n process.env.TO_PAPRIKA_CONFIG_PROMPT ||\n 'You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate.',\n };\n }\n get output() {\n return this.config.output;\n }\n get server(): ConfigContent['server'] {\n return this.config.server;\n }\n\n constructor(private readonly config: ConfigContent) {}\n\n static async getJson() {\n if (!existsSync(this.path)) {\n throw new Error(\n `Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`,\n );\n }\n\n let content: string | undefined;\n try {\n content = await readFile(this.path, 'utf8');\n } catch (err) {\n throw new Error(\n `Failed to read config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n let json: ConfigContent;\n try {\n json = JSON.parse(content) as ConfigContent;\n } catch (err) {\n throw new Error(\n `Failed to parse config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n return json;\n }\n\n static async setup() {\n console.log('\\n👋🏼 Hi there');\n console.log('');\n\n let json: ConfigContent | undefined = undefined;\n try {\n json = await this.getJson();\n } catch {\n // ignore errors\n }\n\n console.log(\n '🌥️ to-paprica requires an OpenAI-compatible LLM provider to function.',\n );\n console.log(' Please provide the following provider details:');\n console.log('');\n\n const llm = await prompts([\n {\n initial: json?.llm.baseUrl,\n message: ' LLM Provider Base URL:',\n name: 'baseUrl',\n type: 'text',\n validate: (value) => {\n try {\n new URL(value);\n return true;\n } catch {\n return 'Invalid Base URL';\n }\n },\n },\n {\n initial: json?.llm.apiKey,\n message: ' LLM Provider API Key:',\n name: 'apiKey',\n type: 'password',\n validate: (value) =>\n value.length > 0 ? true : 'API Key cannot be empty',\n },\n {\n initial: json?.llm.model,\n message: ' LLM Model to use:',\n name: 'model',\n type: 'text',\n validate: (value) =>\n value.length > 0 ? true : 'Model cannot be empty',\n },\n ]);\n\n console.log('');\n console.log(\n '📂 Next, please specify the output directory for your Paprika recipes.',\n );\n console.log(' This is where all converted recipes will be saved.');\n console.log('');\n\n const output = await prompts({\n initial: json?.output || resolve(process.cwd()),\n message: ' Output Directory:',\n name: 'output',\n type: 'text',\n validate: (value) => existsSync(value),\n });\n\n const config: ConfigContent = {\n llm,\n output: output.output,\n server: {\n authToken: json?.server?.authToken,\n },\n };\n if (!config.server.authToken) {\n config.server.authToken = randomBytes(32).toString('hex');\n }\n\n console.log('');\n console.log(\n '🔒 Great. Just in case you would like to use the build-in server,',\n );\n console.log(\n ' please use this API Token to authenticate your requests:\\n',\n );\n console.log(` ${config.server.authToken}`);\n console.log('');\n\n try {\n await writeFile(this.path, JSON.stringify(config, null, 2));\n } catch (error) {\n console.log('🚨 Failed to save config file');\n console.log('');\n throw new Error(\n `Failed to write config file at ${this.path}: ${(error as Error).message}`,\n );\n }\n\n console.log('');\n console.log('🎉 Config file saved successfully');\n console.log('');\n }\n\n static async use() {\n const json = await this.getJson();\n return new Config(json);\n }\n}\n","import archiver from 'archiver';\nimport { PassThrough } from 'node:stream';\nimport * as z from 'zod';\n\nexport const Recipe = z.object({\n cook_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n \"Cooking time required, e.g. '30 min' or '1 h'. Keep it short.\",\n ),\n description: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'A brief description of the recipe. Use newline characters for formatting.',\n ),\n directions: z\n .string()\n .describe(\n 'Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list.',\n ),\n ingredients: z.string(\n 'Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g \"1 cup milk\" or \"3 apples\". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.',\n ),\n name: z.string(),\n notes: z\n .string()\n .describe(\n 'An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields.',\n ),\n prep_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Preparation time required before cooking, e.g. \"15 min\" or \"30 min\". Keep it short.',\n ),\n servings: z\n .string()\n .optional()\n .nullable()\n .describe('Number of servings, e.g. \"2\" or \"4-6 servings\".'),\n source: z\n .string()\n .optional()\n .nullable()\n .describe(\"Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL.\"),\n total_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Total time required to prepare the dish, e.g. \"45 min\" or \"1.5 h\". Keep it short.',\n ),\n});\n\nexport type RecipeType = z.infer<typeof Recipe>;\n\n/**\n * Convert an array of recipes to a .paprikarecipes buffer\n */\nexport async function toRecipes(recipes: RecipeType[]): Promise<Buffer> {\n const zip = archiver('zip', { zlib: { level: 9 } });\n const zipStream = new PassThrough();\n const chunks: Buffer[] = [];\n\n zipStream.on('data', (chunk) => chunks.push(chunk));\n zipStream.on('end', () => {});\n zip.pipe(zipStream);\n\n for (const recipe of recipes) {\n const data = Buffer.from(\n JSON.stringify({\n ...recipe,\n servings: toServings(recipe.servings),\n }),\n );\n zip.append(data, { name: `${recipe.name}.paprikarecipe` });\n }\n\n return new Promise((resolve, reject) => {\n zip.on('error', reject);\n zip.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n zip.finalize();\n });\n}\nfunction toServings(servings: null | string | undefined): null | string {\n if (!servings) {\n return null;\n }\n if (/^[0-9]+ ?[mlg]{1,2}$/i.test(servings.trim())) {\n return servings;\n }\n\n const number = parseInt(servings, 10);\n if (!isNaN(number)) {\n return number.toString();\n }\n\n return servings;\n}\n","import type { Writable } from 'node:stream';\n\nimport convert from 'heic-convert';\nimport { existsSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport { basename, join } from 'node:path';\nimport OpenAI from 'openai';\nimport { zodResponseFormat } from 'openai/helpers/zod';\nimport { pdfToPng } from 'pdf-to-png-converter';\n\nimport { Config } from './config.js';\nimport { Recipe, type RecipeType, toRecipes } from './recipe.js';\n\nexport interface ConverterOptions {\n stdout?: Writable;\n}\n\nexport const mimeTypes = new Map([\n ['.heic', 'image/heic'],\n ['.jpeg', 'image/jpeg'],\n ['.jpg', 'image/jpeg'],\n ['.pdf', 'application/pdf'],\n ['.png', 'image/png'],\n ['.webp', 'image/webp'],\n]);\n\nexport class Converter {\n protected client: OpenAI;\n protected config: Config;\n protected options: ConverterOptions;\n\n protected constructor(config: Config, options: ConverterOptions = {}) {\n this.config = config;\n this.options = options;\n this.client = new OpenAI({\n apiKey: config.llm.apiKey,\n baseURL: config.llm.baseUrl,\n });\n }\n\n static async convert(\n data: Buffer,\n mimeType: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n file: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n files: string[],\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n arg1: Buffer | string | string[],\n arg2?: ConverterOptions | string,\n arg3?: ConverterOptions,\n ): Promise<Buffer> {\n const config = await Config.use();\n\n let recipes: RecipeType[] = [];\n if (\n typeof arg1 === 'string' &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles([arg1]);\n } else if (\n Array.isArray(arg1) &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles(arg1);\n } else if (arg1 instanceof Buffer && typeof arg2 === 'string') {\n recipes = await new Converter(config, arg3).convertBuffer(\n `Buffer (${arg1.byteLength} bytes)`,\n arg1,\n arg2,\n );\n } else {\n throw new Error('Invalid arguments for convert function.');\n }\n\n return await toRecipes(recipes);\n }\n\n static async generateOutputFilePath(inputFiles: string[]): Promise<string> {\n let fileName = `${Date.now().toString()}`;\n if (inputFiles.length === 1) {\n fileName = basename(inputFiles[0]).replace(/\\.[a-z]{3,4}$/g, '');\n }\n\n const config = await Config.use();\n let path = join(config.output, fileName + '.paprikarecipes');\n if (existsSync(path)) {\n let counter = 1;\n do {\n path = join(\n config.output,\n `${fileName}-${counter}.paprikarecipes`,\n );\n counter++;\n } while (existsSync(path));\n }\n\n return path;\n }\n\n protected async convertBuffer(\n title: string,\n data: Buffer,\n mimeType: string,\n ): Promise<RecipeType[]> {\n // heic handling\n if (mimeType === 'image/heic') {\n this.log(title, '🔀');\n const jpeg = Buffer.from(\n await convert({\n buffer: data,\n format: 'JPEG',\n quality: 1,\n }),\n );\n return this.convertBuffer(title, jpeg, 'image/jpeg');\n }\n\n // pdf handling\n if (mimeType === 'application/pdf') {\n this.log(title, '🔀');\n const pages = await pdfToPng(data);\n this.log(title, true);\n\n const result: RecipeType[] = [];\n for (const page of pages) {\n result.push(\n ...(await this.convertBuffer(\n `${title} (p. ${page.pageNumber})`,\n page.content,\n 'image/png',\n )),\n );\n }\n\n return result;\n }\n\n this.log(title, '🪄');\n const response = await this.client.chat.completions.create({\n messages: [\n {\n content: [\n {\n text: this.config.llm.prompt,\n type: 'text',\n },\n ],\n role: 'developer',\n },\n {\n content: [\n {\n image_url: {\n url: `data:${mimeType};base64,${data.toString('base64')}`,\n },\n type: 'image_url',\n },\n ],\n role: 'user',\n },\n ],\n model: this.config.llm.model,\n response_format: zodResponseFormat(Recipe, 'recipe'),\n });\n\n const choice = response.choices[0];\n if (!choice.message || !choice.message.content) {\n throw new Error(\n `Invalid response from LLM for file: ${title} (mime = ${mimeType}, length = ${data.length})`,\n );\n }\n\n let json;\n try {\n json = JSON.parse(choice.message.content);\n } catch (error) {\n throw new Error(\n `Failed to parse LLM response for file: ${title} (mime = ${mimeType}, length = ${data.length}): ${(error as Error).message}`,\n );\n }\n\n const recipe = Recipe.parse(json);\n this.log(title, true);\n return [recipe];\n }\n\n protected async convertFile(file: string): Promise<RecipeType[]> {\n const ext = file.slice(file.lastIndexOf('.')).toLowerCase();\n const mimeType = mimeTypes.get(ext);\n if (mimeType) {\n return this.convertBuffer(file, await readFile(file), mimeType);\n }\n\n throw new Error(`Unsupported file format for file: ${file}`);\n }\n\n protected async convertFiles(files: string[] = []) {\n if (!files.length) {\n throw new Error('No input files specified for conversion.');\n }\n\n // Check all files exist\n for (const file of files) {\n if (!existsSync(file)) {\n throw new Error(`Unable to find file: ${file}`);\n }\n }\n\n // Convert files one by one\n const recipes: RecipeType[] = [];\n for (const file of files) {\n recipes.push(...(await this.convertFile(file)));\n }\n\n return recipes;\n }\n\n protected log(file: string, emojiOrDone: string | true) {\n const emoji = emojiOrDone === true ? '✅' : emojiOrDone;\n this.options.stdout?.write(\n `\\r${emoji} ${file}${emojiOrDone === true ? '\\n' : ''}`,\n );\n }\n}\n","#!/usr/bin/env node\n'use strict';\n\nimport express, { type Express } from 'express';\nimport { Server } from 'http';\n\nimport { Config } from '../lib/config.js';\nimport { Converter } from '../lib/convert.js';\n\nclass AppServer {\n private app: Express;\n private server: Server;\n\n constructor() {\n this.app = express();\n this.app.use(\n express.raw({\n limit: '50mb',\n type: ['image/jpeg', 'image/png', 'image/webp', 'image/heic'],\n }),\n );\n\n this.setupRoutes();\n this.server = this.app.listen(process.env.PORT || 8080);\n\n process.on('SIGINT', () => this.stop());\n process.on('SIGTERM', () => this.stop());\n }\n\n static run() {\n new AppServer();\n }\n\n setupRoutes() {\n this.app.get('/ping', (req, res) => {\n res.send('pong');\n });\n\n let authToken: null | string = null;\n Config.use().then((config) => {\n authToken = config.server.authToken || null;\n });\n\n this.app.post('/convert', (req, res) => {\n const authTokenHeader = req.headers['authorization'] || '';\n if (authToken && authTokenHeader !== `Bearer ${authToken}`) {\n res.sendStatus(401);\n return;\n }\n\n const contentType = req.headers['content-type'] || '';\n Converter.convert(Buffer.from(req.body), contentType)\n .then((response: Buffer) => {\n res.setHeader('Content-Type', 'application/octet-stream');\n res.setHeader('Content-Length', response.byteLength);\n res.setHeader(\n 'Content-Disposition',\n 'attachment; filename=\"recipes.paprikarecipes\"',\n );\n res.send(response);\n })\n .catch((error) => {\n console.error(error);\n res.sendStatus(500);\n });\n });\n\n // add additional routes\n }\n\n async stop() {\n await new Promise((cb) => this.server.close(cb));\n\n // await db.close() if we have a db connection in this app\n // await other things we should cleanup nicely\n\n process.exit();\n }\n}\n\nAppServer.run();\n","#!/usr/bin/env node\n'use strict';\n\nimport { writeFile } from 'node:fs/promises';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\n\nimport { Config } from '../lib/config.js';\nimport { Converter } from '../lib/convert.js';\n\nyargs(hideBin(process.argv))\n .usage('$0 <cmd> [args]')\n .command(\n 'setup',\n 'Create or update the configuration file',\n () => {},\n () => Config.setup(),\n )\n .command(\n 'convert [files..]',\n 'Convert supported files to a .paprikarecipes file',\n (yargs) => {\n yargs.positional('files', {\n array: true,\n demandOption: true,\n describe: 'Input files to convert',\n type: 'string',\n });\n },\n async (argv) => {\n const files = argv.files as string[];\n const recipes = await Converter.convert(files, {\n stdout: process.stdout,\n });\n\n const outputFile = await Converter.generateOutputFilePath(files);\n await writeFile(outputFile, recipes);\n },\n )\n .command(\n 'server',\n 'Launch the server',\n () => {},\n () => {\n import('./start.ts').catch((error) => {\n console.error('Failed to start the server:', error);\n process.exit(1);\n });\n },\n )\n .demandCommand(1, 1)\n .strict()\n .help('h')\n .parse();\n"],"mappings":"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","names":["import_node_crypto","import_node_fs","import_promises","import_node_path","import_prompts","Config","init_config","__esmMin","_Config","config","content","err","json","llm","prompts","value","output","error","toRecipes","recipes","zip","archiver","zipStream","chunks","chunk","recipe","data","toServings","resolve","reject","servings","number","import_archiver","import_node_stream","z","Recipe","init_recipe","__esmMin","import_heic_convert","import_node_fs","import_promises","import_node_path","import_openai","import_zod","import_pdf_to_png_converter","mimeTypes","Converter","init_convert","__esmMin","init_config","init_recipe","_Converter","config","options","OpenAI","arg1","arg2","arg3","Config","recipes","toRecipes","inputFiles","fileName","path","counter","title","data","mimeType","jpeg","convert","pages","result","page","choice","Recipe","json","error","recipe","file","ext","files","emojiOrDone","emoji","start_exports","import_express","AppServer","init_start","__esmMin","init_config","init_convert","_AppServer","express","req","res","authToken","Config","config","authTokenHeader","contentType","Converter","response","error","cb","import_promises","import_yargs","import_helpers","init_config","init_convert","yargs","Config","argv","files","recipes","Converter","outputFile","error"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/bin/cli.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{a as o,d as t}from"../chunk-KARYAMZ2.js";import{writeFile as a}from"node:fs/promises";import n from"yargs";import{hideBin as p}from"yargs/helpers";n(p(process.argv)).usage("$0 <cmd> [args]").command("setup","Create or update the configuration file",()=>{},()=>o.setup()).command("convert [files..]","Convert supported files to a .paprikarecipes file",e=>{e.positional("files",{array:!0,demandOption:!0,describe:"Input files to convert",type:"string"})},async e=>{let r=e.files,s=await t.convert(r,{stdout:process.stdout}),i=await t.generateOutputFilePath(r);await a(i,s)}).command("server","Launch the server",()=>{},()=>{import("./start.js").catch(e=>{console.error("Failed to start the server:",e),process.exit(1)})}).demandCommand(1,1).strict().help("h").parse();
|
|
3
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/bin/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n'use strict';\n\nimport { writeFile } from 'node:fs/promises';\nimport yargs from 'yargs';\nimport { hideBin } from 'yargs/helpers';\n\nimport { Config } from '../lib/config.js';\nimport { Converter } from '../lib/convert.js';\n\nyargs(hideBin(process.argv))\n .usage('$0 <cmd> [args]')\n .command(\n 'setup',\n 'Create or update the configuration file',\n () => {},\n () => Config.setup(),\n )\n .command(\n 'convert [files..]',\n 'Convert supported files to a .paprikarecipes file',\n (yargs) => {\n yargs.positional('files', {\n array: true,\n demandOption: true,\n describe: 'Input files to convert',\n type: 'string',\n });\n },\n async (argv) => {\n const files = argv.files as string[];\n const recipes = await Converter.convert(files, {\n stdout: process.stdout,\n });\n\n const outputFile = await Converter.generateOutputFilePath(files);\n await writeFile(outputFile, recipes);\n },\n )\n .command(\n 'server',\n 'Launch the server',\n () => {},\n () => {\n import('./start.ts').catch((error) => {\n console.error('Failed to start the server:', error);\n process.exit(1);\n });\n },\n )\n .demandCommand(1, 1)\n .strict()\n .help('h')\n .parse();\n"],"mappings":";gDAGA,OAAS,aAAAA,MAAiB,mBAC1B,OAAOC,MAAW,QAClB,OAAS,WAAAC,MAAe,gBAKxBC,EAAMC,EAAQ,QAAQ,IAAI,CAAC,EACtB,MAAM,iBAAiB,EACvB,QACG,QACA,0CACA,IAAM,CAAC,EACP,IAAMC,EAAO,MAAM,CACvB,EACC,QACG,oBACA,oDACCF,GAAU,CACPA,EAAM,WAAW,QAAS,CACtB,MAAO,GACP,aAAc,GACd,SAAU,yBACV,KAAM,QACV,CAAC,CACL,EACA,MAAOG,GAAS,CACZ,IAAMC,EAAQD,EAAK,MACbE,EAAU,MAAMC,EAAU,QAAQF,EAAO,CAC3C,OAAQ,QAAQ,MACpB,CAAC,EAEKG,EAAa,MAAMD,EAAU,uBAAuBF,CAAK,EAC/D,MAAMI,EAAUD,EAAYF,CAAO,CACvC,CACJ,EACC,QACG,SACA,oBACA,IAAM,CAAC,EACP,IAAM,CACF,OAAO,YAAY,EAAE,MAAOI,GAAU,CAClC,QAAQ,MAAM,8BAA+BA,CAAK,EAClD,QAAQ,KAAK,CAAC,CAClB,CAAC,CACL,CACJ,EACC,cAAc,EAAG,CAAC,EAClB,OAAO,EACP,KAAK,GAAG,EACR,MAAM","names":["writeFile","yargs","hideBin","yargs","hideBin","Config","argv","files","recipes","Converter","outputFile","writeFile","error"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";var S=Object.create;var R=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var F=Object.getOwnPropertyNames;var N=Object.getPrototypeOf,U=Object.prototype.hasOwnProperty;var j=(i,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of F(e))!U.call(i,o)&&o!==t&&R(i,o,{get:()=>e[o],enumerable:!(r=A(e,o))||r.enumerable});return i};var c=(i,e,t)=>(t=i!=null?S(N(i)):{},j(e||!i||!i.__esModule?R(t,"default",{value:i,enumerable:!0}):t,i));var w=c(require("express"),1);var T=require("crypto"),d=require("fs"),g=require("fs/promises"),f=require("path"),y=c(require("prompts"),1),p=class i{constructor(e){this.config=e}static get path(){return process.env.TO_PAPRIKA_CONFIG_PATH?(0,f.resolve)(process.env.TO_PAPRIKA_CONFIG_PATH):(0,f.join)(this.homePath,".to-paprika.config.json")}static get homePath(){if(process.platform==="win32"&&process.env.USERPROFILE)return process.env.USERPROFILE;if(process.platform!=="win32"&&process.env.HOME)return process.env.HOME;throw new Error("Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.")}get llm(){return{...this.config.llm,prompt:process.env.TO_PAPRIKA_CONFIG_PROMPT||"You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate."}}get output(){return this.config.output}get server(){return this.config.server}static async getJson(){if(!(0,d.existsSync)(this.path))throw new Error(`Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`);let e;try{e=await(0,g.readFile)(this.path,"utf8")}catch(r){throw new Error(`Failed to read config file at ${this.path}: ${r.message}`)}let t;try{t=JSON.parse(e)}catch(r){throw new Error(`Failed to parse config file at ${this.path}: ${r.message}`)}return t}static async setup(){console.log(`
|
|
3
|
+
\u{1F44B}\u{1F3FC} Hi there`),console.log("");let e;try{e=await this.getJson()}catch{}console.log("\u{1F325}\uFE0F to-paprica requires an OpenAI-compatible LLM provider to function."),console.log(" Please provide the following provider details:"),console.log("");let t=await(0,y.default)([{initial:e?.llm.baseUrl,message:" LLM Provider Base URL:",name:"baseUrl",type:"text",validate:n=>{try{return new URL(n),!0}catch{return"Invalid Base URL"}}},{initial:e?.llm.apiKey,message:" LLM Provider API Key:",name:"apiKey",type:"password",validate:n=>n.length>0?!0:"API Key cannot be empty"},{initial:e?.llm.model,message:" LLM Model to use:",name:"model",type:"text",validate:n=>n.length>0?!0:"Model cannot be empty"}]);console.log(""),console.log("\u{1F4C2} Next, please specify the output directory for your Paprika recipes."),console.log(" This is where all converted recipes will be saved."),console.log("");let r=await(0,y.default)({initial:e?.output||(0,f.resolve)(process.cwd()),message:" Output Directory:",name:"output",type:"text",validate:n=>(0,d.existsSync)(n)}),o={llm:t,output:r.output,server:{authToken:e?.server?.authToken}};o.server.authToken||(o.server.authToken=(0,T.randomBytes)(32).toString("hex")),console.log(""),console.log("\u{1F512} Great. Just in case you would like to use the build-in server,"),console.log(` please use this API Token to authenticate your requests:
|
|
4
|
+
`),console.log(` ${o.server.authToken}`),console.log("");try{await(0,g.writeFile)(this.path,JSON.stringify(o,null,2))}catch(n){throw console.log("\u{1F6A8} Failed to save config file"),console.log(""),new Error(`Failed to write config file at ${this.path}: ${n.message}`)}console.log(""),console.log("\u{1F389} Config file saved successfully"),console.log("")}static async use(){let e=await this.getJson();return new i(e)}};var I=c(require("heic-convert"),1),m=require("fs"),E=require("fs/promises"),u=require("path"),x=c(require("openai"),1),B=require("openai/helpers/zod"),L=require("pdf-to-png-converter");var O=c(require("archiver"),1),$=require("stream"),s=c(require("zod"),1),v=s.object({cook_time:s.string().optional().nullable().describe("Cooking time required, e.g. '30 min' or '1 h'. Keep it short."),description:s.string().optional().nullable().describe("A brief description of the recipe. Use newline characters for formatting."),directions:s.string().describe("Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list."),ingredients:s.string('Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g "1 cup milk" or "3 apples". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.'),name:s.string(),notes:s.string().describe("An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields."),prep_time:s.string().optional().nullable().describe('Preparation time required before cooking, e.g. "15 min" or "30 min". Keep it short.'),servings:s.string().optional().nullable().describe('Number of servings, e.g. "2" or "4-6 servings".'),source:s.string().optional().nullable().describe("Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL."),total_time:s.string().optional().nullable().describe('Total time required to prepare the dish, e.g. "45 min" or "1.5 h". Keep it short.')});async function k(i){let e=(0,O.default)("zip",{zlib:{level:9}}),t=new $.PassThrough,r=[];t.on("data",o=>r.push(o)),t.on("end",()=>{}),e.pipe(t);for(let o of i){let n=Buffer.from(JSON.stringify({...o,servings:K(o.servings)}));e.append(n,{name:`${o.name}.paprikarecipe`})}return new Promise((o,n)=>{e.on("error",n),e.on("end",()=>{o(Buffer.concat(r))}),e.finalize()})}function K(i){if(!i)return null;if(/^[0-9]+ ?[mlg]{1,2}$/i.test(i.trim()))return i;let e=parseInt(i,10);return isNaN(e)?i:e.toString()}var M=new Map([[".heic","image/heic"],[".jpeg","image/jpeg"],[".jpg","image/jpeg"],[".pdf","application/pdf"],[".png","image/png"],[".webp","image/webp"]]),h=class i{client;config;options;constructor(e,t={}){this.config=e,this.options=t,this.client=new x.default({apiKey:e.llm.apiKey,baseURL:e.llm.baseUrl})}static async convert(e,t,r){let o=await p.use(),n=[];if(typeof e=="string"&&(typeof t=="object"||t===void 0))n=await new i(o,t).convertFiles([e]);else if(Array.isArray(e)&&(typeof t=="object"||t===void 0))n=await new i(o,t).convertFiles(e);else if(e instanceof Buffer&&typeof t=="string")n=await new i(o,r).convertBuffer(`Buffer (${e.byteLength} bytes)`,e,t);else throw new Error("Invalid arguments for convert function.");return await k(n)}static async generateOutputFilePath(e){let t=`${Date.now().toString()}`;e.length===1&&(t=(0,u.basename)(e[0]).replace(/\.[a-z]{3,4}$/g,""));let r=await p.use(),o=(0,u.join)(r.output,t+".paprikarecipes");if((0,m.existsSync)(o)){let n=1;do o=(0,u.join)(r.output,`${t}-${n}.paprikarecipes`),n++;while((0,m.existsSync)(o))}return o}async convertBuffer(e,t,r){if(r==="image/heic"){this.log(e,"\u{1F500}");let l=Buffer.from(await(0,I.default)({buffer:t,format:"JPEG",quality:1}));return this.convertBuffer(e,l,"image/jpeg")}if(r==="application/pdf"){this.log(e,"\u{1F500}");let l=await(0,L.pdfToPng)(t);this.log(e,!0);let P=[];for(let C of l)P.push(...await this.convertBuffer(`${e} (p. ${C.pageNumber})`,C.content,"image/png"));return P}this.log(e,"\u{1FA84}");let n=(await this.client.chat.completions.create({messages:[{content:[{text:this.config.llm.prompt,type:"text"}],role:"developer"},{content:[{image_url:{url:`data:${r};base64,${t.toString("base64")}`},type:"image_url"}],role:"user"}],model:this.config.llm.model,response_format:(0,B.zodResponseFormat)(v,"recipe")})).choices[0];if(!n.message||!n.message.content)throw new Error(`Invalid response from LLM for file: ${e} (mime = ${r}, length = ${t.length})`);let a;try{a=JSON.parse(n.message.content)}catch(l){throw new Error(`Failed to parse LLM response for file: ${e} (mime = ${r}, length = ${t.length}): ${l.message}`)}let z=v.parse(a);return this.log(e,!0),[z]}async convertFile(e){let t=e.slice(e.lastIndexOf(".")).toLowerCase(),r=M.get(t);if(r)return this.convertBuffer(e,await(0,E.readFile)(e),r);throw new Error(`Unsupported file format for file: ${e}`)}async convertFiles(e=[]){if(!e.length)throw new Error("No input files specified for conversion.");for(let r of e)if(!(0,m.existsSync)(r))throw new Error(`Unable to find file: ${r}`);let t=[];for(let r of e)t.push(...await this.convertFile(r));return t}log(e,t){let r=t===!0?"\u2705":t;this.options.stdout?.write(`\r${r} ${e}${t===!0?`
|
|
5
|
+
`:""}`)}};var b=class i{app;server;constructor(){this.app=(0,w.default)(),this.app.use(w.default.raw({limit:"50mb",type:["image/jpeg","image/png","image/webp","image/heic"]})),this.setupRoutes(),this.server=this.app.listen(process.env.PORT||8080),process.on("SIGINT",()=>this.stop()),process.on("SIGTERM",()=>this.stop())}static run(){new i}setupRoutes(){this.app.get("/ping",(t,r)=>{r.send("pong")});let e=null;p.use().then(t=>{e=t.server.authToken||null}),this.app.post("/convert",(t,r)=>{let o=t.headers.authorization||"";if(e&&o!==`Bearer ${e}`){r.sendStatus(401);return}let n=t.headers["content-type"]||"";h.convert(Buffer.from(t.body),n).then(a=>{r.setHeader("Content-Type","application/octet-stream"),r.setHeader("Content-Length",a.byteLength),r.setHeader("Content-Disposition",'attachment; filename="recipes.paprikarecipes"'),r.send(a)}).catch(a=>{console.error(a),r.sendStatus(500)})})}async stop(){await new Promise(e=>this.server.close(e)),process.exit()}};b.run();
|
|
6
|
+
//# sourceMappingURL=start.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/bin/start.ts","../../src/lib/config.ts","../../src/lib/convert.ts","../../src/lib/recipe.ts"],"sourcesContent":["#!/usr/bin/env node\n'use strict';\n\nimport express, { type Express } from 'express';\nimport { Server } from 'http';\n\nimport { Config } from '../lib/config.js';\nimport { Converter } from '../lib/convert.js';\n\nclass AppServer {\n private app: Express;\n private server: Server;\n\n constructor() {\n this.app = express();\n this.app.use(\n express.raw({\n limit: '50mb',\n type: ['image/jpeg', 'image/png', 'image/webp', 'image/heic'],\n }),\n );\n\n this.setupRoutes();\n this.server = this.app.listen(process.env.PORT || 8080);\n\n process.on('SIGINT', () => this.stop());\n process.on('SIGTERM', () => this.stop());\n }\n\n static run() {\n new AppServer();\n }\n\n setupRoutes() {\n this.app.get('/ping', (req, res) => {\n res.send('pong');\n });\n\n let authToken: null | string = null;\n Config.use().then((config) => {\n authToken = config.server.authToken || null;\n });\n\n this.app.post('/convert', (req, res) => {\n const authTokenHeader = req.headers['authorization'] || '';\n if (authToken && authTokenHeader !== `Bearer ${authToken}`) {\n res.sendStatus(401);\n return;\n }\n\n const contentType = req.headers['content-type'] || '';\n Converter.convert(Buffer.from(req.body), contentType)\n .then((response: Buffer) => {\n res.setHeader('Content-Type', 'application/octet-stream');\n res.setHeader('Content-Length', response.byteLength);\n res.setHeader(\n 'Content-Disposition',\n 'attachment; filename=\"recipes.paprikarecipes\"',\n );\n res.send(response);\n })\n .catch((error) => {\n console.error(error);\n res.sendStatus(500);\n });\n });\n\n // add additional routes\n }\n\n async stop() {\n await new Promise((cb) => this.server.close(cb));\n\n // await db.close() if we have a db connection in this app\n // await other things we should cleanup nicely\n\n process.exit();\n }\n}\n\nAppServer.run();\n","import { randomBytes } from 'node:crypto';\nimport { existsSync } from 'node:fs';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\nimport prompts from 'prompts';\n\nexport interface ConfigContent {\n llm: {\n apiKey: string;\n baseUrl: string;\n model: string;\n };\n output: string;\n server: {\n authToken?: string;\n };\n}\n\nexport class Config {\n static get path() {\n if (process.env.TO_PAPRIKA_CONFIG_PATH) {\n return resolve(process.env.TO_PAPRIKA_CONFIG_PATH);\n }\n\n return join(this.homePath, '.to-paprika.config.json');\n }\n protected static get homePath() {\n if (process.platform === 'win32' && process.env.USERPROFILE) {\n return process.env.USERPROFILE;\n }\n if (process.platform !== 'win32' && process.env.HOME) {\n return process.env.HOME;\n }\n\n throw new Error(\n 'Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.',\n );\n }\n\n get llm(): ConfigContent['llm'] & { prompt: string } {\n return {\n ...this.config.llm,\n prompt:\n process.env.TO_PAPRIKA_CONFIG_PROMPT ||\n 'You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate.',\n };\n }\n get output() {\n return this.config.output;\n }\n get server(): ConfigContent['server'] {\n return this.config.server;\n }\n\n constructor(private readonly config: ConfigContent) {}\n\n static async getJson() {\n if (!existsSync(this.path)) {\n throw new Error(\n `Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`,\n );\n }\n\n let content: string | undefined;\n try {\n content = await readFile(this.path, 'utf8');\n } catch (err) {\n throw new Error(\n `Failed to read config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n let json: ConfigContent;\n try {\n json = JSON.parse(content) as ConfigContent;\n } catch (err) {\n throw new Error(\n `Failed to parse config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n return json;\n }\n\n static async setup() {\n console.log('\\n👋🏼 Hi there');\n console.log('');\n\n let json: ConfigContent | undefined = undefined;\n try {\n json = await this.getJson();\n } catch {\n // ignore errors\n }\n\n console.log(\n '🌥️ to-paprica requires an OpenAI-compatible LLM provider to function.',\n );\n console.log(' Please provide the following provider details:');\n console.log('');\n\n const llm = await prompts([\n {\n initial: json?.llm.baseUrl,\n message: ' LLM Provider Base URL:',\n name: 'baseUrl',\n type: 'text',\n validate: (value) => {\n try {\n new URL(value);\n return true;\n } catch {\n return 'Invalid Base URL';\n }\n },\n },\n {\n initial: json?.llm.apiKey,\n message: ' LLM Provider API Key:',\n name: 'apiKey',\n type: 'password',\n validate: (value) =>\n value.length > 0 ? true : 'API Key cannot be empty',\n },\n {\n initial: json?.llm.model,\n message: ' LLM Model to use:',\n name: 'model',\n type: 'text',\n validate: (value) =>\n value.length > 0 ? true : 'Model cannot be empty',\n },\n ]);\n\n console.log('');\n console.log(\n '📂 Next, please specify the output directory for your Paprika recipes.',\n );\n console.log(' This is where all converted recipes will be saved.');\n console.log('');\n\n const output = await prompts({\n initial: json?.output || resolve(process.cwd()),\n message: ' Output Directory:',\n name: 'output',\n type: 'text',\n validate: (value) => existsSync(value),\n });\n\n const config: ConfigContent = {\n llm,\n output: output.output,\n server: {\n authToken: json?.server?.authToken,\n },\n };\n if (!config.server.authToken) {\n config.server.authToken = randomBytes(32).toString('hex');\n }\n\n console.log('');\n console.log(\n '🔒 Great. Just in case you would like to use the build-in server,',\n );\n console.log(\n ' please use this API Token to authenticate your requests:\\n',\n );\n console.log(` ${config.server.authToken}`);\n console.log('');\n\n try {\n await writeFile(this.path, JSON.stringify(config, null, 2));\n } catch (error) {\n console.log('🚨 Failed to save config file');\n console.log('');\n throw new Error(\n `Failed to write config file at ${this.path}: ${(error as Error).message}`,\n );\n }\n\n console.log('');\n console.log('🎉 Config file saved successfully');\n console.log('');\n }\n\n static async use() {\n const json = await this.getJson();\n return new Config(json);\n }\n}\n","import type { Writable } from 'node:stream';\n\nimport convert from 'heic-convert';\nimport { existsSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport { basename, join } from 'node:path';\nimport OpenAI from 'openai';\nimport { zodResponseFormat } from 'openai/helpers/zod';\nimport { pdfToPng } from 'pdf-to-png-converter';\n\nimport { Config } from './config.js';\nimport { Recipe, type RecipeType, toRecipes } from './recipe.js';\n\nexport interface ConverterOptions {\n stdout?: Writable;\n}\n\nexport const mimeTypes = new Map([\n ['.heic', 'image/heic'],\n ['.jpeg', 'image/jpeg'],\n ['.jpg', 'image/jpeg'],\n ['.pdf', 'application/pdf'],\n ['.png', 'image/png'],\n ['.webp', 'image/webp'],\n]);\n\nexport class Converter {\n protected client: OpenAI;\n protected config: Config;\n protected options: ConverterOptions;\n\n protected constructor(config: Config, options: ConverterOptions = {}) {\n this.config = config;\n this.options = options;\n this.client = new OpenAI({\n apiKey: config.llm.apiKey,\n baseURL: config.llm.baseUrl,\n });\n }\n\n static async convert(\n data: Buffer,\n mimeType: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n file: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n files: string[],\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n arg1: Buffer | string | string[],\n arg2?: ConverterOptions | string,\n arg3?: ConverterOptions,\n ): Promise<Buffer> {\n const config = await Config.use();\n\n let recipes: RecipeType[] = [];\n if (\n typeof arg1 === 'string' &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles([arg1]);\n } else if (\n Array.isArray(arg1) &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles(arg1);\n } else if (arg1 instanceof Buffer && typeof arg2 === 'string') {\n recipes = await new Converter(config, arg3).convertBuffer(\n `Buffer (${arg1.byteLength} bytes)`,\n arg1,\n arg2,\n );\n } else {\n throw new Error('Invalid arguments for convert function.');\n }\n\n return await toRecipes(recipes);\n }\n\n static async generateOutputFilePath(inputFiles: string[]): Promise<string> {\n let fileName = `${Date.now().toString()}`;\n if (inputFiles.length === 1) {\n fileName = basename(inputFiles[0]).replace(/\\.[a-z]{3,4}$/g, '');\n }\n\n const config = await Config.use();\n let path = join(config.output, fileName + '.paprikarecipes');\n if (existsSync(path)) {\n let counter = 1;\n do {\n path = join(\n config.output,\n `${fileName}-${counter}.paprikarecipes`,\n );\n counter++;\n } while (existsSync(path));\n }\n\n return path;\n }\n\n protected async convertBuffer(\n title: string,\n data: Buffer,\n mimeType: string,\n ): Promise<RecipeType[]> {\n // heic handling\n if (mimeType === 'image/heic') {\n this.log(title, '🔀');\n const jpeg = Buffer.from(\n await convert({\n buffer: data,\n format: 'JPEG',\n quality: 1,\n }),\n );\n return this.convertBuffer(title, jpeg, 'image/jpeg');\n }\n\n // pdf handling\n if (mimeType === 'application/pdf') {\n this.log(title, '🔀');\n const pages = await pdfToPng(data);\n this.log(title, true);\n\n const result: RecipeType[] = [];\n for (const page of pages) {\n result.push(\n ...(await this.convertBuffer(\n `${title} (p. ${page.pageNumber})`,\n page.content,\n 'image/png',\n )),\n );\n }\n\n return result;\n }\n\n this.log(title, '🪄');\n const response = await this.client.chat.completions.create({\n messages: [\n {\n content: [\n {\n text: this.config.llm.prompt,\n type: 'text',\n },\n ],\n role: 'developer',\n },\n {\n content: [\n {\n image_url: {\n url: `data:${mimeType};base64,${data.toString('base64')}`,\n },\n type: 'image_url',\n },\n ],\n role: 'user',\n },\n ],\n model: this.config.llm.model,\n response_format: zodResponseFormat(Recipe, 'recipe'),\n });\n\n const choice = response.choices[0];\n if (!choice.message || !choice.message.content) {\n throw new Error(\n `Invalid response from LLM for file: ${title} (mime = ${mimeType}, length = ${data.length})`,\n );\n }\n\n let json;\n try {\n json = JSON.parse(choice.message.content);\n } catch (error) {\n throw new Error(\n `Failed to parse LLM response for file: ${title} (mime = ${mimeType}, length = ${data.length}): ${(error as Error).message}`,\n );\n }\n\n const recipe = Recipe.parse(json);\n this.log(title, true);\n return [recipe];\n }\n\n protected async convertFile(file: string): Promise<RecipeType[]> {\n const ext = file.slice(file.lastIndexOf('.')).toLowerCase();\n const mimeType = mimeTypes.get(ext);\n if (mimeType) {\n return this.convertBuffer(file, await readFile(file), mimeType);\n }\n\n throw new Error(`Unsupported file format for file: ${file}`);\n }\n\n protected async convertFiles(files: string[] = []) {\n if (!files.length) {\n throw new Error('No input files specified for conversion.');\n }\n\n // Check all files exist\n for (const file of files) {\n if (!existsSync(file)) {\n throw new Error(`Unable to find file: ${file}`);\n }\n }\n\n // Convert files one by one\n const recipes: RecipeType[] = [];\n for (const file of files) {\n recipes.push(...(await this.convertFile(file)));\n }\n\n return recipes;\n }\n\n protected log(file: string, emojiOrDone: string | true) {\n const emoji = emojiOrDone === true ? '✅' : emojiOrDone;\n this.options.stdout?.write(\n `\\r${emoji} ${file}${emojiOrDone === true ? '\\n' : ''}`,\n );\n }\n}\n","import archiver from 'archiver';\nimport { PassThrough } from 'node:stream';\nimport * as z from 'zod';\n\nexport const Recipe = z.object({\n cook_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n \"Cooking time required, e.g. '30 min' or '1 h'. Keep it short.\",\n ),\n description: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'A brief description of the recipe. Use newline characters for formatting.',\n ),\n directions: z\n .string()\n .describe(\n 'Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list.',\n ),\n ingredients: z.string(\n 'Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g \"1 cup milk\" or \"3 apples\". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.',\n ),\n name: z.string(),\n notes: z\n .string()\n .describe(\n 'An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields.',\n ),\n prep_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Preparation time required before cooking, e.g. \"15 min\" or \"30 min\". Keep it short.',\n ),\n servings: z\n .string()\n .optional()\n .nullable()\n .describe('Number of servings, e.g. \"2\" or \"4-6 servings\".'),\n source: z\n .string()\n .optional()\n .nullable()\n .describe(\"Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL.\"),\n total_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Total time required to prepare the dish, e.g. \"45 min\" or \"1.5 h\". Keep it short.',\n ),\n});\n\nexport type RecipeType = z.infer<typeof Recipe>;\n\n/**\n * Convert an array of recipes to a .paprikarecipes buffer\n */\nexport async function toRecipes(recipes: RecipeType[]): Promise<Buffer> {\n const zip = archiver('zip', { zlib: { level: 9 } });\n const zipStream = new PassThrough();\n const chunks: Buffer[] = [];\n\n zipStream.on('data', (chunk) => chunks.push(chunk));\n zipStream.on('end', () => {});\n zip.pipe(zipStream);\n\n for (const recipe of recipes) {\n const data = Buffer.from(\n JSON.stringify({\n ...recipe,\n servings: toServings(recipe.servings),\n }),\n );\n zip.append(data, { name: `${recipe.name}.paprikarecipe` });\n }\n\n return new Promise((resolve, reject) => {\n zip.on('error', reject);\n zip.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n zip.finalize();\n });\n}\nfunction toServings(servings: null | string | undefined): null | string {\n if (!servings) {\n return null;\n }\n if (/^[0-9]+ ?[mlg]{1,2}$/i.test(servings.trim())) {\n return servings;\n }\n\n const number = parseInt(servings, 10);\n if (!isNaN(number)) {\n return number.toString();\n }\n\n return servings;\n}\n"],"mappings":"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","names":["import_express","import_node_crypto","import_node_fs","import_promises","import_node_path","import_prompts","Config","_Config","config","content","err","json","llm","prompts","value","output","error","import_heic_convert","import_node_fs","import_promises","import_node_path","import_openai","import_zod","import_pdf_to_png_converter","import_archiver","import_node_stream","z","Recipe","toRecipes","recipes","zip","archiver","zipStream","chunks","chunk","recipe","data","toServings","resolve","reject","servings","number","mimeTypes","Converter","_Converter","config","options","OpenAI","arg1","arg2","arg3","Config","recipes","toRecipes","inputFiles","fileName","path","counter","title","data","mimeType","jpeg","convert","pages","result","page","choice","Recipe","json","error","recipe","file","ext","files","emojiOrDone","emoji","AppServer","_AppServer","express","req","res","authToken","Config","config","authTokenHeader","contentType","Converter","response","error","cb"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{a as o,d as i}from"../chunk-KARYAMZ2.js";import p from"express";var n=class a{app;server;constructor(){this.app=p(),this.app.use(p.raw({limit:"50mb",type:["image/jpeg","image/png","image/webp","image/heic"]})),this.setupRoutes(),this.server=this.app.listen(process.env.PORT||8080),process.on("SIGINT",()=>this.stop()),process.on("SIGTERM",()=>this.stop())}static run(){new a}setupRoutes(){this.app.get("/ping",(t,e)=>{e.send("pong")});let s=null;o.use().then(t=>{s=t.server.authToken||null}),this.app.post("/convert",(t,e)=>{let c=t.headers.authorization||"";if(s&&c!==`Bearer ${s}`){e.sendStatus(401);return}let h=t.headers["content-type"]||"";i.convert(Buffer.from(t.body),h).then(r=>{e.setHeader("Content-Type","application/octet-stream"),e.setHeader("Content-Length",r.byteLength),e.setHeader("Content-Disposition",'attachment; filename="recipes.paprikarecipes"'),e.send(r)}).catch(r=>{console.error(r),e.sendStatus(500)})})}async stop(){await new Promise(s=>this.server.close(s)),process.exit()}};n.run();
|
|
3
|
+
//# sourceMappingURL=start.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/bin/start.ts"],"sourcesContent":["#!/usr/bin/env node\n'use strict';\n\nimport express, { type Express } from 'express';\nimport { Server } from 'http';\n\nimport { Config } from '../lib/config.js';\nimport { Converter } from '../lib/convert.js';\n\nclass AppServer {\n private app: Express;\n private server: Server;\n\n constructor() {\n this.app = express();\n this.app.use(\n express.raw({\n limit: '50mb',\n type: ['image/jpeg', 'image/png', 'image/webp', 'image/heic'],\n }),\n );\n\n this.setupRoutes();\n this.server = this.app.listen(process.env.PORT || 8080);\n\n process.on('SIGINT', () => this.stop());\n process.on('SIGTERM', () => this.stop());\n }\n\n static run() {\n new AppServer();\n }\n\n setupRoutes() {\n this.app.get('/ping', (req, res) => {\n res.send('pong');\n });\n\n let authToken: null | string = null;\n Config.use().then((config) => {\n authToken = config.server.authToken || null;\n });\n\n this.app.post('/convert', (req, res) => {\n const authTokenHeader = req.headers['authorization'] || '';\n if (authToken && authTokenHeader !== `Bearer ${authToken}`) {\n res.sendStatus(401);\n return;\n }\n\n const contentType = req.headers['content-type'] || '';\n Converter.convert(Buffer.from(req.body), contentType)\n .then((response: Buffer) => {\n res.setHeader('Content-Type', 'application/octet-stream');\n res.setHeader('Content-Length', response.byteLength);\n res.setHeader(\n 'Content-Disposition',\n 'attachment; filename=\"recipes.paprikarecipes\"',\n );\n res.send(response);\n })\n .catch((error) => {\n console.error(error);\n res.sendStatus(500);\n });\n });\n\n // add additional routes\n }\n\n async stop() {\n await new Promise((cb) => this.server.close(cb));\n\n // await db.close() if we have a db connection in this app\n // await other things we should cleanup nicely\n\n process.exit();\n }\n}\n\nAppServer.run();\n"],"mappings":";gDAGA,OAAOA,MAA+B,UAMtC,IAAMC,EAAN,MAAMC,CAAU,CACJ,IACA,OAER,aAAc,CACV,KAAK,IAAMC,EAAQ,EACnB,KAAK,IAAI,IACLA,EAAQ,IAAI,CACR,MAAO,OACP,KAAM,CAAC,aAAc,YAAa,aAAc,YAAY,CAChE,CAAC,CACL,EAEA,KAAK,YAAY,EACjB,KAAK,OAAS,KAAK,IAAI,OAAO,QAAQ,IAAI,MAAQ,IAAI,EAEtD,QAAQ,GAAG,SAAU,IAAM,KAAK,KAAK,CAAC,EACtC,QAAQ,GAAG,UAAW,IAAM,KAAK,KAAK,CAAC,CAC3C,CAEA,OAAO,KAAM,CACT,IAAID,CACR,CAEA,aAAc,CACV,KAAK,IAAI,IAAI,QAAS,CAACE,EAAKC,IAAQ,CAChCA,EAAI,KAAK,MAAM,CACnB,CAAC,EAED,IAAIC,EAA2B,KAC/BC,EAAO,IAAI,EAAE,KAAMC,GAAW,CAC1BF,EAAYE,EAAO,OAAO,WAAa,IAC3C,CAAC,EAED,KAAK,IAAI,KAAK,WAAY,CAACJ,EAAKC,IAAQ,CACpC,IAAMI,EAAkBL,EAAI,QAAQ,eAAoB,GACxD,GAAIE,GAAaG,IAAoB,UAAUH,CAAS,GAAI,CACxDD,EAAI,WAAW,GAAG,EAClB,MACJ,CAEA,IAAMK,EAAcN,EAAI,QAAQ,cAAc,GAAK,GACnDO,EAAU,QAAQ,OAAO,KAAKP,EAAI,IAAI,EAAGM,CAAW,EAC/C,KAAME,GAAqB,CACxBP,EAAI,UAAU,eAAgB,0BAA0B,EACxDA,EAAI,UAAU,iBAAkBO,EAAS,UAAU,EACnDP,EAAI,UACA,sBACA,+CACJ,EACAA,EAAI,KAAKO,CAAQ,CACrB,CAAC,EACA,MAAOC,GAAU,CACd,QAAQ,MAAMA,CAAK,EACnBR,EAAI,WAAW,GAAG,CACtB,CAAC,CACT,CAAC,CAGL,CAEA,MAAM,MAAO,CACT,MAAM,IAAI,QAASS,GAAO,KAAK,OAAO,MAAMA,CAAE,CAAC,EAK/C,QAAQ,KAAK,CACjB,CACJ,EAEAb,EAAU,IAAI","names":["express","AppServer","_AppServer","express","req","res","authToken","Config","config","authTokenHeader","contentType","Converter","response","error","cb"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{randomBytes as P}from"node:crypto";import{existsSync as m}from"node:fs";import{readFile as C,writeFile as O}from"node:fs/promises";import{join as R,resolve as h}from"node:path";import d from"prompts";var p=class n{constructor(e){this.config=e}static get path(){return process.env.TO_PAPRIKA_CONFIG_PATH?h(process.env.TO_PAPRIKA_CONFIG_PATH):R(this.homePath,".to-paprika.config.json")}static get homePath(){if(process.platform==="win32"&&process.env.USERPROFILE)return process.env.USERPROFILE;if(process.platform!=="win32"&&process.env.HOME)return process.env.HOME;throw new Error("Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.")}get llm(){return{...this.config.llm,prompt:process.env.TO_PAPRIKA_CONFIG_PROMPT||"You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate."}}get output(){return this.config.output}get server(){return this.config.server}static async getJson(){if(!m(this.path))throw new Error(`Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`);let e;try{e=await C(this.path,"utf8")}catch(o){throw new Error(`Failed to read config file at ${this.path}: ${o.message}`)}let t;try{t=JSON.parse(e)}catch(o){throw new Error(`Failed to parse config file at ${this.path}: ${o.message}`)}return t}static async setup(){console.log(`
|
|
2
|
+
\u{1F44B}\u{1F3FC} Hi there`),console.log("");let e;try{e=await this.getJson()}catch{}console.log("\u{1F325}\uFE0F to-paprica requires an OpenAI-compatible LLM provider to function."),console.log(" Please provide the following provider details:"),console.log("");let t=await d([{initial:e?.llm.baseUrl,message:" LLM Provider Base URL:",name:"baseUrl",type:"text",validate:r=>{try{return new URL(r),!0}catch{return"Invalid Base URL"}}},{initial:e?.llm.apiKey,message:" LLM Provider API Key:",name:"apiKey",type:"password",validate:r=>r.length>0?!0:"API Key cannot be empty"},{initial:e?.llm.model,message:" LLM Model to use:",name:"model",type:"text",validate:r=>r.length>0?!0:"Model cannot be empty"}]);console.log(""),console.log("\u{1F4C2} Next, please specify the output directory for your Paprika recipes."),console.log(" This is where all converted recipes will be saved."),console.log("");let o=await d({initial:e?.output||h(process.cwd()),message:" Output Directory:",name:"output",type:"text",validate:r=>m(r)}),i={llm:t,output:o.output,server:{authToken:e?.server?.authToken}};i.server.authToken||(i.server.authToken=P(32).toString("hex")),console.log(""),console.log("\u{1F512} Great. Just in case you would like to use the build-in server,"),console.log(` please use this API Token to authenticate your requests:
|
|
3
|
+
`),console.log(` ${i.server.authToken}`),console.log("");try{await O(this.path,JSON.stringify(i,null,2))}catch(r){throw console.log("\u{1F6A8} Failed to save config file"),console.log(""),new Error(`Failed to write config file at ${this.path}: ${r.message}`)}console.log(""),console.log("\u{1F389} Config file saved successfully"),console.log("")}static async use(){let e=await this.getJson();return new n(e)}};import $ from"archiver";import{PassThrough as T}from"node:stream";import*as s from"zod";var c=s.object({cook_time:s.string().optional().nullable().describe("Cooking time required, e.g. '30 min' or '1 h'. Keep it short."),description:s.string().optional().nullable().describe("A brief description of the recipe. Use newline characters for formatting."),directions:s.string().describe("Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list."),ingredients:s.string('Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g "1 cup milk" or "3 apples". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.'),name:s.string(),notes:s.string().describe("An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields."),prep_time:s.string().optional().nullable().describe('Preparation time required before cooking, e.g. "15 min" or "30 min". Keep it short.'),servings:s.string().optional().nullable().describe('Number of servings, e.g. "2" or "4-6 servings".'),source:s.string().optional().nullable().describe("Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL."),total_time:s.string().optional().nullable().describe('Total time required to prepare the dish, e.g. "45 min" or "1.5 h". Keep it short.')});async function y(n){let e=$("zip",{zlib:{level:9}}),t=new T,o=[];t.on("data",i=>o.push(i)),t.on("end",()=>{}),e.pipe(t);for(let i of n){let r=Buffer.from(JSON.stringify({...i,servings:I(i.servings)}));e.append(r,{name:`${i.name}.paprikarecipe`})}return new Promise((i,r)=>{e.on("error",r),e.on("end",()=>{i(Buffer.concat(o))}),e.finalize()})}function I(n){if(!n)return null;if(/^[0-9]+ ?[mlg]{1,2}$/i.test(n.trim()))return n;let e=parseInt(n,10);return isNaN(e)?n:e.toString()}import k from"heic-convert";import{existsSync as l}from"node:fs";import{readFile as E}from"node:fs/promises";import{basename as z,join as w}from"node:path";import A from"openai";import{zodResponseFormat as B}from"openai/helpers/zod";import{pdfToPng as L}from"pdf-to-png-converter";var x=new Map([[".heic","image/heic"],[".jpeg","image/jpeg"],[".jpg","image/jpeg"],[".pdf","application/pdf"],[".png","image/png"],[".webp","image/webp"]]),v=class n{client;config;options;constructor(e,t={}){this.config=e,this.options=t,this.client=new A({apiKey:e.llm.apiKey,baseURL:e.llm.baseUrl})}static async convert(e,t,o){let i=await p.use(),r=[];if(typeof e=="string"&&(typeof t=="object"||t===void 0))r=await new n(i,t).convertFiles([e]);else if(Array.isArray(e)&&(typeof t=="object"||t===void 0))r=await new n(i,t).convertFiles(e);else if(e instanceof Buffer&&typeof t=="string")r=await new n(i,o).convertBuffer(`Buffer (${e.byteLength} bytes)`,e,t);else throw new Error("Invalid arguments for convert function.");return await y(r)}static async generateOutputFilePath(e){let t=`${Date.now().toString()}`;e.length===1&&(t=z(e[0]).replace(/\.[a-z]{3,4}$/g,""));let o=await p.use(),i=w(o.output,t+".paprikarecipes");if(l(i)){let r=1;do i=w(o.output,`${t}-${r}.paprikarecipes`),r++;while(l(i))}return i}async convertBuffer(e,t,o){if(o==="image/heic"){this.log(e,"\u{1F500}");let a=Buffer.from(await k({buffer:t,format:"JPEG",quality:1}));return this.convertBuffer(e,a,"image/jpeg")}if(o==="application/pdf"){this.log(e,"\u{1F500}");let a=await L(t);this.log(e,!0);let u=[];for(let g of a)u.push(...await this.convertBuffer(`${e} (p. ${g.pageNumber})`,g.content,"image/png"));return u}this.log(e,"\u{1FA84}");let r=(await this.client.chat.completions.create({messages:[{content:[{text:this.config.llm.prompt,type:"text"}],role:"developer"},{content:[{image_url:{url:`data:${o};base64,${t.toString("base64")}`},type:"image_url"}],role:"user"}],model:this.config.llm.model,response_format:B(c,"recipe")})).choices[0];if(!r.message||!r.message.content)throw new Error(`Invalid response from LLM for file: ${e} (mime = ${o}, length = ${t.length})`);let f;try{f=JSON.parse(r.message.content)}catch(a){throw new Error(`Failed to parse LLM response for file: ${e} (mime = ${o}, length = ${t.length}): ${a.message}`)}let b=c.parse(f);return this.log(e,!0),[b]}async convertFile(e){let t=e.slice(e.lastIndexOf(".")).toLowerCase(),o=x.get(t);if(o)return this.convertBuffer(e,await E(e),o);throw new Error(`Unsupported file format for file: ${e}`)}async convertFiles(e=[]){if(!e.length)throw new Error("No input files specified for conversion.");for(let o of e)if(!l(o))throw new Error(`Unable to find file: ${o}`);let t=[];for(let o of e)t.push(...await this.convertFile(o));return t}log(e,t){let o=t===!0?"\u2705":t;this.options.stdout?.write(`\r${o} ${e}${t===!0?`
|
|
4
|
+
`:""}`)}};export{p as a,c as b,y as c,v as d};
|
|
5
|
+
//# sourceMappingURL=chunk-KARYAMZ2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/config.ts","../src/lib/recipe.ts","../src/lib/convert.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport { existsSync } from 'node:fs';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\nimport prompts from 'prompts';\n\nexport interface ConfigContent {\n llm: {\n apiKey: string;\n baseUrl: string;\n model: string;\n };\n output: string;\n server: {\n authToken?: string;\n };\n}\n\nexport class Config {\n static get path() {\n if (process.env.TO_PAPRIKA_CONFIG_PATH) {\n return resolve(process.env.TO_PAPRIKA_CONFIG_PATH);\n }\n\n return join(this.homePath, '.to-paprika.config.json');\n }\n protected static get homePath() {\n if (process.platform === 'win32' && process.env.USERPROFILE) {\n return process.env.USERPROFILE;\n }\n if (process.platform !== 'win32' && process.env.HOME) {\n return process.env.HOME;\n }\n\n throw new Error(\n 'Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.',\n );\n }\n\n get llm(): ConfigContent['llm'] & { prompt: string } {\n return {\n ...this.config.llm,\n prompt:\n process.env.TO_PAPRIKA_CONFIG_PROMPT ||\n 'You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate.',\n };\n }\n get output() {\n return this.config.output;\n }\n get server(): ConfigContent['server'] {\n return this.config.server;\n }\n\n constructor(private readonly config: ConfigContent) {}\n\n static async getJson() {\n if (!existsSync(this.path)) {\n throw new Error(\n `Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`,\n );\n }\n\n let content: string | undefined;\n try {\n content = await readFile(this.path, 'utf8');\n } catch (err) {\n throw new Error(\n `Failed to read config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n let json: ConfigContent;\n try {\n json = JSON.parse(content) as ConfigContent;\n } catch (err) {\n throw new Error(\n `Failed to parse config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n return json;\n }\n\n static async setup() {\n console.log('\\n👋🏼 Hi there');\n console.log('');\n\n let json: ConfigContent | undefined = undefined;\n try {\n json = await this.getJson();\n } catch {\n // ignore errors\n }\n\n console.log(\n '🌥️ to-paprica requires an OpenAI-compatible LLM provider to function.',\n );\n console.log(' Please provide the following provider details:');\n console.log('');\n\n const llm = await prompts([\n {\n initial: json?.llm.baseUrl,\n message: ' LLM Provider Base URL:',\n name: 'baseUrl',\n type: 'text',\n validate: (value) => {\n try {\n new URL(value);\n return true;\n } catch {\n return 'Invalid Base URL';\n }\n },\n },\n {\n initial: json?.llm.apiKey,\n message: ' LLM Provider API Key:',\n name: 'apiKey',\n type: 'password',\n validate: (value) =>\n value.length > 0 ? true : 'API Key cannot be empty',\n },\n {\n initial: json?.llm.model,\n message: ' LLM Model to use:',\n name: 'model',\n type: 'text',\n validate: (value) =>\n value.length > 0 ? true : 'Model cannot be empty',\n },\n ]);\n\n console.log('');\n console.log(\n '📂 Next, please specify the output directory for your Paprika recipes.',\n );\n console.log(' This is where all converted recipes will be saved.');\n console.log('');\n\n const output = await prompts({\n initial: json?.output || resolve(process.cwd()),\n message: ' Output Directory:',\n name: 'output',\n type: 'text',\n validate: (value) => existsSync(value),\n });\n\n const config: ConfigContent = {\n llm,\n output: output.output,\n server: {\n authToken: json?.server?.authToken,\n },\n };\n if (!config.server.authToken) {\n config.server.authToken = randomBytes(32).toString('hex');\n }\n\n console.log('');\n console.log(\n '🔒 Great. Just in case you would like to use the build-in server,',\n );\n console.log(\n ' please use this API Token to authenticate your requests:\\n',\n );\n console.log(` ${config.server.authToken}`);\n console.log('');\n\n try {\n await writeFile(this.path, JSON.stringify(config, null, 2));\n } catch (error) {\n console.log('🚨 Failed to save config file');\n console.log('');\n throw new Error(\n `Failed to write config file at ${this.path}: ${(error as Error).message}`,\n );\n }\n\n console.log('');\n console.log('🎉 Config file saved successfully');\n console.log('');\n }\n\n static async use() {\n const json = await this.getJson();\n return new Config(json);\n }\n}\n","import archiver from 'archiver';\nimport { PassThrough } from 'node:stream';\nimport * as z from 'zod';\n\nexport const Recipe = z.object({\n cook_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n \"Cooking time required, e.g. '30 min' or '1 h'. Keep it short.\",\n ),\n description: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'A brief description of the recipe. Use newline characters for formatting.',\n ),\n directions: z\n .string()\n .describe(\n 'Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list.',\n ),\n ingredients: z.string(\n 'Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g \"1 cup milk\" or \"3 apples\". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.',\n ),\n name: z.string(),\n notes: z\n .string()\n .describe(\n 'An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields.',\n ),\n prep_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Preparation time required before cooking, e.g. \"15 min\" or \"30 min\". Keep it short.',\n ),\n servings: z\n .string()\n .optional()\n .nullable()\n .describe('Number of servings, e.g. \"2\" or \"4-6 servings\".'),\n source: z\n .string()\n .optional()\n .nullable()\n .describe(\"Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL.\"),\n total_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Total time required to prepare the dish, e.g. \"45 min\" or \"1.5 h\". Keep it short.',\n ),\n});\n\nexport type RecipeType = z.infer<typeof Recipe>;\n\n/**\n * Convert an array of recipes to a .paprikarecipes buffer\n */\nexport async function toRecipes(recipes: RecipeType[]): Promise<Buffer> {\n const zip = archiver('zip', { zlib: { level: 9 } });\n const zipStream = new PassThrough();\n const chunks: Buffer[] = [];\n\n zipStream.on('data', (chunk) => chunks.push(chunk));\n zipStream.on('end', () => {});\n zip.pipe(zipStream);\n\n for (const recipe of recipes) {\n const data = Buffer.from(\n JSON.stringify({\n ...recipe,\n servings: toServings(recipe.servings),\n }),\n );\n zip.append(data, { name: `${recipe.name}.paprikarecipe` });\n }\n\n return new Promise((resolve, reject) => {\n zip.on('error', reject);\n zip.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n zip.finalize();\n });\n}\nfunction toServings(servings: null | string | undefined): null | string {\n if (!servings) {\n return null;\n }\n if (/^[0-9]+ ?[mlg]{1,2}$/i.test(servings.trim())) {\n return servings;\n }\n\n const number = parseInt(servings, 10);\n if (!isNaN(number)) {\n return number.toString();\n }\n\n return servings;\n}\n","import type { Writable } from 'node:stream';\n\nimport convert from 'heic-convert';\nimport { existsSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport { basename, join } from 'node:path';\nimport OpenAI from 'openai';\nimport { zodResponseFormat } from 'openai/helpers/zod';\nimport { pdfToPng } from 'pdf-to-png-converter';\n\nimport { Config } from './config.js';\nimport { Recipe, type RecipeType, toRecipes } from './recipe.js';\n\nexport interface ConverterOptions {\n stdout?: Writable;\n}\n\nexport const mimeTypes = new Map([\n ['.heic', 'image/heic'],\n ['.jpeg', 'image/jpeg'],\n ['.jpg', 'image/jpeg'],\n ['.pdf', 'application/pdf'],\n ['.png', 'image/png'],\n ['.webp', 'image/webp'],\n]);\n\nexport class Converter {\n protected client: OpenAI;\n protected config: Config;\n protected options: ConverterOptions;\n\n protected constructor(config: Config, options: ConverterOptions = {}) {\n this.config = config;\n this.options = options;\n this.client = new OpenAI({\n apiKey: config.llm.apiKey,\n baseURL: config.llm.baseUrl,\n });\n }\n\n static async convert(\n data: Buffer,\n mimeType: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n file: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n files: string[],\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n arg1: Buffer | string | string[],\n arg2?: ConverterOptions | string,\n arg3?: ConverterOptions,\n ): Promise<Buffer> {\n const config = await Config.use();\n\n let recipes: RecipeType[] = [];\n if (\n typeof arg1 === 'string' &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles([arg1]);\n } else if (\n Array.isArray(arg1) &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles(arg1);\n } else if (arg1 instanceof Buffer && typeof arg2 === 'string') {\n recipes = await new Converter(config, arg3).convertBuffer(\n `Buffer (${arg1.byteLength} bytes)`,\n arg1,\n arg2,\n );\n } else {\n throw new Error('Invalid arguments for convert function.');\n }\n\n return await toRecipes(recipes);\n }\n\n static async generateOutputFilePath(inputFiles: string[]): Promise<string> {\n let fileName = `${Date.now().toString()}`;\n if (inputFiles.length === 1) {\n fileName = basename(inputFiles[0]).replace(/\\.[a-z]{3,4}$/g, '');\n }\n\n const config = await Config.use();\n let path = join(config.output, fileName + '.paprikarecipes');\n if (existsSync(path)) {\n let counter = 1;\n do {\n path = join(\n config.output,\n `${fileName}-${counter}.paprikarecipes`,\n );\n counter++;\n } while (existsSync(path));\n }\n\n return path;\n }\n\n protected async convertBuffer(\n title: string,\n data: Buffer,\n mimeType: string,\n ): Promise<RecipeType[]> {\n // heic handling\n if (mimeType === 'image/heic') {\n this.log(title, '🔀');\n const jpeg = Buffer.from(\n await convert({\n buffer: data,\n format: 'JPEG',\n quality: 1,\n }),\n );\n return this.convertBuffer(title, jpeg, 'image/jpeg');\n }\n\n // pdf handling\n if (mimeType === 'application/pdf') {\n this.log(title, '🔀');\n const pages = await pdfToPng(data);\n this.log(title, true);\n\n const result: RecipeType[] = [];\n for (const page of pages) {\n result.push(\n ...(await this.convertBuffer(\n `${title} (p. ${page.pageNumber})`,\n page.content,\n 'image/png',\n )),\n );\n }\n\n return result;\n }\n\n this.log(title, '🪄');\n const response = await this.client.chat.completions.create({\n messages: [\n {\n content: [\n {\n text: this.config.llm.prompt,\n type: 'text',\n },\n ],\n role: 'developer',\n },\n {\n content: [\n {\n image_url: {\n url: `data:${mimeType};base64,${data.toString('base64')}`,\n },\n type: 'image_url',\n },\n ],\n role: 'user',\n },\n ],\n model: this.config.llm.model,\n response_format: zodResponseFormat(Recipe, 'recipe'),\n });\n\n const choice = response.choices[0];\n if (!choice.message || !choice.message.content) {\n throw new Error(\n `Invalid response from LLM for file: ${title} (mime = ${mimeType}, length = ${data.length})`,\n );\n }\n\n let json;\n try {\n json = JSON.parse(choice.message.content);\n } catch (error) {\n throw new Error(\n `Failed to parse LLM response for file: ${title} (mime = ${mimeType}, length = ${data.length}): ${(error as Error).message}`,\n );\n }\n\n const recipe = Recipe.parse(json);\n this.log(title, true);\n return [recipe];\n }\n\n protected async convertFile(file: string): Promise<RecipeType[]> {\n const ext = file.slice(file.lastIndexOf('.')).toLowerCase();\n const mimeType = mimeTypes.get(ext);\n if (mimeType) {\n return this.convertBuffer(file, await readFile(file), mimeType);\n }\n\n throw new Error(`Unsupported file format for file: ${file}`);\n }\n\n protected async convertFiles(files: string[] = []) {\n if (!files.length) {\n throw new Error('No input files specified for conversion.');\n }\n\n // Check all files exist\n for (const file of files) {\n if (!existsSync(file)) {\n throw new Error(`Unable to find file: ${file}`);\n }\n }\n\n // Convert files one by one\n const recipes: RecipeType[] = [];\n for (const file of files) {\n recipes.push(...(await this.convertFile(file)));\n }\n\n return recipes;\n }\n\n protected log(file: string, emojiOrDone: string | true) {\n const emoji = emojiOrDone === true ? '✅' : emojiOrDone;\n this.options.stdout?.write(\n `\\r${emoji} ${file}${emojiOrDone === true ? '\\n' : ''}`,\n );\n }\n}\n"],"mappings":"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","names":["randomBytes","existsSync","readFile","writeFile","join","resolve","prompts","Config","_Config","config","content","err","json","llm","value","output","error","archiver","PassThrough","z","Recipe","toRecipes","recipes","zip","zipStream","chunks","chunk","recipe","data","toServings","resolve","reject","servings","number","convert","existsSync","readFile","basename","join","OpenAI","zodResponseFormat","pdfToPng","mimeTypes","Converter","_Converter","config","options","OpenAI","arg1","arg2","arg3","Config","recipes","toRecipes","inputFiles","fileName","basename","path","join","existsSync","counter","title","data","mimeType","jpeg","convert","pages","pdfToPng","result","page","choice","zodResponseFormat","Recipe","json","error","recipe","file","ext","readFile","files","emojiOrDone","emoji"]}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";var B=Object.create;var g=Object.defineProperty;var L=Object.getOwnPropertyDescriptor;var F=Object.getOwnPropertyNames;var U=Object.getPrototypeOf,N=Object.prototype.hasOwnProperty;var S=(r,e)=>{for(var t in e)g(r,t,{get:e[t],enumerable:!0})},R=(r,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of F(e))!N.call(r,n)&&n!==t&&g(r,n,{get:()=>e[n],enumerable:!(o=L(e,n))||o.enumerable});return r};var c=(r,e,t)=>(t=r!=null?B(U(r)):{},R(e||!r||!r.__esModule?g(t,"default",{value:r,enumerable:!0}):t,r)),j=r=>R(g({},"__esModule",{value:!0}),r);var M={};S(M,{Config:()=>a,Converter:()=>y,Recipe:()=>f,toRecipes:()=>h});module.exports=j(M);var O=require("crypto"),w=require("fs"),m=require("fs/promises"),l=require("path"),v=c(require("prompts"),1),a=class r{constructor(e){this.config=e}static get path(){return process.env.TO_PAPRIKA_CONFIG_PATH?(0,l.resolve)(process.env.TO_PAPRIKA_CONFIG_PATH):(0,l.join)(this.homePath,".to-paprika.config.json")}static get homePath(){if(process.platform==="win32"&&process.env.USERPROFILE)return process.env.USERPROFILE;if(process.platform!=="win32"&&process.env.HOME)return process.env.HOME;throw new Error("Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.")}get llm(){return{...this.config.llm,prompt:process.env.TO_PAPRIKA_CONFIG_PROMPT||"You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate."}}get output(){return this.config.output}get server(){return this.config.server}static async getJson(){if(!(0,w.existsSync)(this.path))throw new Error(`Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`);let e;try{e=await(0,m.readFile)(this.path,"utf8")}catch(o){throw new Error(`Failed to read config file at ${this.path}: ${o.message}`)}let t;try{t=JSON.parse(e)}catch(o){throw new Error(`Failed to parse config file at ${this.path}: ${o.message}`)}return t}static async setup(){console.log(`
|
|
2
|
+
\u{1F44B}\u{1F3FC} Hi there`),console.log("");let e;try{e=await this.getJson()}catch{}console.log("\u{1F325}\uFE0F to-paprica requires an OpenAI-compatible LLM provider to function."),console.log(" Please provide the following provider details:"),console.log("");let t=await(0,v.default)([{initial:e?.llm.baseUrl,message:" LLM Provider Base URL:",name:"baseUrl",type:"text",validate:i=>{try{return new URL(i),!0}catch{return"Invalid Base URL"}}},{initial:e?.llm.apiKey,message:" LLM Provider API Key:",name:"apiKey",type:"password",validate:i=>i.length>0?!0:"API Key cannot be empty"},{initial:e?.llm.model,message:" LLM Model to use:",name:"model",type:"text",validate:i=>i.length>0?!0:"Model cannot be empty"}]);console.log(""),console.log("\u{1F4C2} Next, please specify the output directory for your Paprika recipes."),console.log(" This is where all converted recipes will be saved."),console.log("");let o=await(0,v.default)({initial:e?.output||(0,l.resolve)(process.cwd()),message:" Output Directory:",name:"output",type:"text",validate:i=>(0,w.existsSync)(i)}),n={llm:t,output:o.output,server:{authToken:e?.server?.authToken}};n.server.authToken||(n.server.authToken=(0,O.randomBytes)(32).toString("hex")),console.log(""),console.log("\u{1F512} Great. Just in case you would like to use the build-in server,"),console.log(` please use this API Token to authenticate your requests:
|
|
3
|
+
`),console.log(` ${n.server.authToken}`),console.log("");try{await(0,m.writeFile)(this.path,JSON.stringify(n,null,2))}catch(i){throw console.log("\u{1F6A8} Failed to save config file"),console.log(""),new Error(`Failed to write config file at ${this.path}: ${i.message}`)}console.log(""),console.log("\u{1F389} Config file saved successfully"),console.log("")}static async use(){let e=await this.getJson();return new r(e)}};var I=c(require("heic-convert"),1),d=require("fs"),k=require("fs/promises"),u=require("path"),x=c(require("openai"),1),E=require("openai/helpers/zod"),z=require("pdf-to-png-converter");var $=c(require("archiver"),1),T=require("stream"),s=c(require("zod"),1),f=s.object({cook_time:s.string().optional().nullable().describe("Cooking time required, e.g. '30 min' or '1 h'. Keep it short."),description:s.string().optional().nullable().describe("A brief description of the recipe. Use newline characters for formatting."),directions:s.string().describe("Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list."),ingredients:s.string('Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g "1 cup milk" or "3 apples". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.'),name:s.string(),notes:s.string().describe("An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields."),prep_time:s.string().optional().nullable().describe('Preparation time required before cooking, e.g. "15 min" or "30 min". Keep it short.'),servings:s.string().optional().nullable().describe('Number of servings, e.g. "2" or "4-6 servings".'),source:s.string().optional().nullable().describe("Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL."),total_time:s.string().optional().nullable().describe('Total time required to prepare the dish, e.g. "45 min" or "1.5 h". Keep it short.')});async function h(r){let e=(0,$.default)("zip",{zlib:{level:9}}),t=new T.PassThrough,o=[];t.on("data",n=>o.push(n)),t.on("end",()=>{}),e.pipe(t);for(let n of r){let i=Buffer.from(JSON.stringify({...n,servings:K(n.servings)}));e.append(i,{name:`${n.name}.paprikarecipe`})}return new Promise((n,i)=>{e.on("error",i),e.on("end",()=>{n(Buffer.concat(o))}),e.finalize()})}function K(r){if(!r)return null;if(/^[0-9]+ ?[mlg]{1,2}$/i.test(r.trim()))return r;let e=parseInt(r,10);return isNaN(e)?r:e.toString()}var _=new Map([[".heic","image/heic"],[".jpeg","image/jpeg"],[".jpg","image/jpeg"],[".pdf","application/pdf"],[".png","image/png"],[".webp","image/webp"]]),y=class r{client;config;options;constructor(e,t={}){this.config=e,this.options=t,this.client=new x.default({apiKey:e.llm.apiKey,baseURL:e.llm.baseUrl})}static async convert(e,t,o){let n=await a.use(),i=[];if(typeof e=="string"&&(typeof t=="object"||t===void 0))i=await new r(n,t).convertFiles([e]);else if(Array.isArray(e)&&(typeof t=="object"||t===void 0))i=await new r(n,t).convertFiles(e);else if(e instanceof Buffer&&typeof t=="string")i=await new r(n,o).convertBuffer(`Buffer (${e.byteLength} bytes)`,e,t);else throw new Error("Invalid arguments for convert function.");return await h(i)}static async generateOutputFilePath(e){let t=`${Date.now().toString()}`;e.length===1&&(t=(0,u.basename)(e[0]).replace(/\.[a-z]{3,4}$/g,""));let o=await a.use(),n=(0,u.join)(o.output,t+".paprikarecipes");if((0,d.existsSync)(n)){let i=1;do n=(0,u.join)(o.output,`${t}-${i}.paprikarecipes`),i++;while((0,d.existsSync)(n))}return n}async convertBuffer(e,t,o){if(o==="image/heic"){this.log(e,"\u{1F500}");let p=Buffer.from(await(0,I.default)({buffer:t,format:"JPEG",quality:1}));return this.convertBuffer(e,p,"image/jpeg")}if(o==="application/pdf"){this.log(e,"\u{1F500}");let p=await(0,z.pdfToPng)(t);this.log(e,!0);let C=[];for(let P of p)C.push(...await this.convertBuffer(`${e} (p. ${P.pageNumber})`,P.content,"image/png"));return C}this.log(e,"\u{1FA84}");let i=(await this.client.chat.completions.create({messages:[{content:[{text:this.config.llm.prompt,type:"text"}],role:"developer"},{content:[{image_url:{url:`data:${o};base64,${t.toString("base64")}`},type:"image_url"}],role:"user"}],model:this.config.llm.model,response_format:(0,E.zodResponseFormat)(f,"recipe")})).choices[0];if(!i.message||!i.message.content)throw new Error(`Invalid response from LLM for file: ${e} (mime = ${o}, length = ${t.length})`);let b;try{b=JSON.parse(i.message.content)}catch(p){throw new Error(`Failed to parse LLM response for file: ${e} (mime = ${o}, length = ${t.length}): ${p.message}`)}let A=f.parse(b);return this.log(e,!0),[A]}async convertFile(e){let t=e.slice(e.lastIndexOf(".")).toLowerCase(),o=_.get(t);if(o)return this.convertBuffer(e,await(0,k.readFile)(e),o);throw new Error(`Unsupported file format for file: ${e}`)}async convertFiles(e=[]){if(!e.length)throw new Error("No input files specified for conversion.");for(let o of e)if(!(0,d.existsSync)(o))throw new Error(`Unable to find file: ${o}`);let t=[];for(let o of e)t.push(...await this.convertFile(o));return t}log(e,t){let o=t===!0?"\u2705":t;this.options.stdout?.write(`\r${o} ${e}${t===!0?`
|
|
4
|
+
`:""}`)}};0&&(module.exports={Config,Converter,Recipe,toRecipes});
|
|
5
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/index.ts","../../src/lib/config.ts","../../src/lib/convert.ts","../../src/lib/recipe.ts"],"sourcesContent":["export { Config, type ConfigContent } from './config.js';\nexport { Converter, type ConverterOptions } from './convert.js';\nexport { Recipe, type RecipeType, toRecipes } from './recipe.js';\n","import { randomBytes } from 'node:crypto';\nimport { existsSync } from 'node:fs';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { join, resolve } from 'node:path';\nimport prompts from 'prompts';\n\nexport interface ConfigContent {\n llm: {\n apiKey: string;\n baseUrl: string;\n model: string;\n };\n output: string;\n server: {\n authToken?: string;\n };\n}\n\nexport class Config {\n static get path() {\n if (process.env.TO_PAPRIKA_CONFIG_PATH) {\n return resolve(process.env.TO_PAPRIKA_CONFIG_PATH);\n }\n\n return join(this.homePath, '.to-paprika.config.json');\n }\n protected static get homePath() {\n if (process.platform === 'win32' && process.env.USERPROFILE) {\n return process.env.USERPROFILE;\n }\n if (process.platform !== 'win32' && process.env.HOME) {\n return process.env.HOME;\n }\n\n throw new Error(\n 'Cannot determine home directory, please set the TO_PAPRIKA_CONFIG_PATH environment variable.',\n );\n }\n\n get llm(): ConfigContent['llm'] & { prompt: string } {\n return {\n ...this.config.llm,\n prompt:\n process.env.TO_PAPRIKA_CONFIG_PROMPT ||\n 'You digitize recipes. Please recognize the recipe sent in the attachment and respond with a precise and complete JSON. This includes all original information such as the title, ingredients, preparation times, cooking steps, or notes. If the title is in all caps, it will be adjusted for better readability. If the source is available, such as the book title (possibly with page number) or the URL, this will also be included. Ensure that the JSON is properly formatted and valid. If any field is missing in the input, use undefined or an empty array as appropriate.',\n };\n }\n get output() {\n return this.config.output;\n }\n get server(): ConfigContent['server'] {\n return this.config.server;\n }\n\n constructor(private readonly config: ConfigContent) {}\n\n static async getJson() {\n if (!existsSync(this.path)) {\n throw new Error(\n `Config file not found at ${this.path}. Run 'to-paprika setup' to create one.`,\n );\n }\n\n let content: string | undefined;\n try {\n content = await readFile(this.path, 'utf8');\n } catch (err) {\n throw new Error(\n `Failed to read config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n let json: ConfigContent;\n try {\n json = JSON.parse(content) as ConfigContent;\n } catch (err) {\n throw new Error(\n `Failed to parse config file at ${this.path}: ${(err as Error).message}`,\n );\n }\n\n return json;\n }\n\n static async setup() {\n console.log('\\n👋🏼 Hi there');\n console.log('');\n\n let json: ConfigContent | undefined = undefined;\n try {\n json = await this.getJson();\n } catch {\n // ignore errors\n }\n\n console.log(\n '🌥️ to-paprica requires an OpenAI-compatible LLM provider to function.',\n );\n console.log(' Please provide the following provider details:');\n console.log('');\n\n const llm = await prompts([\n {\n initial: json?.llm.baseUrl,\n message: ' LLM Provider Base URL:',\n name: 'baseUrl',\n type: 'text',\n validate: (value) => {\n try {\n new URL(value);\n return true;\n } catch {\n return 'Invalid Base URL';\n }\n },\n },\n {\n initial: json?.llm.apiKey,\n message: ' LLM Provider API Key:',\n name: 'apiKey',\n type: 'password',\n validate: (value) =>\n value.length > 0 ? true : 'API Key cannot be empty',\n },\n {\n initial: json?.llm.model,\n message: ' LLM Model to use:',\n name: 'model',\n type: 'text',\n validate: (value) =>\n value.length > 0 ? true : 'Model cannot be empty',\n },\n ]);\n\n console.log('');\n console.log(\n '📂 Next, please specify the output directory for your Paprika recipes.',\n );\n console.log(' This is where all converted recipes will be saved.');\n console.log('');\n\n const output = await prompts({\n initial: json?.output || resolve(process.cwd()),\n message: ' Output Directory:',\n name: 'output',\n type: 'text',\n validate: (value) => existsSync(value),\n });\n\n const config: ConfigContent = {\n llm,\n output: output.output,\n server: {\n authToken: json?.server?.authToken,\n },\n };\n if (!config.server.authToken) {\n config.server.authToken = randomBytes(32).toString('hex');\n }\n\n console.log('');\n console.log(\n '🔒 Great. Just in case you would like to use the build-in server,',\n );\n console.log(\n ' please use this API Token to authenticate your requests:\\n',\n );\n console.log(` ${config.server.authToken}`);\n console.log('');\n\n try {\n await writeFile(this.path, JSON.stringify(config, null, 2));\n } catch (error) {\n console.log('🚨 Failed to save config file');\n console.log('');\n throw new Error(\n `Failed to write config file at ${this.path}: ${(error as Error).message}`,\n );\n }\n\n console.log('');\n console.log('🎉 Config file saved successfully');\n console.log('');\n }\n\n static async use() {\n const json = await this.getJson();\n return new Config(json);\n }\n}\n","import type { Writable } from 'node:stream';\n\nimport convert from 'heic-convert';\nimport { existsSync } from 'node:fs';\nimport { readFile } from 'node:fs/promises';\nimport { basename, join } from 'node:path';\nimport OpenAI from 'openai';\nimport { zodResponseFormat } from 'openai/helpers/zod';\nimport { pdfToPng } from 'pdf-to-png-converter';\n\nimport { Config } from './config.js';\nimport { Recipe, type RecipeType, toRecipes } from './recipe.js';\n\nexport interface ConverterOptions {\n stdout?: Writable;\n}\n\nexport const mimeTypes = new Map([\n ['.heic', 'image/heic'],\n ['.jpeg', 'image/jpeg'],\n ['.jpg', 'image/jpeg'],\n ['.pdf', 'application/pdf'],\n ['.png', 'image/png'],\n ['.webp', 'image/webp'],\n]);\n\nexport class Converter {\n protected client: OpenAI;\n protected config: Config;\n protected options: ConverterOptions;\n\n protected constructor(config: Config, options: ConverterOptions = {}) {\n this.config = config;\n this.options = options;\n this.client = new OpenAI({\n apiKey: config.llm.apiKey,\n baseURL: config.llm.baseUrl,\n });\n }\n\n static async convert(\n data: Buffer,\n mimeType: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n file: string,\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n files: string[],\n options?: ConverterOptions,\n ): Promise<Buffer>;\n static async convert(\n arg1: Buffer | string | string[],\n arg2?: ConverterOptions | string,\n arg3?: ConverterOptions,\n ): Promise<Buffer> {\n const config = await Config.use();\n\n let recipes: RecipeType[] = [];\n if (\n typeof arg1 === 'string' &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles([arg1]);\n } else if (\n Array.isArray(arg1) &&\n (typeof arg2 === 'object' || arg2 === undefined)\n ) {\n recipes = await new Converter(config, arg2).convertFiles(arg1);\n } else if (arg1 instanceof Buffer && typeof arg2 === 'string') {\n recipes = await new Converter(config, arg3).convertBuffer(\n `Buffer (${arg1.byteLength} bytes)`,\n arg1,\n arg2,\n );\n } else {\n throw new Error('Invalid arguments for convert function.');\n }\n\n return await toRecipes(recipes);\n }\n\n static async generateOutputFilePath(inputFiles: string[]): Promise<string> {\n let fileName = `${Date.now().toString()}`;\n if (inputFiles.length === 1) {\n fileName = basename(inputFiles[0]).replace(/\\.[a-z]{3,4}$/g, '');\n }\n\n const config = await Config.use();\n let path = join(config.output, fileName + '.paprikarecipes');\n if (existsSync(path)) {\n let counter = 1;\n do {\n path = join(\n config.output,\n `${fileName}-${counter}.paprikarecipes`,\n );\n counter++;\n } while (existsSync(path));\n }\n\n return path;\n }\n\n protected async convertBuffer(\n title: string,\n data: Buffer,\n mimeType: string,\n ): Promise<RecipeType[]> {\n // heic handling\n if (mimeType === 'image/heic') {\n this.log(title, '🔀');\n const jpeg = Buffer.from(\n await convert({\n buffer: data,\n format: 'JPEG',\n quality: 1,\n }),\n );\n return this.convertBuffer(title, jpeg, 'image/jpeg');\n }\n\n // pdf handling\n if (mimeType === 'application/pdf') {\n this.log(title, '🔀');\n const pages = await pdfToPng(data);\n this.log(title, true);\n\n const result: RecipeType[] = [];\n for (const page of pages) {\n result.push(\n ...(await this.convertBuffer(\n `${title} (p. ${page.pageNumber})`,\n page.content,\n 'image/png',\n )),\n );\n }\n\n return result;\n }\n\n this.log(title, '🪄');\n const response = await this.client.chat.completions.create({\n messages: [\n {\n content: [\n {\n text: this.config.llm.prompt,\n type: 'text',\n },\n ],\n role: 'developer',\n },\n {\n content: [\n {\n image_url: {\n url: `data:${mimeType};base64,${data.toString('base64')}`,\n },\n type: 'image_url',\n },\n ],\n role: 'user',\n },\n ],\n model: this.config.llm.model,\n response_format: zodResponseFormat(Recipe, 'recipe'),\n });\n\n const choice = response.choices[0];\n if (!choice.message || !choice.message.content) {\n throw new Error(\n `Invalid response from LLM for file: ${title} (mime = ${mimeType}, length = ${data.length})`,\n );\n }\n\n let json;\n try {\n json = JSON.parse(choice.message.content);\n } catch (error) {\n throw new Error(\n `Failed to parse LLM response for file: ${title} (mime = ${mimeType}, length = ${data.length}): ${(error as Error).message}`,\n );\n }\n\n const recipe = Recipe.parse(json);\n this.log(title, true);\n return [recipe];\n }\n\n protected async convertFile(file: string): Promise<RecipeType[]> {\n const ext = file.slice(file.lastIndexOf('.')).toLowerCase();\n const mimeType = mimeTypes.get(ext);\n if (mimeType) {\n return this.convertBuffer(file, await readFile(file), mimeType);\n }\n\n throw new Error(`Unsupported file format for file: ${file}`);\n }\n\n protected async convertFiles(files: string[] = []) {\n if (!files.length) {\n throw new Error('No input files specified for conversion.');\n }\n\n // Check all files exist\n for (const file of files) {\n if (!existsSync(file)) {\n throw new Error(`Unable to find file: ${file}`);\n }\n }\n\n // Convert files one by one\n const recipes: RecipeType[] = [];\n for (const file of files) {\n recipes.push(...(await this.convertFile(file)));\n }\n\n return recipes;\n }\n\n protected log(file: string, emojiOrDone: string | true) {\n const emoji = emojiOrDone === true ? '✅' : emojiOrDone;\n this.options.stdout?.write(\n `\\r${emoji} ${file}${emojiOrDone === true ? '\\n' : ''}`,\n );\n }\n}\n","import archiver from 'archiver';\nimport { PassThrough } from 'node:stream';\nimport * as z from 'zod';\n\nexport const Recipe = z.object({\n cook_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n \"Cooking time required, e.g. '30 min' or '1 h'. Keep it short.\",\n ),\n description: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'A brief description of the recipe. Use newline characters for formatting.',\n ),\n directions: z\n .string()\n .describe(\n 'Step-by-step instructions for preparing the recipe. Use newline characters to separate steps, but do not remove any existing bullets or numbers from a list.',\n ),\n ingredients: z.string(\n 'Ingredients must be a string with each ingredient on a new line. Add required quantity at the front, e.g \"1 cup milk\" or \"3 apples\". Use markdown formatting to improve readability, for example to separate ingredients for dough and topping.',\n ),\n name: z.string(),\n notes: z\n .string()\n .describe(\n 'An unconstrained field for providing miscellaneous notes. Use newline characters for formatting. Do not add stuff that belongs in other fields.',\n ),\n prep_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Preparation time required before cooking, e.g. \"15 min\" or \"30 min\". Keep it short.',\n ),\n servings: z\n .string()\n .optional()\n .nullable()\n .describe('Number of servings, e.g. \"2\" or \"4-6 servings\".'),\n source: z\n .string()\n .optional()\n .nullable()\n .describe(\"Recipe source, e.g. 'Grandma's Cookbook, p. 123' or a URL.\"),\n total_time: z\n .string()\n .optional()\n .nullable()\n .describe(\n 'Total time required to prepare the dish, e.g. \"45 min\" or \"1.5 h\". Keep it short.',\n ),\n});\n\nexport type RecipeType = z.infer<typeof Recipe>;\n\n/**\n * Convert an array of recipes to a .paprikarecipes buffer\n */\nexport async function toRecipes(recipes: RecipeType[]): Promise<Buffer> {\n const zip = archiver('zip', { zlib: { level: 9 } });\n const zipStream = new PassThrough();\n const chunks: Buffer[] = [];\n\n zipStream.on('data', (chunk) => chunks.push(chunk));\n zipStream.on('end', () => {});\n zip.pipe(zipStream);\n\n for (const recipe of recipes) {\n const data = Buffer.from(\n JSON.stringify({\n ...recipe,\n servings: toServings(recipe.servings),\n }),\n );\n zip.append(data, { name: `${recipe.name}.paprikarecipe` });\n }\n\n return new Promise((resolve, reject) => {\n zip.on('error', reject);\n zip.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n zip.finalize();\n });\n}\nfunction toServings(servings: null | string | undefined): null | string {\n if (!servings) {\n return null;\n }\n if (/^[0-9]+ ?[mlg]{1,2}$/i.test(servings.trim())) {\n return servings;\n }\n\n const number = parseInt(servings, 10);\n if (!isNaN(number)) {\n return number.toString();\n }\n\n return servings;\n}\n"],"mappings":"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","names":["lib_exports","__export","Config","Converter","Recipe","toRecipes","__toCommonJS","import_node_crypto","import_node_fs","import_promises","import_node_path","import_prompts","Config","_Config","config","content","err","json","llm","prompts","value","output","error","import_heic_convert","import_node_fs","import_promises","import_node_path","import_openai","import_zod","import_pdf_to_png_converter","import_archiver","import_node_stream","z","Recipe","toRecipes","recipes","zip","archiver","zipStream","chunks","chunk","recipe","data","toServings","resolve","reject","servings","number","mimeTypes","Converter","_Converter","config","options","OpenAI","arg1","arg2","arg3","Config","recipes","toRecipes","inputFiles","fileName","path","counter","title","data","mimeType","jpeg","convert","pages","result","page","choice","Recipe","json","error","recipe","file","ext","files","emojiOrDone","emoji"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import OpenAI from 'openai';
|
|
3
|
+
import * as z from 'zod';
|
|
4
|
+
|
|
5
|
+
interface ConfigContent {
|
|
6
|
+
llm: {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
model: string;
|
|
10
|
+
};
|
|
11
|
+
output: string;
|
|
12
|
+
server: {
|
|
13
|
+
authToken?: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
declare class Config {
|
|
17
|
+
private readonly config;
|
|
18
|
+
static get path(): string;
|
|
19
|
+
protected static get homePath(): string;
|
|
20
|
+
get llm(): ConfigContent['llm'] & {
|
|
21
|
+
prompt: string;
|
|
22
|
+
};
|
|
23
|
+
get output(): string;
|
|
24
|
+
get server(): ConfigContent['server'];
|
|
25
|
+
constructor(config: ConfigContent);
|
|
26
|
+
static getJson(): Promise<ConfigContent>;
|
|
27
|
+
static setup(): Promise<void>;
|
|
28
|
+
static use(): Promise<Config>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare const Recipe: z.ZodObject<{
|
|
32
|
+
cook_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
33
|
+
description: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
34
|
+
directions: z.ZodString;
|
|
35
|
+
ingredients: z.ZodString;
|
|
36
|
+
name: z.ZodString;
|
|
37
|
+
notes: z.ZodString;
|
|
38
|
+
prep_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
39
|
+
servings: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
40
|
+
source: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
41
|
+
total_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
type RecipeType = z.infer<typeof Recipe>;
|
|
44
|
+
/**
|
|
45
|
+
* Convert an array of recipes to a .paprikarecipes buffer
|
|
46
|
+
*/
|
|
47
|
+
declare function toRecipes(recipes: RecipeType[]): Promise<Buffer>;
|
|
48
|
+
|
|
49
|
+
interface ConverterOptions {
|
|
50
|
+
stdout?: Writable;
|
|
51
|
+
}
|
|
52
|
+
declare class Converter {
|
|
53
|
+
protected client: OpenAI;
|
|
54
|
+
protected config: Config;
|
|
55
|
+
protected options: ConverterOptions;
|
|
56
|
+
protected constructor(config: Config, options?: ConverterOptions);
|
|
57
|
+
static convert(data: Buffer, mimeType: string, options?: ConverterOptions): Promise<Buffer>;
|
|
58
|
+
static convert(file: string, options?: ConverterOptions): Promise<Buffer>;
|
|
59
|
+
static convert(files: string[], options?: ConverterOptions): Promise<Buffer>;
|
|
60
|
+
static generateOutputFilePath(inputFiles: string[]): Promise<string>;
|
|
61
|
+
protected convertBuffer(title: string, data: Buffer, mimeType: string): Promise<RecipeType[]>;
|
|
62
|
+
protected convertFile(file: string): Promise<RecipeType[]>;
|
|
63
|
+
protected convertFiles(files?: string[]): Promise<{
|
|
64
|
+
directions: string;
|
|
65
|
+
ingredients: string;
|
|
66
|
+
name: string;
|
|
67
|
+
notes: string;
|
|
68
|
+
cook_time?: string | null | undefined;
|
|
69
|
+
description?: string | null | undefined;
|
|
70
|
+
prep_time?: string | null | undefined;
|
|
71
|
+
servings?: string | null | undefined;
|
|
72
|
+
source?: string | null | undefined;
|
|
73
|
+
total_time?: string | null | undefined;
|
|
74
|
+
}[]>;
|
|
75
|
+
protected log(file: string, emojiOrDone: string | true): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { Config, type ConfigContent, Converter, type ConverterOptions, Recipe, type RecipeType, toRecipes };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Writable } from 'node:stream';
|
|
2
|
+
import OpenAI from 'openai';
|
|
3
|
+
import * as z from 'zod';
|
|
4
|
+
|
|
5
|
+
interface ConfigContent {
|
|
6
|
+
llm: {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
model: string;
|
|
10
|
+
};
|
|
11
|
+
output: string;
|
|
12
|
+
server: {
|
|
13
|
+
authToken?: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
declare class Config {
|
|
17
|
+
private readonly config;
|
|
18
|
+
static get path(): string;
|
|
19
|
+
protected static get homePath(): string;
|
|
20
|
+
get llm(): ConfigContent['llm'] & {
|
|
21
|
+
prompt: string;
|
|
22
|
+
};
|
|
23
|
+
get output(): string;
|
|
24
|
+
get server(): ConfigContent['server'];
|
|
25
|
+
constructor(config: ConfigContent);
|
|
26
|
+
static getJson(): Promise<ConfigContent>;
|
|
27
|
+
static setup(): Promise<void>;
|
|
28
|
+
static use(): Promise<Config>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare const Recipe: z.ZodObject<{
|
|
32
|
+
cook_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
33
|
+
description: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
34
|
+
directions: z.ZodString;
|
|
35
|
+
ingredients: z.ZodString;
|
|
36
|
+
name: z.ZodString;
|
|
37
|
+
notes: z.ZodString;
|
|
38
|
+
prep_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
39
|
+
servings: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
40
|
+
source: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
41
|
+
total_time: z.ZodNullable<z.ZodOptional<z.ZodString>>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
type RecipeType = z.infer<typeof Recipe>;
|
|
44
|
+
/**
|
|
45
|
+
* Convert an array of recipes to a .paprikarecipes buffer
|
|
46
|
+
*/
|
|
47
|
+
declare function toRecipes(recipes: RecipeType[]): Promise<Buffer>;
|
|
48
|
+
|
|
49
|
+
interface ConverterOptions {
|
|
50
|
+
stdout?: Writable;
|
|
51
|
+
}
|
|
52
|
+
declare class Converter {
|
|
53
|
+
protected client: OpenAI;
|
|
54
|
+
protected config: Config;
|
|
55
|
+
protected options: ConverterOptions;
|
|
56
|
+
protected constructor(config: Config, options?: ConverterOptions);
|
|
57
|
+
static convert(data: Buffer, mimeType: string, options?: ConverterOptions): Promise<Buffer>;
|
|
58
|
+
static convert(file: string, options?: ConverterOptions): Promise<Buffer>;
|
|
59
|
+
static convert(files: string[], options?: ConverterOptions): Promise<Buffer>;
|
|
60
|
+
static generateOutputFilePath(inputFiles: string[]): Promise<string>;
|
|
61
|
+
protected convertBuffer(title: string, data: Buffer, mimeType: string): Promise<RecipeType[]>;
|
|
62
|
+
protected convertFile(file: string): Promise<RecipeType[]>;
|
|
63
|
+
protected convertFiles(files?: string[]): Promise<{
|
|
64
|
+
directions: string;
|
|
65
|
+
ingredients: string;
|
|
66
|
+
name: string;
|
|
67
|
+
notes: string;
|
|
68
|
+
cook_time?: string | null | undefined;
|
|
69
|
+
description?: string | null | undefined;
|
|
70
|
+
prep_time?: string | null | undefined;
|
|
71
|
+
servings?: string | null | undefined;
|
|
72
|
+
source?: string | null | undefined;
|
|
73
|
+
total_time?: string | null | undefined;
|
|
74
|
+
}[]>;
|
|
75
|
+
protected log(file: string, emojiOrDone: string | true): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { Config, type ConfigContent, Converter, type ConverterOptions, Recipe, type RecipeType, toRecipes };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Sebastian Pekarek <mail@sebbo.net>",
|
|
3
|
+
"bin": {
|
|
4
|
+
"to-paprika": "./dist/bin/cli.js"
|
|
5
|
+
},
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/sebbo2002/to-paprika/issues"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"archiver": "^7.0.1",
|
|
11
|
+
"express": "^4.20.0",
|
|
12
|
+
"heic-convert": "^2.1.0",
|
|
13
|
+
"openai": "^6.7.0",
|
|
14
|
+
"pdf-to-png-converter": "^3.10.0",
|
|
15
|
+
"prompts": "^2.4.2",
|
|
16
|
+
"yargs": "^18.0.0",
|
|
17
|
+
"zod": "^4.1.12"
|
|
18
|
+
},
|
|
19
|
+
"description": "Simple image/pdf to paprika app converter using LLMs",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@eslint/js": "^9.6.0",
|
|
22
|
+
"@qiwi/semantic-release-gh-pages-plugin": "^5.2.12",
|
|
23
|
+
"@sebbo2002/semantic-release-docker": "^2.1.0",
|
|
24
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
25
|
+
"@semantic-release/exec": "^6.0.3",
|
|
26
|
+
"@semantic-release/git": "^10.0.1",
|
|
27
|
+
"@semantic-release/npm": "^12.0.1",
|
|
28
|
+
"@types/archiver": "^7.0.0",
|
|
29
|
+
"@types/express": "^4.17.21",
|
|
30
|
+
"@types/heic-convert": "^2.1.0",
|
|
31
|
+
"@types/mocha": "^10.0.7",
|
|
32
|
+
"@types/node": "^20.14.10",
|
|
33
|
+
"@types/prompts": "^2.4.9",
|
|
34
|
+
"@types/yargs": "^17.0.34",
|
|
35
|
+
"c8": "^10.1.2",
|
|
36
|
+
"eslint": "^9.6.0",
|
|
37
|
+
"eslint-config-prettier": "^10.1.2",
|
|
38
|
+
"eslint-plugin-jsonc": "^2.16.0",
|
|
39
|
+
"eslint-plugin-perfectionist": "^4.12.3",
|
|
40
|
+
"esm": "^3.2.25",
|
|
41
|
+
"husky": "^9.1.7",
|
|
42
|
+
"license-checker": "^25.0.1",
|
|
43
|
+
"mocha": "^10.6.0",
|
|
44
|
+
"mochawesome": "^7.1.3",
|
|
45
|
+
"prettier": "^3.5.3",
|
|
46
|
+
"semantic-release-license": "^1.0.3",
|
|
47
|
+
"source-map-support": "^0.5.21",
|
|
48
|
+
"tsup": "^8.3.5",
|
|
49
|
+
"tsx": "^4.16.2",
|
|
50
|
+
"typedoc": "^0.26.3",
|
|
51
|
+
"typescript": "^5.5.3",
|
|
52
|
+
"typescript-eslint": "^8.0.0-alpha.41"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": "20 || >=22.0.0"
|
|
56
|
+
},
|
|
57
|
+
"exports": {
|
|
58
|
+
"import": "./dist/lib/index.js",
|
|
59
|
+
"require": "./dist/lib/index.cjs"
|
|
60
|
+
},
|
|
61
|
+
"files": [
|
|
62
|
+
"/dist"
|
|
63
|
+
],
|
|
64
|
+
"homepage": "https://github.com/sebbo2002/to-paprika#readme",
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"main": "./dist/lib/index.cjs",
|
|
67
|
+
"module": "./dist/lib/index.js",
|
|
68
|
+
"name": "@sebbo2002/to-paprika",
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/sebbo2002/to-paprika.git"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsup && cp ./dist/lib/index.d.ts ./dist/lib/index.d.cts",
|
|
75
|
+
"build-all": "./.github/workflows/build.sh",
|
|
76
|
+
"coverage": "c8 mocha",
|
|
77
|
+
"develop": "tsx src/bin/start.ts",
|
|
78
|
+
"license-check": "license-checker --production --summary",
|
|
79
|
+
"lint": "eslint . --fix && prettier . --write",
|
|
80
|
+
"start": "node ./dist/bin/start.js",
|
|
81
|
+
"test": "mocha"
|
|
82
|
+
},
|
|
83
|
+
"type": "module",
|
|
84
|
+
"version": "1.0.0-develop.2"
|
|
85
|
+
}
|