diff --git a/ghl-workflow-exporter/README.md b/ghl-workflow-exporter/README.md deleted file mode 100644 index 0f3a3f0..0000000 --- a/ghl-workflow-exporter/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# GHL Workflow Exporter - -A Chrome extension that exports every workflow in the HighLevel sub-account you -are currently viewing as re-importable JSON, packaged as a ZIP you can unzip -straight into a git repo. - -Built for white-labelled HighLevel instances as well as `app.gohighlevel.com` — -it works on whatever domain the tab is already on, because it never hardcodes a -host. - -## Install - -1. Open `chrome://extensions`. -2. Turn on **Developer mode** (top right). -3. **Load unpacked** → select this folder. - -## Use - -1. Open a HighLevel sub-account tab and sign in as normal. -2. Click the extension icon. It shows the sub-account it detected. -3. **Export workflows** → choose where to save the ZIP. - -## What comes out - -``` -legendary-academy-/ -├── index.json # one entry per workflow: name, status, version, counts -├── snapshot.json # everything in one file, for diffing a release as a unit -├── README.md # provenance note, written into every export -└── workflows/ - ├── grant-free-community-access-6c749f21.json - └── … -``` - -Each workflow file is `{ workflowData, triggers, dependentAssets }` — the same -shape the workflow builder's JSON import accepts, so the backup doubles as a -restore path. - -## How it works - -Two authenticated calls, both ones the app itself makes: - -| Purpose | Request | -|---|---| -| List workflows and folders | `GET backend.leadconnectorhq.com/workflow/{locationId}/list` | -| Full definition, one workflow | `GET backend.leadconnectorhq.com/workflow/{locationId}/{workflowId}?includeTriggers=true` | - -`includeTriggers=true` is the whole trick. Without it the endpoint returns bare -metadata; with it you get the action graph and the trigger definitions together. - -## Design notes - -**No credential handling.** The extension injects a function into the page's -MAIN world and borrows `window.SHELL_STORE.$http` — the app's own axios -instance, whose interceptor attaches the session token. The token is never read, -copied, stored, or sent anywhere. If that client is ever unavailable the code -falls back to locating the session JWT in the Vuex auth state, and still keeps -it inside the page. - -**Minimal permissions.** `activeTab`, `scripting`, `downloads`. No -`host_permissions`, no background service worker, no remote code. The API calls -originate from the page, so they are the page's own same-session requests. - -**Deterministic output.** Object keys are sorted recursively, files are written -with a fixed archive timestamp, and two fields are stripped before serialization: - -- `workflowData.fileUrl` — a *signed* Firebase Storage URL. It carries an access - token and must not land in a repo. `filePath`, the stable path it points at, - is kept. -- `permissionMeta` — the exporting user's access rights, not workflow content. - -The result: re-exporting an unchanged workflow produces a byte-identical file, -so `git status` stays quiet and a real diff means a real change. - -**Read-only.** Every request is a GET. Nothing in the sub-account is modified. - -## Limits - -- One sub-account per run — whichever the tab is on. -- Deleted workflows and folders are listed in `index.json` but not exported. -- Restore is manual. Verify in a test sub-account before importing anywhere real. -- These are undocumented internal endpoints. They can change without notice; - if an export starts failing, that is the first place to look. diff --git a/ghl-workflow-exporter/agent.js b/ghl-workflow-exporter/agent.js deleted file mode 100644 index d9fc18d..0000000 --- a/ghl-workflow-exporter/agent.js +++ /dev/null @@ -1,145 +0,0 @@ -// Functions in this file are injected into the HighLevel page's MAIN world by -// chrome.scripting.executeScript. They are serialized and re-parsed there, so -// each one must be entirely self-contained: no imports, no closure variables. -// -// Why the MAIN world: the app keeps its authenticated HTTP client on -// window.SHELL_STORE.$http, whose interceptor attaches the session token. By -// borrowing that client we never read, store, or transmit a credential -// ourselves -- the request goes out exactly as the app's own requests do. - -export const BACKEND = 'https://backend.leadconnectorhq.com/workflow'; - -/** Confirms we are on a HighLevel app page and reports the location in view. */ -export function probePage() { - const findJwt = (node, depth) => { - if (!node || depth > 4) return null; - if (typeof node === 'string') { - return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; - } - if (typeof node !== 'object') return null; - for (const key of Object.keys(node)) { - const hit = findJwt(node[key], depth + 1); - if (hit) return hit; - } - return null; - }; - - const store = window.SHELL_STORE; - if (!store) { - return { ok: false, reason: 'not-highlevel' }; - } - - const fromUrl = location.pathname.match(/\/location\/([A-Za-z0-9]+)/); - let locationId = fromUrl ? fromUrl[1] : null; - let locationName = null; - try { - const current = store.state.locations.currentLocation; - locationId = locationId || current.id || current._id; - locationName = current.name || null; - } catch (e) { /* location name is cosmetic */ } - - if (!locationId) return { ok: false, reason: 'no-location' }; - - const mode = store.$http ? 'client' : (findJwt(store.state && store.state.auth, 0) ? 'token' : null); - if (!mode) return { ok: false, reason: 'no-auth' }; - - return { ok: true, locationId, locationName, mode, origin: location.origin }; -} - -/** Pages through the workflow list for one sub-account. */ -export async function fetchWorkflowList(locationId) { - const request = async (url, params) => { - const store = window.SHELL_STORE; - if (store && store.$http) { - const res = await store.$http.get(url, { params }); - return res.data; - } - const findJwt = (node, depth) => { - if (!node || depth > 4) return null; - if (typeof node === 'string') { - return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; - } - if (typeof node !== 'object') return null; - for (const key of Object.keys(node)) { - const hit = findJwt(node[key], depth + 1); - if (hit) return hit; - } - return null; - }; - const token = findJwt(store && store.state && store.state.auth, 0); - const target = new URL(url); - for (const [k, v] of Object.entries(params || {})) target.searchParams.set(k, String(v)); - const res = await fetch(target.toString(), { - headers: { - Authorization: 'Bearer ' + token, - channel: 'APP', - source: 'WEB_USER', - Version: '2021-07-28' - } - }); - if (!res.ok) throw new Error('HTTP ' + res.status); - return res.json(); - }; - - const base = 'https://backend.leadconnectorhq.com/workflow/' + locationId + '/list'; - const rows = []; - let expected = null; - let skip = 0; - - for (let page = 0; page < 50; page++) { - const data = await request(base, { limit: 200, skip }); - const batch = (data && data.rows) || []; - if (expected === null) expected = typeof data.count === 'number' ? data.count : batch.length; - rows.push(...batch); - skip += batch.length; - if (!batch.length || rows.length >= expected) break; - } - - return { count: expected === null ? rows.length : expected, rows }; -} - -/** - * Full definition for one workflow. includeTriggers=true is the key: it swaps - * the plain metadata response for { workflowData, triggers, dependentAssets }, - * which is the same shape HighLevel's own importer accepts. - */ -export async function fetchWorkflowDetail(locationId, workflowId) { - const url = 'https://backend.leadconnectorhq.com/workflow/' + locationId + '/' + workflowId; - const params = { includeTriggers: true }; - - try { - const store = window.SHELL_STORE; - if (store && store.$http) { - const res = await store.$http.get(url, { params }); - return { ok: true, data: res.data }; - } - const findJwt = (node, depth) => { - if (!node || depth > 4) return null; - if (typeof node === 'string') { - return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; - } - if (typeof node !== 'object') return null; - for (const key of Object.keys(node)) { - const hit = findJwt(node[key], depth + 1); - if (hit) return hit; - } - return null; - }; - const token = findJwt(store && store.state && store.state.auth, 0); - const target = new URL(url); - target.searchParams.set('includeTriggers', 'true'); - const res = await fetch(target.toString(), { - headers: { - Authorization: 'Bearer ' + token, - channel: 'APP', - source: 'WEB_USER', - Version: '2021-07-28' - } - }); - if (!res.ok) throw new Error('HTTP ' + res.status); - return { ok: true, data: await res.json() }; - } catch (err) { - const status = err && err.response && err.response.status; - return { ok: false, error: status ? 'HTTP ' + status : String((err && err.message) || err) }; - } -} diff --git a/ghl-workflow-exporter/popup.css b/ghl-workflow-exporter/popup.css deleted file mode 100644 index 70b063d..0000000 --- a/ghl-workflow-exporter/popup.css +++ /dev/null @@ -1,75 +0,0 @@ -:root { - color-scheme: light dark; - --bg: #ffffff; - --fg: #16181d; - --muted: #6b7280; - --line: #e4e7ec; - --accent: #2f6fed; - --accent-fg: #ffffff; - --ok: #167c46; - --err: #b42318; -} -@media (prefers-color-scheme: dark) { - :root { - --bg: #16181d; - --fg: #f2f4f7; - --muted: #98a2b3; - --line: #2c3038; - --accent: #5b8dff; - --accent-fg: #0b0d11; - --ok: #5fd39b; - --err: #ff9a92; - } -} - -* { box-sizing: border-box; } - -body { - width: 320px; - margin: 0; - padding: 16px; - background: var(--bg); - color: var(--fg); - font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; -} - -h1 { margin: 0; font-size: 15px; font-weight: 600; } -p { margin: 4px 0 0; } -.muted { color: var(--muted); font-size: 12px; } - -header { padding-bottom: 12px; border-bottom: 1px solid var(--line); } - -button { - width: 100%; - margin-top: 12px; - padding: 9px 12px; - border: 0; - border-radius: 7px; - background: var(--accent); - color: var(--accent-fg); - font: inherit; - font-weight: 600; - cursor: pointer; -} -button:disabled { opacity: .45; cursor: default; } - -.bar { - height: 4px; - margin-top: 12px; - border-radius: 2px; - background: var(--line); - overflow: hidden; -} -.bar span { - display: block; - height: 100%; - width: 0; - background: var(--accent); - transition: width .18s ease; -} - -#status { font-size: 12px; } -#status.ok { color: var(--ok); } -#status.err { color: var(--err); } - -footer { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); } diff --git a/ghl-workflow-exporter/popup.html b/ghl-workflow-exporter/popup.html deleted file mode 100644 index b934878..0000000 --- a/ghl-workflow-exporter/popup.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - -GHL Workflow Exporter - - - -
-

Workflow Exporter

-

Checking this tab…

-
- - - - - -

- - - - - - diff --git a/ghl-workflow-exporter/popup.js b/ghl-workflow-exporter/popup.js deleted file mode 100644 index 5a741f2..0000000 --- a/ghl-workflow-exporter/popup.js +++ /dev/null @@ -1,248 +0,0 @@ -import { buildZip, stableJson } from './zip.js'; -import { probePage, fetchWorkflowList, fetchWorkflowDetail } from './agent.js'; - -const els = { - target: document.getElementById('target'), - run: document.getElementById('run'), - progress: document.getElementById('progress'), - fill: document.getElementById('fill'), - step: document.getElementById('step'), - status: document.getElementById('status') -}; - -let context = null; - -function say(message, kind) { - els.status.textContent = message; - els.status.className = kind || ''; -} - -function slug(name, fallback) { - const base = String(name || '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 60); - return base || fallback; -} - -async function inMainWorld(tabId, func, args) { - const [result] = await chrome.scripting.executeScript({ - target: { tabId }, - world: 'MAIN', - func, - args: args || [] - }); - return result ? result.result : undefined; -} - -async function activeTab() { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - return tab; -} - -const REASONS = { - 'not-highlevel': 'Open a HighLevel sub-account tab, then reopen this popup.', - 'no-location': 'No sub-account detected. Navigate into a location first.', - 'no-auth': 'Could not reach the app session. Reload the page and try again.' -}; - -async function init() { - try { - const tab = await activeTab(); - const probe = await inMainWorld(tab.id, probePage); - - if (!probe || !probe.ok) { - els.target.textContent = REASONS[probe && probe.reason] || 'This tab is not a HighLevel app page.'; - return; - } - - context = { tabId: tab.id, ...probe }; - els.target.textContent = probe.locationName - ? probe.locationName - : 'Sub-account ' + probe.locationId; - els.run.disabled = false; - } catch (err) { - els.target.textContent = 'Cannot read this tab.'; - say(String(err.message || err), 'err'); - } -} - -async function run() { - els.run.disabled = true; - els.progress.hidden = false; - say(''); - - try { - els.step.textContent = 'Listing workflows…'; - const list = await inMainWorld(context.tabId, fetchWorkflowList, [context.locationId]); - const rows = (list.rows || []).filter((row) => row.type !== 'folder' && !row.deleted); - const folders = (list.rows || []).filter((row) => row.type === 'folder'); - - if (!rows.length) { - throw new Error('This sub-account has no workflows to export.'); - } - - const folderNames = new Map(folders.map((f) => [f.id || f._id, f.name])); - const entries = []; - const failures = []; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]; - const id = row.id || row._id; - els.step.textContent = 'Exporting ' + (i + 1) + ' of ' + rows.length + ': ' + row.name; - els.fill.style.width = Math.round((i / rows.length) * 100) + '%'; - - const detail = await fetchWithRetry(context.tabId, context.locationId, id); - if (!detail.ok) { - failures.push({ id, name: row.name, error: detail.error }); - continue; - } - - const payload = sanitize(detail.data); - entries.push({ - id, - name: row.name, - status: row.status, - parentId: row.parentId || null, - folder: row.parentId ? folderNames.get(row.parentId) || null : null, - version: payload.workflowData && payload.workflowData.version, - dataVersion: row.dataVersion, - updatedAt: row.updatedAt, - updatedBy: row.updatedBy, - actionCount: countActions(payload), - triggerCount: Array.isArray(payload.triggers) ? payload.triggers.length : 0, - file: 'workflows/' + slug(row.name, 'workflow') + '-' + String(id).slice(0, 8) + '.json', - payload - }); - - // A courteous pause: HighLevel rate-limits bursts per location. - await new Promise((resolve) => setTimeout(resolve, 120)); - } - - els.fill.style.width = '100%'; - els.step.textContent = 'Packaging…'; - - const root = slug(context.locationName, 'sub-account') + '-' + context.locationId; - const files = buildFiles(root, entries, failures); - const blob = buildZip(files); - await download(blob, root); - - const note = failures.length ? ' (' + failures.length + ' failed, see index.json)' : ''; - say('Exported ' + entries.length + ' workflow' + (entries.length === 1 ? '' : 's') + note + '.', 'ok'); - els.step.textContent = ''; - } catch (err) { - say(String(err.message || err), 'err'); - els.step.textContent = ''; - } finally { - els.run.disabled = false; - } -} - -async function fetchWithRetry(tabId, locationId, workflowId) { - let last = null; - for (let attempt = 0; attempt < 3; attempt++) { - last = await inMainWorld(tabId, fetchWorkflowDetail, [locationId, workflowId]); - if (last && last.ok) return last; - await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))); - } - return last || { ok: false, error: 'unknown error' }; -} - -/** - * Strips two fields that would poison a git history: - * - workflowData.fileUrl is a *signed* Firebase URL. It carries an access - * token, changes on every fetch, and has no business in a repo. - * - permissionMeta describes the exporting user's access rights, not the - * workflow, so it churns per operator without adding backup value. - * filePath is kept: it is the stable storage path the signed URL points at. - */ -function sanitize(payload) { - const clone = JSON.parse(JSON.stringify(payload || {})); - delete clone.permissionMeta; - if (clone.workflowData) delete clone.workflowData.fileUrl; - return clone; -} - -function countActions(payload) { - const inner = payload && payload.workflowData && payload.workflowData.workflowData; - return inner && Array.isArray(inner.templates) ? inner.templates.length : 0; -} - -function buildFiles(root, entries, failures) { - const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - - const index = { - locationId: context.locationId, - locationName: context.locationName || null, - workflowCount: sorted.length, - workflows: sorted.map(({ payload, ...meta }) => meta), - failures - }; - - const snapshot = { - locationId: context.locationId, - locationName: context.locationName || null, - workflows: [...entries] - .sort((a, b) => String(a.id).localeCompare(String(b.id))) - .map((entry) => ({ id: entry.id, name: entry.name, ...entry.payload })) - }; - - const files = [ - { name: root + '/index.json', text: stableJson(index) }, - { name: root + '/snapshot.json', text: stableJson(snapshot) }, - { name: root + '/README.md', text: readme(root, index) } - ]; - - for (const entry of sorted) { - files.push({ name: root + '/' + entry.file, text: stableJson(entry.payload) }); - } - return files; -} - -function readme(root, index) { - return [ - '# Workflow backup — ' + (index.locationName || index.locationId), - '', - 'Sub-account: `' + index.locationId + '` ', - 'Workflows: ' + index.workflowCount, - '', - '## Layout', - '', - '- `index.json` — one line per workflow: name, status, version, action and trigger counts.', - '- `workflows/*.json` — one file per workflow, each `{ workflowData, triggers, dependentAssets }`.', - '- `snapshot.json` — every workflow in a single file, for diffing a release as one unit.', - '', - '## Provenance', - '', - 'Each workflow file is the response of', - '`GET backend.leadconnectorhq.com/workflow/{locationId}/{workflowId}?includeTriggers=true`,', - 'taken through the signed-in session in the browser. Keys are sorted and files are', - 'written with a fixed archive timestamp, so an unchanged workflow re-exports byte for byte', - 'and git shows no diff.', - '', - '## Restoring', - '', - 'The `{ workflowData, triggers }` shape matches what the workflow builder’s JSON import', - 'accepts. Treat restore as manual and verify in a test sub-account first — this is a backup,', - 'not an automated round trip.', - '' - ].join('\n'); -} - -async function download(blob, root) { - const url = URL.createObjectURL(blob); - const stamp = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); - try { - await chrome.downloads.download({ - url, - filename: root + '-' + stamp + '.zip', - saveAs: true - }); - } finally { - setTimeout(() => URL.revokeObjectURL(url), 60000); - } -} - -els.run.addEventListener('click', run); -init(); diff --git a/ghl-workflow-tools/README.md b/ghl-workflow-tools/README.md new file mode 100644 index 0000000..7fd2216 --- /dev/null +++ b/ghl-workflow-tools/README.md @@ -0,0 +1,135 @@ +# GHL Workflow Backup + +A Chrome extension that exports every workflow in the HighLevel sub-account you +are viewing as re-importable JSON, and restores them from a backup file. + +Built for white-labelled HighLevel instances as well as `app.gohighlevel.com` — +it works on whatever domain the tab is already on, because it never hardcodes a +host. + +## Install + +1. Open `chrome://extensions`. +2. Turn on **Developer mode** (top right). +3. **Load unpacked** → select this folder. + +## Export + +1. Open a HighLevel sub-account tab and sign in as normal. +2. Click the extension icon → **Export** → **Export workflows**. +3. Choose where to save the ZIP. + +``` +legendary-academy-/ +├── index.json # one entry per workflow: name, status, version, counts +├── snapshot.json # everything in one file, for diffing a release as a unit +├── README.md # provenance note, written into every export +└── workflows/ + ├── grant-free-community-access-6c749f21.json + └── … +``` + +Each workflow file is `{ workflowData, triggers, dependentAssets }`. + +## Import + +**Import** tab → choose a `.zip` export or individual `workflows/*.json` files. +Every workflow found is listed with its action and trigger counts, and a mode: + +- **Create new copy (draft)** — the default, and always safe. Makes a fresh + workflow, never touches an existing one. +- **Overwrite "…"** — offered only when a workflow in this sub-account matches + by id, or failing that by name. Replaces that workflow's actions and triggers + in place. + +Overwriting a **published** workflow requires ticking an extra acknowledgement, +because the workflow keeps its published status and will carry on running with +the imported content the moment the write lands. New copies are always drafts, +and nothing is ever published for you. + +Importing into a different sub-account is allowed. The create call returns the +server's own `assetWarnings`, which are surfaced per row: those flag references +to custom fields, tags, calendars or pipelines that do not exist in the target. + +## How it works + +Read and write both go through calls the app itself makes: + +| Purpose | Request | +|---|---| +| List workflows and folders | `GET backend.leadconnectorhq.com/workflow/{locationId}/list` | +| Full definition | `GET .../workflow/{locationId}/{workflowId}?includeTriggers=true` | +| Create | `POST .../workflow/{locationId}` → `{ id, assetWarnings }` | +| Restore contents | `PUT .../workflow/{locationId}/{workflowId}` | +| Triggers | `GET`/`POST`/`DELETE .../workflow/{locationId}/trigger` | + +`includeTriggers=true` is what makes the export complete. Without it the +endpoint returns bare metadata plus a `fileUrl` pointing at Firebase Storage; +with it the server inlines the action graph and the trigger definitions. + +Import mirrors the app's own restore-from-nodes flow, in this order: + +1. Create the workflow (or take the existing one, when overwriting). +2. Delete whatever triggers are attached to it. +3. Create the incoming triggers, with `id` / `_id` / `predeterminedId` stripped + and `workflowId` plus every `actions[].workflow_id` rewired to the target. +4. Read the created triggers back. +5. `PUT` the action graph with **`isRestoreRequest: true`** — a first-class + restore flag in their API — and `newTriggers` set to what step 4 returned. + +Two details that are easy to get wrong and fail loudly: + +- The `PUT` carries the **target's current `version`**, not the version recorded + in the backup. It is an optimistic-concurrency check; a stale number is + rejected with *"Your version is outdated"*. +- `timezone` is an enum. Exported workflows carry values like `"account"`; + inventing an IANA name such as `America/New_York` fails validation. + +## Design notes + +**No credential handling.** The extension injects a function into the page's +MAIN world and borrows `window.SHELL_STORE.$http` — the app's own axios +instance, whose interceptor attaches the session token. The token is never read, +copied, stored, or sent anywhere. If that client is ever unavailable the code +falls back to locating the session JWT in the Vuex auth state, and still keeps +it inside the page. + +**Minimal permissions.** `activeTab`, `scripting`, `downloads`. No +`host_permissions`, no background service worker, no remote code. The API calls +originate from the page, so they are the page's own same-session requests. + +**Deterministic output.** Object keys are sorted recursively, files are written +with a fixed archive timestamp, and two fields are stripped before serialization: + +- `workflowData.fileUrl` — a *signed* Firebase Storage URL. It carries an access + token and must not land in a repo. `filePath`, the stable path it points at, + is kept. +- `permissionMeta` — the exporting user's access rights, not workflow content. + +Re-exporting an unchanged workflow produces a byte-identical file, so +`git status` stays quiet and a real diff means a real change. + +**No third-party code.** The ZIP writer and reader are in `zip.js` and +`unzip.js`; decompression uses the browser's own `DecompressionStream`. + +## Limits + +- One sub-account per run — whichever the tab is on. +- Deleted workflows and folders are listed in `index.json` but not exported, and + folder placement is recorded but not recreated on import. +- Import replaces a workflow's triggers wholesale rather than diffing them. +- These are undocumented internal endpoints. They can change without notice; if + an export or import starts failing, that is the first place to look. + +## Changelog + +### 1.1.0 +- Import: restore workflows from a backup, create-new or overwrite. +- Reads `.zip` exports directly, or loose `.json` files from a repo. +- **Fixed a paging bug in export.** The list endpoint takes `limit`/`offset`, + not `limit`/`skip`. `skip` is silently ignored, so a sub-account with more + than 200 workflows would have re-fetched page one and produced a duplicated, + truncated export. Paging now also guards against repeated ids. + +### 1.0.0 +- Export every workflow in the current sub-account as a ZIP. diff --git a/ghl-workflow-tools/agent.js b/ghl-workflow-tools/agent.js new file mode 100644 index 0000000..dd404de --- /dev/null +++ b/ghl-workflow-tools/agent.js @@ -0,0 +1,324 @@ +// Functions in this file are injected into the HighLevel page's MAIN world by +// chrome.scripting.executeScript. They are serialized and re-parsed there, so +// each one must be entirely self-contained: no imports, no closure variables, +// which is why the small request helper is repeated rather than shared. +// +// Why the MAIN world: the app keeps its authenticated HTTP client on +// window.SHELL_STORE.$http, whose interceptor attaches the session token. By +// borrowing that client we never read, store, or transmit a credential +// ourselves -- requests go out exactly as the app's own requests do. +// +// Endpoint map, lifted from the app's own workflow service class: +// GET {base}/{loc}/list list workflows and folders +// GET {base}/{loc}/{id}?includeTriggers=true full definition +// POST {base}/{loc} create -> { id, assetWarnings } +// PUT {base}/{loc}/{id} update / restore +// GET {base}/{loc}/trigger?workflowId= triggers for a workflow +// POST {base}/{loc}/trigger create trigger +// DELETE {base}/{loc}/trigger/{id} remove trigger +// where {base} is https://backend.leadconnectorhq.com/workflow + +/** Confirms we are on a HighLevel app page and reports the location in view. */ +export function probePage() { + const findJwt = (node, depth) => { + if (!node || depth > 4) return null; + if (typeof node === 'string') { + return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; + } + if (typeof node !== 'object') return null; + for (const key of Object.keys(node)) { + const hit = findJwt(node[key], depth + 1); + if (hit) return hit; + } + return null; + }; + + const store = window.SHELL_STORE; + if (!store) return { ok: false, reason: 'not-highlevel' }; + + const fromUrl = location.pathname.match(/\/location\/([A-Za-z0-9]+)/); + let locationId = fromUrl ? fromUrl[1] : null; + let locationName = null; + try { + const current = store.state.locations.currentLocation; + locationId = locationId || current.id || current._id; + locationName = current.name || null; + } catch (e) { /* location name is cosmetic */ } + + if (!locationId) return { ok: false, reason: 'no-location' }; + + const mode = store.$http ? 'client' : (findJwt(store.state && store.state.auth, 0) ? 'token' : null); + if (!mode) return { ok: false, reason: 'no-auth' }; + + return { ok: true, locationId, locationName, mode, origin: location.origin }; +} + +/** Pages through the workflow list for one sub-account. */ +export async function fetchWorkflowList(locationId) { + const request = async (url, params) => { + const store = window.SHELL_STORE; + if (store && store.$http) return (await store.$http.get(url, { params })).data; + const findJwt = (node, depth) => { + if (!node || depth > 4) return null; + if (typeof node === 'string') { + return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; + } + if (typeof node !== 'object') return null; + for (const key of Object.keys(node)) { + const hit = findJwt(node[key], depth + 1); + if (hit) return hit; + } + return null; + }; + const token = findJwt(store && store.state && store.state.auth, 0); + const target = new URL(url); + for (const [k, v] of Object.entries(params || {})) target.searchParams.set(k, String(v)); + const res = await fetch(target.toString(), { + headers: { Authorization: 'Bearer ' + token, channel: 'APP', source: 'WEB_USER', Version: '2021-07-28' } + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }; + + const base = 'https://backend.leadconnectorhq.com/workflow/' + locationId + '/list'; + const rows = []; + const seen = new Set(); + let expected = null; + + // The service reads this endpoint with limit/offset, not limit/skip. Using + // the wrong name silently re-serves page one, so paging must be verified by + // watching for ids we have already collected rather than trusted blindly. + for (let page = 0; page < 50; page++) { + const data = await request(base, { limit: 200, offset: rows.length }); + const batch = (data && data.rows) || []; + if (expected === null) expected = typeof data.count === 'number' ? data.count : batch.length; + + let added = 0; + for (const row of batch) { + const id = row.id || row._id; + if (seen.has(id)) continue; + seen.add(id); + rows.push(row); + added++; + } + if (!batch.length || !added || rows.length >= expected) break; + } + + return { count: expected === null ? rows.length : expected, rows }; +} + +/** + * Full definition for one workflow. includeTriggers=true is the key: it swaps + * the plain metadata response for { workflowData, triggers, dependentAssets }, + * which is the same shape the app's own restore path writes back. + */ +export async function fetchWorkflowDetail(locationId, workflowId) { + const url = 'https://backend.leadconnectorhq.com/workflow/' + locationId + '/' + workflowId; + + try { + const store = window.SHELL_STORE; + if (store && store.$http) { + const res = await store.$http.get(url, { params: { includeTriggers: true } }); + return { ok: true, data: res.data }; + } + const findJwt = (node, depth) => { + if (!node || depth > 4) return null; + if (typeof node === 'string') { + return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; + } + if (typeof node !== 'object') return null; + for (const key of Object.keys(node)) { + const hit = findJwt(node[key], depth + 1); + if (hit) return hit; + } + return null; + }; + const token = findJwt(store && store.state && store.state.auth, 0); + const target = new URL(url); + target.searchParams.set('includeTriggers', 'true'); + const res = await fetch(target.toString(), { + headers: { Authorization: 'Bearer ' + token, channel: 'APP', source: 'WEB_USER', Version: '2021-07-28' } + }); + if (!res.ok) throw new Error('HTTP ' + res.status); + return { ok: true, data: await res.json() }; + } catch (err) { + const status = err && err.response && err.response.status; + return { ok: false, error: status ? 'HTTP ' + status : String((err && err.message) || err) }; + } +} + +/** + * Writes one workflow back into a sub-account. + * + * spec = { mode: 'create' | 'overwrite', targetId?, name, status?, payload } + * where payload is a { workflowData, triggers } object from a backup file. + * + * The sequence mirrors the app's own restore-from-nodes flow exactly: + * 1. create the workflow (or take the existing one) + * 2. delete whatever triggers are attached + * 3. create the incoming triggers, rewired to the target workflow id + * 4. read the created triggers back + * 5. PUT the action graph with isRestoreRequest: true + * + * Step 5 must send the *target's* current version, not the version recorded in + * the backup: the endpoint uses it for optimistic concurrency and rejects a + * stale number with "Your version is outdated". + */ +export async function importWorkflow(locationId, spec) { + const BASE = 'https://backend.leadconnectorhq.com/workflow'; + const store = window.SHELL_STORE; + + const findJwt = (node, depth) => { + if (!node || depth > 4) return null; + if (typeof node === 'string') { + return /^ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\./.test(node) ? node : null; + } + if (typeof node !== 'object') return null; + for (const key of Object.keys(node)) { + const hit = findJwt(node[key], depth + 1); + if (hit) return hit; + } + return null; + }; + + const req = async (method, url, body, params) => { + if (store && store.$http) { + const res = await store.$http.request({ method, url, data: body, params }); + return res.data; + } + const token = findJwt(store && store.state && store.state.auth, 0); + const target = new URL(url); + for (const [k, v] of Object.entries(params || {})) target.searchParams.set(k, String(v)); + const res = await fetch(target.toString(), { + method: method.toUpperCase(), + headers: { + Authorization: 'Bearer ' + token, + channel: 'APP', + source: 'WEB_USER', + Version: '2021-07-28', + 'Content-Type': 'application/json' + }, + body: body ? JSON.stringify(body) : undefined + }); + if (!res.ok) { + let detail = ''; + try { detail = (await res.json()).errorMessage || ''; } catch (e) { /* body not json */ } + throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : '')); + } + return res.json(); + }; + + const fail = (err) => { + const body = err && err.response && err.response.data; + const detail = body && (body.errorMessage || body.msg || body.message); + const status = err && err.response && err.response.status; + return { + ok: false, + error: detail || String((err && err.message) || err), + status: status || null + }; + }; + + try { + const record = (spec.payload && spec.payload.workflowData) || {}; + const templates = (record.workflowData && record.workflowData.templates) || []; + const triggers = (spec.payload && spec.payload.triggers) || []; + + let company = null; + let userId; + try { company = store.state.company.company || store.state.company.originalCompany; } catch (e) { /* optional */ } + try { userId = store.state.user.user.id || store.state.user.user._id; } catch (e) { /* optional */ } + + const settings = { + timezone: record.timezone, + allowMultiple: record.allowMultiple, + allowMultipleOpportunity: record.allowMultipleOpportunity, + autoMarkAsRead: record.autoMarkAsRead, + stopOnResponse: record.stopOnResponse, + removeContactFromLastStep: record.removeContactFromLastStep, + window: record.window, + eventStartDate: record.eventStartDate + }; + + let workflowId = spec.targetId; + let assetWarnings = []; + + if (spec.mode === 'create') { + const created = await req('post', BASE + '/' + locationId, { + name: spec.name, + status: 'draft', + type: 'workflow', + location_id: locationId, + company_id: company && (company.id || company._id), + company_age: company && company.age, + ...settings, + workflowData: { templates: [] } + }); + workflowId = created && (created.id || created._id); + assetWarnings = (created && created.assetWarnings) || []; + if (!workflowId) throw new Error('Create returned no workflow id'); + } + + // Triggers are replaced wholesale rather than diffed. On a fresh create + // there is nothing to remove; on an overwrite this is what makes the + // imported trigger set authoritative instead of additive. + let removed = 0; + const existing = await req('get', BASE + '/' + locationId + '/trigger', null, { workflowId }); + for (const trigger of (existing || [])) { + await req('delete', BASE + '/' + locationId + '/trigger/' + (trigger.id || trigger._id), null, { userId }); + removed++; + } + + let added = 0; + for (const trigger of triggers) { + const body = JSON.parse(JSON.stringify(trigger)); + // Server-assigned identity and bookkeeping must not be carried over, or + // the new trigger collides with the one in the source workflow. + delete body.id; + delete body._id; + delete body.predeterminedId; + delete body.date_added; + delete body.date_updated; + delete body.deleted; + body.location_id = locationId; + body.workflow_id = workflowId; + body.workflowId = workflowId; + if (Array.isArray(body.actions)) { + body.actions = body.actions.map((action) => ({ ...action, workflow_id: workflowId })); + } + await req('post', BASE + '/' + locationId + '/trigger', body); + added++; + } + + const current = await req('get', BASE + '/' + locationId + '/' + workflowId); + const live = await req('get', BASE + '/' + locationId + '/trigger', null, { workflowId }); + + await req('put', BASE + '/' + locationId + '/' + workflowId, { + name: spec.name, + isRestoreRequest: true, + status: spec.status || 'draft', + ...settings, + workflowData: { templates }, + updatedBy: userId, + version: current.version, + oldTriggers: [], + newTriggers: live || [], + triggersChanged: true, + modifiedSteps: [], + deletedSteps: [], + createdSteps: [], + meta: record.meta || {} + }); + + return { + ok: true, + id: workflowId, + actions: templates.length, + triggersAdded: added, + triggersRemoved: removed, + assetWarnings + }; + } catch (err) { + return fail(err); + } +} diff --git a/ghl-workflow-exporter/icons/icon128.png b/ghl-workflow-tools/icons/icon128.png similarity index 100% rename from ghl-workflow-exporter/icons/icon128.png rename to ghl-workflow-tools/icons/icon128.png diff --git a/ghl-workflow-exporter/icons/icon16.png b/ghl-workflow-tools/icons/icon16.png similarity index 100% rename from ghl-workflow-exporter/icons/icon16.png rename to ghl-workflow-tools/icons/icon16.png diff --git a/ghl-workflow-exporter/icons/icon48.png b/ghl-workflow-tools/icons/icon48.png similarity index 100% rename from ghl-workflow-exporter/icons/icon48.png rename to ghl-workflow-tools/icons/icon48.png diff --git a/ghl-workflow-exporter/manifest.json b/ghl-workflow-tools/manifest.json similarity index 59% rename from ghl-workflow-exporter/manifest.json rename to ghl-workflow-tools/manifest.json index ed228e7..c0c77ad 100644 --- a/ghl-workflow-exporter/manifest.json +++ b/ghl-workflow-tools/manifest.json @@ -1,11 +1,15 @@ { "manifest_version": 3, - "name": "GHL Workflow Exporter", - "version": "1.0.0", - "description": "Exports every workflow in the HighLevel sub-account you are viewing as re-importable JSON, packaged as a ZIP for version control.", - "permissions": ["activeTab", "scripting", "downloads"], + "name": "GHL Workflow Backup", + "version": "1.1.0", + "description": "Exports every workflow in the HighLevel sub-account you are viewing as re-importable JSON, and restores them from a backup file.", + "permissions": [ + "activeTab", + "scripting", + "downloads" + ], "action": { - "default_title": "Export workflows", + "default_title": "Back up workflows", "default_popup": "popup.html", "default_icon": { "16": "icons/icon16.png", diff --git a/ghl-workflow-tools/popup.css b/ghl-workflow-tools/popup.css new file mode 100644 index 0000000..6ebc27b --- /dev/null +++ b/ghl-workflow-tools/popup.css @@ -0,0 +1,109 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #16181d; + --muted: #6b7280; + --line: #e4e7ec; + --sunk: #f6f7f9; + --accent: #2f6fed; + --accent-fg: #ffffff; + --ok: #167c46; + --err: #b42318; + --warn-bg: #fff6ed; + --warn-line: #f0b27a; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #16181d; + --fg: #f2f4f7; + --muted: #98a2b3; + --line: #2c3038; + --sunk: #1c1f26; + --accent: #5b8dff; + --accent-fg: #0b0d11; + --ok: #5fd39b; + --err: #ff9a92; + --warn-bg: #2a1f14; + --warn-line: #7a5326; + } +} + +* { box-sizing: border-box; } + +body { + width: 400px; + margin: 0; + padding: 16px; + background: var(--bg); + color: var(--fg); + font: 13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} + +h1 { margin: 0; font-size: 15px; font-weight: 600; } +p { margin: 4px 0 0; } +.muted { color: var(--muted); font-size: 12px; } +header { padding-bottom: 12px; border-bottom: 1px solid var(--line); } + +.tabs { display: flex; gap: 4px; margin: 12px 0; } +.tab { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: transparent; + color: var(--muted); + font: inherit; + cursor: pointer; +} +.tab.is-active { background: var(--sunk); color: var(--fg); font-weight: 600; } + +button.primary { + width: 100%; + padding: 9px 12px; + border: 0; + border-radius: 7px; + background: var(--accent); + color: var(--accent-fg); + font: inherit; + font-weight: 600; + cursor: pointer; +} +button.primary:disabled { opacity: .45; cursor: default; } +button.danger { background: var(--err); color: #fff; } + +.filepick { + display: block; + padding: 14px; + border: 1px dashed var(--line); + border-radius: 8px; + text-align: center; + cursor: pointer; +} +.filepick span { font-weight: 600; } +.filepick em { display: block; font-style: normal; margin-top: 2px; } + +.rowhead { margin: 12px 0 6px; } +.check { display: flex; gap: 7px; align-items: flex-start; cursor: pointer; } +.check input { margin: 3px 0 0; flex: none; } + +#list { max-height: 260px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; } +.item { display: flex; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); } +.item:last-child { border-bottom: 0; } +.item .body { flex: 1; min-width: 0; } +.item .name { font-weight: 600; word-break: break-word; } +.item select { width: 100%; margin-top: 4px; padding: 3px 4px; border: 1px solid var(--line); border-radius: 5px; background: var(--bg); color: var(--fg); font: inherit; font-size: 12px; } +.item .result { font-size: 12px; margin-top: 3px; } +.item .result.ok { color: var(--ok); } +.item .result.err { color: var(--err); } + +.warn { margin-top: 10px; padding: 9px 10px; border: 1px solid var(--warn-line); border-radius: 7px; background: var(--warn-bg); font-size: 12px; } + +.bar { height: 4px; margin-top: 12px; border-radius: 2px; background: var(--line); overflow: hidden; } +.bar span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .18s ease; } + +#status, #istatus { font-size: 12px; } +.ok { color: var(--ok); } +.err { color: var(--err); } + +#import { margin-top: 10px; } +.foot { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); } diff --git a/ghl-workflow-tools/popup.html b/ghl-workflow-tools/popup.html new file mode 100644 index 0000000..22c629a --- /dev/null +++ b/ghl-workflow-tools/popup.html @@ -0,0 +1,62 @@ + + + + +GHL Workflow Exporter + + + +
+

Workflow Exporter

+

Checking this tab…

+
+ + + +
+ + +

+

Reads only. Nothing in this sub-account is modified.

+
+ + + + + + diff --git a/ghl-workflow-tools/popup.js b/ghl-workflow-tools/popup.js new file mode 100644 index 0000000..e9ffd26 --- /dev/null +++ b/ghl-workflow-tools/popup.js @@ -0,0 +1,455 @@ +import { buildZip, stableJson } from './zip.js'; +import { readZip } from './unzip.js'; +import { probePage, fetchWorkflowList, fetchWorkflowDetail, importWorkflow } from './agent.js'; + +const $ = (id) => document.getElementById(id); +const els = { + target: $('target'), + tabExport: $('tab-export'), tabImport: $('tab-import'), + panelExport: $('panel-export'), panelImport: $('panel-import'), + run: $('run'), progress: $('progress'), fill: $('fill'), step: $('step'), status: $('status'), + files: $('files'), picked: $('picked'), list: $('list'), all: $('all'), count: $('count'), + liveWarn: $('livewarn'), ack: $('ack'), liveCount: $('livecount'), + importBtn: $('import'), iProgress: $('iprogress'), iFill: $('ifill'), iStep: $('istep'), iStatus: $('istatus') +}; + +let context = null; +let candidates = []; +let targets = []; + +function say(node, message, kind) { + node.textContent = message; + node.className = kind || ''; +} + +function slug(name, fallback) { + const base = String(name || '') + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60); + return base || fallback; +} + +async function inMainWorld(tabId, func, args) { + const [result] = await chrome.scripting.executeScript({ + target: { tabId }, world: 'MAIN', func, args: args || [] + }); + return result ? result.result : undefined; +} + +async function activeTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab; +} + +/* ---------------------------------------------------------------- setup --- */ + +const REASONS = { + 'not-highlevel': 'Open a HighLevel sub-account tab, then reopen this popup.', + 'no-location': 'No sub-account detected. Navigate into a location first.', + 'no-auth': 'Could not reach the app session. Reload the page and try again.' +}; + +async function init() { + try { + const tab = await activeTab(); + const probe = await inMainWorld(tab.id, probePage); + if (!probe || !probe.ok) { + els.target.textContent = REASONS[probe && probe.reason] || 'This tab is not a HighLevel app page.'; + return; + } + context = { tabId: tab.id, ...probe }; + els.target.textContent = probe.locationName || ('Sub-account ' + probe.locationId); + els.run.disabled = false; + } catch (err) { + els.target.textContent = 'Cannot read this tab.'; + say(els.status, String(err.message || err), 'err'); + } +} + +function showTab(which) { + const exporting = which === 'export'; + els.tabExport.classList.toggle('is-active', exporting); + els.tabImport.classList.toggle('is-active', !exporting); + els.tabExport.setAttribute('aria-selected', String(exporting)); + els.tabImport.setAttribute('aria-selected', String(!exporting)); + els.panelExport.hidden = !exporting; + els.panelImport.hidden = exporting; +} + +/* --------------------------------------------------------------- export --- */ + +async function runExport() { + els.run.disabled = true; + els.progress.hidden = false; + say(els.status, ''); + + try { + els.step.textContent = 'Listing workflows…'; + const list = await inMainWorld(context.tabId, fetchWorkflowList, [context.locationId]); + const rows = (list.rows || []).filter((row) => row.type !== 'folder' && !row.deleted); + const folders = (list.rows || []).filter((row) => row.type === 'folder'); + if (!rows.length) throw new Error('This sub-account has no workflows to export.'); + + const folderNames = new Map(folders.map((f) => [f.id || f._id, f.name])); + const entries = []; + const failures = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const id = row.id || row._id; + els.step.textContent = 'Exporting ' + (i + 1) + ' of ' + rows.length + ': ' + row.name; + els.fill.style.width = Math.round((i / rows.length) * 100) + '%'; + + const detail = await retry(() => inMainWorld(context.tabId, fetchWorkflowDetail, [context.locationId, id])); + if (!detail.ok) { + failures.push({ id, name: row.name, error: detail.error }); + continue; + } + const payload = sanitize(detail.data); + entries.push({ + id, name: row.name, status: row.status, + parentId: row.parentId || null, + folder: row.parentId ? folderNames.get(row.parentId) || null : null, + version: payload.workflowData && payload.workflowData.version, + dataVersion: row.dataVersion, + updatedAt: row.updatedAt, updatedBy: row.updatedBy, + actionCount: countActions(payload), + triggerCount: Array.isArray(payload.triggers) ? payload.triggers.length : 0, + file: 'workflows/' + slug(row.name, 'workflow') + '-' + String(id).slice(0, 8) + '.json', + payload + }); + await pause(120); // HighLevel rate-limits bursts per location + } + + els.fill.style.width = '100%'; + els.step.textContent = 'Packaging…'; + const root = slug(context.locationName, 'sub-account') + '-' + context.locationId; + const blob = buildZip(buildFiles(root, entries, failures)); + await download(blob, root); + + const note = failures.length ? ' (' + failures.length + ' failed, see index.json)' : ''; + say(els.status, 'Exported ' + entries.length + ' workflow' + (entries.length === 1 ? '' : 's') + note + '.', 'ok'); + els.step.textContent = ''; + } catch (err) { + say(els.status, String(err.message || err), 'err'); + els.step.textContent = ''; + } finally { + els.run.disabled = false; + } +} + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function retry(fn, attempts = 3) { + let last = null; + for (let i = 0; i < attempts; i++) { + last = await fn(); + if (last && last.ok) return last; + await pause(400 * (i + 1)); + } + return last || { ok: false, error: 'unknown error' }; +} + +/** + * Strips two fields that would poison a git history: + * - workflowData.fileUrl is a *signed* Firebase URL. It carries an access + * token, changes on every fetch, and has no business in a repo. + * - permissionMeta describes the exporting user's access rights, not the + * workflow, so it churns per operator without adding backup value. + * filePath is kept: it is the stable storage path the signed URL points at. + */ +function sanitize(payload) { + const clone = JSON.parse(JSON.stringify(payload || {})); + delete clone.permissionMeta; + if (clone.workflowData) delete clone.workflowData.fileUrl; + return clone; +} + +function countActions(payload) { + const inner = payload && payload.workflowData && payload.workflowData.workflowData; + return inner && Array.isArray(inner.templates) ? inner.templates.length : 0; +} + +function buildFiles(root, entries, failures) { + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + const index = { + locationId: context.locationId, + locationName: context.locationName || null, + workflowCount: sorted.length, + workflows: sorted.map(({ payload, ...meta }) => meta), + failures + }; + const snapshot = { + locationId: context.locationId, + locationName: context.locationName || null, + workflows: [...entries] + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + .map((entry) => ({ id: entry.id, name: entry.name, ...entry.payload })) + }; + const files = [ + { name: root + '/index.json', text: stableJson(index) }, + { name: root + '/snapshot.json', text: stableJson(snapshot) }, + { name: root + '/README.md', text: readme(index) } + ]; + for (const entry of sorted) files.push({ name: root + '/' + entry.file, text: stableJson(entry.payload) }); + return files; +} + +function readme(index) { + return [ + '# Workflow backup — ' + (index.locationName || index.locationId), + '', + 'Sub-account: `' + index.locationId + '` ', + 'Workflows: ' + index.workflowCount, + '', + '## Layout', + '', + '- `index.json` — one line per workflow: name, status, version, action and trigger counts.', + '- `workflows/*.json` — one file per workflow, each `{ workflowData, triggers, dependentAssets }`.', + '- `snapshot.json` — every workflow in a single file, for diffing a release as one unit.', + '', + '## Provenance', + '', + 'Each workflow file is the response of', + '`GET backend.leadconnectorhq.com/workflow/{locationId}/{workflowId}?includeTriggers=true`,', + 'taken through the signed-in session in the browser. Keys are sorted and files are', + 'written with a fixed archive timestamp, so an unchanged workflow re-exports byte for byte', + 'and git shows no diff.', + '', + '## Restoring', + '', + 'Load any of these files back through the extension\'s Import tab. It recreates the', + 'action graph and the triggers, using the same restore call the workflow builder uses.', + 'Imports arrive as drafts. Check one in the builder before you publish it.', + '' + ].join('\n'); +} + +async function download(blob, root) { + const url = URL.createObjectURL(blob); + const stamp = new Date().toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-'); + try { + await chrome.downloads.download({ url, filename: root + '-' + stamp + '.zip', saveAs: true }); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 60000); + } +} + +/* --------------------------------------------------------------- import --- */ + +/** Pulls workflow payloads out of whatever the user picked. */ +async function parseFiles(fileList) { + const docs = []; + for (const file of fileList) { + if (/\.zip$/i.test(file.name)) { + const entries = await readZip(await file.arrayBuffer()); + for (const entry of entries) { + if (!/\.json$/i.test(entry.name)) continue; + if (/\/index\.json$/i.test(entry.name)) continue; // manifest, not content + docs.push({ source: entry.name, text: entry.text }); + } + } else { + docs.push({ source: file.name, text: await file.text() }); + } + } + + const found = []; + for (const doc of docs) { + let parsed; + try { parsed = JSON.parse(doc.text); } catch (e) { continue; } + // snapshot.json holds many; a per-workflow file holds one. + const many = Array.isArray(parsed.workflows) ? parsed.workflows : [parsed]; + for (const item of many) { + if (!item || !item.workflowData) continue; + const record = item.workflowData; + const templates = (record.workflowData && record.workflowData.templates) || []; + found.push({ + source: doc.source, + sourceId: item.id || record.id || record._id || null, + sourceLocationId: record.locationId || null, + name: item.name || record.name || 'Untitled workflow', + actions: templates.length, + triggers: Array.isArray(item.triggers) ? item.triggers.length : 0, + payload: { workflowData: record, triggers: item.triggers || [] } + }); + } + } + + // Picking both snapshot.json and the per-workflow files is an easy mistake; + // collapse to one entry per source workflow rather than importing twice. + const seen = new Set(); + const unique = []; + for (const entry of found) { + const key = entry.sourceId || (entry.name + '|' + entry.actions); + if (seen.has(key)) continue; + seen.add(key); + unique.push(entry); + } + return unique; +} + +async function onFiles(event) { + const files = [...(event.target.files || [])]; + if (!files.length) return; + say(els.iStatus, ''); + els.picked.hidden = true; + + try { + candidates = await parseFiles(files); + if (!candidates.length) throw new Error('No workflow definitions found in those files.'); + + const list = await inMainWorld(context.tabId, fetchWorkflowList, [context.locationId]); + targets = (list.rows || []).filter((row) => row.type !== 'folder' && !row.deleted); + + renderCandidates(); + els.picked.hidden = false; + } catch (err) { + say(els.iStatus, String(err.message || err), 'err'); + } +} + +function matchTarget(entry) { + const byId = targets.find((row) => (row.id || row._id) === entry.sourceId); + if (byId) return byId; + return targets.find((row) => row.name === entry.name) || null; +} + +function renderCandidates() { + els.list.textContent = ''; + candidates.forEach((entry, index) => { + const match = matchTarget(entry); + entry.match = match; + + const item = document.createElement('div'); + item.className = 'item'; + + const box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = true; + box.dataset.index = String(index); + box.addEventListener('change', refreshSelection); + + const body = document.createElement('div'); + body.className = 'body'; + + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = entry.name; + + const meta = document.createElement('div'); + meta.className = 'muted'; + const foreign = entry.sourceLocationId && entry.sourceLocationId !== context.locationId; + meta.textContent = entry.actions + ' action' + (entry.actions === 1 ? '' : 's') + + ', ' + entry.triggers + ' trigger' + (entry.triggers === 1 ? '' : 's') + + (foreign ? ' · from another sub-account' : ''); + + const mode = document.createElement('select'); + mode.dataset.index = String(index); + const createOpt = new Option('Create new copy (draft)', 'create'); + mode.add(createOpt); + if (match) { + const label = 'Overwrite “' + match.name + '”' + (match.status === 'published' ? ' — LIVE' : ''); + mode.add(new Option(label, 'overwrite')); + } + mode.addEventListener('change', refreshSelection); + + const result = document.createElement('div'); + result.className = 'result'; + result.dataset.index = String(index); + + body.append(name, meta, mode, result); + item.append(box, body); + els.list.append(item); + }); + refreshSelection(); +} + +function selection() { + const boxes = [...els.list.querySelectorAll('input[type=checkbox]')]; + const modes = [...els.list.querySelectorAll('select')]; + return candidates + .map((entry, index) => ({ entry, index, on: boxes[index].checked, mode: modes[index].value })) + .filter((row) => row.on); +} + +function refreshSelection() { + const chosen = selection(); + els.count.textContent = chosen.length + ' of ' + candidates.length + ' selected'; + + const live = chosen.filter((row) => row.mode === 'overwrite' && row.entry.match && row.entry.match.status === 'published'); + els.liveWarn.hidden = live.length === 0; + els.liveCount.textContent = String(live.length); + if (!live.length) els.ack.checked = false; + + els.importBtn.disabled = !chosen.length || (live.length > 0 && !els.ack.checked); + els.importBtn.textContent = chosen.length + ? 'Import ' + chosen.length + ' workflow' + (chosen.length === 1 ? '' : 's') + : 'Import'; +} + +async function runImport() { + const chosen = selection(); + if (!chosen.length) return; + + els.importBtn.disabled = true; + els.files.disabled = true; + els.iProgress.hidden = false; + say(els.iStatus, ''); + + let done = 0; + let failed = 0; + + for (let i = 0; i < chosen.length; i++) { + const { entry, index, mode } = chosen[i]; + els.iStep.textContent = (mode === 'overwrite' ? 'Overwriting ' : 'Creating ') + (i + 1) + ' of ' + chosen.length + ': ' + entry.name; + els.iFill.style.width = Math.round((i / chosen.length) * 100) + '%'; + + const spec = { + mode, + targetId: mode === 'overwrite' && entry.match ? (entry.match.id || entry.match._id) : undefined, + name: entry.name, + // Overwriting keeps the target's current status, so replacing a live + // workflow does not silently switch it off. New copies are always drafts. + status: mode === 'overwrite' && entry.match ? entry.match.status : 'draft', + payload: entry.payload + }; + + const result = await inMainWorld(context.tabId, importWorkflow, [context.locationId, spec]); + const node = els.list.querySelector('.result[data-index="' + index + '"]'); + + if (result && result.ok) { + done++; + const warn = (result.assetWarnings || []).length; + node.className = 'result ok'; + node.textContent = (mode === 'overwrite' ? 'Replaced' : 'Created') + + ' · ' + result.actions + ' actions, ' + result.triggersAdded + ' triggers' + + (warn ? ' · ' + warn + ' asset warning' + (warn === 1 ? '' : 's') : ''); + } else { + failed++; + node.className = 'result err'; + node.textContent = 'Failed: ' + ((result && result.error) || 'unknown error'); + } + await pause(200); + } + + els.iFill.style.width = '100%'; + els.iStep.textContent = ''; + els.files.disabled = false; + els.importBtn.disabled = false; + + if (failed) say(els.iStatus, done + ' imported, ' + failed + ' failed.', 'err'); + else say(els.iStatus, 'Imported ' + done + ' workflow' + (done === 1 ? '' : 's') + '. Open the builder to check before publishing.', 'ok'); +} + +/* --------------------------------------------------------------- wiring --- */ + +els.tabExport.addEventListener('click', () => showTab('export')); +els.tabImport.addEventListener('click', () => showTab('import')); +els.run.addEventListener('click', runExport); +els.files.addEventListener('change', onFiles); +els.ack.addEventListener('change', refreshSelection); +els.all.addEventListener('change', () => { + els.list.querySelectorAll('input[type=checkbox]').forEach((box) => { box.checked = els.all.checked; }); + refreshSelection(); +}); +els.importBtn.addEventListener('click', runImport); +init(); diff --git a/ghl-workflow-tools/unzip.js b/ghl-workflow-tools/unzip.js new file mode 100644 index 0000000..51c3d3b --- /dev/null +++ b/ghl-workflow-tools/unzip.js @@ -0,0 +1,68 @@ +// Minimal ZIP reader, enough to read back an archive this extension wrote and +// the deflated ones most tools produce. Decompression uses the platform's own +// DecompressionStream, so there is still no third-party code in the bundle. + +const EOCD_SIG = 0x06054b50; +const CENTRAL_SIG = 0x02014b50; + +function findEocd(view) { + // The end-of-central-directory record sits at the tail, after an optional + // comment of up to 64KB, so scan backwards for its signature. + const min = Math.max(0, view.byteLength - 22 - 0xffff); + for (let i = view.byteLength - 22; i >= min; i--) { + if (view.getUint32(i, true) === EOCD_SIG) return i; + } + return -1; +} + +async function inflateRaw(bytes) { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +/** + * @param {ArrayBuffer} buffer + * @returns {Promise>} + */ +export async function readZip(buffer) { + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + const eocd = findEocd(view); + if (eocd < 0) throw new Error('Not a ZIP file.'); + + const total = view.getUint16(eocd + 10, true); + let pointer = view.getUint32(eocd + 16, true); + const decoder = new TextDecoder(); + const entries = []; + + for (let i = 0; i < total; i++) { + if (view.getUint32(pointer, true) !== CENTRAL_SIG) break; + + const method = view.getUint16(pointer + 10, true); + const compressedSize = view.getUint32(pointer + 20, true); + const nameLength = view.getUint16(pointer + 28, true); + const extraLength = view.getUint16(pointer + 30, true); + const commentLength = view.getUint16(pointer + 32, true); + const localOffset = view.getUint32(pointer + 42, true); + const name = decoder.decode(bytes.subarray(pointer + 46, pointer + 46 + nameLength)); + + // The local header repeats the name and extra fields, and its extra field + // length can differ from the central one, so read it rather than assume. + const localNameLength = view.getUint16(localOffset + 26, true); + const localExtraLength = view.getUint16(localOffset + 28, true); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const raw = bytes.subarray(dataStart, dataStart + compressedSize); + + if (!name.endsWith('/')) { + let content; + if (method === 0) content = raw; + else if (method === 8) content = await inflateRaw(raw); + else throw new Error('Unsupported compression in ' + name); + entries.push({ name, text: decoder.decode(content) }); + } + + pointer += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} diff --git a/ghl-workflow-exporter/zip.js b/ghl-workflow-tools/zip.js similarity index 100% rename from ghl-workflow-exporter/zip.js rename to ghl-workflow-tools/zip.js diff --git a/output/imagegen/legion-toolset-ghl-grounded-warfighter.jpg b/output/imagegen/legion-toolset-ghl-grounded-warfighter.jpg new file mode 100644 index 0000000..2afbc5c Binary files /dev/null and b/output/imagegen/legion-toolset-ghl-grounded-warfighter.jpg differ diff --git a/output/imagegen/legion-toolset-ghl-grounded-warfighter.lineage.json b/output/imagegen/legion-toolset-ghl-grounded-warfighter.lineage.json new file mode 100644 index 0000000..599e70a --- /dev/null +++ b/output/imagegen/legion-toolset-ghl-grounded-warfighter.lineage.json @@ -0,0 +1,26 @@ +{ + "case": "B_scene", + "written_utc": "2026-08-21T07:27:12.940205+00:00", + "source_frame": null, + "output": "C:\\Users\\mario\\.codex\\skills\\natural-photography-stinger\\models\\mario-aldayuz-source\\03-outputs\\legion-toolset-ghl-grounded-warfighter.jpg", + "output_sha256": "3b165aa4d01f8c5215f490f183f556386b3647df6662ab7acf96cf104bf4528d", + "digital_source_type": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + "profile": { + "FocalLength": "40", + "FocalLengthIn35mmFormat": "40", + "FNumber": "3.2", + "ExposureTime": "1/250", + "ISO": "3200", + "ExposureProgram": "Aperture-priority AE", + "MeteringMode": "Multi-segment", + "WhiteBalance": "Auto", + "Flash": "No Flash", + "ColorSpace": "sRGB", + "Orientation": "Horizontal (normal)", + "Artist": "Mario Aldayuz", + "Copyright": "Copyright Mario Aldayuz 2026", + "Creator": "Mario Aldayuz", + "Rights": "Copyright Mario Aldayuz 2026" + }, + "capture_identity_written": false +} \ No newline at end of file diff --git a/output/imagegen/legion-toolset-ghl-grounded-warfighter.png b/output/imagegen/legion-toolset-ghl-grounded-warfighter.png new file mode 100644 index 0000000..ad2d964 Binary files /dev/null and b/output/imagegen/legion-toolset-ghl-grounded-warfighter.png differ