Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707e2c0f1e | ||
|
|
6c82ff20eb | ||
|
|
67ed3eecca | ||
|
|
80e9c91b88 | ||
|
|
88d3692116 | ||
|
|
a20b26b4c7 |
@@ -78,7 +78,7 @@ Copy the layout from `plugins/joplin-api`, `plugins/send-sms`, or `examples/plug
|
|||||||
Add `dependencies` only if the script `require()`s extra npm packages. Then:
|
Add `dependencies` only if the script `require()`s extra npm packages. Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline
|
pnpm install --dir plugins/<id> --ignore-scripts --prefer-offline --ignore-workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
|
`plugins/*/node_modules/` is gitignored. Host-allowlisted modules (`axios`, `jsonata`, …) come from the server; extra deps resolve from the plugin directory.
|
||||||
|
|||||||
@@ -51,9 +51,18 @@ export async function pnpmInstallPlugin(dir) {
|
|||||||
}
|
}
|
||||||
if (!hasDeps) return { skipped: true };
|
if (!hasDeps) return { skipped: true };
|
||||||
|
|
||||||
|
// --ignore-workspace: plugins live under the app tree but are not workspace
|
||||||
|
// packages; without this, pnpm install --dir can no-op against the root monorepo.
|
||||||
await execFileAsync(
|
await execFileAsync(
|
||||||
"pnpm",
|
"pnpm",
|
||||||
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"],
|
[
|
||||||
|
"install",
|
||||||
|
"--dir",
|
||||||
|
dir,
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--prefer-offline",
|
||||||
|
"--ignore-workspace",
|
||||||
|
],
|
||||||
{
|
{
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
env: { ...process.env, CI: "1" },
|
env: { ...process.env, CI: "1" },
|
||||||
|
|||||||
@@ -380,6 +380,96 @@ export function forkCoreScript(coreName, newId, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy an installed plugin to a new plugin id.
|
||||||
|
*
|
||||||
|
* @param {string} sourceId
|
||||||
|
* @param {string} newId
|
||||||
|
* @param {{ description?: string }} [opts]
|
||||||
|
*/
|
||||||
|
export function duplicatePlugin(sourceId, newId, opts = {}) {
|
||||||
|
const fromId = assertPluginId(sourceId);
|
||||||
|
const id = assertPluginId(newId);
|
||||||
|
if (id === fromId) {
|
||||||
|
const err = new Error("cannot duplicate onto itself");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (coreBareNames().has(id)) {
|
||||||
|
const err = new Error(`plugin id collides with core script: ${id}`);
|
||||||
|
err.statusCode = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(pluginDir(id))) {
|
||||||
|
const err = new Error(`plugin already exists: ${id}`);
|
||||||
|
err.statusCode = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = getInstalledPlugin(fromId);
|
||||||
|
if (!source) {
|
||||||
|
const err = new Error(`plugin not found: ${fromId}`);
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!source.manifest) {
|
||||||
|
const err = new Error(
|
||||||
|
source.compatError || `plugin has no valid manifest: ${fromId}`,
|
||||||
|
);
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staging = path.join(PLUGINS_DIR, `.staging-dup-${id}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
try {
|
||||||
|
fs.cpSync(source.dir, staging, {
|
||||||
|
recursive: true,
|
||||||
|
filter: (src) => {
|
||||||
|
const base = path.basename(src);
|
||||||
|
return base !== "node_modules" && base !== ".disabled";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest = buildManifest({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
version: source.manifest.version,
|
||||||
|
jerapah: source.manifest.jerapah,
|
||||||
|
main: source.manifest.main,
|
||||||
|
description:
|
||||||
|
opts.description ?? source.manifest.description ?? null,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(staging, PLUGIN_MANIFEST),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pkgPath = path.join(staging, "package.json");
|
||||||
|
if (fs.existsSync(pkgPath)) {
|
||||||
|
let pkg = {};
|
||||||
|
try {
|
||||||
|
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
pkg = {};
|
||||||
|
}
|
||||||
|
if (pkg == null || typeof pkg !== "object" || Array.isArray(pkg)) {
|
||||||
|
pkg = {};
|
||||||
|
}
|
||||||
|
pkg.name = `jflow-plugin-${id}`;
|
||||||
|
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return installPluginFromDirectory(staging, {
|
||||||
|
overwrite: false,
|
||||||
|
reason: `plugin:${id} duplicated from ${fromId}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} pluginDirectory
|
* @param {string} pluginDirectory
|
||||||
* @returns {((id: string) => unknown) | null}
|
* @returns {((id: string) => unknown) | null}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const ALLOWED_MODULES = new Set([
|
|||||||
"basic-ftp",
|
"basic-ftp",
|
||||||
"jsonata",
|
"jsonata",
|
||||||
"mustache",
|
"mustache",
|
||||||
|
"net",
|
||||||
"node-html-parser",
|
"node-html-parser",
|
||||||
|
"node:net",
|
||||||
"node:stream",
|
"node:stream",
|
||||||
"nodemailer",
|
"nodemailer",
|
||||||
"rss-parser",
|
"rss-parser",
|
||||||
@@ -557,16 +559,23 @@ function instantiateCompiled(
|
|||||||
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
|
||||||
* pluginDir?: string | null,
|
* pluginDir?: string | null,
|
||||||
* }} [opts]
|
* }} [opts]
|
||||||
|
*
|
||||||
|
* When `pluginDir` is omitted, it is resolved from `script` so inspect/dry-run
|
||||||
|
* of `plugin/<id>` can `require()` extra packages from that plugin's node_modules.
|
||||||
*/
|
*/
|
||||||
export function instantiateScriptSource(script, source, opts = {}) {
|
export function instantiateScriptSource(script, source, opts = {}) {
|
||||||
const compiled = compileScriptSource(source, script);
|
const compiled = compileScriptSource(source, script);
|
||||||
|
const pluginDir =
|
||||||
|
"pluginDir" in opts
|
||||||
|
? opts.pluginDir ?? null
|
||||||
|
: (resolveScriptRef(script).pluginDir ?? null);
|
||||||
const fn = instantiateCompiled(compiled, {
|
const fn = instantiateCompiled(compiled, {
|
||||||
log: opts.log ?? inspectLog,
|
log: opts.log ?? inspectLog,
|
||||||
script,
|
script,
|
||||||
workflowName: opts.workflowName ?? "inspect",
|
workflowName: opts.workflowName ?? "inspect",
|
||||||
owner: opts.owner ?? DEFAULT_OWNER,
|
owner: opts.owner ?? DEFAULT_OWNER,
|
||||||
$workflows: opts.$workflows,
|
$workflows: opts.$workflows,
|
||||||
pluginDir: opts.pluginDir ?? null,
|
pluginDir,
|
||||||
});
|
});
|
||||||
return { fn, ...extractScriptMeta(fn) };
|
return { fn, ...extractScriptMeta(fn) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,24 @@ function ntfyHeaders(ctx) {
|
|||||||
headers.Title = ctx.data.title;
|
headers.Title = ctx.data.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isMarkdownEnabled(ctx.config?.markdown)) {
|
||||||
|
// ntfy accepts X-Markdown / Markdown / md with true | 1 | yes
|
||||||
|
headers.Markdown = "yes";
|
||||||
|
log.info("ntfy: Markdown enabled");
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMarkdownEnabled(value) {
|
||||||
|
if (value === true || value === 1) return true;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
return v === "true" || v === "yes" || v === "1";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFingerprintKey(config) {
|
function resolveFingerprintKey(config) {
|
||||||
const fingerprint = config?.fingerprint;
|
const fingerprint = config?.fingerprint;
|
||||||
if (fingerprint === true) return "fingerprint:ntfy";
|
if (fingerprint === true) return "fingerprint:ntfy";
|
||||||
@@ -149,6 +164,12 @@ ntfy.meta = {
|
|||||||
default: "https://ntfy.sh/jerapah-flow",
|
default: "https://ntfy.sh/jerapah-flow",
|
||||||
description: "ntfy topic URL",
|
description: "ntfy topic URL",
|
||||||
},
|
},
|
||||||
|
markdown: {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
"Render body as Markdown (sets Markdown: yes; aliases X-Markdown / md also accepted by ntfy)",
|
||||||
|
},
|
||||||
fingerprint: {
|
fingerprint: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import * as fsStore from "../../fs-store.js";
|
import * as fsStore from "../../fs-store.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
|
duplicatePlugin,
|
||||||
listCoreScriptNames,
|
listCoreScriptNames,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
@@ -29,7 +30,7 @@ import { normalizeStepResult } from "../../step-result.js";
|
|||||||
import { resolveConfigRefs } from "../../config-refs.js";
|
import { resolveConfigRefs } from "../../config-refs.js";
|
||||||
import { getAppVersion } from "../../app-version.js";
|
import { getAppVersion } from "../../app-version.js";
|
||||||
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
import { EXAMPLE_PLUGINS_DIR } from "../../paths.js";
|
||||||
import { pluginScriptRef } from "../../plugin-manifest.js";
|
import { parsePluginScriptRef, pluginScriptRef } from "../../plugin-manifest.js";
|
||||||
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
import { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
|
||||||
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
|
||||||
|
|
||||||
@@ -251,6 +252,40 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post("/scripts/:name/duplicate", async (req, reply) => {
|
||||||
|
const rawName = decodeURIComponent(
|
||||||
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
|
);
|
||||||
|
const body = /** @type {{ id?: string, description?: string }} */ (
|
||||||
|
req.body ?? {}
|
||||||
|
);
|
||||||
|
if (typeof body.id !== "string" || !body.id.trim()) {
|
||||||
|
return reply.code(400).send({ error: "id is required" });
|
||||||
|
}
|
||||||
|
const parsed = parsePluginScriptRef(rawName);
|
||||||
|
if (!parsed) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: "name must be a plugin ref (plugin/<id>)" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const installed = duplicatePlugin(parsed.id, body.id.trim(), {
|
||||||
|
description: body.description,
|
||||||
|
});
|
||||||
|
clearScriptCache();
|
||||||
|
return reply.code(201).send({
|
||||||
|
...installed,
|
||||||
|
restartNeeded: true,
|
||||||
|
warning:
|
||||||
|
"Plugins run as the JerapahFlow process user. Review code before install.",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return reply
|
||||||
|
.code(/** @type {any} */ (err).statusCode ?? 500)
|
||||||
|
.send({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
fastify.post("/scripts/:name/dry-run", async (req, reply) => {
|
||||||
const rawName = decodeURIComponent(
|
const rawName = decodeURIComponent(
|
||||||
/** @type {{ name: string }} */ (req.params).name,
|
/** @type {{ name: string }} */ (req.params).name,
|
||||||
@@ -431,7 +466,6 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/plugins/create",
|
"/plugins/create",
|
||||||
{ onRequest: [fastify.requireAdmin] },
|
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
|
const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
|
||||||
req.body ?? {}
|
req.body ?? {}
|
||||||
@@ -463,7 +497,6 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/plugins/install",
|
"/plugins/install",
|
||||||
{ onRequest: [fastify.requireAdmin] },
|
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = /** @type {{
|
const body = /** @type {{
|
||||||
source?: string,
|
source?: string,
|
||||||
@@ -538,7 +571,6 @@ export default function scriptsPluginFactory(registry) {
|
|||||||
|
|
||||||
fastify.delete(
|
fastify.delete(
|
||||||
"/plugins/:id",
|
"/plugins/:id",
|
||||||
{ onRequest: [fastify.requireAdmin] },
|
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,14 +9,17 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import path from "node:path";
|
||||||
import { migrate, db } from "../db.js";
|
import { migrate, db } from "../db.js";
|
||||||
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
import { getAppVersion, satisfiesRange } from "../app-version.js";
|
||||||
import {
|
import {
|
||||||
forkCoreScript,
|
forkCoreScript,
|
||||||
|
duplicatePlugin,
|
||||||
resolveScriptRef,
|
resolveScriptRef,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
listInstalledPlugins,
|
listInstalledPlugins,
|
||||||
createBlankPlugin,
|
createBlankPlugin,
|
||||||
|
pluginDir,
|
||||||
} from "../plugin-store.js";
|
} from "../plugin-store.js";
|
||||||
import { installExamplePlugin } from "../plugin-install.js";
|
import { installExamplePlugin } from "../plugin-install.js";
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +86,26 @@ async function main() {
|
|||||||
);
|
);
|
||||||
assert.equal(blankRun.output.ok, true);
|
assert.equal(blankRun.output.ok, true);
|
||||||
|
|
||||||
|
const duplicated = duplicatePlugin("blank-smoke", "blank-smoke-copy");
|
||||||
|
assert.equal(duplicated.scriptRef, "plugin/blank-smoke-copy");
|
||||||
|
clearScriptCache();
|
||||||
|
assert.equal(resolveScriptRef("plugin/blank-smoke-copy").kind, "plugin");
|
||||||
|
const dupRun = await runScript(
|
||||||
|
"plugin/blank-smoke-copy",
|
||||||
|
{ data: 1, context: {}, config: null },
|
||||||
|
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||||
|
);
|
||||||
|
assert.equal(dupRun.output.ok, true);
|
||||||
|
|
||||||
|
let hitDupSelf = false;
|
||||||
|
try {
|
||||||
|
duplicatePlugin("blank-smoke", "blank-smoke");
|
||||||
|
} catch (err) {
|
||||||
|
hitDupSelf = true;
|
||||||
|
assert.match(String(err.message), /itself/);
|
||||||
|
}
|
||||||
|
assert.equal(hitDupSelf, true);
|
||||||
|
|
||||||
let hit = false;
|
let hit = false;
|
||||||
try {
|
try {
|
||||||
forkCoreScript("ntfy.js", "ntfy");
|
forkCoreScript("ntfy.js", "ntfy");
|
||||||
@@ -98,10 +121,58 @@ async function main() {
|
|||||||
);
|
);
|
||||||
assert.ok(meta);
|
assert.ok(meta);
|
||||||
|
|
||||||
|
const extra = createBlankPlugin(
|
||||||
|
"extra-require-smoke",
|
||||||
|
`const extra = require("smoke-extra");
|
||||||
|
async function main() {
|
||||||
|
return { output: { n: extra.n } };
|
||||||
|
}
|
||||||
|
main.meta = {
|
||||||
|
description: "smoke extra require",
|
||||||
|
config: {},
|
||||||
|
input: {},
|
||||||
|
output: { n: { type: "number" } },
|
||||||
|
example: { data: {}, config: {} },
|
||||||
|
};
|
||||||
|
export default main;
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
assert.equal(extra.scriptRef, "plugin/extra-require-smoke");
|
||||||
|
const extraPkg = path.join(
|
||||||
|
pluginDir("extra-require-smoke"),
|
||||||
|
"node_modules",
|
||||||
|
"smoke-extra",
|
||||||
|
);
|
||||||
|
fs.mkdirSync(extraPkg, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(extraPkg, "package.json"),
|
||||||
|
`${JSON.stringify({ name: "smoke-extra", main: "index.js" })}\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(extraPkg, "index.js"), "module.exports = { n: 9 };\n");
|
||||||
|
clearScriptCache();
|
||||||
|
const extraSource = fs.readFileSync(
|
||||||
|
path.join(pluginDir("extra-require-smoke"), "script.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const extraInspect = inspectScriptSource(
|
||||||
|
"plugin/extra-require-smoke",
|
||||||
|
extraSource,
|
||||||
|
);
|
||||||
|
assert.equal(extraInspect.metaError, null, extraInspect.metaError);
|
||||||
|
assert.equal(extraInspect.meta?.description, "smoke extra require");
|
||||||
|
const extraRun = await runScript(
|
||||||
|
"plugin/extra-require-smoke",
|
||||||
|
{ data: null, context: {}, config: null },
|
||||||
|
{ log: silent, workflowName: "smoke", owner: "default" },
|
||||||
|
);
|
||||||
|
assert.equal(extraRun.output.n, 9);
|
||||||
|
|
||||||
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
|
assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
|
||||||
|
|
||||||
uninstallPlugin("jsonata-smoke-fork");
|
uninstallPlugin("jsonata-smoke-fork");
|
||||||
uninstallPlugin("blank-smoke");
|
uninstallPlugin("blank-smoke");
|
||||||
|
uninstallPlugin("blank-smoke-copy");
|
||||||
|
uninstallPlugin("extra-require-smoke");
|
||||||
uninstallPlugin("get-current-time");
|
uninstallPlugin("get-current-time");
|
||||||
|
|
||||||
console.log("plugins-smoke: ok");
|
console.log("plugins-smoke: ok");
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ export function useForkScript() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDuplicatePlugin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ name, id, description }) =>
|
||||||
|
(
|
||||||
|
await api.post(`/scripts/${encodeURIComponent(name)}/duplicate`, {
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useInstallPlugin() {
|
export function useInstallPlugin() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
|
|||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useCreatePlugin,
|
useCreatePlugin,
|
||||||
|
useDuplicatePlugin,
|
||||||
useForkScript,
|
useForkScript,
|
||||||
useSaveScript,
|
useSaveScript,
|
||||||
useScript,
|
useScript,
|
||||||
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
|
|||||||
const existing = useScript(name);
|
const existing = useScript(name);
|
||||||
const save = useSaveScript();
|
const save = useSaveScript();
|
||||||
const fork = useForkScript();
|
const fork = useForkScript();
|
||||||
|
const duplicate = useDuplicatePlugin();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
const [forkId, setForkId] = useState("");
|
const [forkId, setForkId] = useState("");
|
||||||
|
const [duplicateId, setDuplicateId] = useState("");
|
||||||
|
|
||||||
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
||||||
|
|
||||||
@@ -163,6 +166,21 @@ export function ScriptEditPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDuplicate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = normalizePluginId(duplicateId);
|
||||||
|
if (!id) return;
|
||||||
|
duplicate.mutate(
|
||||||
|
{ name, id },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
notify.success("Duplicated — drain-restart recommended");
|
||||||
|
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (existing.isLoading) {
|
if (existing.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[12rem] items-center justify-center">
|
<div className="flex min-h-[12rem] items-center justify-center">
|
||||||
@@ -238,7 +256,27 @@ export function ScriptEditPage() {
|
|||||||
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
|
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onDuplicate} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input input-sm font-mono w-56"
|
||||||
|
placeholder="duplicate id (e.g. my-plugin-copy)"
|
||||||
|
value={duplicateId}
|
||||||
|
onChange={(e) => setDuplicateId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={duplicate.isPending || !normalizePluginId(duplicateId)}
|
||||||
|
>
|
||||||
|
<LuCopy className="size-4" />
|
||||||
|
Duplicate plugin
|
||||||
|
</button>
|
||||||
|
{duplicate.isError ? (
|
||||||
|
<span className="text-error text-sm">{errorMessage(duplicate.error)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
|
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icons/lu";
|
import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2, LuUpload } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useDeleteScript,
|
useDeleteScript,
|
||||||
|
useDuplicatePlugin,
|
||||||
useForkScript,
|
useForkScript,
|
||||||
useInstallPlugin,
|
useInstallPlugin,
|
||||||
useScripts,
|
useScripts,
|
||||||
@@ -14,6 +15,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
|
|||||||
import { scriptTags } from "../lib/script.js";
|
import { scriptTags } from "../lib/script.js";
|
||||||
import { useNotifications } from "../notifications.jsx";
|
import { useNotifications } from "../notifications.jsx";
|
||||||
|
|
||||||
|
const BASE64_CHUNK = 0x8000;
|
||||||
|
|
||||||
|
function uint8ArrayToBase64(bytes) {
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileToBase64(file) {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
return uint8ArrayToBase64(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
export function ScriptsPage() {
|
export function ScriptsPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,10 +39,17 @@ export function ScriptsPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [forkFor, setForkFor] = useState(null);
|
const [forkFor, setForkFor] = useState(null);
|
||||||
const [forkId, setForkId] = useState("");
|
const [forkId, setForkId] = useState("");
|
||||||
|
const [duplicateFor, setDuplicateFor] = useState(null);
|
||||||
|
const [duplicateId, setDuplicateId] = useState("");
|
||||||
const del = useDeleteScript();
|
const del = useDeleteScript();
|
||||||
const fork = useForkScript();
|
const fork = useForkScript();
|
||||||
|
const duplicate = useDuplicatePlugin();
|
||||||
const install = useInstallPlugin();
|
const install = useInstallPlugin();
|
||||||
const { notify } = useNotifications();
|
const { notify } = useNotifications();
|
||||||
|
const [pendingZip, setPendingZip] = useState(null);
|
||||||
|
const [zipEncoding, setZipEncoding] = useState(false);
|
||||||
|
const zipInFlight = useRef(false);
|
||||||
|
const zipBusy = zipEncoding || install.isPending;
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const term = query.trim().toLowerCase();
|
const term = query.trim().toLowerCase();
|
||||||
@@ -45,6 +68,44 @@ export function ScriptsPage() {
|
|||||||
});
|
});
|
||||||
}, [scripts, query]);
|
}, [scripts, query]);
|
||||||
|
|
||||||
|
function onPickZip(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
install.reset();
|
||||||
|
setPendingZip(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPendingZip() {
|
||||||
|
if (!pendingZip || zipInFlight.current) return;
|
||||||
|
zipInFlight.current = true;
|
||||||
|
setZipEncoding(true);
|
||||||
|
try {
|
||||||
|
const zipBase64 = await fileToBase64(pendingZip);
|
||||||
|
install.mutate(
|
||||||
|
{ source: "zip", zipBase64, overwrite: true },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setPendingZip(null);
|
||||||
|
const ref =
|
||||||
|
data.scriptRef ?? (data.id ? `plugin/${data.id}` : "plugin");
|
||||||
|
notify.success(`Installed ${ref} — drain-restart to load workers`);
|
||||||
|
if (data.warning) notify.warning(data.warning);
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(errorMessage(err)),
|
||||||
|
onSettled: () => {
|
||||||
|
zipInFlight.current = false;
|
||||||
|
setZipEncoding(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
zipInFlight.current = false;
|
||||||
|
setZipEncoding(false);
|
||||||
|
notify.error(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (editName) {
|
if (editName) {
|
||||||
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
|
||||||
}
|
}
|
||||||
@@ -67,7 +128,7 @@ export function ScriptsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-outline btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
disabled={install.isPending}
|
disabled={zipBusy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
install.mutate(
|
install.mutate(
|
||||||
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
{ source: "example", exampleId: "get-current-time", overwrite: true },
|
||||||
@@ -80,6 +141,21 @@ export function ScriptsPage() {
|
|||||||
>
|
>
|
||||||
Install example
|
Install example
|
||||||
</button>
|
</button>
|
||||||
|
<label className={`btn btn-outline btn-sm ${zipBusy ? "btn-disabled" : ""}`}>
|
||||||
|
{zipBusy ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : (
|
||||||
|
<LuUpload className="size-4" />
|
||||||
|
)}
|
||||||
|
Install from zip
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
className="hidden"
|
||||||
|
disabled={zipBusy}
|
||||||
|
onChange={onPickZip}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
<Link to="/scripts/new" className="btn btn-primary btn-sm">
|
||||||
<LuPlus className="size-4" />
|
<LuPlus className="size-4" />
|
||||||
Add plugin
|
Add plugin
|
||||||
@@ -182,6 +258,7 @@ export function ScriptsPage() {
|
|||||||
<LuCopy className="size-4" />
|
<LuCopy className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
@@ -190,6 +267,24 @@ export function ScriptsPage() {
|
|||||||
>
|
>
|
||||||
<LuPencil className="size-4" />
|
<LuPencil className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
title="Duplicate"
|
||||||
|
aria-label="Duplicate"
|
||||||
|
onClick={() => {
|
||||||
|
setDuplicateFor(name);
|
||||||
|
setDuplicateId(
|
||||||
|
String(name)
|
||||||
|
.replace(/^plugin\//i, "")
|
||||||
|
.replace(/\.js$/i, "")
|
||||||
|
.toLowerCase() + "-copy",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuCopy className="size-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!isCore ? (
|
{!isCore ? (
|
||||||
<button
|
<button
|
||||||
@@ -264,6 +359,84 @@ export function ScriptsPage() {
|
|||||||
</dialog>
|
</dialog>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{duplicateFor ? (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-semibold">Duplicate {duplicateFor}</h3>
|
||||||
|
<p className="text-sm opacity-70 py-2">
|
||||||
|
Creates <code>plugin/<id></code> from this plugin.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-sm w-full font-mono"
|
||||||
|
value={duplicateId}
|
||||||
|
onChange={(e) => setDuplicateId(e.target.value)}
|
||||||
|
/>
|
||||||
|
{duplicate.isError ? (
|
||||||
|
<p className="text-error text-sm mt-2">{errorMessage(duplicate.error)}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setDuplicateFor(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={duplicate.isPending || !duplicateId.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
duplicate.mutate(
|
||||||
|
{ name: duplicateFor, id: duplicateId.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setDuplicateFor(null);
|
||||||
|
notify.success("Duplicated — drain-restart recommended");
|
||||||
|
navigate(
|
||||||
|
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={() => setDuplicateFor(null)}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(pendingZip)}
|
||||||
|
title="Install plugin from zip?"
|
||||||
|
message={
|
||||||
|
pendingZip ? (
|
||||||
|
<>
|
||||||
|
Installing{" "}
|
||||||
|
<span className="font-mono">{pendingZip.name}</span> replaces an
|
||||||
|
existing plugin with the same id. Drain-restart workers after
|
||||||
|
install so new dependencies load.
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
confirmLabel="Install"
|
||||||
|
confirmClass="btn-primary"
|
||||||
|
error={install.isError ? errorMessage(install.error) : null}
|
||||||
|
loading={zipBusy}
|
||||||
|
onCancel={() => {
|
||||||
|
if (zipBusy) return;
|
||||||
|
setPendingZip(null);
|
||||||
|
}}
|
||||||
|
onConfirm={installPendingZip}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(confirmDelete)}
|
open={Boolean(confirmDelete)}
|
||||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "jakarta-prayer-time-parser-for-ntfy",
|
||||||
|
"name": "Jakarta prayer times → ntfy",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "Format today/tomorrow prayer times as ntfy Markdown (headings + tables)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-jakarta-prayer-time-parser-for-ntfy",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: format Jakarta prayer times as ntfy Markdown"
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const PRAYERS = [
|
||||||
|
["imsak", "Imsak"],
|
||||||
|
["subuh", "Subuh"],
|
||||||
|
["terbit", "Terbit"],
|
||||||
|
["dhuha", "Dhuha"],
|
||||||
|
["dzuhur", "Dzuhur"],
|
||||||
|
["ashar", "Ashar"],
|
||||||
|
["maghrib", "Maghrib"],
|
||||||
|
["isya", "Isya"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapScalar(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireDay(day, label) {
|
||||||
|
if (day == null || typeof day !== "object" || Array.isArray(day)) {
|
||||||
|
throw new Error(
|
||||||
|
`parser: missing ${label} prayer-time data (run plugin/jakarta-prayer-time-today-and-tomorrow first)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayCaption(day, fallbackDate) {
|
||||||
|
const tanggal = unwrapScalar(day.tanggal);
|
||||||
|
if (tanggal) return tanggal;
|
||||||
|
return unwrapScalar(fallbackDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayTable(heading, day) {
|
||||||
|
const lines = [
|
||||||
|
`### ${heading}`,
|
||||||
|
"",
|
||||||
|
"| Prayer | Time |",
|
||||||
|
"| --- | --- |",
|
||||||
|
];
|
||||||
|
for (const [key, label] of PRAYERS) {
|
||||||
|
const time = unwrapScalar(day[key]) || "—";
|
||||||
|
lines.push(`| ${label} | ${time} |`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parsePrayerTimesForNtfy(ctx) {
|
||||||
|
const data = ctx?.data && typeof ctx.data === "object" ? ctx.data : {};
|
||||||
|
const today = requireDay(data.today, "today");
|
||||||
|
const tomorrow = requireDay(data.tomorrow, "tomorrow");
|
||||||
|
const location = unwrapScalar(ctx.config?.location) || "Jakarta";
|
||||||
|
|
||||||
|
const todayCaption = dayCaption(today, data.todayDate);
|
||||||
|
const tomorrowCaption = dayCaption(tomorrow, data.tomorrowDate);
|
||||||
|
|
||||||
|
const todayHeading = todayCaption ? `Today — ${todayCaption}` : "Today";
|
||||||
|
const tomorrowHeading = tomorrowCaption
|
||||||
|
? `Tomorrow — ${tomorrowCaption}`
|
||||||
|
: "Tomorrow";
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
formatDayTable(todayHeading, today),
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
formatDayTable(tomorrowHeading, tomorrow),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const title = todayCaption
|
||||||
|
? `Prayer Times (${location}): ${todayCaption}`
|
||||||
|
: `Prayer Times (${location})`;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ location, todayCaption, tomorrowCaption },
|
||||||
|
"jakarta-prayer-time-parser-for-ntfy: formatted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = { ok: true, title, message };
|
||||||
|
return { output, context: { ...passContext(ctx), ...output } };
|
||||||
|
}
|
||||||
|
|
||||||
|
parsePrayerTimesForNtfy.meta = {
|
||||||
|
description:
|
||||||
|
"Format today/tomorrow prayer times as ntfy Markdown (headings + tables). Set ntfy.js config.markdown to true.",
|
||||||
|
previewConfigKey: "location",
|
||||||
|
tags: ["ntfy", "Markdown", "Prayer Times"],
|
||||||
|
config: {
|
||||||
|
location: {
|
||||||
|
type: "string",
|
||||||
|
default: "Jakarta",
|
||||||
|
description: "City name used in the ntfy title",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
today: {
|
||||||
|
type: "any",
|
||||||
|
required: true,
|
||||||
|
description: "Today's prayer-time record from the fetcher",
|
||||||
|
},
|
||||||
|
tomorrow: {
|
||||||
|
type: "any",
|
||||||
|
required: true,
|
||||||
|
description: "Tomorrow's prayer-time record from the fetcher",
|
||||||
|
},
|
||||||
|
todayDate: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "YYYY-MM-DD fallback when today.tanggal is missing",
|
||||||
|
},
|
||||||
|
tomorrowDate: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "YYYY-MM-DD fallback when tomorrow.tanggal is missing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
title: { type: "string", description: "ntfy Title header" },
|
||||||
|
message: { type: "string", description: "Markdown body (headings + tables)" },
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
title: { type: "string" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: {
|
||||||
|
todayDate: "2026-08-30",
|
||||||
|
tomorrowDate: "2026-08-31",
|
||||||
|
today: {
|
||||||
|
tanggal: "Minggu, 30/08/2026",
|
||||||
|
imsak: "04:32",
|
||||||
|
subuh: "04:42",
|
||||||
|
terbit: "05:57",
|
||||||
|
dhuha: "06:26",
|
||||||
|
dzuhur: "11:58",
|
||||||
|
ashar: "15:18",
|
||||||
|
maghrib: "17:58",
|
||||||
|
isya: "19:08",
|
||||||
|
},
|
||||||
|
tomorrow: {
|
||||||
|
tanggal: "Senin, 31/08/2026",
|
||||||
|
imsak: "04:32",
|
||||||
|
subuh: "04:42",
|
||||||
|
terbit: "05:57",
|
||||||
|
dhuha: "06:26",
|
||||||
|
dzuhur: "11:58",
|
||||||
|
ashar: "15:17",
|
||||||
|
maghrib: "17:58",
|
||||||
|
isya: "19:08",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config: { location: "Jakarta" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default parsePrayerTimesForNtfy;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "jakarta-prayer-time-today-and-tomorrow",
|
||||||
|
"name": "Jakarta prayer times (today & tomorrow)",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"jerapah": ">=0.1.0 <1.0.0",
|
||||||
|
"main": "script.js",
|
||||||
|
"description": "Fetch Kemenag prayer times for today and tomorrow using dayjs (Asia/Jakarta)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "jflow-plugin-jakarta-prayer-time-today-and-tomorrow",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "JerapahFlow plugin: fetch Jakarta prayer times for today and tomorrow",
|
||||||
|
"dependencies": {
|
||||||
|
"dayjs": "^1.11.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.21
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
dayjs@1.11.21:
|
||||||
|
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
dayjs@1.11.21: {}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
const PAGE_URL = "https://bimasislam.kemenag.go.id/jadwalshalat";
|
||||||
|
const API_URL = "https://bimasislam.kemenag.go.id/ajax/getShalatbln";
|
||||||
|
|
||||||
|
/** Default Kemenag hashes: DKI Jakarta / Kota Jakarta. */
|
||||||
|
const DEFAULT_X = "c51ce410c124a10e0db5e4b97fc2af39";
|
||||||
|
const DEFAULT_Y = "58a2fc6ed39fd083f55d4182bf88826d";
|
||||||
|
const DEFAULT_TIMEZONE = "Asia/Jakarta";
|
||||||
|
|
||||||
|
const BROWSER_HEADERS = {
|
||||||
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"accept-language": "en-US,en;q=0.9,id;q=0.8",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
pragma: "no-cache",
|
||||||
|
};
|
||||||
|
|
||||||
|
function passContext(ctx) {
|
||||||
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
|
return { ...ctx.context };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapScalar(value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthKey(d) {
|
||||||
|
return `${d.year()}-${d.month() + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(setCookie) {
|
||||||
|
if (!setCookie) return "";
|
||||||
|
return setCookie
|
||||||
|
.split(/,(?=[^;,]+=)/)
|
||||||
|
.map((cookie) => cookie.split(";")[0])
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionCookie() {
|
||||||
|
const cookieFetch = await fetch(PAGE_URL, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...BROWSER_HEADERS,
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = parseCookies(cookieFetch.headers.get("set-cookie"));
|
||||||
|
if (!cookies) {
|
||||||
|
throw new Error(
|
||||||
|
`Prayer-time session cookie missing (HTTP ${cookieFetch.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrayerMonth(cookies, month, year, x, y) {
|
||||||
|
const resp = await fetch(API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json, text/javascript, */*; q=0.01",
|
||||||
|
"accept-language": BROWSER_HEADERS["accept-language"],
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
pragma: "no-cache",
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
cookie: cookies,
|
||||||
|
Referer: PAGE_URL,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
bln: String(month),
|
||||||
|
thn: String(year),
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await resp.text();
|
||||||
|
let responseData;
|
||||||
|
try {
|
||||||
|
responseData = JSON.parse(responseText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Prayer-time API returned invalid JSON (HTTP ${resp.status}) for ${year}-${month}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Prayer-time API request failed with HTTP ${resp.status} for ${year}-${month}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: responseData,
|
||||||
|
status: resp.status,
|
||||||
|
month: Number(month),
|
||||||
|
year: Number(year),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one day's record from a monthly API payload.
|
||||||
|
* Accepts `{ data: { "YYYY-MM-DD": {...} } }` or a date-keyed object.
|
||||||
|
*/
|
||||||
|
function getDateData(monthResponse, dateKey) {
|
||||||
|
const candidates = [
|
||||||
|
monthResponse?.data?.data,
|
||||||
|
monthResponse?.data,
|
||||||
|
monthResponse,
|
||||||
|
];
|
||||||
|
for (const map of candidates) {
|
||||||
|
if (map && typeof map === "object" && !Array.isArray(map) && map[dateKey]) {
|
||||||
|
const day = map[dateKey];
|
||||||
|
if (typeof day === "object" && !Array.isArray(day)) {
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No prayer times for ${dateKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrayerTimes(ctx) {
|
||||||
|
const timezoneName = unwrapScalar(ctx.config?.timezone) || DEFAULT_TIMEZONE;
|
||||||
|
const x = unwrapScalar(ctx.config?.x) || DEFAULT_X;
|
||||||
|
const y = unwrapScalar(ctx.config?.y) || DEFAULT_Y;
|
||||||
|
|
||||||
|
const today = dayjs().tz(timezoneName);
|
||||||
|
if (!today.isValid()) {
|
||||||
|
throw new Error(`invalid timezone "${timezoneName}"`);
|
||||||
|
}
|
||||||
|
const tomorrow = today.add(1, "day");
|
||||||
|
const todayDate = today.format("YYYY-MM-DD");
|
||||||
|
const tomorrowDate = tomorrow.format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ today: todayDate, tomorrow: tomorrowDate, timezone: timezoneName },
|
||||||
|
"jakarta-prayer-time-today-and-tomorrow: fetching",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cookies = await getSessionCookie();
|
||||||
|
|
||||||
|
const needed = [
|
||||||
|
{ key: monthKey(today), month: today.month() + 1, year: today.year() },
|
||||||
|
{ key: monthKey(tomorrow), month: tomorrow.month() + 1, year: tomorrow.year() },
|
||||||
|
];
|
||||||
|
const unique = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of needed) {
|
||||||
|
if (seen.has(item.key)) continue;
|
||||||
|
seen.add(item.key);
|
||||||
|
unique.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthResponses = {};
|
||||||
|
await Promise.all(
|
||||||
|
unique.map(async (item) => {
|
||||||
|
monthResponses[item.key] = await fetchPrayerMonth(
|
||||||
|
cookies,
|
||||||
|
item.month,
|
||||||
|
item.year,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const todayData = getDateData(monthResponses[monthKey(today)], todayDate);
|
||||||
|
const tomorrowData = getDateData(
|
||||||
|
monthResponses[monthKey(tomorrow)],
|
||||||
|
tomorrowDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
ok: true,
|
||||||
|
today: todayData,
|
||||||
|
tomorrow: tomorrowData,
|
||||||
|
todayDate,
|
||||||
|
tomorrowDate,
|
||||||
|
timezone: timezoneName,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
todayFound: Boolean(output.today),
|
||||||
|
tomorrowFound: Boolean(output.tomorrow),
|
||||||
|
monthsFetched: unique.length,
|
||||||
|
},
|
||||||
|
"jakarta-prayer-time-today-and-tomorrow: extracted",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
output,
|
||||||
|
context: { ...passContext(ctx), ...output },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPrayerTimes.meta = {
|
||||||
|
description:
|
||||||
|
"Fetch Kemenag Bimas Islam prayer times for today and tomorrow (Asia/Jakarta by default).",
|
||||||
|
previewConfigKey: "timezone",
|
||||||
|
tags: ["HTTP", "Prayer Times"],
|
||||||
|
config: {
|
||||||
|
timezone: {
|
||||||
|
type: "string",
|
||||||
|
default: DEFAULT_TIMEZONE,
|
||||||
|
description: "IANA timezone used to pick today/tomorrow",
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: "string",
|
||||||
|
default: DEFAULT_X,
|
||||||
|
description: "Kemenag kabupaten hash (x). Default is DKI Jakarta",
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: "string",
|
||||||
|
default: DEFAULT_Y,
|
||||||
|
description: "Kemenag kota hash (y). Default is Kota Jakarta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {},
|
||||||
|
output: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
today: { type: "any", description: "Prayer-time record for today" },
|
||||||
|
tomorrow: { type: "any", description: "Prayer-time record for tomorrow" },
|
||||||
|
todayDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||||
|
tomorrowDate: { type: "string", description: "YYYY-MM-DD in the configured timezone" },
|
||||||
|
timezone: { type: "string" },
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
ok: { type: "boolean" },
|
||||||
|
today: { type: "any" },
|
||||||
|
tomorrow: { type: "any" },
|
||||||
|
todayDate: { type: "string" },
|
||||||
|
tomorrowDate: { type: "string" },
|
||||||
|
timezone: { type: "string" },
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
data: {},
|
||||||
|
config: {
|
||||||
|
timezone: "Asia/Jakarta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default fetchPrayerTimes;
|
||||||
+67
-28
@@ -1,3 +1,5 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
|
||||||
function passContext(ctx) {
|
function passContext(ctx) {
|
||||||
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
|
||||||
return { ...ctx.context };
|
return { ...ctx.context };
|
||||||
@@ -65,38 +67,75 @@ function parseResult(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function leftoverFromError(err) {
|
function httpBodyFromRaw(raw) {
|
||||||
let current = err;
|
const normalized = String(raw).replace(/\r\n/g, "\n");
|
||||||
for (let i = 0; i < 6 && current; i++) {
|
const split = normalized.indexOf("\n\n");
|
||||||
if (typeof current.data === "string") return current.data;
|
return split === -1 ? normalized : normalized.slice(split + 2);
|
||||||
current = current.cause;
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
/**
|
||||||
|
* ZTE Demo-Webs uses LF-only status lines (`HTTP/1.1 200 OK\n`).
|
||||||
|
* Node 24 fetch/http throw HPE_CR_EXPECTED before any body is read.
|
||||||
|
*/
|
||||||
|
function zteHttpPost(url, fields) {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (target.protocol !== "http:") {
|
||||||
|
throw new Error(`send-sms: only http: is supported (got ${target.protocol})`);
|
||||||
|
}
|
||||||
|
const body = new URLSearchParams(fields).toString();
|
||||||
|
const request = [
|
||||||
|
`POST ${target.pathname}${target.search} HTTP/1.1`,
|
||||||
|
`Host: ${target.host}`,
|
||||||
|
"accept: application/json, text/javascript, */*; q=0.01",
|
||||||
|
"accept-language: en-US,en;q=0.9",
|
||||||
|
"content-type: application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"x-requested-with: XMLHttpRequest",
|
||||||
|
`Referer: ${refererFromUrl(url)}`,
|
||||||
|
`Content-Length: ${Buffer.byteLength(body)}`,
|
||||||
|
"Connection: close",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect(
|
||||||
|
{ host: target.hostname, port: Number(target.port) || 80 },
|
||||||
|
() => {
|
||||||
|
socket.write(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const chunks = [];
|
||||||
|
let settled = false;
|
||||||
|
const finish = (raw) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
resolve(parseResult(httpBodyFromRaw(raw)));
|
||||||
|
};
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8");
|
||||||
|
const parsed = parseResult(httpBodyFromRaw(raw));
|
||||||
|
if (parsed.result !== undefined) finish(raw);
|
||||||
|
});
|
||||||
|
socket.on("end", () => finish(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
socket.setTimeout(15000, () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
reject(new Error("ZTE HTTP timeout"));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ztePost(url, fields) {
|
async function ztePost(url, fields) {
|
||||||
const body = new URLSearchParams(fields).toString();
|
return zteHttpPost(url, fields);
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json, text/javascript, */*; q=0.01",
|
|
||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
Referer: refererFromUrl(url),
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
return parseResult(await response.text());
|
|
||||||
} catch (err) {
|
|
||||||
const leftover = leftoverFromError(err);
|
|
||||||
if (leftover != null) {
|
|
||||||
const parsed = parseResult(leftover);
|
|
||||||
log.info({ result: parsed.result ?? parsed }, "send-sms: recovered malformed response");
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendZteSms(ctx) {
|
async function sendZteSms(ctx) {
|
||||||
|
|||||||
Reference in New Issue
Block a user