commit 49aebf0f153cbc4a4cef56b6122a8ab8a36da9d5
Author: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Fri Aug 28 13:27:21 2026 +0200
Commit: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Fri Aug 28 13:27:21 2026 +0200
Add control flow, debug tooling, and full i18n to the flow builder
- if/else-if/else blocks and labeled jump_to_label with infinite-jump guard
- variables debug panel (click a var to inspect its value/type)
- list step now includes a text preview of the first matches
- English-default UI with a Swedish translation, switchable via a language
selector; drop BankID-specific wording (tool supports any manual login)
- README updated to document the new steps
---
README.md | 20 ++
rpa/flow_engine.py | 129 +++++++-
rpa/runs.py | 5 +-
webui/templates/index.html | 764 +++++++++++++++++++++++++++++++++++++++------
4 files changed, 806 insertions(+), 112 deletions(-)
diff --git a/README.md b/README.md
index 6d0e846..f065188 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,18 @@ I UI:t bygger du ett flöde som en lista av steg:
felsökning av flöden)
- **Lista poster** – hittar alla element som matchar en CSS-selector och
sparar dem under ett variabelnamn (t.ex. `poster`)
+- **Om / annars om / annars** ("if") – kör bara sina inre steg om ett
+ villkor stämmer. Första grenen ("Om") vars villkor är sant körs; fler
+ grenar kan läggas till som "...annars om"; en valfri "...annars
+ (else)"-gren körs om ingen av grenarna matchade. Villkor: "Element
+ finns"/"Element finns inte" (samma via/text/CSS/roll som andra steg)
+ eller "Variabel är lika med"/"är INTE lika med"/"innehåller" (jämför
+ mot en variabels textinnehåll).
+- **Hoppa till etikett** ("goto") – hoppar till ett annat steg som fått
+ samma text i sitt "Etikett"-fält (syns bredvid typ-väljaren på varje
+ steg), framåt eller bakåt. Fungerar bara mellan steg på samma nivå -
+ kan inte hoppa in i eller ut ur en Loop/If-gren. Ett skydd stoppar
+ körningen om för många hopp sker (misstänkt oändlig loop).
- **Loop (för varje post)** – itererar över en variabel från "Lista poster";
steg inuti loopen kan sätta Sökområde = "item" för att bara söka inom
aktuell post
@@ -118,6 +130,14 @@ Bredvid "Kör flöde" väljer du vad som ska hända med webbläsaren efteråt:
När webbläsaren hålls öppen visas en "Stäng webbläsare"-knapp i sidan –
klicka på den när du är klar med att inspektera.
+### Se variablernas innehåll
+
+Under körloggen finns en "Variabler"-panel som fylls i när flödet är klart
+(eller vid fel). Klicka på ett variabelnamn för att expandera och se dess
+faktiska innehåll (text visas rakt av, listor/objekt som JSON) – praktiskt
+för att felsöka t.ex. vad "Lägg till i lista" faktiskt samlade ihop innan
+det sparades till CSV.
+
### Felsöka en körning (trace)
Varje körning spelar automatiskt in en Playwright-trace – skärmdumpar,
diff --git a/rpa/flow_engine.py b/rpa/flow_engine.py
index 3e17dcb..c42dc59 100644
--- a/rpa/flow_engine.py
+++ b/rpa/flow_engine.py
@@ -26,11 +26,32 @@ Stegtyper:
-- stdout (JSON om möjligt) i var
screenshot {name?} -- sparar skärmdump i data/screenshots
list {value, var} -- CSS-selector, sparar antal+selector i var
+ (samt en textförhandsvisning av de första 3
+ träffarna, till hjälp vid felsökning i UI:ts
+ variabelpanel - loop-steget bryr sig bara om
+ selector/count)
loop {over, steps: [...]} -- itererar över "list"-resultat
download {by, value, role?, scope?, nth?}
+ if {branches: [{condition, steps: [...]}, ...], else?: [...]}
+ -- första grenen vars condition är sann körs
+ -- (branches[0] = "if", övriga = "else if");
+ -- körs ingen körs "else" (om satt)
+ jump_to_label {target} -- hoppar till steget vars "label" matchar "target", i
+ -- SAMMA stegdel (bara syskon, inte in i/ut ur
+ -- en loop/if); FlowError om etiketten saknas
+ -- eller om för många hopp sker (skydd mot
+ -- oändlig loop)
+
+condition (för "if"-grenar): {kind, ...}
+ kind: "element_exists" | "element_not_exists" -- {by, value, role?, scope?, nth?}
+ | "var_equals" | "var_not_equals" | "var_contains" -- {var, text}
by: "text" | "css" | "role"
scope: "page" (default) | "item" -- "item" begränsar sökningen till aktuell loop-post
+label: valfri nyckel på vilket steg som helst - namnger steget som mål för "jump_to_label"
+enabled: valfri nyckel på vilket steg som helst - satt till false hoppar
+ körningen över steget (om det är en "loop"/"if" skippas hela
+ blocket inklusive dess inre steg).
"""
import csv
@@ -83,6 +104,32 @@ def _selector_or_literal_text(page: Page, item_locator, step: dict) -> str:
return step.get("text", "")
+def _evaluate_condition(page: Page, item_locator, vars_: dict, condition: dict) -> bool:
+ kind = condition.get("kind", "element_exists")
+
+ if kind in ("element_exists", "element_not_exists"):
+ loc = _resolve_locator(page, item_locator, condition)
+ exists = loc.count() > 0
+ return exists if kind == "element_exists" else not exists
+
+ if kind in ("var_equals", "var_not_equals", "var_contains"):
+ actual = vars_.get(condition.get("var", ""), "")
+ actual_str = actual if isinstance(actual, str) else json.dumps(actual, ensure_ascii=False)
+ expected = condition.get("text", "")
+ if kind == "var_equals":
+ return actual_str == expected
+ if kind == "var_not_equals":
+ return actual_str != expected
+ return expected in actual_str
+
+ raise FlowError(f"Okänt villkor: {kind!r}")
+
+
+# Skydd mot oändliga hopp via jump_to_label - delas (muteras) genom hela
+# körningens rekursiva _run_steps-anrop.
+MAX_JUMPS_PER_RUN = 1000
+
+
def _run_steps(
page: Page,
steps: list,
@@ -90,10 +137,36 @@ def _run_steps(
vars_: dict,
item_locator,
download_dir: Path,
+ jump_budget: dict,
) -> None:
- for step in steps:
+ i = 0
+ while i < len(steps):
+ step = steps[i]
t = step.get("type")
+ if step.get("enabled") is False:
+ log(f"(Inaktiverat, hoppar över: {t})")
+ i += 1
+ continue
+
+ if t == "jump_to_label":
+ target_label = step.get("target", "")
+ target_index = next((j for j, s in enumerate(steps) if s.get("label") == target_label), None)
+ if target_index is None:
+ raise FlowError(
+ f"Hittar ingen etikett '{target_label}' bland stegen på samma nivå "
+ "(kan inte hoppa in i/ut ur en loop eller if-gren)."
+ )
+ jump_budget["remaining"] -= 1
+ if jump_budget["remaining"] <= 0:
+ raise FlowError(
+ "För många hopp i den här körningen - avbryter "
+ "(troligen en oändlig hopp-loop)."
+ )
+ log(f"Hoppar till etikett '{target_label}'")
+ i = target_index
+ continue
+
if t == "goto":
log(f"Går till {step['url']}")
page.goto(step["url"], wait_until="domcontentloaded")
@@ -274,8 +347,15 @@ def _run_steps(
elif t == "list":
selector = step["value"]
var_name = step["var"]
- count = page.locator(selector).count()
- vars_[var_name] = {"selector": selector, "count": count}
+ loc = page.locator(selector)
+ count = loc.count()
+ preview = []
+ for preview_idx in range(min(count, 3)):
+ try:
+ preview.append(loc.nth(preview_idx).inner_text(timeout=2000)[:200])
+ except Exception: # noqa: BLE001
+ preview.append("<kunde inte läsa text>")
+ vars_[var_name] = {"selector": selector, "count": count, "preview": preview}
log(f"Hittade {count} post(er) -> variabel '{var_name}'")
elif t == "loop":
@@ -287,10 +367,25 @@ def _run_steps(
)
selector = over["selector"]
count = over["count"]
- for i in range(count):
- log(f"-- Post {i + 1}/{count} --")
- current_item = page.locator(selector).nth(i)
- _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir)
+ for post_idx in range(count):
+ log(f"-- Post {post_idx + 1}/{count} --")
+ current_item = page.locator(selector).nth(post_idx)
+ _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir, jump_budget)
+
+ elif t == "if":
+ matched_steps = None
+ for branch in step.get("branches", []):
+ if _evaluate_condition(page, item_locator, vars_, branch.get("condition", {})):
+ matched_steps = branch.get("steps", [])
+ break
+ if matched_steps is not None:
+ log("Villkor uppfyllt, kör gren")
+ _run_steps(page, matched_steps, log, vars_, item_locator, download_dir, jump_budget)
+ elif step.get("else") is not None:
+ log("Inget villkor uppfyllt, kör 'else'-gren")
+ _run_steps(page, step["else"], log, vars_, item_locator, download_dir, jump_budget)
+ else:
+ log("Inget villkor uppfyllt, ingen 'else'-gren - fortsätter")
elif t == "download":
loc = _resolve_locator(page, item_locator, step)
@@ -307,6 +402,8 @@ def _run_steps(
else:
raise FlowError(f"Okänd stegtyp: {t!r}")
+ i += 1
+
def run_flow(
steps: list,
@@ -314,7 +411,7 @@ def run_flow(
download_dir: Path = None,
close_mode: str = "always",
close_event=None,
- on_finished: Callable[[Exception | None], None] | None = None,
+ on_finished: Callable[[Exception | None, dict], None] | None = None,
trace_path: Path | None = None,
) -> None:
"""Kör ett flöde.
@@ -324,9 +421,10 @@ def run_flow(
"on_success" -- stäng bara om inget steg gav fel, håll öppen vid fel
"never" -- håll alltid öppen tills close_event sätts
- on_finished(error) anropas direkt när stegen är klara (innan ev. väntan
- på close_event), så anroparen kan uppdatera körningens status utan att
- behöva vänta på att webbläsaren faktiskt stängs.
+ on_finished(error, vars_) anropas direkt när stegen är klara (innan ev.
+ väntan på close_event), så anroparen kan uppdatera körningens status och
+ visa variablernas innehåll utan att behöva vänta på att webbläsaren
+ faktiskt stängs.
Om trace_path anges spelas en Playwright-trace (skärmdumpar, DOM-snapshots,
nätverk) in under hela körningen och sparas dit. Öppna den efteråt med
@@ -340,15 +438,20 @@ def run_flow(
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = get_page(context)
error: Exception | None = None
+ vars_: dict = {}
+ jump_budget = {"remaining": MAX_JUMPS_PER_RUN}
try:
- _run_steps(page, steps, log, vars_={}, item_locator=None, download_dir=download_dir)
+ _run_steps(
+ page, steps, log, vars_=vars_, item_locator=None,
+ download_dir=download_dir, jump_budget=jump_budget,
+ )
log("Flöde klart.")
except Exception as exc: # noqa: BLE001
error = exc
log(f"FEL: {exc}")
if on_finished is not None:
- on_finished(error)
+ on_finished(error, vars_)
should_close_now = close_mode == "always" or (close_mode == "on_success" and error is None)
if not should_close_now:
diff --git a/rpa/runs.py b/rpa/runs.py
index a4bad18..bd2088e 100644
--- a/rpa/runs.py
+++ b/rpa/runs.py
@@ -29,11 +29,13 @@ def start_run(steps: list, close_mode: str = "always") -> str:
"close_event": close_event,
"trace_path": trace_path,
"trace_ready": False,
+ "vars": {},
}
- def on_finished(error: Exception | None) -> None:
+ def on_finished(error: Exception | None, vars_: dict) -> None:
with _LOCK:
_RUNS[run_id]["status"] = "error" if error else "done"
+ _RUNS[run_id]["vars"] = vars_
def target() -> None:
try:
@@ -69,6 +71,7 @@ def get_run(run_id: str) -> dict | None:
"logs": list(run["logs"]),
"browser_open": run["browser_open"],
"trace_ready": run["trace_ready"],
+ "vars": run["vars"],
}
diff --git a/webui/templates/index.html b/webui/templates/index.html
index 449925a..7a39cff 100644
--- a/webui/templates/index.html
+++ b/webui/templates/index.html
@@ -1,8 +1,8 @@
<!DOCTYPE html>
-<html lang="sv">
+<html lang="en">
<head>
<meta charset="UTF-8">
-<title>Egenremiss RPA – flödesbyggare</title>
+<title>RPA Tool – flow builder</title>
<style>
:root {
color-scheme: light dark;
@@ -85,15 +85,52 @@
background: var(--panel);
}
.step-card.loop { background: var(--loop-bg); }
+ .step-card.step-disabled { opacity: 0.5; }
.step-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
+ .step-head .enabled-toggle { flex: 0 0 auto; cursor: pointer; }
.step-head .type-select { font-weight: 600; }
.step-head .spacer { flex: 1; }
.step-head button { padding: 3px 8px; font-size: 12px; }
+ .insert-divider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ height: 14px;
+ cursor: pointer;
+ }
+ .insert-divider .line {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ border-top: 1px dashed transparent;
+ }
+ .insert-divider:hover .line { border-top: 1px dashed var(--accent); }
+ .insert-divider .plus {
+ position: relative;
+ width: 16px;
+ height: 16px;
+ line-height: 14px;
+ text-align: center;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ color: var(--muted);
+ font-size: 12px;
+ opacity: 0;
+ transition: opacity 0.1s;
+ }
+ .insert-divider:hover .plus {
+ opacity: 1;
+ color: var(--accent);
+ border-color: var(--accent);
+ }
.fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -114,6 +151,14 @@
}
.nested .steps { margin-bottom: 8px; }
.add-step-row { margin-top: 4px; }
+ .branch {
+ border: 1px dashed var(--border);
+ border-radius: 6px;
+ padding: 8px;
+ margin-bottom: 8px;
+ }
+ .branch .step-head { margin-bottom: 6px; }
+ .branch.else-branch { border-style: solid; }
#log {
background: #111114;
color: #d6d6d8;
@@ -136,147 +181,417 @@
.status-done { background: #3aa757; color: #fff; }
.status-error { background: var(--danger); color: #fff; }
.hint { color: var(--muted); font-size: 12px; margin: 0 0 10px; }
+ .side-col { display: flex; flex-direction: column; gap: 16px; }
+ .var-row {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 6px;
+ overflow: hidden;
+ }
+ .var-header {
+ padding: 6px 10px;
+ cursor: pointer;
+ font-family: ui-monospace, Consolas, monospace;
+ font-size: 12px;
+ background: var(--bg);
+ }
+ .var-header:hover { background: var(--loop-bg); }
+ .var-header .var-type { color: var(--muted); font-weight: normal; }
+ .var-value {
+ margin: 0;
+ padding: 8px 10px;
+ background: #111114;
+ color: #d6d6d8;
+ font-family: ui-monospace, Consolas, monospace;
+ font-size: 12px;
+ max-height: 240px;
+ overflow: auto;
+ white-space: pre-wrap;
+ }
</style>
</head>
<body>
<header>
- <h1>Egenremiss RPA</h1>
+ <h1 id="appTitle">RPA Tool</h1>
+ <select id="langSelect">
+ <option value="en">English</option>
+ <option value="sv">Svenska</option>
+ </select>
<select id="flowSelect"></select>
- <button class="secondary" id="newFlowBtn">Nytt flöde</button>
- <button class="danger" id="deleteFlowBtn">Ta bort flöde</button>
+ <button class="secondary" id="newFlowBtn">New flow</button>
+ <button class="danger" id="deleteFlowBtn">Delete flow</button>
<span class="spacer" style="flex:1"></span>
- <select id="closeModeSelect" title="Vad ska hända med webbläsaren när flödet är klart?">
- <option value="always">Stäng webbläsaren</option>
- <option value="on_success">Håll öppen vid fel</option>
- <option value="never">Håll alltid öppen</option>
+ <select id="closeModeSelect">
+ <option value="always">Close the browser</option>
+ <option value="on_success">Keep open on error</option>
+ <option value="never">Always keep open</option>
</select>
- <button class="secondary" id="saveBtn">Spara</button>
- <button id="runBtn">Kör flöde</button>
- <button class="secondary" id="closeBrowserBtn" style="display:none">Stäng webbläsare</button>
- <button class="secondary" id="showTraceBtn" style="display:none">Visa trace</button>
+ <button class="secondary" id="saveBtn">Save</button>
+ <button id="runBtn">Run flow</button>
+ <button class="secondary" id="closeBrowserBtn" style="display:none">Close browser</button>
+ <button class="secondary" id="showTraceBtn" style="display:none">Show trace</button>
<span id="statusBadge"></span>
</header>
<main>
<div class="panel">
- <p class="hint">
- Bygg flödet steg för steg. "Lista poster" hittar element via en CSS-selector
- och sparar dem i en variabel; "Loop" itererar över den variabeln. Inuti en
- loop kan Sökområde sättas till "item" för att bara söka inom aktuell post.
- </p>
+ <p class="hint" id="stepsHint"></p>
<div id="rootSteps" class="steps"></div>
<div class="add-step-row">
- <button class="secondary" id="addRootStepBtn">+ Lägg till steg</button>
+ <button class="secondary" id="addRootStepBtn">+ Add step</button>
</div>
</div>
- <div class="panel">
- <strong>Körlogg</strong>
- <div id="log"></div>
+ <div class="side-col">
+ <div class="panel">
+ <strong id="logTitle">Run log</strong>
+ <div id="log"></div>
+ </div>
+ <div class="panel">
+ <strong id="varsTitle">Variables</strong>
+ <p class="hint" id="varsHint"></p>
+ <div id="varsPanel"></div>
+ </div>
</div>
</main>
<script>
+// ---------------------------------------------------------------------
+// i18n: English is the default UI language; Swedish is a full translation
+// selectable via the language dropdown (persisted in localStorage). Only
+// the browser UI is translated here - the run log is produced by the
+// Python backend and stays in Swedish regardless of the UI language.
+// ---------------------------------------------------------------------
+
+const I18N = {
+ en: {
+ ui_title: "RPA Tool",
+ ui_page_title: "RPA Tool – flow builder",
+ ui_new_flow: "New flow",
+ ui_delete_flow: "Delete flow",
+ ui_close_mode_title: "What should happen to the browser when the flow finishes?",
+ ui_close_always: "Close the browser",
+ ui_close_on_success: "Keep open on error",
+ ui_close_never: "Always keep open",
+ ui_save: "Save",
+ ui_run: "Run flow",
+ ui_close_browser_btn: "Close browser",
+ ui_show_trace: "Show trace",
+ ui_steps_hint: 'Build the flow step by step. "List items" finds elements via a CSS selector and saves them in a variable; "Loop" iterates over that variable. Inside a loop, Search scope can be set to "item" to search only within the current item.',
+ ui_add_step: "+ Add step",
+ ui_run_log: "Run log",
+ ui_variables: "Variables",
+ ui_variables_hint: "Click a variable name to see its content (filled in once the flow finishes).",
+ ui_no_vars_yet: "No variables yet.",
+ ui_insert_step_here: "Insert step here",
+ ui_label_placeholder: "Label (optional)",
+ ui_label_title: "Name this step so a 'Jump to label' step can jump here",
+ ui_enabled_title: "Enabled (unchecked = step is skipped when running)",
+ ui_remove: "Remove",
+ ui_add_step_in_loop: "+ Add step in loop",
+ ui_branch_if: "If",
+ ui_branch_else_if: "...else if",
+ ui_remove_branch: "Remove branch",
+ ui_add_else_if: "+ Add 'else if' branch",
+ ui_else_toggle: "...else",
+ ui_condition_label: "Condition",
+ ui_status_running: "Running…",
+ ui_status_done: "Done",
+ ui_status_error: "Error",
+ ui_var_list: "list, {n} item(s)",
+ ui_var_object: "object",
+ ui_var_text: "text, {n} characters",
+ ui_new_flow_prompt: "Name of new flow:",
+ ui_delete_flow_confirm: 'Delete the flow "{name}"?',
+
+ step_goto: "Go to URL",
+ step_wait: "Wait (seconds)",
+ step_wait_for_load: "Wait for page to load",
+ step_wait_for_login: "Wait for login",
+ step_wait_for_selector: "Wait for element",
+ step_click: "Click",
+ step_type: "Type in field",
+ step_press_key: "Press key",
+ step_select_option: "Select in dropdown",
+ step_check: "Check checkbox/radio",
+ step_extract_text: "Read text into variable",
+ step_set_var: "Set variable",
+ step_list_append: "Append to list",
+ step_save_var: "Save variable (JSON/CSV)",
+ step_load_var: "Load variable (JSON/CSV)",
+ step_run_script: "Run Python script",
+ step_screenshot: "Take screenshot",
+ step_list: "List items",
+ step_if: "If / else if / else",
+ step_jump_to_label: "Jump to label",
+ step_loop: "Loop (for each item)",
+ step_download: "Download",
+
+ cond_element_exists: "Element exists",
+ cond_element_not_exists: "Element does not exist",
+ cond_var_equals: "Variable equals",
+ cond_var_not_equals: "Variable does NOT equal",
+ cond_var_contains: "Variable contains",
+
+ f_by: "Find by",
+ f_value: "Text / CSS selector / role name",
+ f_role: "Role (if by=role, e.g. link, button)",
+ f_scope: "Search scope",
+ f_nth: "Index (0 = first match)",
+
+ condf_var_name: "Variable name",
+ condf_compare_text: "Compare with",
+ condf_contains_text: "Should contain",
+
+ fd_goto_url: "URL",
+ fd_wait_seconds: "Seconds",
+ fd_timeout_seconds: "Timeout (seconds)",
+ fd_type_text: "Text to type",
+ fd_press_key_key: "Key (e.g. Enter, Tab, Escape)",
+ fd_press_key_value: "Text / CSS selector / role name (empty = whole page)",
+ fd_by_conditional: "Find by (if the above is filled in)",
+ fd_role_short: "Role (if by=role)",
+ fd_select_option_option: "Option (visible text)",
+ fd_check_checked: "Check (unchecked = uncheck)",
+ fd_save_as_var: "Save as variable",
+ fd_var_name: "Variable name",
+ fd_set_var_text: "Value",
+ fd_list_append_var: "List variable",
+ fd_list_append_text: "Literal value (used if the selector below is empty)",
+ fd_list_append_value: "Or: Text / CSS selector / role name (reads from the page)",
+ fd_save_var_var: "Variable to save",
+ fd_export_filename: "Filename in data/exports/ (.json or .csv)",
+ fd_save_var_format: "Format (empty = guess from filename)",
+ fd_save_var_mode: "Mode",
+ fd_run_script_path: "Path to Python script",
+ fd_run_script_args: "Arguments (space-separated)",
+ fd_run_script_var: "Save stdout as variable (optional, JSON if possible)",
+ fd_screenshot_name: "Filename (without .png)",
+ fd_list_value: "CSS selector",
+ fd_loop_over: "Loop over variable",
+ fd_jump_target: "Jump to label",
+ },
+ sv: {
+ ui_title: "RPA-verktyg",
+ ui_page_title: "RPA-verktyg – flödesbyggare",
+ ui_new_flow: "Nytt flöde",
+ ui_delete_flow: "Ta bort flöde",
+ ui_close_mode_title: "Vad ska hända med webbläsaren när flödet är klart?",
+ ui_close_always: "Stäng webbläsaren",
+ ui_close_on_success: "Håll öppen vid fel",
+ ui_close_never: "Håll alltid öppen",
+ ui_save: "Spara",
+ ui_run: "Kör flöde",
+ ui_close_browser_btn: "Stäng webbläsare",
+ ui_show_trace: "Visa trace",
+ ui_steps_hint: 'Bygg flödet steg för steg. "Lista poster" hittar element via en CSS-selector och sparar dem i en variabel; "Loop" itererar över den variabeln. Inuti en loop kan Sökområde sättas till "item" för att bara söka inom aktuell post.',
+ ui_add_step: "+ Lägg till steg",
+ ui_run_log: "Körlogg",
+ ui_variables: "Variabler",
+ ui_variables_hint: "Klicka på ett variabelnamn för att se dess innehåll (fylls i när flödet är klart).",
+ ui_no_vars_yet: "Inga variabler ännu.",
+ ui_insert_step_here: "Infoga steg här",
+ ui_label_placeholder: "Etikett (valfri)",
+ ui_label_title: "Namnge steget så ett 'Hoppa till etikett'-steg kan hoppa hit",
+ ui_enabled_title: "Aktiverat (avbockad = steget hoppas över vid körning)",
+ ui_remove: "Ta bort",
+ ui_add_step_in_loop: "+ Lägg till steg i loop",
+ ui_branch_if: "Om",
+ ui_branch_else_if: "...annars om",
+ ui_remove_branch: "Ta bort gren",
+ ui_add_else_if: "+ Lägg till 'else if'-gren",
+ ui_else_toggle: "...annars (else)",
+ ui_condition_label: "Villkor",
+ ui_status_running: "Kör...",
+ ui_status_done: "Klar",
+ ui_status_error: "Fel",
+ ui_var_list: "lista, {n} post(er)",
+ ui_var_object: "objekt",
+ ui_var_text: "text, {n} tecken",
+ ui_new_flow_prompt: "Namn på nytt flöde:",
+ ui_delete_flow_confirm: 'Ta bort flödet "{name}"?',
+
+ step_goto: "Gå till URL",
+ step_wait: "Vänta (sekunder)",
+ step_wait_for_load: "Vänta på att sidan laddas",
+ step_wait_for_login: "Vänta på inloggning",
+ step_wait_for_selector: "Vänta på element",
+ step_click: "Klicka",
+ step_type: "Skriv i textfält",
+ step_press_key: "Tryck tangent",
+ step_select_option: "Välj i dropdown",
+ step_check: "Kryssa checkbox/radio",
+ step_extract_text: "Läs text till variabel",
+ step_set_var: "Sätt variabel",
+ step_list_append: "Lägg till i lista",
+ step_save_var: "Spara variabel (JSON/CSV)",
+ step_load_var: "Läs in variabel (JSON/CSV)",
+ step_run_script: "Kör Python-script",
+ step_screenshot: "Ta skärmdump",
+ step_list: "Lista poster",
+ step_if: "Om / annars om / annars",
+ step_jump_to_label: "Hoppa till etikett",
+ step_loop: "Loop (för varje post)",
+ step_download: "Ladda ner",
+
+ cond_element_exists: "Element finns",
+ cond_element_not_exists: "Element finns inte",
+ cond_var_equals: "Variabel är lika med",
+ cond_var_not_equals: "Variabel är INTE lika med",
+ cond_var_contains: "Variabel innehåller",
+
+ f_by: "Hitta via",
+ f_value: "Text / CSS-selector / rollnamn",
+ f_role: "Roll (om by=role, ex. link, button)",
+ f_scope: "Sökområde",
+ f_nth: "Index (0 = första matchning)",
+
+ condf_var_name: "Variabelnamn",
+ condf_compare_text: "Jämför med",
+ condf_contains_text: "Ska innehålla",
+
+ fd_goto_url: "URL",
+ fd_wait_seconds: "Sekunder",
+ fd_timeout_seconds: "Timeout (sekunder)",
+ fd_type_text: "Text att skriva",
+ fd_press_key_key: "Tangent (ex. Enter, Tab, Escape)",
+ fd_press_key_value: "Text / CSS-selector / rollnamn (tomt = hela sidan)",
+ fd_by_conditional: "Hitta via (om ovan ifylld)",
+ fd_role_short: "Roll (om by=role)",
+ fd_select_option_option: "Alternativ (synlig text)",
+ fd_check_checked: "Kryssa i (av = kryssa ur)",
+ fd_save_as_var: "Spara som variabel",
+ fd_var_name: "Variabelnamn",
+ fd_set_var_text: "Värde",
+ fd_list_append_var: "Listvariabel",
+ fd_list_append_text: "Literalt värde (används om selector nedan är tom)",
+ fd_list_append_value: "Eller: Text / CSS-selector / rollnamn (hämtar från sidan)",
+ fd_save_var_var: "Variabel att spara",
+ fd_export_filename: "Filnamn i data/exports/ (.json eller .csv)",
+ fd_save_var_format: "Format (tomt = gissa från filnamn)",
+ fd_save_var_mode: "Läge",
+ fd_run_script_path: "Sökväg till Python-script",
+ fd_run_script_args: "Argument (mellanslagsseparerade)",
+ fd_run_script_var: "Spara stdout som variabel (valfritt, JSON om möjligt)",
+ fd_screenshot_name: "Filnamn (utan .png)",
+ fd_list_value: "CSS-selector",
+ fd_loop_over: "Loopa över variabel",
+ fd_jump_target: "Hoppa till etikett",
+ },
+};
+
+let LANG = localStorage.getItem("rpa_lang") || "en";
+
+function t(key) {
+ return (I18N[LANG] && I18N[LANG][key]) ?? (I18N.en && I18N.en[key]) ?? key;
+}
+
+function tf(key, params) {
+ let s = t(key);
+ for (const [k, v] of Object.entries(params || {})) {
+ s = s.replace(`{${k}}`, v);
+ }
+ return s;
+}
+
const STEP_TYPES = [
"goto", "wait", "wait_for_load", "wait_for_login", "wait_for_selector",
"click", "type", "press_key", "select_option", "check",
"extract_text", "set_var", "list_append", "save_var", "load_var", "run_script",
- "screenshot", "list", "loop", "download",
+ "screenshot", "list", "if", "jump_to_label", "loop", "download",
];
-const STEP_LABELS = {
- goto: "Gå till URL",
- wait: "Vänta (sekunder)",
- wait_for_load: "Vänta på att sidan laddas",
- wait_for_login: "Vänta på inloggning (BankID)",
- wait_for_selector: "Vänta på element",
- click: "Klicka",
- type: "Skriv i textfält",
- press_key: "Tryck tangent",
- select_option: "Välj i dropdown",
- check: "Kryssa checkbox/radio",
- extract_text: "Läs text till variabel",
- set_var: "Sätt variabel",
- list_append: "Lägg till i lista",
- save_var: "Spara variabel (JSON/CSV)",
- load_var: "Läs in variabel (JSON/CSV)",
- run_script: "Kör Python-script",
- screenshot: "Ta skärmdump",
- list: "Lista poster",
- loop: "Loop (för varje post)",
- download: "Ladda ner",
-};
-
const SELECTOR_FIELDS = [
- { key: "by", label: "Hitta via", type: "select", options: ["text", "css", "role"], default: "text" },
- { key: "value", label: "Text / CSS-selector / rollnamn", type: "text" },
- { key: "role", label: "Roll (om by=role, ex. link, button)", type: "text" },
- { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
- { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+ { key: "by", labelKey: "f_by", type: "select", options: ["text", "css", "role"], default: "text" },
+ { key: "value", labelKey: "f_value", type: "text" },
+ { key: "role", labelKey: "f_role", type: "text" },
+ { key: "scope", labelKey: "f_scope", type: "select", options: ["page", "item"], default: "page" },
+ { key: "nth", labelKey: "f_nth", type: "number", default: 0 },
];
+const CONDITION_KINDS = ["element_exists", "element_not_exists", "var_equals", "var_not_equals", "var_contains"];
+
+const CONDITION_FIELD_DEFS = {
+ element_exists: SELECTOR_FIELDS,
+ element_not_exists: SELECTOR_FIELDS,
+ var_equals: [
+ { key: "var", labelKey: "condf_var_name", type: "text" },
+ { key: "text", labelKey: "condf_compare_text", type: "text" },
+ ],
+ var_not_equals: [
+ { key: "var", labelKey: "condf_var_name", type: "text" },
+ { key: "text", labelKey: "condf_compare_text", type: "text" },
+ ],
+ var_contains: [
+ { key: "var", labelKey: "condf_var_name", type: "text" },
+ { key: "text", labelKey: "condf_contains_text", type: "text" },
+ ],
+};
+
const FIELD_DEFS = {
- goto: [{ key: "url", label: "URL", type: "text" }],
- wait: [{ key: "seconds", label: "Sekunder", type: "number", default: 1 }],
+ goto: [{ key: "url", labelKey: "fd_goto_url", type: "text" }],
+ wait: [{ key: "seconds", labelKey: "fd_wait_seconds", type: "number", default: 1 }],
wait_for_load: [],
- wait_for_login: [{ key: "timeout", label: "Timeout (sekunder)", type: "number", default: 180 }],
+ wait_for_login: [{ key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 180 }],
wait_for_selector: [
...SELECTOR_FIELDS,
- { key: "timeout", label: "Timeout (sekunder)", type: "number", default: 15 },
+ { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 15 },
],
click: SELECTOR_FIELDS,
- type: [...SELECTOR_FIELDS, { key: "text", label: "Text att skriva", type: "text" }],
+ type: [...SELECTOR_FIELDS, { key: "text", labelKey: "fd_type_text", type: "text" }],
press_key: [
- { key: "key", label: "Tangent (ex. Enter, Tab, Escape)", type: "text", default: "Enter" },
- { key: "value", label: "Text / CSS-selector / rollnamn (tomt = hela sidan)", type: "text" },
- { key: "by", label: "Hitta via (om ovan ifylld)", type: "select", options: ["text", "css", "role"], default: "text" },
- { key: "role", label: "Roll (om by=role)", type: "text" },
- { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
- { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+ { key: "key", labelKey: "fd_press_key_key", type: "text", default: "Enter" },
+ { key: "value", labelKey: "fd_press_key_value", type: "text" },
+ { key: "by", labelKey: "fd_by_conditional", type: "select", options: ["text", "css", "role"], default: "text" },
+ { key: "role", labelKey: "fd_role_short", type: "text" },
+ { key: "scope", labelKey: "f_scope", type: "select", options: ["page", "item"], default: "page" },
+ { key: "nth", labelKey: "f_nth", type: "number", default: 0 },
],
- select_option: [...SELECTOR_FIELDS, { key: "option", label: "Alternativ (synlig text)", type: "text" }],
+ select_option: [...SELECTOR_FIELDS, { key: "option", labelKey: "fd_select_option_option", type: "text" }],
check: [
...SELECTOR_FIELDS,
- { key: "checked", label: "Kryssa i (av = kryssa ur)", type: "select", options: ["true", "false"], default: "true" },
+ { key: "checked", labelKey: "fd_check_checked", type: "select", options: ["true", "false"], default: "true" },
],
- extract_text: [...SELECTOR_FIELDS, { key: "var", label: "Spara som variabel", type: "text" }],
+ extract_text: [...SELECTOR_FIELDS, { key: "var", labelKey: "fd_save_as_var", type: "text" }],
set_var: [
- { key: "var", label: "Variabelnamn", type: "text" },
- { key: "text", label: "Värde", type: "text" },
+ { key: "var", labelKey: "fd_var_name", type: "text" },
+ { key: "text", labelKey: "fd_set_var_text", type: "text" },
],
list_append: [
- { key: "var", label: "Listvariabel", type: "text" },
- { key: "text", label: "Literalt värde (används om selector nedan är tom)", type: "text" },
- { key: "value", label: "Eller: Text / CSS-selector / rollnamn (hämtar från sidan)", type: "text" },
- { key: "by", label: "Hitta via (om ovan ifylld)", type: "select", options: ["text", "css", "role"], default: "text" },
- { key: "role", label: "Roll (om by=role)", type: "text" },
- { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
- { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+ { key: "var", labelKey: "fd_list_append_var", type: "text" },
+ { key: "text", labelKey: "fd_list_append_text", type: "text" },
+ { key: "value", labelKey: "fd_list_append_value", type: "text" },
+ { key: "by", labelKey: "fd_by_conditional", type: "select", options: ["text", "css", "role"], default: "text" },
+ { key: "role", labelKey: "fd_role_short", type: "text" },
+ { key: "scope", labelKey: "f_scope", type: "select", options: ["page", "item"], default: "page" },
+ { key: "nth", labelKey: "f_nth", type: "number", default: 0 },
],
save_var: [
- { key: "var", label: "Variabel att spara", type: "text" },
- { key: "filename", label: "Filnamn i data/exports/ (.json eller .csv)", type: "text" },
- { key: "format", label: "Format (tomt = gissa från filnamn)", type: "select", options: ["", "json", "csv"], default: "" },
- { key: "mode", label: "Läge", type: "select", options: ["overwrite", "append"], default: "overwrite" },
+ { key: "var", labelKey: "fd_save_var_var", type: "text" },
+ { key: "filename", labelKey: "fd_export_filename", type: "text" },
+ { key: "format", labelKey: "fd_save_var_format", type: "select", options: ["", "json", "csv"], default: "" },
+ { key: "mode", labelKey: "fd_save_var_mode", type: "select", options: ["overwrite", "append"], default: "overwrite" },
],
load_var: [
- { key: "var", label: "Spara som variabel", type: "text" },
- { key: "filename", label: "Filnamn i data/exports/ (.json eller .csv)", type: "text" },
+ { key: "var", labelKey: "fd_save_as_var", type: "text" },
+ { key: "filename", labelKey: "fd_export_filename", type: "text" },
],
run_script: [
- { key: "path", label: "Sökväg till Python-script", type: "text" },
- { key: "args", label: "Argument (mellanslagsseparerade)", type: "text" },
- { key: "timeout", label: "Timeout (sekunder)", type: "number", default: 60 },
- { key: "var", label: "Spara stdout som variabel (valfritt, JSON om möjligt)", type: "text" },
+ { key: "path", labelKey: "fd_run_script_path", type: "text" },
+ { key: "args", labelKey: "fd_run_script_args", type: "text" },
+ { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 60 },
+ { key: "var", labelKey: "fd_run_script_var", type: "text" },
],
- screenshot: [{ key: "name", label: "Filnamn (utan .png)", type: "text", default: "screenshot" }],
+ screenshot: [{ key: "name", labelKey: "fd_screenshot_name", type: "text", default: "screenshot" }],
list: [
- { key: "value", label: "CSS-selector", type: "text" },
- { key: "var", label: "Spara som variabel", type: "text" },
+ { key: "value", labelKey: "fd_list_value", type: "text" },
+ { key: "var", labelKey: "fd_save_as_var", type: "text" },
],
- loop: [{ key: "over", label: "Loopa över variabel", type: "text" }],
+ loop: [{ key: "over", labelKey: "fd_loop_over", type: "text" }],
download: SELECTOR_FIELDS,
+ if: [],
+ jump_to_label: [{ key: "target", labelKey: "fd_jump_target", type: "text" }],
};
let currentFlowName = null;
@@ -289,6 +604,7 @@ function newStep(type) {
if (f.default !== undefined) step[f.key] = f.default;
}
if (type === "loop") step.steps = [];
+ if (type === "if") step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
return step;
}
@@ -296,7 +612,7 @@ function fieldRow(step, def, onStructuralChange) {
const wrap = document.createElement("div");
wrap.className = "field";
const label = document.createElement("label");
- label.textContent = def.label;
+ label.textContent = t(def.labelKey);
wrap.appendChild(label);
let input;
@@ -321,19 +637,61 @@ function fieldRow(step, def, onStructuralChange) {
return wrap;
}
+function renderCondition(condition, onStructuralChange) {
+ const wrap = document.createElement("div");
+ wrap.className = "fields";
+
+ const kindWrap = document.createElement("div");
+ kindWrap.className = "field";
+ const kindLabel = document.createElement("label");
+ kindLabel.textContent = t("ui_condition_label");
+ kindWrap.appendChild(kindLabel);
+ const kindSelect = document.createElement("select");
+ for (const k of CONDITION_KINDS) {
+ const o = document.createElement("option");
+ o.value = k;
+ o.textContent = t("cond_" + k);
+ kindSelect.appendChild(o);
+ }
+ kindSelect.value = condition.kind || "element_exists";
+ kindSelect.addEventListener("change", () => {
+ for (const key of Object.keys(condition)) delete condition[key];
+ condition.kind = kindSelect.value;
+ onStructuralChange();
+ });
+ kindWrap.appendChild(kindSelect);
+ wrap.appendChild(kindWrap);
+
+ for (const def of CONDITION_FIELD_DEFS[condition.kind || "element_exists"] || []) {
+ wrap.appendChild(fieldRow(condition, def, onStructuralChange));
+ }
+ return wrap;
+}
+
function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
const card = document.createElement("div");
- card.className = "step-card" + (step.type === "loop" ? " loop" : "");
+ card.className = "step-card" + (step.type === "loop" ? " loop" : "") + (step.enabled === false ? " step-disabled" : "");
const head = document.createElement("div");
head.className = "step-head";
+ const enabledToggle = document.createElement("input");
+ enabledToggle.type = "checkbox";
+ enabledToggle.className = "enabled-toggle";
+ enabledToggle.title = t("ui_enabled_title");
+ enabledToggle.checked = step.enabled !== false;
+ enabledToggle.addEventListener("change", () => {
+ step.enabled = enabledToggle.checked;
+ card.classList.toggle("step-disabled", !enabledToggle.checked);
+ });
+ head.appendChild(enabledToggle);
+
const typeSelect = document.createElement("select");
typeSelect.className = "type-select";
- for (const t of STEP_TYPES) {
+ for (const stepType of STEP_TYPES) {
const o = document.createElement("option");
- o.value = t;
- o.textContent = STEP_LABELS[t];
+ o.value = stepType;
+ o.textContent = t("step_" + stepType);
typeSelect.appendChild(o);
}
typeSelect.value = step.type;
@@ -344,6 +702,17 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
});
head.appendChild(typeSelect);
+ const labelInput = document.createElement("input");
+ labelInput.type = "text";
+ labelInput.placeholder = t("ui_label_placeholder");
+ labelInput.title = t("ui_label_title");
+ labelInput.style.width = "120px";
+ labelInput.value = step.label || "";
+ labelInput.addEventListener("input", () => {
+ step.label = labelInput.value || undefined;
+ });
+ head.appendChild(labelInput);
+
head.appendChild(document.createElement("span")).className = "spacer";
const upBtn = document.createElement("button");
@@ -366,7 +735,7 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
const delBtn = document.createElement("button");
delBtn.className = "danger";
- delBtn.textContent = "Ta bort";
+ delBtn.textContent = t("ui_remove");
delBtn.addEventListener("click", () => {
arr.splice(idx, 1);
onStructuralChange();
@@ -398,7 +767,7 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
const addBtn = document.createElement("button");
addBtn.className = "secondary add-step-row";
- addBtn.textContent = "+ Lägg till steg i loop";
+ addBtn.textContent = t("ui_add_step_in_loop");
addBtn.addEventListener("click", () => {
step.steps.push(newStep("click"));
onStructuralChange();
@@ -407,13 +776,117 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
card.appendChild(nested);
}
+ if (step.type === "if") {
+ if (!Array.isArray(step.branches) || step.branches.length === 0) {
+ step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
+ }
+
+ const ifBlock = document.createElement("div");
+ ifBlock.className = "nested";
+
+ step.branches.forEach((branch, bIdx) => {
+ if (!branch.condition) branch.condition = { kind: "element_exists" };
+ if (!Array.isArray(branch.steps)) branch.steps = [];
+
+ const branchWrap = document.createElement("div");
+ branchWrap.className = "branch";
+
+ const branchHead = document.createElement("div");
+ branchHead.className = "step-head";
+ const branchTitle = document.createElement("strong");
+ branchTitle.textContent = bIdx === 0 ? t("ui_branch_if") : t("ui_branch_else_if");
+ branchHead.appendChild(branchTitle);
+ branchHead.appendChild(document.createElement("span")).className = "spacer";
+ if (bIdx > 0) {
+ const removeBranchBtn = document.createElement("button");
+ removeBranchBtn.className = "danger";
+ removeBranchBtn.textContent = t("ui_remove_branch");
+ removeBranchBtn.addEventListener("click", () => {
+ step.branches.splice(bIdx, 1);
+ onStructuralChange();
+ });
+ branchHead.appendChild(removeBranchBtn);
+ }
+ branchWrap.appendChild(branchHead);
+
+ branchWrap.appendChild(renderCondition(branch.condition, onStructuralChange));
+
+ const branchSteps = document.createElement("div");
+ branchSteps.className = "steps";
+ renderStepsInto(branchSteps, branch.steps, isItemScope, onStructuralChange);
+ branchWrap.appendChild(branchSteps);
+
+ ifBlock.appendChild(branchWrap);
+ });
+
+ const addBranchBtn = document.createElement("button");
+ addBranchBtn.className = "secondary add-step-row";
+ addBranchBtn.textContent = t("ui_add_else_if");
+ addBranchBtn.addEventListener("click", () => {
+ step.branches.push({ condition: { kind: "element_exists" }, steps: [] });
+ onStructuralChange();
+ });
+ ifBlock.appendChild(addBranchBtn);
+
+ const elseWrap = document.createElement("div");
+ elseWrap.className = "branch else-branch";
+ const elseHead = document.createElement("div");
+ elseHead.className = "step-head";
+ const elseToggleLabel = document.createElement("label");
+ elseToggleLabel.style.fontSize = "13px";
+ elseToggleLabel.style.display = "flex";
+ elseToggleLabel.style.alignItems = "center";
+ elseToggleLabel.style.gap = "6px";
+ const elseCheckbox = document.createElement("input");
+ elseCheckbox.type = "checkbox";
+ elseCheckbox.checked = Array.isArray(step.else);
+ elseCheckbox.addEventListener("change", () => {
+ step.else = elseCheckbox.checked ? [] : undefined;
+ onStructuralChange();
+ });
+ elseToggleLabel.appendChild(elseCheckbox);
+ elseToggleLabel.appendChild(document.createTextNode(t("ui_else_toggle")));
+ elseHead.appendChild(elseToggleLabel);
+ elseWrap.appendChild(elseHead);
+
+ if (Array.isArray(step.else)) {
+ const elseSteps = document.createElement("div");
+ elseSteps.className = "steps";
+ renderStepsInto(elseSteps, step.else, isItemScope, onStructuralChange);
+ elseWrap.appendChild(elseSteps);
+ }
+ ifBlock.appendChild(elseWrap);
+
+ card.appendChild(ifBlock);
+ }
+
return card;
}
+function insertDivider(arr, index, onStructuralChange) {
+ const div = document.createElement("div");
+ div.className = "insert-divider";
+ div.title = t("ui_insert_step_here");
+ const line = document.createElement("div");
+ line.className = "line";
+ div.appendChild(line);
+ const plus = document.createElement("div");
+ plus.className = "plus";
+ plus.textContent = "+";
+ div.appendChild(plus);
+ div.addEventListener("click", () => {
+ arr.splice(index, 0, newStep("click"));
+ onStructuralChange();
+ });
+ return div;
+}
+
function renderStepsInto(container, arr, isItemScope, onStructuralChange) {
container.innerHTML = "";
+ container.appendChild(insertDivider(arr, 0, onStructuralChange));
arr.forEach((step, idx) => {
container.appendChild(renderStepCard(step, arr, idx, isItemScope, onStructuralChange));
+ container.appendChild(insertDivider(arr, idx + 1, onStructuralChange));
});
}
@@ -468,7 +941,7 @@ document.getElementById("flowSelect").addEventListener("change", (e) => {
});
document.getElementById("newFlowBtn").addEventListener("click", async () => {
- const name = prompt("Namn på nytt flöde:");
+ const name = prompt(t("ui_new_flow_prompt"));
if (!name) return;
currentFlowName = name;
flow = { steps: [] };
@@ -479,7 +952,7 @@ document.getElementById("newFlowBtn").addEventListener("click", async () => {
document.getElementById("deleteFlowBtn").addEventListener("click", async () => {
if (!currentFlowName) return;
- if (!confirm(`Ta bort flödet "${currentFlowName}"?`)) return;
+ if (!confirm(tf("ui_delete_flow_confirm", { name: currentFlowName }))) return;
await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}`, { method: "DELETE" });
currentFlowName = null;
const names = await loadFlowList();
@@ -495,6 +968,54 @@ const runBtn = document.getElementById("runBtn");
const closeModeSelect = document.getElementById("closeModeSelect");
const closeBrowserBtn = document.getElementById("closeBrowserBtn");
const showTraceBtn = document.getElementById("showTraceBtn");
+const varsPanel = document.getElementById("varsPanel");
+const langSelect = document.getElementById("langSelect");
+const expandedVars = new Set();
+let lastStatus = null;
+
+function describeVarType(value) {
+ if (Array.isArray(value)) return tf("ui_var_list", { n: value.length });
+ if (value && typeof value === "object") return t("ui_var_object");
+ if (typeof value === "string") return tf("ui_var_text", { n: value.length });
+ return typeof value;
+}
+
+function renderVars(vars) {
+ varsPanel.innerHTML = "";
+ const names = Object.keys(vars || {});
+ if (!names.length) {
+ const p = document.createElement("p");
+ p.className = "hint";
+ p.textContent = t("ui_no_vars_yet");
+ varsPanel.appendChild(p);
+ return;
+ }
+ for (const name of names) {
+ const row = document.createElement("div");
+ row.className = "var-row";
+
+ const header = document.createElement("div");
+ header.className = "var-header";
+ header.innerHTML = `${name} <span class="var-type">(${describeVarType(vars[name])})</span>`;
+
+ const valueEl = document.createElement("pre");
+ valueEl.className = "var-value";
+ const value = vars[name];
+ valueEl.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
+ valueEl.style.display = expandedVars.has(name) ? "block" : "none";
+
+ header.addEventListener("click", () => {
+ const show = valueEl.style.display === "none";
+ valueEl.style.display = show ? "block" : "none";
+ if (show) expandedVars.add(name);
+ else expandedVars.delete(name);
+ });
+
+ row.appendChild(header);
+ row.appendChild(valueEl);
+ varsPanel.appendChild(row);
+ }
+}
const savedCloseMode = localStorage.getItem("rpa_close_mode");
if (savedCloseMode) closeModeSelect.value = savedCloseMode;
@@ -503,8 +1024,9 @@ closeModeSelect.addEventListener("change", () => {
});
function setStatus(status) {
+ lastStatus = status;
statusBadge.className = "status-badge status-" + status;
- statusBadge.textContent = { running: "Kör...", done: "Klar", error: "Fel" }[status] || "";
+ statusBadge.textContent = { running: t("ui_status_running"), done: t("ui_status_done"), error: t("ui_status_error") }[status] || "";
}
async function pollRun(runId) {
@@ -523,6 +1045,10 @@ async function pollRun(runId) {
showTraceBtn.style.display = data.trace_ready ? "inline-block" : "none";
+ if (data.status !== "running") {
+ renderVars(data.vars);
+ }
+
if (data.status === "running" || data.browser_open) {
setTimeout(() => pollRun(runId), 1000);
}
@@ -534,6 +1060,8 @@ document.getElementById("runBtn").addEventListener("click", async () => {
runBtn.disabled = true;
closeBrowserBtn.style.display = "none";
showTraceBtn.style.display = "none";
+ expandedVars.clear();
+ renderVars({});
setStatus("running");
const res = await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}/run`, {
method: "POST",
@@ -554,6 +1082,46 @@ showTraceBtn.addEventListener("click", async () => {
await fetch(`/api/runs/${encodeURIComponent(currentRunId)}/show-trace`, { method: "POST" });
});
+function applyStaticTranslations() {
+ document.title = t("ui_page_title");
+ document.documentElement.lang = LANG;
+ document.getElementById("appTitle").textContent = t("ui_title");
+ document.getElementById("newFlowBtn").textContent = t("ui_new_flow");
+ document.getElementById("deleteFlowBtn").textContent = t("ui_delete_flow");
+ closeModeSelect.title = t("ui_close_mode_title");
+ closeModeSelect.options[0].textContent = t("ui_close_always");
+ closeModeSelect.options[1].textContent = t("ui_close_on_success");
+ closeModeSelect.options[2].textContent = t("ui_close_never");
+ document.getElementById("saveBtn").textContent = t("ui_save");
+ runBtn.textContent = t("ui_run");
+ closeBrowserBtn.textContent = t("ui_close_browser_btn");
+ showTraceBtn.textContent = t("ui_show_trace");
+ document.getElementById("stepsHint").textContent = t("ui_steps_hint");
+ document.getElementById("addRootStepBtn").textContent = t("ui_add_step");
+ document.getElementById("logTitle").textContent = t("ui_run_log");
+ document.getElementById("varsTitle").textContent = t("ui_variables");
+ document.getElementById("varsHint").textContent = t("ui_variables_hint");
+ if (lastStatus) setStatus(lastStatus);
+}
+
+langSelect.value = LANG;
+langSelect.addEventListener("change", () => {
+ LANG = langSelect.value;
+ localStorage.setItem("rpa_lang", LANG);
+ applyStaticTranslations();
+ render();
+ renderVars(lastRenderedVars);
+});
+
+let lastRenderedVars = {};
+const _renderVarsOrig = renderVars;
+renderVars = function (vars) {
+ lastRenderedVars = vars || {};
+ _renderVarsOrig(vars);
+};
+
+applyStaticTranslations();
+
(async function init() {
const names = await loadFlowList();
if (names.length) {