Avatar

stevekrouse

mayor of val town
833 public vals
Joined July 11, 2022

CronGPT

This is a minisite to help you create cron expressions, particularly for crons on Val Town. It was inspired by Cron Prompt, but also does the timezone conversion from wherever you are to UTC (typically the server timezone).

Tech

  • Hono for routing (GET / and POST /compile.)
  • Hono JSX
  • HTMX (probably overcomplicates things; should remove)
  • @stevekrouse/openai, which is a light wrapper around @std/openai

I'm finding HTMX a bit overpowered for this, so I have two experimental forks without it:

  1. Vanilla client-side JavaScript: @stevekrouse/cron_client_side_script_fork
  2. Client-side ReactJS (no SSR): @stevekrouse/cron_client_react_fork

I think (2) Client-side React without any SSR is the simplest architecture. Maybe will move to that.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource npm:hono@3/jsx */
import { chat } from "https://esm.town/v/stevekrouse/openai";
import cronstrue from "npm:cronstrue";
import { Hono } from "npm:hono@3";
const css = `
.htmx-indicator{
display:none;
}
.htmx-request .htmx-indicator{
display:inline;
}
.htmx-request.htmx-indicator{
display:inline;
}`;
const app = new Hono();
export default app.fetch;
app.get("/", (c) =>
c.html(
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com" />
<script
src="https://unpkg.com/htmx.org@1.9.11"
integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0"
crossorigin="anonymous"
>
</script>
<style dangerouslySetInnerHTML={{ __html: css }} />
<title>CronGPT</title>
</head>
<body class="flex p-6 mt-4 flex-col space-y-12 max-w-2xl mx-auto">
<div class="flex flex-col text-center space-y-2">
<h1 class="text-3xl font-bold">CronGPT</h1>
<p class="text-lg">Generate cron expressions with ChatGPT</p>
</div>
<form hx-post="/compile" hx-target="#cron" class="flex flex-col space-y-4" hx-disabled-elt="button">
<div class="flex flex-col">
<label for="description">Natural language description</label>
<input
name="description"
value="On weekdays at noon"
required
class="border-2 rounded-lg p-2 text-lg text-center"
/>
</div>
<div class="flex flex-col">
<label for="description">Timezone</label>
<select name="timezone" class=" border-2 rounded-lg text-lg p-2 text-center">
{Intl.supportedValuesOf("timeZone").map((tz) => (
<option value={tz} id={tz.replace("/", "-")}>{tz}</option>
))}
</select>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
document.querySelector("#" + tz.replace("/", "-")).selected = true
`,
}}
/>
<button class="bg-sky-500 hover:bg-sky-700 text-white font-bold py-2 px-4 rounded disabled:hidden">
Compile
</button>
<button class="htmx-indicator text-white font-bold py-2 px-4 rounded bg-gray-400" disabled>
Loading...
</button>
</form>
<div hx-swap-oob="true" id="cron">
<Cron cron="0 16 * * 1-5" timezone="America/New_York" />
</div>
<div class="text-gray-500 text-center flex flex-col space-y-2">
<div>
Need a place to run cron jobs? Try <Link href="https://val.town">Val Town</Link>
</div>{" "}
<div>
<Link href="https://www.val.town/v/stevekrouse/cron">View source</Link>
{" | "} Inspired by <Link href="https://cronprompt.com/">Cron Prompt</Link>
</div>
</div>
</body>
</html>,
));
app.post("/compile", async (c) => {
const form = await c.req.formData();
const description = form.get("description") as string;
const timezone = form.get("timezone") as string;
const { content } = await chat([
{
role: "system",
content: `You are an natural language to cron syntax compiler.
Return a cron expression in UTC.
The user is in ${timezone}. The timezone offset is ${getOffset(timezone)}.
Return ONLY the cron expression.`,
},
{ role: "user", content: description },

This is an example call of @stevekrouse/insecureFetch

1
2
3
4
5
6
import { insecureFetch } from "https://esm.town/v/stevekrouse/insecureFetch";
const url = "https://assignment-api.uspto.gov/patent/basicSearch?query=1234567&fields=main&rows=20";
const data = await insecureFetch(url);
const text = await data.text();
console.log(text);

Starter App for ssr_react_mini

You need to export four things:

  1. loader - runs on any GET request, on the server. it accepts the Request and returns the props of your React compnent.
  2. action- runs on the server on any non-GET, ie POST, PUT, DELETE, or <form>s submit
  3. Component - your React component. it's initially server-rendered and then client-hydrated
  4. default - you should mostly leave this line alone

This is framework is bleeding-edge. You'll need to read the code of the framework itself (it's very short) to understand what it's doing.

If you have questions or comments, please comment below on this val! (or any of these vals)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/** @jsxImportSource https://esm.sh/react */
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Button, Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini";
// runs on page load, on the server
export async function loader(req: Request) {
// ensure any server-side imports only run server side
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
// you can optionally create your table here IF NOT EXISTS
`CREATE TABLE IF NOT EXISTS ssr_react_mini_startesr_clicks (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
// get the data your page needs on load
`select count(*) from ssr_react_mini_starter_clicks`,
]);
// return the props for your Component
return { initialClicks: rows[0][0] };
}
// handle <Form> submissions and other server-requests
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
if (req.method === "POST") {
await sqlite.execute(`INSERT INTO ssr_react_mini_starter_clicks DEFAULT VALUES`);
}
return Response.json("OK");
}
export function Component({ initialClicks }: { initialClicks: number }) {
const [clicks, setClicks] = useState(0);
const addClick = () => setClicks(clicks + 1); // optimistic, client-side
return (
<html>
<head>
<title>SSR React Starter</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h1 className="text-3xl font-bold">Hello Val Town</h1>
<div className="flex justify-between">
<div>
<div>{clicks + initialClicks} clicks ever</div>
<div>{clicks} clicks since the page loaded</div>
</div>
<Form onSubmit={addClick}>
<Button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Click!</Button>
</Form>
</div>
</body>
</html>
);
}
export default codeOnValTown(hydrate(import.meta.url));

Date Me Directory

This is entry-point val for the source code for the Date Me Directory. Contributions welcome!

This app uses Hono as the server framework and for JSX.

The vals are stored in Val Town SQLite.

Contributing

Forking this repo should mostly work, except for the sqlite database. You'll need to create the table & populate it with some data. This script should do it, but I think it has a couple bugs. If you're interested in contributing to this project contact me or comment on this val and I'll get it working for ya!

Todos

  • Make the SQLite database forkable and build a widget/workflow for that, ie fix @stevekrouse/dateme_sqlite
  • Require an email (that isn't shared publicly)
    • Verify the email address with a "magic link"
  • Refactor Location to an array of Lat, Lon
    • Geocode all the existing locations
    • Add a geocoder map input to the form
    • Allow selecting multiple location through the form
  • Profile performance & speed up site, possibly add more caching
  • Let people edit their forms
  • Featured profiles
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { modifyFetchHandler } from "https://esm.town/v/andreterron/codeOnValTown?v=50";
import { form } from "https://esm.town/v/stevekrouse/date_me_form";
import browse from "https://esm.town/v/stevekrouse/dateme_browse";
import faq from "https://esm.town/v/stevekrouse/dateme_faq";
import home from "https://esm.town/v/stevekrouse/dateme_home";
import { dateMeRSS } from "https://esm.town/v/stevekrouse/dateMeRSS";
import { Hono } from "npm:hono@3";
const app = new Hono();
app.get("/", home);
app.get("/browse", browse);
app.route("/submit", form);
app.get("/faq", faq);
app.get("/rss.xml", c => dateMeRSS(c.req as unknown as Request));
export default modifyFetchHandler(app.fetch, {
style: `@media (max-width: 500px) {
.github-fork-ribbon {
display: none !important;
}
}`,
val: { handle: "stevekrouse", name: "dateme" },
});

SQLite Admin

This is a lightweight SQLite Admin interface to view and debug your SQLite data.

Screenshot 2023-12-08 at 13.35.04.gif

It's currently super limited (no pagination, editing data, data-type specific viewers), and is just a couple dozens lines of code over a couple different vals. Forks encouraged! Just comment on the val if you add any features that you want to share.

To use it on your own Val Town SQLite database, fork it to your account.

It uses basic authentication with your Val Town API Token as the password (leave the username field blank).

1
2
3
4
5
6
7
8
9
10
11
import { basicAuth } from "https://esm.town/v/pomdtr/basicAuth";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html";
import { sqlite_admin_table } from "https://esm.town/v/stevekrouse/sqlite_admin_table";
import { sqlite_admin_tables } from "https://esm.town/v/stevekrouse/sqlite_admin_tables";
import { Hono } from "npm:hono@3.9.2";
const app = new Hono();
app.get("/", async (c) => c.html(await sqlite_admin_tables()));
app.get("/:table", async (c) => c.html(await sqlite_admin_table(c.req.param("table"))));
export default basicAuth(app.fetch);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { email } from "https://esm.town/v/std/email?v=11";
import { OpenAI } from "npm:openai";
let location = "brooklyn ny";
let lang = "en";
const weather = await fetch(
`https://wttr.in/${location}?lang=${lang}&format=j1`,
).then(r => r.json());
const openai = new OpenAI();
let chatCompletion = await openai.chat.completions.create({
messages: [{
role: "user",
content: `Based the weather data below,
give me suggestions on how warmly to dress,
ie pants or shorts, a light jacket or a warm jacket,
a scarf and gloves or not, if I should carry an umbrella, etc.
In your response, use temperature data from the weather data below
throughout the day to explain your reccomendation.
Be as concice as possible. Assume I'll wear the same thing the whole day.
Do not use a bulleted list. Use 2-3 sentences. Only use Fahrenheit`.replaceAll("\n", ""),
}, {
role: "user",
content: JSON.stringify(weather),
}],
model: "gpt-4o",
max_tokens: 150,
});
const text = chatCompletion.choices[0].message.content;
console.log(text);
export async function weatherGPT() {
await email({ subject: "Weather Today", text });
}

GPT4 Example

This uses the brand new gpt-4-1106-preview.

To use this, set OPENAI_API_KEY in your Val Town Secrets.

1
2
3
4
5
6
7
8
9
10
11
12
import { OpenAI } from "npm:openai";
const openai = new OpenAI();
let chatCompletion = await openai.chat.completions.create({
messages: [{
role: "user",
content: "Teach me about a super rare word",
}],
model: "gpt-4-1106-preview",
max_tokens: 30,
});
export let gpt4Example = chatCompletion.choices[0].message.content;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
export const remarkDemoJSX = async (req: Request) =>
new Response(
render(
<html>
<head>
<title>Title</title>
<style>
{`
@import url(https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz);
@import url(https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic);
@import url(https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700,400italic);
body { font-family: 'Droid Serif'; }
h1, h2, h3 {
font-family: 'Yanone Kaffeesatz';
font-weight: normal;
}
.remark-code, .remark-inline-code { font-family: 'Ubuntu Mono'; }
`}
</style>
</head>
<body>
<textarea id="source">
{`
class: center, middle
# Title
---
# Agenda
1. Introduction
2. Deep-dive
3. ...
---
# Introduction
`}
</textarea>
<script src="https://remarkjs.com/downloads/remark-latest.min.js">
</script>
<script>
var slideshow = remark.create();
</script>
</body>
</html>,
),
{
headers: {
"Content-Type": "text/html",
},
},
);
1
2
3
4
5
6
7
8
9
10
11
12
export const refs = (): ValRef[] => {
return new Error().stack.split("\n").map((line) =>
new URLPattern({ pathname: "/v/:userHandle/:valName" }).exec(
line.match(/https?:\/\/[^\s\)]+/)?.at(0) ?? "",
)?.pathname?.groups
).filter(Boolean).slice(1);
};
type ValRef = {
userHandle: string;
valName: string;
callNumber?: number;
};
1
2
3
4
5
6
7
8
9
10
11
import { sqlite } from "https://esm.town/v/std/sqlite";
import { sql } from "npm:drizzle-orm";
import { drizzle } from "npm:drizzle-orm/libsql";
import { integer, sqliteTable, text } from "npm:drizzle-orm/sqlite-core";
const db = drizzle(sqlite as any);
const kv = sqliteTable("kv", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
export let sqliteDrizzleExample = db.select().from(kv).all();