snowkit 0.0.0-stage → 0.1.0

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/src/text.js ADDED
@@ -0,0 +1,189 @@
1
+ import { ToolError, readTextInput, writeOutput } from "./files.js";
2
+ import { parseJson, stringify, lineCol } from "./engine/devtools-kit.js";
3
+ import { parseJsonText, validateJson } from "./engine/jsonkit.js";
4
+ import { formatSql, minifySql } from "./engine/sqlformat.js";
5
+ import { stats, convertCase, dedupeLines, slugLine } from "./engine/texttools-kit.js";
6
+ import { readability, easeLabel, gradeLabel } from "./engine/readability.js";
7
+ import { parseColor, toHex, formatColor, nearestName, contrastRatio, wcag, ratioText } from "./engine/colorkit.js";
8
+ import { describe, next } from "./engine/cron.js";
9
+
10
+ const MAX_TEXT = 20 * 1024 * 1024;
11
+ const endLine = (s) => String(s).replace(/\n*$/, "\n");
12
+ const plural = (n, one, many) => n.toLocaleString("en-CA") + " " + (n === 1 ? one : many || one + "s");
13
+
14
+ export async function takeText({ text, input }, what = "text") {
15
+ if ((text === undefined) === (input === undefined)) throw new ToolError("Give either input (a file path) or text, not both.");
16
+ if (input !== undefined) {
17
+ const file = await readTextInput(input);
18
+ return { text: file.text, path: file.path };
19
+ }
20
+ const value = String(text);
21
+ if (value.length > MAX_TEXT) throw new ToolError("That " + what + " is too long. Save it to a file and pass the file path instead.");
22
+ return { text: value, path: null };
23
+ }
24
+
25
+ async function deliver(body, { output, overwrite }, summary, data = {}) {
26
+ if (!output) return { text: summary, output: body, data: { ...data, result: body } };
27
+ const path = await writeOutput(output, Buffer.from(body, "utf8"), { overwrite });
28
+ return { text: summary + "\nSaved to " + path + ".", data: { ...data, output: path } };
29
+ }
30
+
31
+ function parseStrict(source) {
32
+ try {
33
+ return parseJson(source);
34
+ } catch (e) {
35
+ const at = e && typeof e.pos === "number" ? lineCol(source, e.pos) : null;
36
+ throw new ToolError("This is not valid JSON: " + String((e && e.message) || e) + (at ? " (line " + at.line + ", column " + at.col + ")" : "") + ".");
37
+ }
38
+ }
39
+
40
+ export async function jsonFormat(args) {
41
+ const { text } = await takeText(args, "JSON");
42
+ if (!text.trim()) throw new ToolError("The JSON is empty.");
43
+ const parsed = parseStrict(text);
44
+ const indent = args.indent === undefined ? 2 : Number(args.indent);
45
+ if (!Number.isInteger(indent) || indent < 0 || indent > 8) throw new ToolError("indent must be a whole number from 0 to 8.");
46
+ const body = endLine(stringify(parsed.value, args.minify ? "" : " ".repeat(indent), !!args.sort_keys));
47
+ const notes = [];
48
+ if (parsed.big && parsed.big.length) notes.push(plural(parsed.big.length, "large number") + " kept with every digit.");
49
+ if (parsed.dupes && parsed.dupes.length) notes.push(plural(parsed.dupes.length, "duplicate key") + " found; the last value of each is kept.");
50
+ const summary = (args.minify ? "Minified" : "Formatted") + " the JSON" + (args.sort_keys ? " with keys sorted" : "") + "." + (notes.length ? " " + notes.join(" ") : "");
51
+ return deliver(body, args, summary);
52
+ }
53
+
54
+ export async function jsonValidate(args) {
55
+ const { text } = await takeText(args, "JSON");
56
+ let data;
57
+ try {
58
+ data = parseJsonText(text);
59
+ } catch (e) {
60
+ return {
61
+ text: "Not valid JSON. " + e.message + ".",
62
+ data: { valid: false, syntax: { line: e.line || null, column: e.column || null, message: e.reason || e.message } },
63
+ };
64
+ }
65
+ let dupes = [];
66
+ try {
67
+ dupes = parseJson(text).dupes || [];
68
+ } catch (e) {
69
+ dupes = [];
70
+ }
71
+ const dupeNote = dupes.length ? "\nNote: " + plural(dupes.length, "duplicate key") + ", where only the last value counts." : "";
72
+ if (args.schema === undefined && args.schema_path === undefined) {
73
+ return { text: "Valid JSON." + dupeNote, data: { valid: true, duplicate_keys: dupes.length } };
74
+ }
75
+ if (args.schema !== undefined && args.schema_path !== undefined) throw new ToolError("Give either schema or schema_path, not both.");
76
+ let schema;
77
+ try {
78
+ schema = args.schema_path !== undefined ? parseJsonText((await readTextInput(args.schema_path, "schema path")).text) : typeof args.schema === "string" ? parseJsonText(args.schema) : args.schema;
79
+ } catch (e) {
80
+ if (e instanceof ToolError) throw e;
81
+ throw new ToolError("The schema is not valid JSON. " + e.message + ".");
82
+ }
83
+ const errors = validateJson(data, schema);
84
+ if (!errors.length) return { text: "Valid JSON, and it matches the schema." + dupeNote, data: { valid: true, schema_errors: [] } };
85
+ const shown = errors.slice(0, 50).map((e) => " " + e.path + ": " + e.message).join("\n");
86
+ return {
87
+ text: "Valid JSON, but it does not match the schema (" + plural(errors.length, "problem") + "):\n" + shown + (errors.length > 50 ? "\n and " + (errors.length - 50) + " more" : ""),
88
+ data: { valid: false, schema_errors: errors },
89
+ };
90
+ }
91
+
92
+ export async function sqlFormat(args) {
93
+ const { text } = await takeText(args, "SQL");
94
+ if (!text.trim()) throw new ToolError("The SQL is empty.");
95
+ const keywordCase = args.keyword_case || "upper";
96
+ const indent = args.indent === undefined ? 2 : Number(args.indent);
97
+ if (!Number.isInteger(indent) || indent < 1 || indent > 8) throw new ToolError("indent must be a whole number from 1 to 8.");
98
+ const body = endLine(args.minify ? minifySql(text, { keywordCase }) : formatSql(text, { keywordCase, indent: " ".repeat(indent) }));
99
+ return deliver(body, args, (args.minify ? "Minified" : "Formatted") + " the SQL.");
100
+ }
101
+
102
+ export async function textStats(args) {
103
+ const { text } = await takeText(args);
104
+ const s = stats(text);
105
+ const r = readability(text);
106
+ const lines = [
107
+ "Words: " + s.words.toLocaleString("en-CA"),
108
+ "Characters: " + s.chars.toLocaleString("en-CA") + " (" + s.nospace.toLocaleString("en-CA") + " without spaces)",
109
+ "Sentences: " + s.sentences.toLocaleString("en-CA"),
110
+ "Paragraphs: " + s.paragraphs.toLocaleString("en-CA"),
111
+ "Lines: " + s.lines.toLocaleString("en-CA"),
112
+ "Reading time: " + s.read,
113
+ "Speaking time: " + s.speak,
114
+ ];
115
+ const data = { words: s.words, characters: s.chars, characters_without_spaces: s.nospace, sentences: s.sentences, paragraphs: s.paragraphs, lines: s.lines, reading_time: s.read, speaking_time: s.speak };
116
+ if (r && r.words >= 30) {
117
+ lines.push("Readability (English): " + r.fleschEase + " Flesch reading ease, " + easeLabel(r.fleschEase).toLowerCase() + "; " + gradeLabel(r.averageGrade).toLowerCase() + " level");
118
+ if (r.longSentences.length) lines.push("Sentences over 25 words: " + r.longSentences.length);
119
+ data.readability = { flesch_reading_ease: r.fleschEase, ease: easeLabel(r.fleschEase), flesch_kincaid_grade: r.fleschKincaid, gunning_fog: r.gunningFog, smog: r.smog, coleman_liau: r.colemanLiau, ari: r.ari, average_grade: r.averageGrade, words_per_sentence: r.wordsPerSentence, long_sentences: r.longSentences.length };
120
+ }
121
+ return { text: lines.join("\n"), data };
122
+ }
123
+
124
+ export const CASES = ["upper", "lower", "title", "sentence", "camel", "pascal", "snake", "kebab", "constant"];
125
+ export const TRANSFORMS = [...CASES, "slug", "dedupe"];
126
+
127
+ export async function textTransform(args) {
128
+ const { text } = await takeText(args);
129
+ const action = String(args.action || "");
130
+ if (!TRANSFORMS.includes(action)) throw new ToolError("action must be one of: " + TRANSFORMS.join(", ") + ".");
131
+ if (action === "dedupe") {
132
+ const r = dedupeLines(text, { trim: args.trim !== false, ignoreCase: !!args.ignore_case, dropEmpty: !!args.remove_empty });
133
+ const removed = r.dupes ? plural(r.dupes, "duplicate line") + " removed" : "No duplicate lines found";
134
+ return deliver(endLine(r.out.join("\n")), args, removed + (r.empties ? ", plus " + plural(r.empties, "empty line") : "") + ".", { removed: r.dupes, empty_removed: r.empties });
135
+ }
136
+ if (action === "slug") {
137
+ const slugs = text.replace(/\r\n?/g, "\n").split("\n").filter((l) => l.trim()).map((l) => slugLine(l, { ascii: true, lower: true, sep: "-", stop: false, max: 0 }));
138
+ return deliver(endLine(slugs.join("\n")), args, "Made " + plural(slugs.length, "slug") + ".");
139
+ }
140
+ const body = convertCase(text, action === "constant" ? "constant" : action);
141
+ return deliver(body, args, "Converted to " + action + " case.");
142
+ }
143
+
144
+ function readColor(value) {
145
+ try {
146
+ return parseColor(String(value == null ? "" : value).trim()) || null;
147
+ } catch (e) {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export function colorConvert({ color, against }) {
153
+ const c = readColor(color);
154
+ if (!c) throw new ToolError("“" + color + "” does not look like a colour. Try #336699, rgb(51 102 153), hsl(210 50% 40%) or a name such as teal.");
155
+ const near = nearestName(c);
156
+ const data = { hex: toHex(c), rgb: formatColor(c, "rgb"), hsl: formatColor(c, "hsl"), hsv: formatColor(c, "hsv"), oklch: formatColor(c, "oklch"), name: near.name, exact_name: !!near.exact };
157
+ const lines = ["Hex: " + data.hex, "RGB: " + data.rgb, "HSL: " + data.hsl, "HSV: " + data.hsv, "OKLCH: " + data.oklch, (near.exact ? "Name: " : "Nearest name: ") + near.name];
158
+ if (against !== undefined && against !== null && against !== "") {
159
+ const b = readColor(against);
160
+ if (!b) throw new ToolError("“" + against + "” does not look like a colour.");
161
+ const ratio = contrastRatio(c, b);
162
+ const w = wcag(ratio);
163
+ const yes = (v) => (v ? "pass" : "fail");
164
+ Object.assign(data, { against: toHex(b), contrast: ratioText(ratio), aa_normal: !!w.aaNormal, aa_large: !!w.aaLarge, aaa_normal: !!w.aaaNormal });
165
+ lines.push("", "Contrast with " + data.against + ": " + data.contrast, "WCAG AA normal text: " + yes(w.aaNormal) + ", AA large text: " + yes(w.aaLarge) + ", AAA normal text: " + yes(w.aaaNormal));
166
+ }
167
+ return { text: lines.join("\n"), data };
168
+ }
169
+
170
+ export function cronExplain({ expression, count = 5, utc = false }) {
171
+ const expr = String(expression == null ? "" : expression).trim();
172
+ if (!expr) throw new ToolError("Give a cron expression, such as */15 9-17 * * 1-5.");
173
+ const n = Number(count);
174
+ if (!Number.isInteger(n) || n < 1 || n > 50) throw new ToolError("count must be a whole number from 1 to 50.");
175
+ let words;
176
+ let runs;
177
+ try {
178
+ words = describe(expr);
179
+ runs = next(expr, n, new Date(), { utc: !!utc });
180
+ } catch (e) {
181
+ throw new ToolError("That cron expression does not work: " + String((e && e.message) || e) + ".");
182
+ }
183
+ const zone = utc ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone || "local time";
184
+ const show = (d) => (utc ? d.toISOString().replace("T", " ").replace(/:00\.000Z$/, " UTC") : d.toLocaleString("en-CA", { dateStyle: "medium", timeStyle: "short" }));
185
+ return {
186
+ text: words + "\n\nNext runs (" + zone + "):\n" + runs.map((d) => " " + show(d)).join("\n"),
187
+ data: { description: words, time_zone: zone, next_runs: runs.map((d) => d.toISOString()) },
188
+ };
189
+ }
package/src/tools.js ADDED
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ import { mergePdfs, splitPdf, extractPages, rotatePdf, pdfInfo, markdownToPdf } from "./pdf.js";
3
+ import { makeQr } from "./qr.js";
4
+ import { jsonFormat, jsonValidate, sqlFormat, textStats, textTransform, colorConvert, cronExplain, TRANSFORMS } from "./text.js";
5
+ import { ToolError } from "./files.js";
6
+
7
+ export { ToolError };
8
+
9
+ const SITE = "https://snowkit.io/";
10
+
11
+ const path = (what) => z.string().min(1).describe(what);
12
+ const outputPath = (fallback) => z.string().min(1).optional().describe("Where to save the result. " + fallback);
13
+ const overwrite = z.boolean().optional().describe("Replace the output file if it already exists. Defaults to false.");
14
+ const textOrFile = {
15
+ text: z.string().optional().describe("The text itself. Use this or input."),
16
+ input: z.string().min(1).optional().describe("Path to a file to read instead of text."),
17
+ };
18
+ const saveText = {
19
+ output: z.string().min(1).optional().describe("Save the result to this file instead of returning it."),
20
+ overwrite,
21
+ };
22
+
23
+ export const TOOLS = [
24
+ {
25
+ name: "pdf_merge",
26
+ title: "Merge PDFs",
27
+ description: "Combine two or more PDF files into one, in the order given. Saves next to the first file as name-merged.pdf unless output is set.",
28
+ link: "merge-pdf",
29
+ schema: {
30
+ inputs: z.array(z.string().min(1)).min(2).describe("Paths of the PDF files, in order."),
31
+ output: outputPath("Defaults to the first file's name with -merged, in the same folder."),
32
+ overwrite,
33
+ },
34
+ run: mergePdfs,
35
+ },
36
+ {
37
+ name: "pdf_split",
38
+ title: "Split a PDF",
39
+ description: "Split a PDF into several files: one per page by default, every N pages, or one file for each page range. Files are saved beside the original unless output_folder is set.",
40
+ link: "split-pdf",
41
+ schema: {
42
+ input: path("Path of the PDF to split."),
43
+ ranges: z.array(z.string().min(1)).min(1).optional().describe("One file per range, such as [\"1-3\", \"4-10\", \"11-\"]."),
44
+ every: z.number().int().min(1).optional().describe("Start a new file every this many pages. Defaults to 1."),
45
+ output_folder: z.string().min(1).optional().describe("An existing folder for the new files. Defaults to the original's folder."),
46
+ overwrite,
47
+ },
48
+ run: splitPdf,
49
+ },
50
+ {
51
+ name: "pdf_extract_pages",
52
+ title: "Extract PDF pages",
53
+ description: "Copy chosen pages of a PDF into a new PDF, in the order listed. Also works to reorder, duplicate or drop pages. Saves as name-extracted.pdf beside the original unless output is set.",
54
+ link: "extract-pages-from-pdf",
55
+ schema: {
56
+ input: path("Path of the PDF."),
57
+ pages: z.string().min(1).describe("Pages in order, such as \"1-3, 7, 10-\", \"5-1\" for reverse, \"odd\", \"even\" or \"last\"."),
58
+ output: outputPath("Defaults to the original's name with -extracted, in the same folder."),
59
+ overwrite,
60
+ },
61
+ run: extractPages,
62
+ },
63
+ {
64
+ name: "pdf_rotate",
65
+ title: "Rotate PDF pages",
66
+ description: "Turn all or some pages of a PDF by 90, 180 or 270 degrees clockwise, or -90 for anticlockwise. Everything else in the file is kept. Saves as name-rotated.pdf unless output is set.",
67
+ link: "rotate-pdf",
68
+ schema: {
69
+ input: path("Path of the PDF."),
70
+ angle: z.union([z.literal(90), z.literal(180), z.literal(270), z.literal(-90)]).describe("Degrees clockwise: 90, 180, 270, or -90."),
71
+ pages: z.string().min(1).optional().describe("Which pages to turn, such as \"2, 4-6\". Defaults to all pages."),
72
+ output: outputPath("Defaults to the original's name with -rotated, in the same folder."),
73
+ overwrite,
74
+ },
75
+ run: rotatePdf,
76
+ },
77
+ {
78
+ name: "pdf_info",
79
+ title: "PDF details",
80
+ description: "Read a PDF's page count, page sizes, file size, PDF version, form fields, password protection and document details such as title and author. Changes nothing.",
81
+ link: "edit-pdf-metadata",
82
+ readOnly: true,
83
+ schema: { input: path("Path of the PDF.") },
84
+ run: pdfInfo,
85
+ },
86
+ {
87
+ name: "markdown_to_pdf",
88
+ title: "Markdown to PDF",
89
+ description: "Turn Markdown into a clean, paginated PDF with headings, lists, tables, code blocks and links. Uses built-in fonts for Western European text; images are left out. Saves beside the Markdown file unless output is set.",
90
+ link: "markdown-to-pdf",
91
+ schema: {
92
+ input: z.string().min(1).optional().describe("Path to a Markdown file. Use this or markdown."),
93
+ markdown: z.string().optional().describe("The Markdown text itself. Needs an output path."),
94
+ output: outputPath("Defaults to the Markdown file's name with .pdf, in the same folder."),
95
+ page: z.enum(["letter", "a4", "legal", "a5"]).optional().describe("Paper size. Defaults to Letter or A4 based on the computer's region."),
96
+ title: z.string().optional().describe("Document title. Defaults to the first heading."),
97
+ page_numbers: z.boolean().optional().describe("Add page numbers at the foot of each page."),
98
+ overwrite,
99
+ },
100
+ run: markdownToPdf,
101
+ },
102
+ {
103
+ name: "qr_code",
104
+ title: "Make a QR code",
105
+ description: "Make a QR code for a link or any text, as SVG or PNG. Without an output path the SVG is returned as text. The content lives in the code itself, so it never expires.",
106
+ link: "qr-code",
107
+ schema: {
108
+ text: z.string().min(1).describe("The link or text to encode."),
109
+ output: z.string().min(1).optional().describe("File to save, ending in .svg or .png."),
110
+ format: z.enum(["svg", "png"]).optional().describe("Defaults to the output file's extension, or svg."),
111
+ level: z.enum(["L", "M", "Q", "H"]).optional().describe("Error correction: L 7%, M 15%, Q 25%, H 30%. Defaults to M."),
112
+ size: z.number().int().min(64).max(4096).optional().describe("Width in pixels. Defaults to 512."),
113
+ overwrite,
114
+ },
115
+ run: makeQr,
116
+ },
117
+ {
118
+ name: "json_format",
119
+ title: "Format JSON",
120
+ description: "Pretty-print or minify JSON, optionally sorting keys. Large numbers keep every digit and errors point to the line and column.",
121
+ link: "json-formatter",
122
+ schema: {
123
+ ...textOrFile,
124
+ indent: z.number().int().min(0).max(8).optional().describe("Spaces per level. Defaults to 2."),
125
+ sort_keys: z.boolean().optional().describe("Sort object keys A to Z."),
126
+ minify: z.boolean().optional().describe("Put everything on one line."),
127
+ ...saveText,
128
+ },
129
+ run: jsonFormat,
130
+ },
131
+ {
132
+ name: "json_validate",
133
+ title: "Validate JSON",
134
+ description: "Check that JSON is valid and, optionally, that it matches a JSON Schema. Reports the line and column of syntax errors and the path of every schema problem.",
135
+ link: "json-validator",
136
+ readOnly: true,
137
+ schema: {
138
+ ...textOrFile,
139
+ schema: z.string().optional().describe("A JSON Schema as text."),
140
+ schema_path: z.string().min(1).optional().describe("Path to a JSON Schema file."),
141
+ },
142
+ run: jsonValidate,
143
+ },
144
+ {
145
+ name: "sql_format",
146
+ title: "Format SQL",
147
+ description: "Tidy SQL into readable, indented lines, or minify it onto one line. Works with common dialects such as PostgreSQL, MySQL, SQLite and SQL Server.",
148
+ link: "sql-formatter",
149
+ schema: {
150
+ ...textOrFile,
151
+ keyword_case: z.enum(["upper", "lower", "preserve"]).optional().describe("How to write keywords. Defaults to upper."),
152
+ indent: z.number().int().min(1).max(8).optional().describe("Spaces per level. Defaults to 2."),
153
+ minify: z.boolean().optional().describe("Put the query on one line and drop comments."),
154
+ ...saveText,
155
+ },
156
+ run: sqlFormat,
157
+ },
158
+ {
159
+ name: "text_stats",
160
+ title: "Text statistics",
161
+ description: "Count words, characters, sentences, paragraphs and lines, estimate reading and speaking time, and score readability for English text.",
162
+ link: "word-counter",
163
+ readOnly: true,
164
+ schema: { ...textOrFile },
165
+ run: textStats,
166
+ },
167
+ {
168
+ name: "text_transform",
169
+ title: "Transform text",
170
+ description: "Change text case (upper, lower, title, sentence, camel, pascal, snake, kebab, constant), turn each line into a URL slug, or remove duplicate lines while keeping the original order.",
171
+ link: (args) => (args && args.action === "slug" ? "slug-generator" : args && args.action === "dedupe" ? "remove-duplicate-lines" : "case-converter"),
172
+ schema: {
173
+ ...textOrFile,
174
+ action: z.enum(TRANSFORMS).describe("What to do."),
175
+ ignore_case: z.boolean().optional().describe("For dedupe: treat Apple and apple as the same line."),
176
+ remove_empty: z.boolean().optional().describe("For dedupe: also drop empty lines."),
177
+ trim: z.boolean().optional().describe("For dedupe: ignore spaces at the start and end of lines when comparing. Defaults to true."),
178
+ ...saveText,
179
+ },
180
+ run: textTransform,
181
+ },
182
+ {
183
+ name: "color_convert",
184
+ title: "Convert a colour",
185
+ description: "Show a colour as hex, RGB, HSL, HSV and OKLCH with its nearest named colour. Give a second colour to get the WCAG contrast ratio and pass or fail results.",
186
+ link: (args) => (args && args.against ? "contrast-checker" : "color-converter"),
187
+ readOnly: true,
188
+ schema: {
189
+ color: z.string().min(1).describe("Such as #336699, rgb(51 102 153), hsl(210 50% 40%) or teal."),
190
+ against: z.string().min(1).optional().describe("A second colour, such as a background, to check contrast."),
191
+ },
192
+ run: colorConvert,
193
+ },
194
+ {
195
+ name: "cron_explain",
196
+ title: "Explain a cron schedule",
197
+ description: "Describe a five-field cron expression in plain English and list its next run times.",
198
+ link: "cron-expression",
199
+ readOnly: true,
200
+ schema: {
201
+ expression: z.string().min(1).describe("Such as \"*/15 9-17 * * 1-5\"."),
202
+ count: z.number().int().min(1).max(50).optional().describe("How many upcoming runs to list. Defaults to 5."),
203
+ utc: z.boolean().optional().describe("List times in UTC instead of this computer's time zone."),
204
+ },
205
+ run: cronExplain,
206
+ },
207
+ ];
208
+
209
+ export function findTool(name) {
210
+ return TOOLS.find((t) => t.name === name);
211
+ }
212
+
213
+ export function toolLink(tool, args) {
214
+ const slug = typeof tool.link === "function" ? tool.link(args) : tool.link;
215
+ return SITE + slug + "/";
216
+ }
217
+
218
+ export async function runTool(name, args = {}) {
219
+ const tool = findTool(name);
220
+ if (!tool) throw new ToolError("Unknown tool: " + name);
221
+ const result = await tool.run(args);
222
+ return { ...result, link: toolLink(tool, args) };
223
+ }