commit fb4d90ba38fb03fb8f232cb03301eadec482c9d9
Author: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Sep 2 13:27:01 2026 +0200
Commit: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Sep 2 13:32:39 2026 +0200
Lägg till nya stegtyper, inställningsdialog, script-export och wiki
Nya steg: Gå tillbaka, Öka/Nollställ variabel, Ladda ner bilaga,
Fånga PDF (skriv ut-knapp). Loop kan nu iterera vanliga listvariabler
med {variabel}-interpolering i text/role-fält, och per steg väljs
"Vid fel: stanna/fortsätt".
Inställningsdialog (headless, viewport, locale, slow motion,
skrivbordsnotis) sparas med flödet. "Export as script" (rpa/export.py)
laddar ner ett fristående .py som kör samma flöde via run_flow().
docs/wiki.html: fullständig referens över stegtyper och verktyg.
scripts/merge_and_print.py: slå ihop och skriv ut nedladdade PDF:er.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
README.md | 97 ++++-
TODO.md | 6 +
docs/wiki.html | 830 +++++++++++++++++++++++++++++++++++
requirements.txt | 2 +
rpa/browser.py | 34 +-
rpa/export.py | 73 ++++
rpa/flow_engine.py | 1020 +++++++++++++++++++++++++++++++++-----------
rpa/runs.py | 3 +-
scripts/merge_and_print.py | 202 +++++++++
webui/app.py | 28 +-
webui/templates/index.html | 306 ++++++++++++-
11 files changed, 2331 insertions(+), 270 deletions(-)
diff --git a/README.md b/README.md
index 306950b..89d4b82 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,10 @@ Repot innehåller dels det generella ramverket (webui + `rpa/`-paketet),
dels ett fristående exempel på en färdig automation byggd med det
(hämtning av egenremisser från personal.1177.se) - se sist i dokumentet.
+För en fullständig referens över alla stegtyper, villkor, variabel-
+interpolering, loop-typer och felsökningsverktyg, se
+[`docs/wiki.html`](docs/wiki.html) (öppna lokalt i en webbläsare).
+
## Setup
**Linux/macOS:** använd medföljande skript, som skapar `.venv`,
@@ -76,6 +80,8 @@ I UI:t bygger du ett flöde som en lista av steg:
- **Gå till URL** – navigerar (t.ex. `https://example.com/`)
- **Vänta (sekunder)** – fast paus
- **Vänta på att sidan laddas** – väntar på domcontentloaded
+- **Gå tillbaka** – webbläsarens "bakåt" (historik), t.ex. efter att ha
+ klickat in på en post från en lista, för att fortsätta en loop över listan
- **Vänta på inloggning (BankID)** – pausar tills du godkänt inloggningen manuellt
- **Vänta på element** – pausar tills ett element via text/CSS/roll finns/syns
- **Klicka** – hittar ett element via text/CSS/roll och klickar
@@ -87,6 +93,12 @@ I UI:t bygger du ett flöde som en lista av steg:
- **Läs text till variabel** – läser textinnehållet i ett element och sparar
det under ett variabelnamn
- **Sätt variabel** – sätter en variabel till ett fast textvärde
+- **Öka variabel** – ökar en heltalsvariabel med ett steg (default 1,
+ negativt värde minskar); saknad/icke-numerisk variabel räknas som 0.
+ Lägg sist i en loop för att numrera filer: `{räknare}` blir 1, 2, 3 …
+- **Nollställ variabel** – tar bort en variabel; nästa "Lägg till i lista" /
+ "Ladda ner bilaga" skapar den på nytt tom. Lägg först i en loop för att
+ nollställa en samlar-lista per varv.
- **Lägg till i lista** – lägger till ett värde i en listvariabel (skapas
automatiskt vid behov); värdet kan antingen läsas från ett element
(samma via/text/CSS/roll som "Läs text till variabel") eller vara ett
@@ -119,10 +131,49 @@ I UI:t bygger du ett flöde som en lista av steg:
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
+- **Loop (för varje post)** – itererar över en variabel, på två sätt:
+ - Resultat från "Lista poster" (DOM-element): steg inuti loopen kan sätta
+ Sökområde = "item" för att bara söka inom aktuell post.
+ - En vanlig listvariabel (t.ex. från "Lägg till i lista" eller "Läs in
+ variabel", som en tidigare sparad CSV-fil) – då finns varje varvs värde
+ i variabeln "item" (eller det namn du anger i "Variabel för aktuell
+ post"), och kan användas som `{item}` i ett "Hitta via: text"/"role"-fält
+ i ett steg längre ner (t.ex. "Klicka") för att hitta och interagera med
+ motsvarande rad på sidan.
+ - Varvnumret (1, 2, 3 …) finns dessutom alltid i variabeln "index" (eller
+ namnet i "Variabel för varvräknare"), oavsett looptyp – användbart som
+ `{index}` i filnamn.
- **Ladda ner** – klickar ett element och sparar filen i `data/downloads/`
+- **Ladda ner bilaga** – hittar en `<a>` i (eller under) elementet och sparar
+ filen i `data/downloads/`. Har länken en riktig `href` hämtas filen direkt
+ via webbläsarsessionen (ingen ny flik/skrivardialog); är det en
+ `javascript:`-länk klickas den och en nedladdning/ny flik fångas. `var`
+ lägger sökvägen i en **lista** – loopa bilagerader (`list` +
+ `Loop`, `scope=item`, `value=a[href]`) för att få 0, 1 eller flera filer.
+- **Fånga PDF (skriv ut-knapp)** – för "Skriv ut"-knappar som öppnar
+ webbläsarens skrivardialog i stället för att ladda ner en fil. Neutraliserar
+ `window.print`, klickar ev. triggern och fångar PDF:en via ett
+ PDF-nätverkssvar, en inbäddad PDF i sidan, eller Chrome "printToPDF" av sidan.
+ Sparar i `data/downloads/` och kan lägga sökvägen i en variabel.
+
+### Vid fel: stanna / fortsätt (per steg)
+
+Bredvid etikettfältet på varje steg väljer du vad som händer om steget
+misslyckas eller får timeout: **"Vid fel: stanna"** (default – avbryter
+körningen) eller **"Vid fel: fortsätt"** – felet loggas och nästa steg körs
+ändå. Använd det för steg som är "hit or miss", t.ex. ett `Vänta på element`
+eller `Klicka` som bara ibland dyker upp. På en Loop/If gäller valet hela
+blocket. (`Ladda ner`, `Ladda ner bilaga` och `Fånga PDF` fortsätter redan
+alltid vid fel.)
+
+### Variabler i textfält ({variabelnamn})
+
+Fält av typen "Hitta via: text/role" samt de literala textfälten i "Skriv i
+textfält", "Sätt variabel" och "Lägg till i lista" stöder `{variabelnamn}` –
+ersätts med variabelns aktuella textvärde vid körning. Fungerar **inte** i
+CSS-selectors, filnamn eller URL:er. Praktiskt tillsammans med "Loop" över en
+vanlig lista (se ovan) för att hitta rätt rad på sidan utifrån ett tidigare
+sparat värde.
Ett typiskt flöde: `Gå till URL → Vänta på inloggning → Klicka → Lista
poster → Loop { Läs text till variabel, Lägg till i lista, Ladda ner } →
@@ -165,6 +216,37 @@ python -m rpa.show_trace # senaste sparade trace
python -m rpa.show_trace <run_id> # en specifik körning
```
+### Inställningar
+
+"⚙ Inställningar" i headern öppnar en dialog med körningsinställningar,
+sparade tillsammans med flödet (så de följer med vid export – se nedan):
+
+- **Headless** – kör utan synligt webbläsarfönster. Kräver en redan giltig
+ inloggningssession (BankID/2FA går inte att godkänna i ett headless-fönster).
+- **Fönsterbredd/-höjd** – webbläsarens viewport-storlek.
+- **Locale** – t.ex. `sv-SE` (standard).
+- **Slow motion** – millisekunder paus mellan varje åtgärd, praktiskt för att
+ kunna följa med ögat när man felsöker ett flöde.
+- **Skrivbordsnotis** – visas när körningen är klar (kräver `plyer`, redan i
+ `requirements.txt`).
+
+### Exportera som fristående script
+
+"Export as script" i headern laddar ner en `.py`-fil som kör exakt samma
+flöde (steg + inställningar) utan webui:t – bra för schemaläggning via
+Windows Task Scheduler av ett flöde byggt i UI:t, inte bara `main.py`.
+Scriptet importerar `rpa`-paketet och anropar samma `run_flow()`-motor som
+webui:t, så beteendet blir identiskt.
+
+```powershell
+python mitt_flode.py # kör med flödets sparade inställningar
+python mitt_flode.py --headless # tvinga headless oavsett sparad inställning
+python mitt_flode.py --close-mode never # håll webbläsaren öppen efteråt
+```
+
+Kräver att scriptet körs där `rpa`-paketet är importerbart (från
+projektroten, eller med `rpa/`-mappen kopierad bredvid).
+
## Persistent inloggning
Sessionen sparas i `data/browser_profile/` (persistent Chromium-profil),
@@ -184,10 +266,11 @@ script istället för webui:t.
## Schemaläggning
-Kör manuellt vid behov. Vill du schemalägga via Windows Task Scheduler:
-tänk på att manuell inloggning (BankID/2FA) krävs varje gång sessionen i
-`data/browser_profile/` har gått ut, så helt obevakad körning fungerar
-bara så länge sessionen är giltig.
+Kör manuellt vid behov, eller exportera ett flöde som ett fristående script
+(se "Exportera som fristående script" ovan) och schemalägg det via Windows
+Task Scheduler. Tänk på att manuell inloggning (BankID/2FA) krävs varje
+gång sessionen i `data/browser_profile/` har gått ut, så helt obevakad
+körning fungerar bara så länge sessionen är giltig.
## Bygga en egen fristående automation
diff --git a/TODO.md b/TODO.md
index 56e8b6d..88b0079 100644
--- a/TODO.md
+++ b/TODO.md
@@ -20,3 +20,9 @@
användarvillkor. Klargör exakt vad som ska styras innan
implementation.
+2. Lägga till att kunna köra custom python kod mellan
+ ex. lista extraheras
+ lista körs genom custom script för att få ut datan
+ lägg som vairabel
+
+3. lägga till att kunna köra headless?
diff --git a/docs/wiki.html b/docs/wiki.html
new file mode 100644
index 0000000..bfa8683
--- /dev/null
+++ b/docs/wiki.html
@@ -0,0 +1,830 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+<meta charset="UTF-8">
+<title>RPA-verktyg – Wiki</title>
+<style>
+ :root {
+ color-scheme: light dark;
+ --bg: #f5f5f7;
+ --panel: #ffffff;
+ --border: #d8d8dd;
+ --text: #1c1c1e;
+ --muted: #6b6b70;
+ --accent: #2f6fed;
+ --accent-text: #ffffff;
+ --danger: #d1453b;
+ --ok: #3aa757;
+ --code-bg: #111114;
+ --code-text: #d6d6d8;
+ --tag-bg: #eef2fb;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1c1c1e;
+ --panel: #2a2a2d;
+ --border: #444448;
+ --text: #f2f2f3;
+ --muted: #9a9a9e;
+ --tag-bg: #232b3d;
+ }
+ }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0;
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.55;
+ }
+ a { color: var(--accent); }
+ code {
+ font-family: ui-monospace, Consolas, monospace;
+ background: var(--tag-bg);
+ padding: 1px 5px;
+ border-radius: 4px;
+ font-size: 0.92em;
+ }
+ pre {
+ background: var(--code-bg);
+ color: var(--code-text);
+ padding: 12px 14px;
+ border-radius: 8px;
+ overflow-x: auto;
+ font-family: ui-monospace, Consolas, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+ pre code { background: none; padding: 0; color: inherit; }
+ .layout {
+ display: grid;
+ grid-template-columns: 250px minmax(0, 1fr);
+ min-height: 100vh;
+ }
+ nav.toc {
+ position: sticky;
+ top: 0;
+ align-self: start;
+ height: 100vh;
+ overflow-y: auto;
+ background: var(--panel);
+ border-right: 1px solid var(--border);
+ padding: 20px 16px;
+ }
+ nav.toc h2 { font-size: 14px; margin: 0 0 12px; }
+ nav.toc ul { list-style: none; margin: 0; padding: 0; }
+ nav.toc li { margin: 2px 0; }
+ nav.toc a {
+ display: block;
+ padding: 5px 8px;
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--text);
+ font-size: 13px;
+ }
+ nav.toc a:hover { background: var(--tag-bg); }
+ nav.toc .sub { padding-left: 14px; font-size: 12px; color: var(--muted); }
+ main {
+ padding: 32px 40px 80px;
+ max-width: 880px;
+ }
+ main h1 { margin-top: 0; }
+ section { margin-bottom: 48px; scroll-margin-top: 16px; }
+ section h2 {
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 8px;
+ margin-top: 0;
+ }
+ h3 { margin-bottom: 6px; }
+ h3 code { font-size: 1em; }
+ .hint {
+ background: var(--tag-bg);
+ border-left: 3px solid var(--accent);
+ padding: 10px 14px;
+ border-radius: 0 8px 8px 0;
+ font-size: 14px;
+ margin: 12px 0;
+ }
+ .warn {
+ background: var(--tag-bg);
+ border-left: 3px solid var(--danger);
+ padding: 10px 14px;
+ border-radius: 0 8px 8px 0;
+ font-size: 14px;
+ margin: 12px 0;
+ }
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13.5px;
+ margin: 10px 0 20px;
+ }
+ th, td {
+ text-align: left;
+ padding: 7px 10px;
+ border-bottom: 1px solid var(--border);
+ vertical-align: top;
+ }
+ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.02em; }
+ .step-card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 16px 18px;
+ margin: 14px 0;
+ }
+ .step-card h3 { display: flex; align-items: center; gap: 8px; }
+ .tag {
+ display: inline-block;
+ background: var(--tag-bg);
+ color: var(--accent);
+ border-radius: 999px;
+ padding: 1px 9px;
+ font-size: 11px;
+ font-weight: 600;
+ }
+ .field-list { margin: 8px 0 0; padding-left: 18px; font-size: 13.5px; }
+ .field-list li { margin: 3px 0; }
+ .toplink { font-size: 12px; }
+ footer { color: var(--muted); font-size: 12px; margin-top: 40px; }
+ @media (max-width: 760px) {
+ .layout { grid-template-columns: 1fr; }
+ nav.toc { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
+ main { padding: 24px 20px 60px; }
+ }
+</style>
+</head>
+<body>
+<div class="layout">
+<nav class="toc">
+ <h2>RPA-verktyg – Wiki</h2>
+ <ul>
+ <li><a href="#oversikt">Översikt</a></li>
+ <li><a href="#komigang">Kom igång</a></li>
+ <li><a href="#stegreferens">Stegreferens</a>
+ <ul class="sub">
+ <li><a href="#steg-navigation">Navigation & väntan</a></li>
+ <li><a href="#steg-interaktion">Sidinteraktion</a></li>
+ <li><a href="#steg-variabler">Variabler & data</a></li>
+ <li><a href="#steg-kontrollflode">Kontrollflöde</a></li>
+ </ul>
+ </li>
+ <li><a href="#villkor">Villkor (If)</a></li>
+ <li><a href="#interpolering">Variabler i text ({var})</a></li>
+ <li><a href="#loop">Loop: DOM vs. lista</a></li>
+ <li><a href="#recept">Recept (arbetsflöden)</a></li>
+ <li><a href="#etiketter">Etiketter & hopp</a></li>
+ <li><a href="#felsokning">Felsökning</a></li>
+ <li><a href="#datamappar">Datamappar</a></li>
+ <li><a href="#sprak">Språk (i18n)</a></li>
+ <li><a href="#installningar">Inställningar & export</a></li>
+ <li><a href="#git">Version & git</a></li>
+ </ul>
+</nav>
+<main>
+ <h1>RPA-verktyg – Wiki</h1>
+ <p class="hint">
+ Referensdokumentation för det interna RPA-verktyget (Playwright + webbaserad
+ flödesbyggare). Öppna den här sidan lokalt i en webbläsare när du bygger
+ flöden. Se <code>README.md</code> för installation och kom-igång-instruktioner
+ – den här sidan är en djupare referens över alla stegtyper, villkor och
+ mekanismer.
+ </p>
+
+ <section id="oversikt">
+ <h2>Översikt</h2>
+ <p>
+ Ett flöde är en JSON-fil (<code>data/flows/<namn>.json</code>) med en
+ lista av <strong>steg</strong>. Varje steg har en <code>type</code> och
+ typ-specifika fält. Webb-UI:t (<code>python run_webui.py</code>,
+ <a href="http://127.0.0.1:8765">http://127.0.0.1:8765</a>) bygger och
+ redigerar dessa JSON-filer visuellt, men de går lika bra att redigera för
+ hand.
+ </p>
+ <p>
+ Motorn som kör flödet är <code>rpa/flow_engine.py</code> –
+ <code>run_flow()</code> kör en lista av steg sekventiellt, med stöd för
+ loopar, villkor och hopp (se nedan).
+ </p>
+ </section>
+
+ <section id="komigang">
+ <h2>Kom igång</h2>
+ <ol>
+ <li><code>python -m venv .venv</code> & <code>pip install -r requirements.txt</code> & <code>python -m playwright install chromium</code></li>
+ <li><code>python -m rpa.codegen <url></code> för att hitta exakta selectors mot en riktig, inloggad sida (öppnar en inspector, ingen gissning behövs).</li>
+ <li><code>python run_webui.py</code> och öppna <code>http://127.0.0.1:8765</code> för att bygga flödet.</li>
+ <li>"Kör flöde" sparar och kör flödet i en synlig webbläsare, med live-logg och en variabelpanel.</li>
+ </ol>
+ <p>Fullständiga instruktioner finns i <code>README.md</code> i projektroten.</p>
+ </section>
+
+ <section id="stegreferens">
+ <h2>Stegreferens</h2>
+ <p>
+ <code>by</code>-fältet (var förekommande) styr hur ett element hittas:
+ <code>text</code> (skiftlägesokänslig delsträng), <code>css</code>
+ (CSS-selector, stödjer Playwrights utökningar som
+ <code>:text-is()</code>, <code>:has-text()</code>, <code>:scope</code>) eller
+ <code>role</code> (ARIA-roll + tillgängligt namn). <code>scope</code> är
+ <code>page</code> (hela sidan, standard) eller <code>item</code> (bara
+ inom aktuell loop-post). <code>nth</code> väljer vilken matchning
+ (0 = första).
+ </p>
+
+ <h3>CSS-selektorer: flera varianter i samma sträng</h3>
+ <p>En <code>css</code>-selektor kan vara en <strong>kommaseparerad lista</strong> –
+ varje del är en egen fullständig selektor, och elementet matchar om
+ <em>någon</em> del gör det. Praktiskt för att täcka flera filändelser:</p>
+ <pre><code>a[href$=".png"], a[href$=".jpg"], a[href$=".jpeg"], a[href$=".gif"]</code></pre>
+ <ul>
+ <li><strong>Skiftläge:</strong> attributvärden matchas skiftlägeskänsligt –
+ <code>.JPG</code> matchar inte <code>.jpg</code>. Lägg till <code>i</code>-flaggan
+ (stöds i Chromium): <code>a[href$=".jpg" i]</code>.</li>
+ <li><strong><code>$=</code> vs <code>*=</code>:</strong> <code>$=".png"</code> kräver
+ att värdet <em>slutar</em> på <code>.png</code> – matchar inte
+ <code>bild.png?token=…</code>. Använd <code>*=".png"</code> (innehåller) då.</li>
+ <li>Ofta stabilast: matcha på en <strong>ikon-/typklass</strong> i stället för
+ filändelsen, t.ex. <code>.ids-icon-file-image</code> (alla bildtyper) eller
+ <code>.ids-icon-file-pdf</code> – en selektor, oberoende av skiftläge och
+ query-strängar.</li>
+ </ul>
+ <p>Samma kommalista fungerar överallt där <code>by=css</code> gäller, inklusive
+ <code>if</code>-villkorens <code>value</code>.</p>
+
+ <h3 id="steg-navigation">Navigation & väntan</h3>
+
+ <div class="step-card">
+ <h3><code>goto</code> <span class="tag">Gå till URL</span></h3>
+ <ul class="field-list"><li><code>url</code> – adressen att navigera till</li></ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>wait</code> <span class="tag">Vänta (sekunder)</span></h3>
+ <ul class="field-list"><li><code>seconds</code> – fast paus</li></ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>wait_for_load</code> <span class="tag">Vänta på sidladdning</span></h3>
+ <p>Väntar på <code>domcontentloaded</code>. Inga fält.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>go_back</code> <span class="tag">Gå tillbaka</span></h3>
+ <p>Webbläsarens "bakåt" (historik). Inga fält. Praktiskt efter att ha
+ klickat in på en post från en lista (t.ex. för att öppna en detaljvy),
+ för att komma tillbaka till listan och fortsätta en loop.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>wait_for_login</code> <span class="tag">Vänta på inloggning</span></h3>
+ <ul class="field-list"><li><code>timeout</code> – sekunder (default 180)</li></ul>
+ <p>Pollar sidan tills någon text i <code>config.LOGGED_IN_INDICATORS</code>
+ syns (eller ger upp). Fungerar för BankID, 2FA eller annan manuell
+ inloggning – verktyget bryr sig bara om att en inloggad-indikator dyker
+ upp, inte om <em>hur</em> du loggade in.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>wait_for_selector</code> <span class="tag">Vänta på element</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+ <li><code>timeout</code> – sekunder (default 15)</li>
+ </ul>
+ </div>
+
+ <h3 id="steg-interaktion">Sidinteraktion</h3>
+
+ <div class="step-card">
+ <h3><code>click</code> <span class="tag">Klicka</span></h3>
+ <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li></ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>type</code> <span class="tag">Skriv i textfält</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+ <li><code>text</code> – texten som skrivs (stödjer <a href="#interpolering">{variabel}</a>)</li>
+ </ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>press_key</code> <span class="tag">Tryck tangent</span></h3>
+ <ul class="field-list">
+ <li><code>key</code> – t.ex. <code>Enter</code>, <code>Tab</code>, <code>Escape</code></li>
+ <li><code>value</code> tomt → tangenten trycks mot hela sidan. Annars <code>by/value/role/scope/nth</code> för att trycka på ett specifikt element.</li>
+ </ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>select_option</code> <span class="tag">Välj i dropdown</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+ <li><code>option</code> – synlig text på alternativet</li>
+ </ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>check</code> <span class="tag">Kryssa checkbox/radio</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li>
+ <li><code>checked</code> – <code>true</code> (kryssa i, default) eller <code>false</code> (kryssa ur)</li>
+ </ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>download</code> <span class="tag">Ladda ner</span></h3>
+ <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code></li></ul>
+ <p>Klickar och sparar den nedladdade filen i <code>data/downloads/</code>.
+ Misslyckad nedladdning loggas men avbryter inte flödet.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>save_attachment</code> <span class="tag">Ladda ner bilaga</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code> – pekar ut länken (eller ett element som innehåller en <code><a></code>). I en loop över bilagerader: <code>by=css</code>, <code>scope=item</code>, <code>value=a[href]</code>.</li>
+ <li><code>filename?</code> – filnamn i <code>data/downloads/</code> (tomt = tas från filens URL; stödjer <code>{variabel}</code>). Krock döps om till <code>namn (2).ext</code>.</li>
+ <li><code>var?</code> – lägger den sparade sökvägen i en <strong>listvariabel</strong> (så flera bilagor samlas i en lista)</li>
+ <li><code>timeout?</code> – sekunder (default 15)</li>
+ </ul>
+ <p>Hittar en <code><a></code> och: har den en riktig <code>href</code>
+ (t.ex. PDF-bilagor) hämtas filen direkt via webbläsarens session (behåller
+ inloggningen, ingen ny flik/skrivardialog). Är det en <code>javascript:</code>-länk
+ (t.ex. bildbilagor) klickas den i stället – öppnas då en <strong>popup med en
+ nedladdningslänk</strong> följer steget den och hämtar filen därifrån (popupens
+ URL, en fil-/nedladdningslänk i den, eller ett klick som startar nedladdningen).
+ 0 bilagor → <code>list</code> ger antal 0 och loopen kör inga varv. Misslyckad
+ hämtning loggas men avbryter inte flödet.</p>
+ <p><strong>Olika hantering för PDF vs. bild:</strong> lägg ett <code>if</code>
+ i loopen med villkoret <code>element_exists</code>, <code>by=css</code>,
+ <code>scope=item</code>, <code>value</code> <code>.ids-icon-file-pdf</code>
+ (eller <code>a[href$=".pdf"]</code>) för PDF-grenen och <code>else</code> för
+ bild. Behövs oftast inte – ett enda <code>save_attachment</code> klarar båda –
+ men gör det möjligt att t.ex. bara spara PDF:er, eller döpa dem olika.</p>
+ <pre><code>list value "etj-file-attachment-link", var "bilaga_element"
+loop over "bilaga_element":
+ save_attachment by=css scope=item value="a[href]" var="bilagor"
+save_var var "bilagor", filename "bilagor.json"
+run_script scripts/merge_and_print.py
+ --files-from bilagor.json --out data/downloads/klar.pdf --print</code></pre>
+ </div>
+
+ <div class="step-card">
+ <h3><code>capture_pdf</code> <span class="tag">Fånga PDF (skriv ut-knapp)</span></h3>
+ <ul class="field-list">
+ <li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code> – triggern att klicka (t.ex. "SKRIV UT"). Lämna <code>value</code> tomt för att fånga sidan som redan visas.</li>
+ <li><code>filename?</code> – filnamn i <code>data/downloads/</code> (default <code>utskrift.pdf</code>, stödjer <code>{variabel}</code>)</li>
+ <li><code>var?</code> – variabel att lägga den sparade sökvägen i (praktiskt för ett efterföljande <code>run_script</code>-steg)</li>
+ <li><code>timeout?</code> – sekunder att vänta på ett PDF-nätverkssvar (default 15)</li>
+ </ul>
+ <p>För "Skriv ut"-knappar som öppnar webbläsarens skrivardialog i stället för
+ att ladda ner en fil. Steget neutraliserar <code>window.print</code>, klickar ev.
+ triggern och fångar PDF:en i tur och ordning via: (1) ett PDF-nätverkssvar,
+ (2) en inbäddad PDF i sidan (<code>embed</code>/<code>iframe</code>/<code>object</code>/<code>a</code>
+ med <code>.pdf</code>), (3) Chrome <em>printToPDF</em> av sidan som den ser ut
+ (fungerar även i icke-headless läge). Misslyckad fångst loggas men avbryter
+ inte flödet.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>screenshot</code> <span class="tag">Ta skärmdump</span></h3>
+ <ul class="field-list"><li><code>name?</code> – filnamn utan <code>.png</code> (default <code>screenshot</code>)</li></ul>
+ <p>Sparas i <code>data/screenshots/</code>.</p>
+ </div>
+
+ <h3 id="steg-variabler">Variabler & data</h3>
+
+ <div class="step-card">
+ <h3><code>set_var</code> <span class="tag">Sätt variabel</span></h3>
+ <ul class="field-list"><li><code>var</code>, <code>text</code> (stödjer {variabel})</li></ul>
+ </div>
+
+ <div class="step-card">
+ <h3><code>increment</code> <span class="tag">Öka variabel</span></h3>
+ <ul class="field-list">
+ <li><code>var</code> – variabeln att öka</li>
+ <li><code>by?</code> – steg (default <code>1</code>, negativt värde minskar)</li>
+ </ul>
+ <p>Saknad eller icke-numerisk variabel behandlas som <code>0</code>. Lägg ett
+ sådant steg sist i en <code>loop</code> så blir <code>{var}</code> 1, 2, 3 …
+ och kan användas för att särskilja filnamn
+ (<code>capture_pdf</code> <code>filename</code> stödjer <code>{variabel}</code>).
+ Behöver du bara varvnumret räcker loopens inbyggda <code>index_var</code>
+ (default <code>{index}</code>).</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>clear_var</code> <span class="tag">Nollställ variabel</span></h3>
+ <ul class="field-list"><li><code>var</code> – variabeln att ta bort</li></ul>
+ <p>Nästa <code>list_append</code> / <code>save_attachment</code> / <code>extract_row</code>
+ skapar den på nytt som tom lista. Lägg först i en <code>loop</code> för att
+ nollställa en samlar-lista per varv (t.ex. bilagor per ärende).</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>extract_text</code> <span class="tag">Läs text till variabel</span></h3>
+ <ul class="field-list"><li><code>by</code>, <code>value</code>, <code>role?</code>, <code>scope?</code>, <code>nth?</code>, <code>var</code></li></ul>
+ <p>Läser <em>ett</em> elements text till en variabel (skalärt textvärde).</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>extract_row</code> <span class="tag">Extrahera rad (flera kolumner)</span></h3>
+ <ul class="field-list">
+ <li><code>var</code> – listvariabel att lägga raden i</li>
+ <li><code>scope?</code> – normalt <code>item</code> (körs inuti en loop)</li>
+ <li><code>fields</code> – lista av <code>{name, by, value, role?}</code>, ett per kolumn</li>
+ </ul>
+ <p>
+ Läser <strong>flera namngivna fält</strong> (celler) från aktuell post i
+ ett svep och lägger dem som <strong>en</strong> rad-post (dict) i
+ listvariabeln – motsvarar Power Automates "Extract data from table". Ger
+ strukturerade kolumner istället för en textklump, vilket gör att
+ <code>save_var</code> skriver riktiga kolumnrubriker i CSV (fältnamnen)
+ och att en enskild kolumn kan refereras senare med
+ <code>{item.Kolumnnamn}</code> (se <a href="#interpolering">interpolering</a>).
+ </p>
+ <pre><code>{
+ "type": "extract_row",
+ "var": "Tabell_lista",
+ "scope": "item",
+ "fields": [
+ { "name": "Uppdaterat", "by": "css", "value": "td:nth-child(1)" },
+ { "name": "Ärendetyp", "by": "css", "value": "td:nth-child(2)" },
+ { "name": "Status", "by": "css", "value": "td:nth-child(6)" }
+ ]
+}</code></pre>
+ </div>
+
+ <div class="step-card">
+ <h3><code>list_append</code> <span class="tag">Lägg till i lista</span></h3>
+ <ul class="field-list">
+ <li><code>var</code> – listvariabel (skapas automatiskt)</li>
+ <li>Antingen <code>by/value/role/scope/nth</code> (läser elementets text) <em>eller</em> <code>text</code> (literalt värde, stödjer {variabel}) om <code>value</code> lämnas tomt</li>
+ </ul>
+ <p>Lägger till <strong>ett</strong> textvärde per anrop – jämför med
+ <code>extract_row</code> som lägger till en hel namngiven rad i ett
+ anrop.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>save_var</code> <span class="tag">Spara variabel (JSON/CSV)</span></h3>
+ <ul class="field-list">
+ <li><code>var</code> – variabeln att spara</li>
+ <li><code>filename</code> – i <code>data/exports/</code>, inga sökvägar tillåtna</li>
+ <li><code>format?</code> – <code>json</code>/<code>csv</code>, gissas från filändelsen om tomt</li>
+ <li><code>mode?</code> – <code>overwrite</code> (default) eller <code>append</code></li>
+ </ul>
+ <p>
+ CSV: en lista av dicts (t.ex. från <code>extract_row</code>) ger riktiga
+ kolumnrubriker från dict-nycklarna. En lista av vanliga textvärden
+ (t.ex. från <code>list_append</code> med literalt värde) får en enda
+ kolumn med rubriken <code>value</code> – det är <em>inte</em> en bugg,
+ CSV kräver en kolumnrubrik och "value" är namnet på den enda kolumnen
+ när posterna saknar egna fältnamn. Använd <code>extract_row</code> om du
+ vill ha meningsfulla kolumnnamn istället.
+ </p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>load_var</code> <span class="tag">Läs in variabel (JSON/CSV)</span></h3>
+ <ul class="field-list"><li><code>var</code>, <code>filename</code> (samma regler som <code>save_var</code>)</li></ul>
+ <p>CSV med en enda kolumn "value" läses tillbaka som en lista av textvärden;
+ CSV med flera kolumner läses tillbaka som en lista av dicts (samma
+ struktur som <code>extract_row</code> producerar) – round-trip fungerar
+ alltså sömlöst i båda riktningarna.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>run_script</code> <span class="tag">Kör Python-script</span></h3>
+ <ul class="field-list">
+ <li><code>path</code> – relativ mot projektroten, eller absolut</li>
+ <li><code>args?</code> – mellanslagsseparerade argument</li>
+ <li><code>timeout?</code> – sekunder (default 60)</li>
+ <li><code>var?</code> – sparar scriptets stdout (JSON-tolkat om möjligt, annars text)</li>
+ </ul>
+ <p>Loggar stdout/stderr rad för rad. Flödet stoppas om scriptet avslutar
+ med en felkod.</p>
+ <div class="warn">Kör med samma rättigheter som webui-processen – bara för
+ egna, betrodda script.</div>
+ </div>
+
+ <div class="step-card">
+ <h3><code>list</code> <span class="tag">Lista poster</span></h3>
+ <ul class="field-list">
+ <li><code>value</code> – CSS-selector som matchar flera element</li>
+ <li><code>var</code> – variabeln att spara resultatet i</li>
+ </ul>
+ <p>Sparar <code>{selector, count, preview}</code> i variabeln –
+ <code>preview</code> är textinnehållet i de tre första träffarna, bara
+ för att kunna sanity-checka selectorn i <a href="#felsokning">variabelpanelen</a>
+ innan man bygger en hel loop kring den. <code>Loop</code> bryr sig bara
+ om <code>selector</code>/<code>count</code>.</p>
+ </div>
+
+ <h3 id="steg-kontrollflode">Kontrollflöde</h3>
+
+ <div class="step-card">
+ <h3><code>loop</code> <span class="tag">Loop (för varje post)</span></h3>
+ <ul class="field-list">
+ <li><code>over</code> – variabelnamn att loopa över</li>
+ <li><code>item_var?</code> – variabelnamn för aktuellt värde vid loop över en lista (default <code>item</code>)</li>
+ <li><code>index_var?</code> – variabelnamn för varvräknaren <code>1, 2, 3 …</code> (default <code>index</code>); sätts oavsett looptyp och kan användas som <code>{index}</code> i t.ex. filnamn</li>
+ <li><code>steps</code> – nästlade steg som körs en gång per post</li>
+ </ul>
+ <p>Se <a href="#loop">Loop: DOM vs. lista</a> för de två lägena.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>if</code> <span class="tag">Om / annars om / annars</span></h3>
+ <ul class="field-list">
+ <li><code>branches</code> – lista av <code>{condition, steps}</code>, i ordning (första sanna vinner)</li>
+ <li><code>else?</code> – körs om ingen gren matchade</li>
+ </ul>
+ <p>Se <a href="#villkor">Villkor</a>.</p>
+ </div>
+
+ <div class="step-card">
+ <h3><code>jump_to_label</code> <span class="tag">Hoppa till etikett</span></h3>
+ <ul class="field-list"><li><code>target</code> – etikettnamnet att hoppa till</li></ul>
+ <p>Se <a href="#etiketter">Etiketter & hopp</a>.</p>
+ </div>
+
+ <div class="step-card">
+ <h3>Universella fält (alla steg)</h3>
+ <ul class="field-list">
+ <li><code>enabled</code> – <code>false</code> hoppar över steget (visas som en kryssruta i UI:t). En avstängd <code>loop</code>/<code>if</code> hoppar över hela blocket.</li>
+ <li><code>label</code> – valfritt namn på steget, används som mål för <code>jump_to_label</code>.</li>
+ <li><code>on_error</code> – <code>"stop"</code> (default) avbryter körningen om steget misslyckas/får timeout; <code>"continue"</code> loggar felet och går vidare (steget blir "hit or miss"). Väljs i listan bredvid etikettfältet. På en <code>loop</code>/<code>if</code> gäller det hela blocket.</li>
+ </ul>
+ </div>
+ </section>
+
+ <section id="villkor">
+ <h2>Villkor (If)</h2>
+ <p><code>if</code>-steget utvärderar <code>branches</code> i ordning – första
+ grenen vars <code>condition</code> är sann kör sina <code>steps</code> och
+ resten hoppas över. Ingen gren sann → <code>else</code> körs om den finns,
+ annars fortsätter flödet till nästa steg.</p>
+ <table>
+ <tr><th>kind</th><th>Fält</th><th>Betyder</th></tr>
+ <tr><td><code>element_exists</code></td><td><code>by, value, role?, scope?, nth?</code></td><td>Sant om selectorn matchar minst ett element</td></tr>
+ <tr><td><code>element_not_exists</code></td><td>samma som ovan</td><td>Sant om selectorn INTE matchar något</td></tr>
+ <tr><td><code>var_equals</code></td><td><code>var, text</code></td><td>Sant om variabelns textvärde är exakt <code>text</code></td></tr>
+ <tr><td><code>var_not_equals</code></td><td><code>var, text</code></td><td>Sant om variabelns textvärde INTE är <code>text</code></td></tr>
+ <tr><td><code>var_contains</code></td><td><code>var, text</code></td><td>Sant om <code>text</code> finns som delsträng i variabeln</td></tr>
+ </table>
+ <div class="hint">
+ Playwrights CSS-utökning <code>:text-is("...")</code> (exakt, skiftlägeskänslig
+ matchning) är ofta bättre än <code>by=text</code> (skiftlägesokänslig
+ delsträng) när du vill särskilja t.ex. en cell som är exakt
+ <code>"Tilldelat"</code> från en som innehåller <code>"Automatiskt tilldelat"</code>.
+ </div>
+ <pre><code>{
+ "type": "if",
+ "branches": [
+ {
+ "condition": {
+ "kind": "element_not_exists",
+ "by": "css", "scope": "item",
+ "value": ":text-is(\"Tilldelat\")"
+ },
+ "steps": [ /* körs bara för rader som INTE redan är Tilldelat */ ]
+ }
+ ]
+}</code></pre>
+ </section>
+
+ <section id="interpolering">
+ <h2>Variabler i text (<code>{variabelnamn}</code>)</h2>
+ <p>Följande fält stödjer <code>{variabelnamn}</code>, ersatt med variabelns
+ textvärde vid körning:</p>
+ <ul>
+ <li><code>by=text</code>/<code>by=role</code> sökvärden (i alla steg som har dem)</li>
+ <li><code>type</code>-stegets <code>text</code></li>
+ <li><code>set_var</code>-stegets <code>text</code></li>
+ <li><code>list_append</code>-stegets literala <code>text</code> (när <code>value</code> är tomt)</li>
+ <li><code>capture_pdf</code>-stegets <code>filename</code> (undantaget – övriga stegs filnamn interpoleras inte)</li>
+ </ul>
+ <p><strong>Fungerar INTE</strong> i CSS-selectors (<code>by=css</code>),
+ URL:er eller filnamn (utom <code>capture_pdf</code>, se ovan) – där tas
+ <code>{...}</code> bokstavligt.</p>
+ <p>Punkt-notation: <code>{variabel.fält}</code> läser ett namngivet fält ur en
+ dict-variabel (t.ex. en rad från <code>extract_row</code> eller en inläst
+ flerkolumns-CSV). Okänt variabel- eller fältnamn lämnas oersatt i texten
+ (bra för att upptäcka stavfel – flödet kraschar inte, men texten blir
+ synligt fel i loggen).</p>
+ <pre><code>// item = {"Ärendetyp": "Egenremiss", "Status": "Tilldelat"}
+"{item.Ärendetyp}" → "Egenremiss"
+"{item}" → '{"Ärendetyp": "Egenremiss", "Status": "Tilldelat"}' (hela dicten som JSON)
+"{okänd_variabel}" → "{okänd_variabel}" (oersatt, ingen krasch)</code></pre>
+ </section>
+
+ <section id="loop">
+ <h2>Loop: DOM vs. lista</h2>
+ <p><code>loop</code> stödjer två helt olika typer av <code>over</code>-variabel:</p>
+ <table>
+ <tr><th></th><th>DOM-loop (från "Lista poster")</th><th>Listloop (vanlig lista)</th></tr>
+ <tr>
+ <td>Variabelns form</td>
+ <td><code>{selector, count, preview}</code></td>
+ <td>En vanlig JSON-array, t.ex. från <code>list_append</code> eller <code>load_var</code></td>
+ </tr>
+ <tr>
+ <td>Vad varje varv ger</td>
+ <td>Ett riktigt DOM-element (aktuell rad) – <code>scope=item</code> i inre steg söker inom det</td>
+ <td>Inget DOM-element; aktuellt <em>värde</em> läggs i variabeln <code>item</code> (eller <code>item_var</code>)</td>
+ </tr>
+ <tr>
+ <td>Hitta rätt element på sidan</td>
+ <td>Automatiskt (<code>scope=item</code>)</td>
+ <td>Måste sökas upp manuellt, t.ex. med <code>by=text, value="{item.Namn}"</code></td>
+ </tr>
+ </table>
+ <div class="warn">
+ Innan detta stöd fanns kunde <code>loop</code> bara hantera "Lista
+ poster"-resultat – att sätta <code>over</code> till en vanlig listvariabel
+ gav <code>TypeError: list indices must be integers or slices, not str</code>.
+ Det är åtgärdat; loop-steget avgör nu automatiskt vilken typ variabeln har.
+ </div>
+
+ <h3>Klicka på ett element i (eller på) aktuell loop-rad</h3>
+ <p>I en DOM-loop söker <code>scope=item</code> <em>inom</em> aktuell rad. Oftast
+ vill du klicka ett <strong>barn-element</strong> – en länk, en cell, en knapp –
+ inte hela raden (rader har sällan någon klick-hanterare):</p>
+ <pre><code>list value "table.ids-data-table tbody tr", var "rader"
+loop over "rader":
+ click by=css scope=item value="a" (länken i raden, t.ex. filnamnet)
+ click by=css scope=item value="td:nth-child(5)" (en viss cell)</code></pre>
+ <p>Vill du träffa <strong>raden/elementet självt</strong> (t.ex. spara den i en
+ lista, eller om hela raden faktiskt är en länk) använd
+ <code>value=":scope"</code> – CSS-pseudoklassen för "det element sökningen
+ utgår från":</p>
+ <pre><code> list_append by=css scope=item value=":scope" var "traffar"</code></pre>
+ <p>Loggen visar vad som faktiskt träffades, t.ex.
+ <code>Klickar: css='a' → <a.ids-link href=/case/123 "Bilresa - aktivitetshäfte.pdf"></code>
+ – jämför det mot vad du menade att klicka.</p>
+ </section>
+
+ <section id="recept">
+ <h2>Recept (vanliga arbetsflöden)</h2>
+
+ <h3>Ladda ner alla bilagor från ett ärende</h3>
+ <p>Bilagorna ligger på ärendets detaljsida. Antalet varierar (0, 1 eller
+ flera). PDF-bilagor har en riktig länk; bildbilagor öppnar en popup med en
+ nedladdningslänk – <code>save_attachment</code> hanterar båda.</p>
+ <pre><code>click (öppna ärendet, t.ex. td:nth-child(5))
+wait 1–2 s
+clear_var var "bilagor" (nollställ per ärende)
+list value "etj-file-attachment-link", var "bilaga_element"
+loop over "bilaga_element":
+ save_attachment by=css scope=item value="a[href]" var="bilagor"
+go_back (tillbaka till listan inför nästa varv)</code></pre>
+ <p>Filerna hamnar i <code>data/downloads/</code> med sina riktiga namn;
+ namnkrock döps om till <code>namn (2).ext</code>. <code>bilagor</code> blir
+ en lista med sökvägarna.</p>
+
+ <h3>Bara PDF:er, eller olika hantering för PDF vs. bild</h3>
+ <pre><code>loop over "bilaga_element":
+ if element_exists by=css scope=item value=".ids-icon-file-pdf":
+ save_attachment by=css scope=item value="a[href]" var="pdf_lista"
+ else:
+ save_attachment by=css scope=item value="a[href]" var="bild_lista"</code></pre>
+ <p><code>.ids-icon-file-pdf</code> / <code>.ids-icon-file-image</code> är
+ ikonklasserna i raden. Utelämna <code>else</code>-grenen för att hoppa över
+ bilder helt.</p>
+
+ <h3>Slå ihop bilagor till en PDF och skriv ut</h3>
+ <p>Hjälpskriptet <code>scripts/merge_and_print.py</code> (kräver
+ <code>pypdf</code> + <code>Pillow</code>) tar en ordnad blandning av PDF:er
+ och bilder: bilder blir centrerade A4-sidor, PDF:er läggs in sida för sida.</p>
+ <pre><code>save_var var "bilagor", filename "bilagor.json"
+run_script path "scripts/merge_and_print.py"
+ args "--files-from bilagor.json --out data/downloads/arende_{index}.pdf --print"</code></pre>
+ <table>
+ <tr><th>Argument</th><th>Betydelse</th></tr>
+ <tr><td><code>--files-from <fil></code></td><td>JSON-array med sökvägar (fil i <code>data/exports/</code> eller en sökväg) – t.ex. det <code>save_var</code> skrev</td></tr>
+ <tr><td><code>--file <sökväg></code></td><td>En fil till (upprepa för fler; ordningen bevaras)</td></tr>
+ <tr><td><code>--pdf-latest</code></td><td>Nyaste <code>*.pdf</code> i <code>data/downloads/</code> först</td></tr>
+ <tr><td><code>--out <sökväg></code></td><td>Utfil (default <code>data/downloads/merged.pdf</code>)</td></tr>
+ <tr><td><code>--print</code></td><td>Skicka utfilen till skrivare</td></tr>
+ <tr><td><code>--printer "Namn"</code></td><td>Specifik skrivare (default: Windows standardskrivare)</td></tr>
+ </table>
+ <p>Skriptet skriver en rad JSON till stdout
+ (<code>{"out": ..., "pages": 3, "printed": true, "skipped": []}</code>) – sätt
+ <code>run_script</code>-stegets <code>var</code> för att fånga den.</p>
+
+ <h3>Numrera nedladdade filer i en loop</h3>
+ <ul>
+ <li>Löpande varvnummer räcker: använd <code>{index}</code> direkt i
+ <code>filename</code> (loopen sätter det automatiskt).</li>
+ <li>Tät numrering av bara de faktiskt sparade filerna: lägg ett
+ <code>increment</code>-steg (<code>var "n"</code>) precis före
+ nedladdningen och använd <code>{n}</code> i <code>filename</code>. Nollställ
+ med <code>clear_var</code> om räknaren ska börja om per ärende.</li>
+ </ul>
+
+ <h3>Göra ett steg "hit or miss"</h3>
+ <p>Sätt stegets <code>on_error</code> till <code>"continue"</code> (listan
+ bredvid etikettfältet) så loggas ett fel/timeout och nästa steg körs ändå –
+ bra för ett <code>wait_for_selector</code> eller <code>click</code> som bara
+ ibland är relevant. På en <code>loop</code>/<code>if</code> gäller det hela
+ blocket.</p>
+ </section>
+
+ <section id="etiketter">
+ <h2>Etiketter & hopp</h2>
+ <p>Vilket steg som helst kan få ett <code>label</code> (syns som ett textfält
+ bredvid typ-väljaren i UI:t). Ett <code>jump_to_label</code>-steg med
+ matchande <code>target</code> hoppar dit, framåt eller bakåt.</p>
+ <ul>
+ <li>Fungerar bara mellan <strong>syskon</strong> på samma nivå – ett hopp
+ kan inte gå in i eller ut ur en <code>loop</code>/<code>if</code>-gren.</li>
+ <li>Ett skydd stoppar körningen efter max 1000 hopp i en och samma körning
+ (troligen en oändlig hopp-loop) – <code>FlowError</code> istället för att
+ hänga sig.</li>
+ </ul>
+ </section>
+
+ <section id="felsokning">
+ <h2>Felsökning</h2>
+ <table>
+ <tr><th>Verktyg</th><th>Vad det gör</th></tr>
+ <tr><td>Variabelpanel (webui)</td><td>Klicka på ett variabelnamn efter en körning för att se dess faktiska innehåll (typ + JSON/text)</td></tr>
+ <tr><td>Trace (<code>python -m rpa.show_trace</code>)</td><td>Öppnar Playwrights Trace Viewer – skärmdumpar, DOM, nätverk per steg, sparas automatiskt varje körning i <code>data/traces/</code></td></tr>
+ <tr><td><code>python -m rpa.codegen <url></code></td><td>Öppnar en inspector mot en riktig, inloggad sida (samma profil) – hovra/klicka för att få exakta selectors, istället för att gissa</td></tr>
+ <tr><td>Håll webbläsaren öppen</td><td>Vid "Kör flöde", välj "Håll öppen vid fel"/"Håll alltid öppen" i dropdownen för att kunna inspektera sidan manuellt efteråt</td></tr>
+ <tr><td>Körloggen</td><td>Visar varje stegs faktiska (interpolerade) selector/text-värde, inte den oersatta mallen. För <code>click</code>/<code>type</code>/<code>check</code>/<code>select_option</code>/<code>download</code>/<code>save_attachment</code> visas dessutom vilket element som träffades: <code>→ <tagg.klass href=… "text"></code></td></tr>
+ </table>
+ </section>
+
+ <section id="datamappar">
+ <h2>Datamappar</h2>
+ <p>Allt under <code>data/</code> är gitignorat – kan innehålla
+ inloggningssession och personlig information.</p>
+ <table>
+ <tr><th>Mapp</th><th>Innehåll</th></tr>
+ <tr><td><code>data/flows/</code></td><td>Sparade flöden (JSON)</td></tr>
+ <tr><td><code>data/browser_profile/</code></td><td>Persistent Chromium-profil (inloggningssession)</td></tr>
+ <tr><td><code>data/downloads/</code></td><td>Nedladdade filer ("Ladda ner"-steget)</td></tr>
+ <tr><td><code>data/exports/</code></td><td>JSON/CSV från "Spara variabel"/"Läs in variabel"</td></tr>
+ <tr><td><code>data/screenshots/</code></td><td>Från "Ta skärmdump"</td></tr>
+ <tr><td><code>data/traces/</code></td><td>Playwright-traces, en per körning</td></tr>
+ <tr><td><code>data/discover/</code></td><td>HTML/skärmdumpar från <code>rpa.discover</code></td></tr>
+ </table>
+ </section>
+
+ <section id="sprak">
+ <h2>Språk (i18n)</h2>
+ <p>Webb-UI:t har engelska som standardspråk med svenska som växlingsbar
+ översättning (språkväljare i headern, sparas i webbläsarens
+ <code>localStorage</code>). Bara själva gränssnittet (steg-namn,
+ fältetiketter, knappar) är översatt – körloggens meddelanden kommer
+ alltid på svenska direkt från Python-backend, oavsett valt UI-språk.</p>
+ </section>
+
+ <section id="installningar">
+ <h2>Inställningar & export</h2>
+ <p>"⚙ Settings" i headern öppnar en dialog med körningsinställningar,
+ sparade som ett <code>settings</code>-objekt tillsammans med flödets
+ <code>steps</code> i samma JSON-fil (<code>data/flows/<namn>.json</code>):</p>
+ <table>
+ <tr><th>Nyckel</th><th>Standard</th><th>Betyder</th></tr>
+ <tr><td><code>headless</code></td><td><code>false</code></td><td>Kör utan synligt fönster. Kräver en redan giltig inloggningssession.</td></tr>
+ <tr><td><code>viewport_width</code> / <code>viewport_height</code></td><td>1280 / 900</td><td>Webbläsarens fönsterstorlek</td></tr>
+ <tr><td><code>locale</code></td><td><code>"sv-SE"</code></td><td>Webbläsarens språk/region</td></tr>
+ <tr><td><code>slow_mo</code></td><td>0</td><td>Millisekunder paus mellan varje Playwright-åtgärd, för felsökning</td></tr>
+ <tr><td><code>notify_on_complete</code></td><td><code>false</code></td><td>Skrivbordsnotis när körningen är klar</td></tr>
+ </table>
+ <p>"Export as script" laddar ner en fristående <code>.py</code>-fil som
+ kör exakt samma flöde (<code>STEPS</code> + <code>SETTINGS</code>
+ inbäddade som Python-literaler) genom att importera <code>rpa</code>
+ och anropa samma <code>run_flow()</code>-motor som webui:t – ingen
+ logik dupliceras, beteendet blir identiskt. Praktiskt för
+ schemaläggning (Task Scheduler) av ett flöde byggt i UI:t.</p>
+ <pre><code>python mitt_flode.py
+python mitt_flode.py --headless # tvinga headless oavsett sparad inställning
+python mitt_flode.py --close-mode never # håll webbläsaren öppen efteråt</code></pre>
+ <div class="warn">Scriptet kräver att <code>rpa</code>-paketet går att
+ importera – kör det från projektroten, eller kopiera med hela
+ <code>rpa/</code>-mappen.</div>
+ </section>
+
+ <section id="git">
+ <h2>Version & git</h2>
+ <p>Projektet är kopplat till en git-remote
+ (<code>git.kristoffersson.info/repos/RPA-Remisser.git</code>). Push kräver
+ en personlig token (finns inte sparad i klartext någonstans i repot –
+ måste anges vid varje push-tillfälle i den här miljön eftersom
+ credential-cachning inte fungerar i sandboxen).</p>
+ </section>
+
+ <footer>
+ Genererad som en statisk referenssida i <code>docs/wiki.html</code> – öppna
+ lokalt i valfri webbläsare. Håll den uppdaterad manuellt när nya stegtyper
+ läggs till (källan att stämma av mot är modul-docstringen i
+ <code>rpa/flow_engine.py</code>).
+ </footer>
+</main>
+</div>
+</body>
+</html>
diff --git a/requirements.txt b/requirements.txt
index 48ba2f8..61b7d29 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,5 @@ playwright>=1.45
plyer>=2.1
fastapi>=0.111
uvicorn>=0.30
+pypdf>=4.0
+Pillow>=10.0
diff --git a/rpa/browser.py b/rpa/browser.py
index 830ece2..dca1649 100644
--- a/rpa/browser.py
+++ b/rpa/browser.py
@@ -2,20 +2,42 @@ from playwright.sync_api import sync_playwright, BrowserContext
from . import config
+DEFAULT_SETTINGS = {
+ "headless": False,
+ "viewport_width": 1280,
+ "viewport_height": 900,
+ "locale": "sv-SE",
+ "slow_mo": 0,
+ "notify_on_complete": False,
+}
-def launch_context(playwright) -> BrowserContext:
- """Startar en synlig, persistent webbläsarprofil.
+
+def launch_context(playwright, settings: dict | None = None) -> BrowserContext:
+ """Startar en persistent webbläsarprofil.
Persistent context = cookies/session sparas mellan körningar i
config.BROWSER_PROFILE_DIR. Är sessionen fortfarande giltig nästa
- gång skriptet körs behöver du inte godkänna BankID igen.
+ gång skriptet körs behöver du inte logga in igen.
+
+ settings (alla valfria, se DEFAULT_SETTINGS för standardvärden):
+ headless -- kör utan synligt fönster (kräver redan giltig
+ inloggningssession - BankID/2FA går inte att
+ godkänna i ett headless-fönster)
+ viewport_width/height
+ locale -- t.ex. "sv-SE"
+ slow_mo -- millisekunder paus mellan varje Playwright-
+ åtgärd, för att kunna följa med ögat vid felsökning
+ notify_on_complete -- skrivbordsnotis när körningen är klar (hanteras
+ av flow_engine, inte här)
"""
+ s = {**DEFAULT_SETTINGS, **(settings or {})}
context = playwright.chromium.launch_persistent_context(
user_data_dir=str(config.BROWSER_PROFILE_DIR),
- headless=False,
+ headless=bool(s["headless"]),
accept_downloads=True,
- viewport={"width": 1280, "height": 900},
- locale="sv-SE",
+ viewport={"width": int(s["viewport_width"]), "height": int(s["viewport_height"])},
+ locale=s["locale"],
+ slow_mo=float(s["slow_mo"]),
)
return context
diff --git a/rpa/export.py b/rpa/export.py
new file mode 100644
index 0000000..a2abd05
--- /dev/null
+++ b/rpa/export.py
@@ -0,0 +1,73 @@
+"""Genererar ett fristående Python-script från ett sparat flöde.
+
+Det exporterade scriptet importerar rpa-paketet och anropar samma
+run_flow()-motor som webui:t använder - ingen logik dupliceras, så
+beteendet blir identiskt med att köra flödet via "Kör flöde".
+"""
+
+from pprint import pformat
+
+SCRIPT_TEMPLATE = '''#!/usr/bin/env python
+"""Fristående script, exporterat från flödet "{name}" (RPA-verktyg).
+
+Kör med:
+ python {filename}.py
+ python {filename}.py --headless
+ python {filename}.py --close-mode never
+
+Kräver att rpa-paketet finns tillgängligt (kör från projektroten där
+rpa/ och data/ ligger, eller kopiera med hela rpa/-mappen och låt en
+data/-mapp skapas bredvid scriptet vid första körningen).
+"""
+
+import argparse
+
+from rpa import config
+from rpa.flow_engine import run_flow
+
+STEPS = {steps_json}
+
+SETTINGS = {settings_json}
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Kör flödet: {name}")
+ parser.add_argument(
+ "--headless", action="store_true",
+ help="Kör utan synligt webbläsarfönster (kräver en redan giltig inloggningssession)",
+ )
+ parser.add_argument(
+ "--close-mode", choices=["always", "on_success", "never"], default="always",
+ help="Vad som ska hända med webbläsaren när flödet är klart",
+ )
+ args = parser.parse_args()
+
+ settings = dict(SETTINGS)
+ if args.headless:
+ settings["headless"] = True
+
+ run_flow(
+ STEPS,
+ log=print,
+ download_dir=config.DOWNLOAD_DIR,
+ close_mode=args.close_mode,
+ settings=settings,
+ )
+
+
+if __name__ == "__main__":
+ main()
+'''
+
+
+def generate_standalone_script(name: str, flow: dict) -> str:
+ # pformat (inte json.dumps) - genererad kod är Python, inte JSON: JSON:s
+ # true/false/null är inte giltig Python-syntax (True/False/None krävs).
+ steps_src = pformat(flow.get("steps", []), indent=2, width=100, sort_dicts=False)
+ settings_src = pformat(flow.get("settings", {}), indent=2, width=100, sort_dicts=False)
+ return SCRIPT_TEMPLATE.format(
+ name=name,
+ filename=name,
+ steps_json=steps_src,
+ settings_json=settings_src,
+ )
diff --git a/rpa/flow_engine.py b/rpa/flow_engine.py
index 1d7aa5a..d37d9db 100644
--- a/rpa/flow_engine.py
+++ b/rpa/flow_engine.py
@@ -4,6 +4,9 @@ Stegtyper:
goto {url}
wait {seconds}
wait_for_load {}
+ go_back {} -- webbläsarens "bakåt" (historik), t.ex.
+ efter att ha klickat in på en post från
+ en lista, för att fortsätta en loop
wait_for_login {timeout}
wait_for_selector {by, value, role?, scope?, nth?, timeout}
click {by, value, role?, scope?, nth?}
@@ -12,7 +15,25 @@ Stegtyper:
select_option {by, value, role?, scope?, nth?, option} -- väljer alternativ i <select>
check {by, value, role?, scope?, nth?, checked} -- kryssar i/ur checkbox/radio
extract_text {by, value, role?, scope?, nth?, var} -- läser textinnehåll till variabel
+ extract_row {var, scope?, fields: [{name, by, value, role?}, ...]}
+ -- läser FLERA namngivna fält (celler) från
+ -- aktuell post till EN rad-post (dict) och
+ -- lägger den i listvariabeln var - ger
+ -- strukturerade kolumner (som i Power
+ -- Automates "Extract data from table")
+ -- istället för en textklump. save_var
+ -- (CSV) skriver då riktiga kolumnrubriker
+ -- från fältnamnen; load_var läser tillbaka
+ -- samma struktur.
set_var {var, text} -- sätter variabel till ett literalt värde
+ clear_var {var} -- tar bort en variabel (nästa list_append/
+ -- save_attachment/extract_row skapar den på
+ -- nytt som tom lista). Lägg först i en loop
+ -- för att nollställa en samlar-lista per varv.
+ increment {var, by?} -- ökar en heltalsvariabel med "by" (default 1,
+ -- negativt värde minskar); saknad/icke-numerisk
+ -- variabel behandlas som 0. Praktiskt i en loop
+ -- för att särskilja filer: {var} blir 1, 2, 3 ...
list_append {var, by?, value?, role?, scope?, nth?, text?}
-- lägger till i en listvariabel (skapas
-- vid behov); via selector (som extract_text)
@@ -30,8 +51,43 @@ Stegtyper:
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
+ loop {over, steps: [...], item_var?, index_var?}
+ -- itererar över "list"-resultat (DOM-element,
+ scope=item fungerar som vanligt) ELLER över en
+ vanlig listvariabel (t.ex. från "Lägg till i
+ lista"/"Läs in variabel") - då finns varje varvs
+ värde i variabeln item_var (default "item"), som
+ kan användas som {item} i by=text/role-fält
+ (se interpolering nedan). Varvnumret (1, 2, 3 ...)
+ läggs i index_var (default "index") oavsett
+ looptyp - {index} kan användas i t.ex. filnamn.
download {by, value, role?, scope?, nth?}
+ save_attachment {by, value, role?, scope?, nth?, filename?, var?, timeout?}
+ -- laddar ner en bilagelänk och sparar filen i
+ -- data/downloads/. Hittar en <a> (elementet självt
+ -- eller en inuti det) och: har den en riktig href
+ -- hämtas filen direkt via webbläsarens session
+ -- (behåller inloggning); är det en javascript:-länk
+ -- klickas den och en nedladdning fångas - eller,
+ -- om en popup öppnas (t.ex. bildbilagor), hämtas
+ -- filen ur popupen (dess URL, en fil-/nedladdnings-
+ -- länk i den, eller ett klick som startar nedladdn.).
+ -- filename (stödjer {variabel}) annars filnamnet
+ -- från URL:en; krockar döps om till "namn (2).ext".
+ -- var: lägger den sparade sökvägen i en LISTA
+ -- (bra att loopa bilagor och sen skicka listan till
+ -- ett merge/skriv ut-script).
+ capture_pdf {by?, value?, role?, scope?, nth?, filename?, var?, timeout?}
+ -- för "Skriv ut"-knappar som öppnar webbläsarens
+ -- skrivardialog i stället för att ladda ner en fil.
+ -- Neutraliserar window.print, klickar ev. triggern
+ -- (by/value...) och fångar PDF:en på tre sätt i tur
+ -- och ordning: (1) ett PDF-nätverkssvar, (2) en
+ -- inbäddad PDF i sidan (embed/iframe/object/a med
+ -- .pdf), (3) Chrome "printToPDF" av sidan som den
+ -- ser ut. Sparar i data/downloads/ (filename,
+ -- default "utskrift.pdf", stödjer {variabel}) och
+ -- lägger ev. sökvägen i var.
if {branches: [{condition, steps: [...]}, ...], else?: [...]}
-- första grenen vars condition är sann körs
-- (branches[0] = "if", övriga = "else if");
@@ -48,20 +104,34 @@ condition (för "if"-grenar): {kind, ...}
by: "text" | "css" | "role"
scope: "page" (default) | "item" -- "item" begränsar sökningen till aktuell loop-post
+interpolering: text-fält (by=text/role-värden, "type"/"set_var"/list_append-litteralens
+ "text") kan innehålla {variabelnamn} eller {variabelnamn.fält} (för att läsa ett
+ namngivet fält ur en dict-variabel, t.ex. en rad från "Extrahera rad"), vilket
+ ersätts med textvärdet vid körning. Fungerar INTE i CSS-selectors (by=css),
+ URL:er eller filnamn - utom capture_pdf/save_attachment-stegens "filename".
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).
+on_error: valfri nyckel på vilket steg som helst - "stop" (default) avbryter
+ körningen om steget kastar fel/timeout; "continue" loggar felet och
+ går vidare till nästa steg (gör steget "hit or miss"). Sätts det på
+ en "loop"/"if" gäller det hela blocket - ett ofångat fel i ett inre
+ steg låter blocket avbrytas men körningen fortsätter efter det.
"""
+import base64
import csv
import json
import re
import shlex
import subprocess
import sys
+import tempfile
+import unicodedata
from pathlib import Path
from typing import Callable
+from urllib.parse import unquote, urljoin, urlsplit
from playwright.sync_api import Page, sync_playwright
@@ -76,7 +146,122 @@ class FlowError(Exception):
pass
-def _resolve_locator(page: Page, item_locator, step: dict):
+_BAD_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+
+def _safe_filename(name: str, fallback: str = "fil") -> str:
+ """Rensar bort sökvägsdelar och otillåtna tecken ur ett filnamn."""
+ name = unicodedata.normalize("NFC", name or "")
+ name = _BAD_FILENAME_CHARS.sub("_", name.strip()).strip(". ")
+ return name or fallback
+
+
+def _unique_path(directory: Path, filename: str) -> Path:
+ """Ger en sökväg som inte redan finns: "namn.ext", "namn (2).ext", ..."""
+ target = directory / filename
+ if not target.exists():
+ return target
+ stem, suffix = target.stem, target.suffix
+ n = 2
+ while (directory / f"{stem} ({n}){suffix}").exists():
+ n += 1
+ return directory / f"{stem} ({n}){suffix}"
+
+
+_FILE_LINK_SELECTOR = (
+ 'a[download], a[href*="/fileservice/"], a[href*="/file/"], '
+ 'a[href$=".pdf"], a[href$=".jpg"], a[href$=".jpeg"], a[href$=".png"], '
+ 'a[href$=".gif"], a[href$=".tif"], a[href$=".tiff"], a[href$=".doc"], '
+ 'a[href$=".docx"], img[src]:not([src^="data:"])'
+)
+
+
+def _grab_file_from_popup(popup, context, timeout_ms: float, log: LogFn):
+ """Hämtar filen ur en popup som öppnats av en bilagelänk.
+
+ Popupen kan vara (a) själva filen, (b) en sida med en nedladdningslänk,
+ (c) en sida som startar nedladdningen när man klickar. Returnerar
+ (bytes|None, filnamn|None).
+ """
+ try:
+ popup.wait_for_load_state("domcontentloaded")
+ except Exception: # noqa: BLE001
+ pass
+
+ def _fetch(url: str):
+ u = urljoin(popup.url, url)
+ if u.startswith("blob:"):
+ b64 = popup.evaluate(
+ "async (u) => { const r = await fetch(u); const buf = await (await r.blob())"
+ ".arrayBuffer(); let s=''; const a=new Uint8Array(buf);"
+ " for (let i=0;i<a.length;i++) s+=String.fromCharCode(a[i]); return btoa(s); }",
+ u,
+ )
+ return base64.b64decode(b64), None
+ r = context.request.get(u, timeout=timeout_ms)
+ if not r.ok:
+ log(f" Popup-URL svarade {r.status}: {u}")
+ return None, None
+ return r.body(), unquote(urlsplit(u).path.rsplit("/", 1)[-1]) or None
+
+ # (a) popupens egen URL är filen
+ popup_path = urlsplit(popup.url).path.lower()
+ if "/fileservice/" in popup_path or popup_path.rsplit(".", 1)[-1] in (
+ "pdf", "jpg", "jpeg", "png", "gif", "tif", "tiff", "doc", "docx"
+ ):
+ data, name = _fetch(popup.url)
+ if data is not None:
+ return data, name
+
+ # (b) en fil-/nedladdningslänk i popupen
+ try:
+ info = popup.evaluate(
+ "(sel) => { const el = document.querySelector(sel);"
+ " if (!el) return null;"
+ " return {url: el.href || el.src || null,"
+ " name: (el.getAttribute && el.getAttribute('download')) || (el.textContent||'').trim() || null}; }",
+ _FILE_LINK_SELECTOR,
+ )
+ except Exception: # noqa: BLE001
+ info = None
+ if info and info.get("url"):
+ data, name = _fetch(info["url"])
+ if data is not None:
+ return data, (info.get("name") or name)
+
+ # (c) klicka första länken/knappen och fånga en nedladdning i popupen
+ try:
+ with popup.expect_download(timeout=timeout_ms) as dl_info:
+ popup.locator("a, button").first.click()
+ d = dl_info.value
+ tmp = Path(tempfile.mkdtemp()) / (d.suggested_filename or "bilaga")
+ d.save_as(str(tmp))
+ return tmp.read_bytes(), (d.suggested_filename or None)
+ except Exception: # noqa: BLE001
+ return None, None
+
+
+def _interpolate(text: str, vars_: dict) -> str:
+ """Ersätter {variabelnamn} eller {variabelnamn.fält} med textvärdet.
+
+ Punkt-notation läser ett namngivet fält ur en dict-variabel (t.ex. en rad
+ från "Extrahera rad" eller en inläst CSV-rad). Okända namn/fält lämnas
+ orörda i texten.
+ """
+ def repl(m: re.Match) -> str:
+ parts = m.group(1).split(".")
+ val = vars_
+ for part in parts:
+ if isinstance(val, dict) and part in val:
+ val = val[part]
+ else:
+ return m.group(0)
+ return val if isinstance(val, str) else json.dumps(val, ensure_ascii=False)
+
+ return re.sub(r"\{([\w.]+)\}", repl, text)
+
+
+def _resolve_locator(page: Page, item_locator, step: dict, vars_: dict):
scope = step.get("scope", "page")
base = item_locator if (scope == "item" and item_locator is not None) else page
by = step.get("by", "css")
@@ -84,10 +269,10 @@ def _resolve_locator(page: Page, item_locator, step: dict):
nth = int(step.get("nth", 0))
if by == "text":
- loc = base.get_by_text(re.compile(re.escape(value), re.I))
+ loc = base.get_by_text(re.compile(re.escape(_interpolate(value, vars_)), re.I))
elif by == "role":
role = step.get("role", "button")
- loc = base.get_by_role(role, name=re.compile(re.escape(value), re.I))
+ loc = base.get_by_role(role, name=re.compile(re.escape(_interpolate(value, vars_)), re.I))
elif by == "css":
loc = base.locator(value)
else:
@@ -96,19 +281,59 @@ def _resolve_locator(page: Page, item_locator, step: dict):
return loc.nth(nth)
-def _selector_or_literal_text(page: Page, item_locator, step: dict) -> str:
+def _describe_selector(step: dict, vars_: dict) -> str:
+ """Loggvänlig beskrivning av en selector, med {var} redan ersatt."""
+ by = step.get("by", "css")
+ value = step.get("value", "")
+ if by in ("text", "role"):
+ value = _interpolate(value, vars_)
+ return f"{by}={value!r}"
+
+
+def _describe_target(loc) -> str:
+ """Kort beskrivning av det faktiska elementet en locator pekar på.
+
+ Ger t.ex. ' -> <a.ids-link "Öppna ärende" href=/case/123>' så loggen visar
+ vad som faktiskt klickades - särskilt värdefullt för value=":scope" och
+ scope=item där selectorn i sig inte säger något.
+ """
+ try:
+ info = loc.first.evaluate(
+ "el => ({t: el.tagName.toLowerCase(),"
+ " id: el.id || '',"
+ " cls: (el.getAttribute && el.getAttribute('class') || '').split(/\\s+/).filter(Boolean).slice(0,2).join('.'),"
+ " href: (el.getAttribute && el.getAttribute('href')) || '',"
+ " txt: (el.innerText || el.textContent || '').trim().replace(/\\s+/g,' ').slice(0,60)})",
+ timeout=2000,
+ )
+ except Exception: # noqa: BLE001
+ return ""
+ head = "<" + info["t"]
+ if info["id"]:
+ head += "#" + info["id"]
+ elif info["cls"]:
+ head += "." + info["cls"]
+ extra = ""
+ if info["href"]:
+ extra += f" href={info['href']}"
+ if info["txt"]:
+ extra += f' "{info["txt"]}"'
+ return f" → {head}>{extra}"
+
+
+def _selector_or_literal_text(page: Page, item_locator, step: dict, vars_: dict) -> str:
"""Text från ett element (om 'value'/selector är satt) annars step['text']."""
if step.get("value"):
- loc = _resolve_locator(page, item_locator, step)
+ loc = _resolve_locator(page, item_locator, step, vars_)
return loc.first.inner_text()
- return step.get("text", "")
+ return _interpolate(step.get("text", ""), vars_)
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)
+ loc = _resolve_locator(page, item_locator, condition, vars_)
exists = loc.count() > 0
return exists if kind == "element_exists" else not exists
@@ -130,6 +355,525 @@ def _evaluate_condition(page: Page, item_locator, vars_: dict, condition: dict)
MAX_JUMPS_PER_RUN = 1000
+
+def _execute_step(step, t, page, log, vars_, item_locator, download_dir, jump_budget) -> None:
+ """Kör ett enskilt steg. Undantag propagerar - anroparen avgör (via
+ stegets "on_error") om körningen ska stanna eller fortsätta."""
+ if t == "goto":
+ log(f"Går till {step['url']}")
+ page.goto(step["url"], wait_until="domcontentloaded")
+
+ elif t == "wait":
+ seconds = float(step.get("seconds", 1))
+ log(f"Väntar {seconds}s")
+ page.wait_for_timeout(seconds * 1000)
+
+ elif t == "wait_for_load":
+ log("Väntar på att sidan laddas klart")
+ page.wait_for_load_state("domcontentloaded")
+
+ elif t == "go_back":
+ log("Går tillbaka i webbläsarhistoriken")
+ page.go_back(wait_until="domcontentloaded")
+
+ elif t == "wait_for_login":
+ timeout_s = int(step.get("timeout", 180))
+ raw_indicators = step.get("indicators") or ""
+ indicators = [s.strip() for s in raw_indicators.split(",") if s.strip()] or None
+ wait_for_login(page, timeout_s, log=log, indicators=indicators)
+
+ elif t == "wait_for_selector":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ timeout_s = float(step.get("timeout", 15))
+ log(f"Väntar på element: {_describe_selector(step, vars_)}")
+ loc.first.wait_for(timeout=timeout_s * 1000)
+
+ elif t == "click":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ log(f"Klickar: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ loc.first.click()
+ page.wait_for_load_state("domcontentloaded")
+
+ elif t == "type":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ text = _interpolate(step.get("text", ""), vars_)
+ log(f"Skriver text i: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ loc.first.fill(text)
+
+ elif t == "press_key":
+ key = step.get("key", "Enter")
+ if step.get("value"):
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ log(f"Trycker '{key}' på: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ loc.first.press(key)
+ else:
+ log(f"Trycker '{key}'")
+ page.keyboard.press(key)
+
+ elif t == "select_option":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ option = step.get("option", "")
+ log(f"Väljer '{option}' i: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ loc.first.select_option(label=option)
+
+ elif t == "check":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ checked_raw = step.get("checked", True)
+ checked = checked_raw if isinstance(checked_raw, bool) else str(checked_raw).lower() == "true"
+ log(f"{'Kryssar i' if checked else 'Kryssar ur'}: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ if checked:
+ loc.first.check()
+ else:
+ loc.first.uncheck()
+
+ elif t == "extract_text":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ var_name = step["var"]
+ text = loc.first.inner_text()
+ vars_[var_name] = text
+ log(f"Läste text -> variabel '{var_name}': {text[:60]!r}")
+
+ elif t == "extract_row":
+ var_name = step["var"]
+ scope = step.get("scope", "item")
+ row = {}
+ for field in step.get("fields", []):
+ name = field.get("name", "")
+ if not name:
+ continue
+ sub_step = {
+ "by": field.get("by", "css"),
+ "value": field.get("value", ""),
+ "role": field.get("role", ""),
+ "scope": scope,
+ }
+ try:
+ loc = _resolve_locator(page, item_locator, sub_step, vars_)
+ row[name] = loc.first.inner_text()
+ except Exception as exc: # noqa: BLE001
+ row[name] = ""
+ log(f" Fältet '{name}' kunde inte läsas: {exc}")
+ lst = vars_.setdefault(var_name, [])
+ if not isinstance(lst, list):
+ raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
+ lst.append(row)
+ log(f"Extraherade rad -> lista '{var_name}' ({len(lst)} poster): {row}")
+
+ elif t == "set_var":
+ var_name = step["var"]
+ value = _interpolate(step.get("text", ""), vars_)
+ vars_[var_name] = value
+ log(f"Sätter variabel '{var_name}' = {value[:60]!r}")
+
+ elif t == "increment":
+ var_name = step["var"]
+ try:
+ by = int(step.get("by", 1))
+ except (TypeError, ValueError):
+ by = 1
+ try:
+ current = int(vars_.get(var_name, 0))
+ except (TypeError, ValueError):
+ current = 0
+ vars_[var_name] = current + by
+ log(f"Ökar variabel '{var_name}' med {by} -> {vars_[var_name]}")
+
+ elif t == "clear_var":
+ var_name = step["var"]
+ existed = vars_.pop(var_name, None) is not None
+ log(f"Nollställer variabel '{var_name}'" + ("" if existed else " (fanns inte)"))
+
+ elif t == "list_append":
+ var_name = step["var"]
+ value = _selector_or_literal_text(page, item_locator, step, vars_)
+ lst = vars_.setdefault(var_name, [])
+ if not isinstance(lst, list):
+ raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
+ lst.append(value)
+ log(f"Lägger till i lista '{var_name}' ({len(lst)} poster): {value[:60]!r}")
+
+ elif t == "save_var":
+ var_name = step["var"]
+ if var_name not in vars_:
+ raise FlowError(f"Okänd variabel '{var_name}' i save_var.")
+ value = vars_[var_name]
+ filename = step.get("filename") or f"{var_name}.json"
+ if Path(filename).name != filename:
+ raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+ fmt = step.get("format") or ("csv" if filename.lower().endswith(".csv") else "json")
+ append = step.get("mode") == "append"
+ config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
+ target = config.EXPORT_DIR / filename
+
+ if fmt == "csv":
+ items = value if isinstance(value, list) else [value]
+ rows = [item if isinstance(item, dict) else {"value": item} for item in items]
+ fieldnames = list(rows[0].keys()) if rows else ["value"]
+ write_header = not (append and target.exists())
+ with open(target, "a" if append else "w", newline="", encoding="utf-8") as f:
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
+ if write_header:
+ writer.writeheader()
+ writer.writerows(rows)
+ else:
+ to_write = value
+ if append and target.exists():
+ if not isinstance(value, list):
+ raise FlowError("save_var i append-läge (JSON) kräver att variabeln är en lista.")
+ try:
+ existing = json.loads(target.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, OSError):
+ existing = []
+ to_write = (existing if isinstance(existing, list) else [existing]) + value
+ target.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
+
+ log(f"Sparade '{var_name}' -> {target.name} ({fmt}{', append' if append else ''})")
+
+ elif t == "load_var":
+ var_name = step["var"]
+ filename = step["filename"]
+ if Path(filename).name != filename:
+ raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+ source = config.EXPORT_DIR / filename
+ if not source.exists():
+ raise FlowError(f"Filen finns inte: {source}")
+ if source.suffix.lower() == ".csv":
+ with open(source, newline="", encoding="utf-8") as f:
+ rows = list(csv.DictReader(f))
+ if rows and set(rows[0].keys()) == {"value"}:
+ vars_[var_name] = [row["value"] for row in rows]
+ else:
+ vars_[var_name] = rows
+ else:
+ vars_[var_name] = json.loads(source.read_text(encoding="utf-8"))
+ log(f"Läste in '{var_name}' <- {source.name}")
+
+ elif t == "run_script":
+ script = step.get("path", "")
+ if not script:
+ raise FlowError("run_script kräver 'path'.")
+ script_path = Path(script)
+ if not script_path.is_absolute():
+ script_path = config.ROOT_DIR / script_path
+ if not script_path.exists():
+ raise FlowError(f"Skriptet finns inte: {script_path}")
+ args = shlex.split(step.get("args", ""))
+ timeout_s = float(step.get("timeout", 60))
+ log(f"Kör script: {script_path.name} {' '.join(args)}")
+ result = subprocess.run(
+ [sys.executable, str(script_path), *args],
+ cwd=str(config.ROOT_DIR),
+ capture_output=True,
+ text=True,
+ timeout=timeout_s,
+ )
+ for line in result.stdout.splitlines():
+ log(f" stdout: {line}")
+ for line in result.stderr.splitlines():
+ log(f" stderr: {line}")
+ if result.returncode != 0:
+ raise FlowError(f"Script avslutade med felkod {result.returncode}: {script_path.name}")
+ var_name = step.get("var")
+ if var_name:
+ try:
+ vars_[var_name] = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ vars_[var_name] = result.stdout.strip()
+
+ elif t == "screenshot":
+ name = step.get("name") or "screenshot"
+ target = config.SCREENSHOT_DIR / f"{name}.png"
+ page.screenshot(path=str(target))
+ log(f"Skärmdump sparad: {target.name}")
+
+ elif t == "list":
+ selector = step["value"]
+ var_name = step["var"]
+ 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":
+ over_name = step.get("over")
+ over = vars_.get(over_name)
+ if over is None:
+ raise FlowError(
+ f"Loop refererar okänd variabel '{over_name}' "
+ "(kör ett 'Lista poster'/'Lägg till i lista'/'Läs in variabel'-steg "
+ "innan loopen och ange samma namn)."
+ )
+
+ index_var = step.get("index_var") or "index"
+
+ if isinstance(over, dict) and "selector" in over and "count" in over:
+ # DOM-baserad loop, från "Lista poster": item_locator = den aktuella
+ # rad-/postens element, scope=item i inre steg söker inom den.
+ selector = over["selector"]
+ count = over["count"]
+ for post_idx in range(count):
+ log(f"-- Post {post_idx + 1}/{count} --")
+ vars_[index_var] = post_idx + 1
+ current_item = page.locator(selector).nth(post_idx)
+ _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir, jump_budget)
+ elif isinstance(over, list):
+ # Loop över en vanlig listvariabel (t.ex. från "Lägg till i lista" eller
+ # "Läs in variabel"). Inget DOM-element per varv - varje värde läggs i
+ # variabeln item_var (default "item") och kan användas som {item} i
+ # by=text/role-fält för att hitta motsvarande element på sidan.
+ item_var = step.get("item_var") or "item"
+ count = len(over)
+ for post_idx, item_value in enumerate(over):
+ log(f"-- Post {post_idx + 1}/{count} --")
+ vars_[index_var] = post_idx + 1
+ vars_[item_var] = item_value
+ _run_steps(page, step.get("steps", []), log, vars_, item_locator, download_dir, jump_budget)
+ else:
+ raise FlowError(
+ f"Variabel '{over_name}' kan inte loopas över (varken en lista eller "
+ "ett 'Lista poster'-resultat)."
+ )
+
+ 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, vars_)
+ log(f"Laddar ner via: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+ try:
+ with page.expect_download(timeout=15000) as dl_info:
+ loc.first.click()
+ download = dl_info.value
+ target = download_dir / download.suggested_filename
+ download.save_as(str(target))
+ log(f"Nedladdad: {target.name}")
+ except Exception as exc: # noqa: BLE001
+ log(f"Nedladdning misslyckades: {exc}")
+
+ elif t == "save_attachment":
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ timeout_s = float(step.get("timeout", 15))
+ download_dir.mkdir(parents=True, exist_ok=True)
+ log(f"Hämtar bilaga via: {_describe_selector(step, vars_)}{_describe_target(loc)}")
+
+ el = loc.first
+ href = None
+ try:
+ href = el.get_attribute("href")
+ except Exception: # noqa: BLE001
+ href = None
+ if not href:
+ try:
+ inner = el.locator("a[href]").first
+ if inner.count():
+ href = inner.get_attribute("href")
+ except Exception: # noqa: BLE001
+ pass
+
+ explicit_name = _interpolate(step.get("filename", ""), vars_).strip()
+ saved_path = None
+
+ is_real_link = bool(href) and not href.strip().lower().startswith("javascript:")
+ if is_real_link:
+ url = urljoin(page.url, href)
+ try:
+ r = page.context.request.get(url, timeout=timeout_s * 1000)
+ if r.ok:
+ name = explicit_name or unquote(urlsplit(url).path.rsplit("/", 1)[-1]) or "bilaga"
+ saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+ saved_path.write_bytes(r.body())
+ log(f" Hämtade via URL: {url}")
+ else:
+ log(f" URL svarade {r.status}: {url}")
+ except Exception as exc: # noqa: BLE001
+ log(f" Kunde inte hämta via URL: {exc}")
+
+ if saved_path is None:
+ # javascript:-länk eller misslyckad URL-hämtning: klicka och fånga
+ # antingen en nedladdning direkt eller en popup med filen/länken.
+ popup_holder = []
+ download_holder = []
+
+ def _on_popup(p):
+ popup_holder.append(p)
+
+ def _on_download(d):
+ download_holder.append(d)
+
+ page.on("popup", _on_popup)
+ page.on("download", _on_download)
+ try:
+ try:
+ el.click()
+ except Exception as exc: # noqa: BLE001
+ log(f" Klick misslyckades: {exc}")
+ waited = 0.0
+ while waited < timeout_s and not popup_holder and not download_holder:
+ page.wait_for_timeout(200)
+ waited += 0.2
+ finally:
+ for evt, fn in (("popup", _on_popup), ("download", _on_download)):
+ try:
+ page.remove_listener(evt, fn)
+ except Exception: # noqa: BLE001
+ pass
+
+ download = download_holder[0] if download_holder else None
+ if download is not None:
+ name = explicit_name or download.suggested_filename or "bilaga"
+ saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+ download.save_as(str(saved_path))
+ log(f" Nedladdning fångad: {saved_path.name}")
+ elif popup_holder:
+ pp = popup_holder[0]
+ try:
+ data, popup_name = _grab_file_from_popup(pp, page.context, timeout_s * 1000, log)
+ if data is not None:
+ name = explicit_name or popup_name or "bilaga"
+ saved_path = _unique_path(download_dir, _safe_filename(name, "bilaga"))
+ saved_path.write_bytes(data)
+ log(f" Hämtade via popup: {saved_path.name}")
+ else:
+ log(" Hittade ingen fil i popupen.")
+ except Exception as exc: # noqa: BLE001
+ log(f" Kunde inte hämta via popup: {exc}")
+ finally:
+ try:
+ pp.close()
+ except Exception: # noqa: BLE001
+ pass
+
+ if saved_path is None:
+ log(" Ingen fil kunde hämtas för bilagan.")
+ else:
+ log(f"Bilaga sparad: {saved_path.name}")
+ var_name = step.get("var")
+ if var_name:
+ lst = vars_.setdefault(var_name, [])
+ if not isinstance(lst, list):
+ raise FlowError(
+ f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__})."
+ )
+ lst.append(str(saved_path))
+
+ elif t == "capture_pdf":
+ filename = _interpolate(step.get("filename") or "utskrift.pdf", vars_)
+ if Path(filename).name != filename:
+ raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+ if not filename.lower().endswith(".pdf"):
+ filename += ".pdf"
+ timeout_s = float(step.get("timeout", 15))
+ download_dir.mkdir(parents=True, exist_ok=True)
+ target = download_dir / filename
+ has_trigger = bool(step.get("value"))
+
+ # Hindra webbläsarens skrivardialog från att blockera körningen -
+ # både på sidan just nu och på ev. sidor som laddas av klicket.
+ neutralize = "window.print = () => {}"
+ try:
+ page.add_init_script(neutralize)
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ page.evaluate(neutralize)
+ except Exception: # noqa: BLE001
+ pass
+
+ def _looks_like_pdf(resp) -> bool:
+ try:
+ ct = (resp.headers or {}).get("content-type", "").lower()
+ except Exception: # noqa: BLE001
+ ct = ""
+ return "application/pdf" in ct or resp.url.lower().split("?")[0].endswith(".pdf")
+
+ pdf_bytes = None
+
+ # (1) Fånga ett PDF-nätverkssvar (öppnas ofta i en flik / <embed>).
+ if has_trigger:
+ loc = _resolve_locator(page, item_locator, step, vars_)
+ log(f"Fångar PDF via: {_describe_selector(step, vars_)}")
+ try:
+ with page.expect_response(_looks_like_pdf, timeout=timeout_s * 1000) as resp_info:
+ loc.first.click()
+ pdf_bytes = resp_info.value.body()
+ log(f" PDF-svar fångat: {resp_info.value.url}")
+ except Exception as exc: # noqa: BLE001
+ log(f" Inget direkt PDF-svar ({exc}); provar andra metoder")
+ page.wait_for_load_state("domcontentloaded")
+
+ # (2) Inbäddad PDF i sidan (eller någon iframe).
+ if pdf_bytes is None:
+ pdf_src = None
+ for frame in page.frames:
+ try:
+ pdf_src = frame.evaluate(
+ """() => {
+ const el = document.querySelector(
+ 'embed[type=\\"application/pdf\\"], embed[src*=\\".pdf\\"], '
+ + 'iframe[src*=\\".pdf\\"], object[data*=\\".pdf\\"], a[href*=\\".pdf\\"]'
+ );
+ return el ? (el.src || el.data || el.href || null) : null;
+ }"""
+ )
+ except Exception: # noqa: BLE001
+ pdf_src = None
+ if pdf_src:
+ break
+ if pdf_src:
+ try:
+ r = page.context.request.get(pdf_src)
+ if r.ok:
+ pdf_bytes = r.body()
+ log(f" Hämtade inbäddad PDF: {pdf_src}")
+ else:
+ log(f" Inbäddad PDF svarade {r.status}: {pdf_src}")
+ except Exception as exc: # noqa: BLE001
+ log(f" Kunde inte hämta inbäddad PDF: {exc}")
+
+ # (3) Rendera sidan som den ser ut till PDF via Chrome (fungerar
+ # även i icke-headless läge, till skillnad från page.pdf()).
+ if pdf_bytes is None:
+ try:
+ client = page.context.new_cdp_session(page)
+ result = client.send(
+ "Page.printToPDF", {"printBackground": True, "preferCSSPageSize": True}
+ )
+ pdf_bytes = base64.b64decode(result["data"])
+ log(" Genererade PDF från sidan via Chrome (printToPDF)")
+ except Exception as exc: # noqa: BLE001
+ log(f" printToPDF misslyckades: {exc}")
+
+ if pdf_bytes is None:
+ log("Ingen PDF kunde fångas.")
+ else:
+ target.write_bytes(pdf_bytes)
+ log(f"PDF sparad: {target.name} ({len(pdf_bytes)} byte)")
+ var_name = step.get("var")
+ if var_name:
+ vars_[var_name] = str(target)
+
+ else:
+ raise FlowError(f"Okänd stegtyp: {t!r}")
+
+
def _run_steps(
page: Page,
steps: list,
@@ -167,244 +911,28 @@ def _run_steps(
i = target_index
continue
- if t == "goto":
- log(f"Går till {step['url']}")
- page.goto(step["url"], wait_until="domcontentloaded")
-
- elif t == "wait":
- seconds = float(step.get("seconds", 1))
- log(f"Väntar {seconds}s")
- page.wait_for_timeout(seconds * 1000)
-
- elif t == "wait_for_load":
- log("Väntar på att sidan laddas klart")
- page.wait_for_load_state("domcontentloaded")
-
- elif t == "wait_for_login":
- timeout_s = int(step.get("timeout", 180))
- raw_indicators = step.get("indicators") or ""
- indicators = [s.strip() for s in raw_indicators.split(",") if s.strip()] or None
- wait_for_login(page, timeout_s, log=log, indicators=indicators)
-
- elif t == "wait_for_selector":
- loc = _resolve_locator(page, item_locator, step)
- timeout_s = float(step.get("timeout", 15))
- log(f"Väntar på element: {step.get('by')}={step.get('value')!r}")
- loc.first.wait_for(timeout=timeout_s * 1000)
-
- elif t == "click":
- loc = _resolve_locator(page, item_locator, step)
- log(f"Klickar: {step.get('by')}={step.get('value')!r}")
- loc.first.click()
- page.wait_for_load_state("domcontentloaded")
-
- elif t == "type":
- loc = _resolve_locator(page, item_locator, step)
- text = step.get("text", "")
- log(f"Skriver text i: {step.get('by')}={step.get('value')!r}")
- loc.first.fill(text)
-
- elif t == "press_key":
- key = step.get("key", "Enter")
- if step.get("value"):
- loc = _resolve_locator(page, item_locator, step)
- log(f"Trycker '{key}' på: {step.get('by')}={step.get('value')!r}")
- loc.first.press(key)
- else:
- log(f"Trycker '{key}'")
- page.keyboard.press(key)
-
- elif t == "select_option":
- loc = _resolve_locator(page, item_locator, step)
- option = step.get("option", "")
- log(f"Väljer '{option}' i: {step.get('by')}={step.get('value')!r}")
- loc.first.select_option(label=option)
-
- elif t == "check":
- loc = _resolve_locator(page, item_locator, step)
- checked_raw = step.get("checked", True)
- checked = checked_raw if isinstance(checked_raw, bool) else str(checked_raw).lower() == "true"
- log(f"{'Kryssar i' if checked else 'Kryssar ur'}: {step.get('by')}={step.get('value')!r}")
- if checked:
- loc.first.check()
- else:
- loc.first.uncheck()
-
- elif t == "extract_text":
- loc = _resolve_locator(page, item_locator, step)
- var_name = step["var"]
- text = loc.first.inner_text()
- vars_[var_name] = text
- log(f"Läste text -> variabel '{var_name}': {text[:60]!r}")
-
- elif t == "set_var":
- var_name = step["var"]
- value = step.get("text", "")
- vars_[var_name] = value
- log(f"Sätter variabel '{var_name}' = {value[:60]!r}")
-
- elif t == "list_append":
- var_name = step["var"]
- value = _selector_or_literal_text(page, item_locator, step)
- lst = vars_.setdefault(var_name, [])
- if not isinstance(lst, list):
- raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
- lst.append(value)
- log(f"Lägger till i lista '{var_name}' ({len(lst)} poster): {value[:60]!r}")
-
- elif t == "save_var":
- var_name = step["var"]
- if var_name not in vars_:
- raise FlowError(f"Okänd variabel '{var_name}' i save_var.")
- value = vars_[var_name]
- filename = step.get("filename") or f"{var_name}.json"
- if Path(filename).name != filename:
- raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
- fmt = step.get("format") or ("csv" if filename.lower().endswith(".csv") else "json")
- append = step.get("mode") == "append"
- config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
- target = config.EXPORT_DIR / filename
-
- if fmt == "csv":
- items = value if isinstance(value, list) else [value]
- rows = [item if isinstance(item, dict) else {"value": item} for item in items]
- fieldnames = list(rows[0].keys()) if rows else ["value"]
- write_header = not (append and target.exists())
- with open(target, "a" if append else "w", newline="", encoding="utf-8") as f:
- writer = csv.DictWriter(f, fieldnames=fieldnames)
- if write_header:
- writer.writeheader()
- writer.writerows(rows)
- else:
- to_write = value
- if append and target.exists():
- if not isinstance(value, list):
- raise FlowError("save_var i append-läge (JSON) kräver att variabeln är en lista.")
- try:
- existing = json.loads(target.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError):
- existing = []
- to_write = (existing if isinstance(existing, list) else [existing]) + value
- target.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
-
- log(f"Sparade '{var_name}' -> {target.name} ({fmt}{', append' if append else ''})")
-
- elif t == "load_var":
- var_name = step["var"]
- filename = step["filename"]
- if Path(filename).name != filename:
- raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
- source = config.EXPORT_DIR / filename
- if not source.exists():
- raise FlowError(f"Filen finns inte: {source}")
- if source.suffix.lower() == ".csv":
- with open(source, newline="", encoding="utf-8") as f:
- rows = list(csv.DictReader(f))
- if rows and set(rows[0].keys()) == {"value"}:
- vars_[var_name] = [row["value"] for row in rows]
- else:
- vars_[var_name] = rows
+ try:
+ _execute_step(step, t, page, log, vars_, item_locator, download_dir, jump_budget)
+ except Exception as _step_exc:
+ if step.get("on_error") == "continue":
+ log(f"Steget '{t}' misslyckades - fortsätter (on_error=continue): {_step_exc}")
else:
- vars_[var_name] = json.loads(source.read_text(encoding="utf-8"))
- log(f"Läste in '{var_name}' <- {source.name}")
-
- elif t == "run_script":
- script = step.get("path", "")
- if not script:
- raise FlowError("run_script kräver 'path'.")
- script_path = Path(script)
- if not script_path.is_absolute():
- script_path = config.ROOT_DIR / script_path
- if not script_path.exists():
- raise FlowError(f"Skriptet finns inte: {script_path}")
- args = shlex.split(step.get("args", ""))
- timeout_s = float(step.get("timeout", 60))
- log(f"Kör script: {script_path.name} {' '.join(args)}")
- result = subprocess.run(
- [sys.executable, str(script_path), *args],
- cwd=str(config.ROOT_DIR),
- capture_output=True,
- text=True,
- timeout=timeout_s,
- )
- for line in result.stdout.splitlines():
- log(f" stdout: {line}")
- for line in result.stderr.splitlines():
- log(f" stderr: {line}")
- if result.returncode != 0:
- raise FlowError(f"Script avslutade med felkod {result.returncode}: {script_path.name}")
- var_name = step.get("var")
- if var_name:
- try:
- vars_[var_name] = json.loads(result.stdout)
- except json.JSONDecodeError:
- vars_[var_name] = result.stdout.strip()
-
- elif t == "screenshot":
- name = step.get("name") or "screenshot"
- target = config.SCREENSHOT_DIR / f"{name}.png"
- page.screenshot(path=str(target))
- log(f"Skärmdump sparad: {target.name}")
-
- elif t == "list":
- selector = step["value"]
- var_name = step["var"]
- 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}'")
+ raise
- elif t == "loop":
- over = vars_.get(step.get("over"))
- if not over:
- raise FlowError(
- f"Loop refererar okänd variabel '{step.get('over')}' "
- "(kör ett 'list'-steg innan loopen och ange samma namn)."
- )
- selector = over["selector"]
- count = over["count"]
- 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)
+ i += 1
- 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)
- try:
- with page.expect_download(timeout=15000) as dl_info:
- loc.first.click()
- download = dl_info.value
- target = download_dir / download.suggested_filename
- download.save_as(str(target))
- log(f"Nedladdad: {target.name}")
- except Exception as exc: # noqa: BLE001
- log(f"Nedladdning misslyckades: {exc}")
+def _notify_run_complete(error: Exception | None) -> None:
+ try:
+ from plyer import notification
+ if error is None:
+ title, message = "Flöde klart", "Körningen är klar."
else:
- raise FlowError(f"Okänd stegtyp: {t!r}")
-
- i += 1
+ title, message = "Flöde misslyckades", f"Fel: {error}"
+ notification.notify(title=title, message=message, timeout=10)
+ except Exception: # noqa: BLE001
+ pass
def run_flow(
@@ -415,6 +943,7 @@ def run_flow(
close_event=None,
on_finished: Callable[[Exception | None, dict], None] | None = None,
trace_path: Path | None = None,
+ settings: dict | None = None,
) -> None:
"""Kör ett flöde.
@@ -432,10 +961,16 @@ def run_flow(
nätverk) in under hela körningen och sparas dit. Öppna den efteråt med
`python -m playwright show-trace <trace_path>` (eller `python -m rpa.show_trace`)
för att steg-för-steg se exakt vad som klickades och när det gick fel.
+
+ settings vidarebefordras till browser.launch_context() (headless,
+ viewport_width/height, locale, slow_mo) - se DEFAULT_SETTINGS i
+ rpa/browser.py. settings["notify_on_complete"] = true visar en
+ skrivbordsnotis när körningen är klar (via plyer, samma som main.py).
"""
+ settings = settings or {}
download_dir = download_dir or config.DOWNLOAD_DIR
with sync_playwright() as p:
- context = launch_context(p)
+ context = launch_context(p, settings)
if trace_path is not None:
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = get_page(context)
@@ -452,6 +987,9 @@ def run_flow(
error = exc
log(f"FEL: {exc}")
+ if settings.get("notify_on_complete"):
+ _notify_run_complete(error)
+
if on_finished is not None:
on_finished(error, vars_)
diff --git a/rpa/runs.py b/rpa/runs.py
index bd2088e..7d920af 100644
--- a/rpa/runs.py
+++ b/rpa/runs.py
@@ -17,7 +17,7 @@ def _append_log(run_id: str, message: str) -> None:
print(f"[run {run_id}] {message}")
-def start_run(steps: list, close_mode: str = "always") -> str:
+def start_run(steps: list, close_mode: str = "always", settings: dict | None = None) -> str:
run_id = uuid.uuid4().hex[:12]
close_event = threading.Event()
trace_path = config.TRACE_DIR / f"{run_id}.zip"
@@ -47,6 +47,7 @@ def start_run(steps: list, close_mode: str = "always") -> str:
close_event=close_event,
on_finished=on_finished,
trace_path=trace_path,
+ settings=settings,
)
except Exception as exc: # noqa: BLE001
_append_log(run_id, f"Oväntat fel: {exc}")
diff --git a/scripts/merge_and_print.py b/scripts/merge_and_print.py
new file mode 100644
index 0000000..1e8946d
--- /dev/null
+++ b/scripts/merge_and_print.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python
+"""Slår ihop flera filer (PDF:er och/eller bilder) till en PDF - och skriver ut den.
+
+Tänkt att köras som ett "Kör Python-script"-steg (run_script) efter att ett
+eller flera "Ladda ner bilaga" (save_attachment) / "Fånga PDF" (capture_pdf)
+-steg har hämtat hem filerna, men fungerar lika bra fristående.
+
+Filordning = den ordning filerna anges. Bilder (.jpg/.png/...) läggs som en
+centrerad, skalad A4-sida; PDF:er läggs in sida för sida som de är.
+
+Exempel:
+ # Explicita filer i vald ordning -> merged.pdf, skriv INTE ut
+ python scripts/merge_and_print.py --file data/downloads/remiss.pdf --file data/downloads/karta.jpg
+
+ # Läs fillistan från en JSON-array (t.ex. sparad med "Spara variabel")
+ python scripts/merge_and_print.py --files-from bilagor.json --out data/downloads/klar.pdf --print
+
+ # Nyaste PDF:en i data/downloads/ + en bild
+ python scripts/merge_and_print.py --pdf-latest --image bild.png
+
+I ett flöde (bilagor kan vara 0, 1 eller flera):
+ 1. click -> öppna ärendet
+ 2. list -> value "etj-file-attachment-link", var "bilaga_element"
+ 3. loop over "bilaga_element":
+ save_attachment -> by css, scope item, value "a[href]", var "bilagor"
+ 4. save_var -> var "bilagor", filename "bilagor.json"
+ 5. run_script -> path "scripts/merge_and_print.py"
+ args "--files-from bilagor.json --out data/downloads/klar.pdf --print"
+
+Skriver en rad JSON till stdout så run_script kan spara resultatet i en variabel:
+ {"out": "...\\klar.pdf", "pages": 3, "printed": true, "skipped": []}
+
+Kräver: pypdf, Pillow (se requirements.txt)
+Utskrift sker via Windows standardhanterare för PDF (os.startfile(..., "print")).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+from PIL import Image
+from pypdf import PdfReader, PdfWriter
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+DOWNLOAD_DIR = ROOT_DIR / "data" / "downloads"
+EXPORT_DIR = ROOT_DIR / "data" / "exports"
+
+# A4 i punkter (1 pt = 1/72 tum) - PDF:ens standardenhet.
+A4_W, A4_H = 595.276, 841.890
+IMAGE_RENDER_DPI = 150 # upplösning bilden rastreras till på A4-sidan
+
+IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".webp"}
+
+
+def _resolve(path_str: str) -> Path:
+ """Tillåter absoluta sökvägar och sökvägar relativt projektroten."""
+ p = Path(path_str)
+ return p if p.is_absolute() else (ROOT_DIR / p)
+
+
+def _latest_pdf(folder: Path) -> Path:
+ pdfs = sorted(folder.glob("*.pdf"), key=lambda p: p.stat().st_mtime, reverse=True)
+ if not pdfs:
+ sys.exit(f"Hittar ingen *.pdf i {folder}")
+ return pdfs[0]
+
+
+def _load_files_from(spec: str) -> list[str]:
+ """Läser en JSON-array med sökvägar. spec = fil i data/exports/ eller sökväg."""
+ candidates = [_resolve(spec), EXPORT_DIR / spec]
+ for path in candidates:
+ if path.exists():
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(data, list):
+ sys.exit(f"{path} innehåller ingen JSON-lista.")
+ return [str(x) for x in data]
+ sys.exit(f"Hittar ingen fillista: prövade {', '.join(str(c) for c in candidates)}")
+
+
+def _image_to_pdf_page(image_path: Path, out_path: Path) -> None:
+ """Lägger bilden centrerad och skalad på en stående A4-sida, sparad som 1-sidig PDF."""
+ img = Image.open(image_path)
+ if img.mode not in ("RGB", "L"):
+ img = img.convert("RGB")
+
+ page_px = (round(A4_W / 72 * IMAGE_RENDER_DPI), round(A4_H / 72 * IMAGE_RENDER_DPI))
+ canvas = Image.new("RGB", page_px, "white")
+
+ scale = min(page_px[0] / img.width, page_px[1] / img.height)
+ new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
+ canvas.paste(img.resize(new_size, Image.LANCZOS), ((page_px[0] - new_size[0]) // 2, (page_px[1] - new_size[1]) // 2))
+ canvas.save(out_path, "PDF", resolution=float(IMAGE_RENDER_DPI))
+
+
+def _send_to_printer(pdf_path: Path, printer: str | None) -> None:
+ """Skriver ut via Windows standardhanterare. printer=None => standardskrivaren."""
+ if os.name != "nt":
+ sys.exit("Utskrift stöds bara på Windows i det här scriptet.")
+ if printer:
+ import ctypes
+
+ rc = ctypes.windll.shell32.ShellExecuteW(
+ None, "printto", str(pdf_path), f'"{printer}"', str(pdf_path.parent), 0
+ )
+ if rc <= 32:
+ sys.exit(f"ShellExecute 'printto' misslyckades (kod {rc}). Prova utan --printer.")
+ else:
+ os.startfile(str(pdf_path), "print") # noqa: S606 (Windows-only, avsiktligt)
+ time.sleep(3) # ge hanteraren en stund att spoola
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--file", action="append", default=[], help="Fil att lägga till (PDF eller bild). Upprepa för fler; ordningen bevaras.")
+ parser.add_argument("--files-from", help="JSON-array med sökvägar (fil i data/exports/ eller en sökväg)")
+ parser.add_argument("--pdf", help="(bakåtkompat) en PDF att lägga först")
+ parser.add_argument("--pdf-latest", action="store_true", help="(bakåtkompat) nyaste *.pdf i data/downloads/ först")
+ parser.add_argument("--image", action="append", default=[], help="(bakåtkompat) bild att lägga efter PDF:en")
+ parser.add_argument("--image-first", action="store_true", help="(bakåtkompat) lägg --image före --pdf")
+ parser.add_argument("--out", default=str(DOWNLOAD_DIR / "merged.pdf"), help="Utfil (default data/downloads/merged.pdf)")
+ parser.add_argument("--print", dest="do_print", action="store_true", help="Skicka utfilen till skrivare")
+ parser.add_argument("--printer", default=None, help="Skrivarnamn (default: Windows standardskrivare)")
+ args = parser.parse_args()
+
+ # Bygg en ordnad lista av källfiler.
+ ordered: list[str] = []
+ if args.pdf_latest:
+ ordered.append(str(_latest_pdf(DOWNLOAD_DIR)))
+ elif args.pdf:
+ ordered.append(args.pdf)
+ if args.image_first:
+ ordered = args.image + ordered
+ else:
+ ordered += args.image
+ if args.files_from:
+ ordered += _load_files_from(args.files_from)
+ ordered += args.file
+
+ sources = [_resolve(p) for p in ordered]
+ missing = [str(p) for p in sources if not p.exists()]
+ if missing:
+ sys.exit("Följande filer finns inte:\n " + "\n ".join(missing))
+ if not sources:
+ sys.exit("Inga källfiler angivna (--file / --files-from / --pdf / --image).")
+
+ out_path = _resolve(args.out)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+
+ writer = PdfWriter()
+ tmp_files: list[Path] = []
+ skipped: list[str] = []
+
+ try:
+ for src in sources:
+ suffix = src.suffix.lower()
+ if suffix == ".pdf":
+ try:
+ for page in PdfReader(str(src)).pages:
+ writer.add_page(page)
+ except Exception as exc: # noqa: BLE001
+ skipped.append(f"{src.name}: {exc}")
+ continue
+ # Bild (eller okänd typ - försök som bild).
+ try:
+ fd, tmp_name = tempfile.mkstemp(suffix=".pdf", prefix="img_")
+ os.close(fd)
+ tmp = Path(tmp_name)
+ tmp_files.append(tmp)
+ _image_to_pdf_page(src, tmp)
+ for page in PdfReader(str(tmp)).pages:
+ writer.add_page(page)
+ except Exception as exc: # noqa: BLE001
+ skipped.append(f"{src.name}: {exc}")
+
+ if len(writer.pages) == 0:
+ sys.exit("Ingen sida kunde läggas till - inget att skriva ut.\n " + "\n ".join(skipped))
+
+ with open(out_path, "wb") as f:
+ writer.write(f)
+ finally:
+ for tmp in tmp_files:
+ tmp.unlink(missing_ok=True)
+
+ printed = False
+ if args.do_print:
+ _send_to_printer(out_path, args.printer)
+ printed = True
+
+ print(json.dumps(
+ {"out": str(out_path), "pages": len(writer.pages), "printed": printed, "skipped": skipped},
+ ensure_ascii=False,
+ ))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/webui/app.py b/webui/app.py
index 36a36b0..7f26a27 100644
--- a/webui/app.py
+++ b/webui/app.py
@@ -5,21 +5,24 @@ import sys
from pathlib import Path
from fastapi import FastAPI, HTTPException
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, PlainTextResponse
from pydantic import BaseModel
from rpa import config
+from rpa.export import generate_standalone_script
from rpa.runs import close_browser, get_run, get_trace_path, start_run
app = FastAPI(title="Egenremiss RPA - flödesbyggare")
TEMPLATES_DIR = Path(__file__).parent / "templates"
+WIKI_PATH = config.ROOT_DIR / "docs" / "wiki.html"
NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$")
CLOSE_MODES = {"always", "on_success", "never"}
class Flow(BaseModel):
steps: list = []
+ settings: dict = {}
class RunOptions(BaseModel):
@@ -37,6 +40,13 @@ def index() -> str:
return (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")
+@app.get("/wiki", response_class=HTMLResponse)
+def wiki() -> str:
+ if not WIKI_PATH.exists():
+ raise HTTPException(404, "Wiki-sidan hittas inte (docs/wiki.html saknas)")
+ return WIKI_PATH.read_text(encoding="utf-8")
+
+
@app.get("/api/flows")
def list_flows() -> list[str]:
return sorted(p.stem for p in config.FLOWS_DIR.glob("*.json"))
@@ -72,10 +82,24 @@ def run_flow_endpoint(name: str, options: RunOptions = RunOptions()) -> dict:
raise HTTPException(404, "Flödet finns inte")
flow = json.loads(path.read_text(encoding="utf-8"))
close_mode = options.close_mode if options.close_mode in CLOSE_MODES else "always"
- run_id = start_run(flow.get("steps", []), close_mode=close_mode)
+ run_id = start_run(flow.get("steps", []), close_mode=close_mode, settings=flow.get("settings", {}))
return {"run_id": run_id}
+@app.get("/api/flows/{name}/export")
+def export_flow(name: str) -> PlainTextResponse:
+ path = _flow_path(name)
+ if not path.exists():
+ raise HTTPException(404, "Flödet finns inte")
+ flow = json.loads(path.read_text(encoding="utf-8"))
+ script = generate_standalone_script(name, flow)
+ return PlainTextResponse(
+ script,
+ media_type="text/x-python",
+ headers={"Content-Disposition": f'attachment; filename="{name}.py"'},
+ )
+
+
@app.get("/api/runs/{run_id}")
def run_status(run_id: str) -> dict:
run = get_run(run_id)
diff --git a/webui/templates/index.html b/webui/templates/index.html
index e0b1433..f0a11d4 100644
--- a/webui/templates/index.html
+++ b/webui/templates/index.html
@@ -43,7 +43,7 @@
flex-wrap: wrap;
}
header h1 { font-size: 16px; margin: 0 16px 0 0; white-space: nowrap; }
- select, input, button {
+ select, input, button, a.secondary {
font: inherit;
padding: 6px 10px;
border-radius: 6px;
@@ -57,11 +57,18 @@
color: var(--accent-text);
border: none;
}
- button.secondary {
+ button.secondary, a.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
+ a.secondary {
+ display: inline-block;
+ text-decoration: none;
+ cursor: pointer;
+ line-height: 1.2;
+ }
+ a.secondary:hover { background: var(--loop-bg); }
button.danger { background: var(--danger); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
main {
@@ -208,6 +215,17 @@
overflow: auto;
white-space: pre-wrap;
}
+ dialog#settingsDialog {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 20px 22px;
+ width: min(520px, 90vw);
+ }
+ dialog#settingsDialog::backdrop { background: rgba(0, 0, 0, 0.4); }
+ dialog#settingsDialog h2 { margin: 0 0 4px; font-size: 16px; }
+ dialog#settingsDialog .field input[type="checkbox"] { width: auto; }
</style>
</head>
<body>
@@ -221,6 +239,9 @@
<select id="flowSelect"></select>
<button class="secondary" id="newFlowBtn">New flow</button>
<button class="danger" id="deleteFlowBtn">Delete flow</button>
+ <a class="secondary" id="wikiLink" href="/wiki" target="_blank" rel="noopener">Wiki</a>
+ <button class="secondary" id="settingsBtn">⚙ Settings</button>
+ <a class="secondary" id="exportLink" href="#" target="_blank" rel="noopener">Export as script</a>
<span class="spacer" style="flex:1"></span>
<select id="closeModeSelect">
<option value="always">Close the browser</option>
@@ -234,6 +255,41 @@
<span id="statusBadge"></span>
</header>
+<dialog id="settingsDialog">
+ <h2 id="settingsTitle">Settings</h2>
+ <p class="hint" id="settingsHint"></p>
+ <div class="fields">
+ <div class="field">
+ <label id="settingsHeadlessLabel">Headless</label>
+ <input type="checkbox" id="settingsHeadless" style="width:auto">
+ </div>
+ <div class="field">
+ <label id="settingsViewportWidthLabel">Viewport width</label>
+ <input type="number" id="settingsViewportWidth" value="1280">
+ </div>
+ <div class="field">
+ <label id="settingsViewportHeightLabel">Viewport height</label>
+ <input type="number" id="settingsViewportHeight" value="900">
+ </div>
+ <div class="field">
+ <label id="settingsLocaleLabel">Locale</label>
+ <input type="text" id="settingsLocale" value="sv-SE">
+ </div>
+ <div class="field">
+ <label id="settingsSlowMoLabel">Slow motion (ms)</label>
+ <input type="number" id="settingsSlowMo" value="0">
+ </div>
+ <div class="field">
+ <label id="settingsNotifyLabel">Notify on completion</label>
+ <input type="checkbox" id="settingsNotify" style="width:auto">
+ </div>
+ </div>
+ <div class="add-step-row" style="display:flex; gap:8px; justify-content:flex-end;">
+ <button class="secondary" id="settingsCancelBtn">Cancel</button>
+ <button id="settingsSaveBtn">Save</button>
+ </div>
+</dialog>
+
<main>
<div class="panel">
<p class="hint" id="stepsHint"></p>
@@ -278,7 +334,22 @@ const I18N = {
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_wiki_link: "Wiki",
+ ui_wiki_title: "Open the step/field reference in a new tab",
+ ui_settings_btn: "⚙ Settings",
+ ui_export_link: "Export as script",
+ ui_export_title: "Download a standalone Python script for this flow",
+ ui_settings_title: "Settings",
+ ui_settings_hint: "Saved with this flow, so they travel along when you export it as a standalone script.",
+ ui_settings_headless: "Headless (no visible browser window)",
+ ui_settings_viewport_width: "Viewport width",
+ ui_settings_viewport_height: "Viewport height",
+ ui_settings_locale: "Locale",
+ ui_settings_slow_mo: "Slow motion (ms between actions)",
+ ui_settings_notify: "Desktop notification when the run finishes",
+ ui_settings_cancel: "Cancel",
+ ui_settings_save: "Save",
+ 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 - either DOM results (Search scope "item" searches only within the current item) or a plain list (e.g. from "Append to list"/"Load variable"), where the current value is available as {item} in "Find by: text/role" fields.',
ui_add_step: "+ Add step",
ui_run_log: "Run log",
ui_variables: "Variables",
@@ -288,6 +359,9 @@ const I18N = {
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_on_error_title: "What happens if this step fails / times out",
+ ui_on_error_stop: "On error: stop",
+ ui_on_error_continue: "On error: continue",
ui_remove: "Remove",
ui_add_step_in_loop: "+ Add step in loop",
ui_branch_if: "If",
@@ -304,10 +378,13 @@ const I18N = {
ui_var_text: "text, {n} characters",
ui_new_flow_prompt: "Name of new flow:",
ui_delete_flow_confirm: 'Delete the flow "{name}"?',
+ ui_field_name_placeholder: "Column name",
+ ui_add_field: "+ Add field",
step_goto: "Go to URL",
step_wait: "Wait (seconds)",
step_wait_for_load: "Wait for page to load",
+ step_go_back: "Go back (browser history)",
step_wait_for_login: "Wait for login",
step_wait_for_selector: "Wait for element",
step_click: "Click",
@@ -316,7 +393,10 @@ const I18N = {
step_select_option: "Select in dropdown",
step_check: "Check checkbox/radio",
step_extract_text: "Read text into variable",
+ step_extract_row: "Extract row (multiple columns)",
step_set_var: "Set variable",
+ step_increment: "Increment variable",
+ step_clear_var: "Clear variable",
step_list_append: "Append to list",
step_save_var: "Save variable (JSON/CSV)",
step_load_var: "Load variable (JSON/CSV)",
@@ -327,6 +407,8 @@ const I18N = {
step_jump_to_label: "Jump to label",
step_loop: "Loop (for each item)",
step_download: "Download",
+ step_save_attachment: "Download attachment",
+ step_capture_pdf: "Capture PDF (print button)",
cond_element_exists: "Element exists",
cond_element_not_exists: "Element does not exist",
@@ -358,6 +440,8 @@ const I18N = {
fd_save_as_var: "Save as variable",
fd_var_name: "Variable name",
fd_set_var_text: "Value",
+ fd_increment_by: "Increase by (negative = decrease)",
+ fd_loop_index_var: "Iteration counter variable (1, 2, 3 … each round)",
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)",
@@ -371,7 +455,11 @@ const I18N = {
fd_screenshot_name: "Filename (without .png)",
fd_list_value: "CSS selector",
fd_loop_over: "Loop over variable",
+ fd_loop_item_var: "Current item variable (only used when looping over a plain list)",
fd_jump_target: "Jump to label",
+ fd_capture_pdf_filename: "PDF filename in data/downloads/ (supports {variable})",
+ fd_save_attachment_filename: "Filename in data/downloads/ (blank = from the file's URL; supports {variable})",
+ fd_save_attachment_var: "Append saved path to this list variable (optional)",
},
sv: {
ui_title: "RPA-verktyg",
@@ -386,7 +474,22 @@ const I18N = {
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_wiki_link: "Wiki",
+ ui_wiki_title: "Öppna steg-/fältreferensen i en ny flik",
+ ui_settings_btn: "⚙ Inställningar",
+ ui_export_link: "Exportera som script",
+ ui_export_title: "Ladda ner ett fristående Python-script för det här flödet",
+ ui_settings_title: "Inställningar",
+ ui_settings_hint: "Sparas tillsammans med flödet, så de följer med om du exporterar det som ett fristående script.",
+ ui_settings_headless: "Headless (inget synligt webbläsarfönster)",
+ ui_settings_viewport_width: "Fönsterbredd (viewport)",
+ ui_settings_viewport_height: "Fönsterhöjd (viewport)",
+ ui_settings_locale: "Locale",
+ ui_settings_slow_mo: "Slow motion (ms mellan åtgärder)",
+ ui_settings_notify: "Skrivbordsnotis när körningen är klar",
+ ui_settings_cancel: "Avbryt",
+ ui_settings_save: "Spara",
+ 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 - antingen DOM-resultat (Sökområde "item" söker bara inom aktuell post) eller en vanlig lista (t.ex. från "Lägg till i lista"/"Läs in variabel"), där aktuellt värde finns som {item} i "Hitta via: text/role"-fält.',
ui_add_step: "+ Lägg till steg",
ui_run_log: "Körlogg",
ui_variables: "Variabler",
@@ -396,6 +499,9 @@ const I18N = {
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_on_error_title: "Vad som händer om steget misslyckas / får timeout",
+ ui_on_error_stop: "Vid fel: stanna",
+ ui_on_error_continue: "Vid fel: fortsätt",
ui_remove: "Ta bort",
ui_add_step_in_loop: "+ Lägg till steg i loop",
ui_branch_if: "Om",
@@ -412,10 +518,13 @@ const I18N = {
ui_var_text: "text, {n} tecken",
ui_new_flow_prompt: "Namn på nytt flöde:",
ui_delete_flow_confirm: 'Ta bort flödet "{name}"?',
+ ui_field_name_placeholder: "Kolumnnamn",
+ ui_add_field: "+ Lägg till fält",
step_goto: "Gå till URL",
step_wait: "Vänta (sekunder)",
step_wait_for_load: "Vänta på att sidan laddas",
+ step_go_back: "Gå tillbaka (webbläsarhistorik)",
step_wait_for_login: "Vänta på inloggning",
step_wait_for_selector: "Vänta på element",
step_click: "Klicka",
@@ -424,7 +533,10 @@ const I18N = {
step_select_option: "Välj i dropdown",
step_check: "Kryssa checkbox/radio",
step_extract_text: "Läs text till variabel",
+ step_extract_row: "Extrahera rad (flera kolumner)",
step_set_var: "Sätt variabel",
+ step_increment: "Öka variabel",
+ step_clear_var: "Nollställ 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)",
@@ -435,6 +547,8 @@ const I18N = {
step_jump_to_label: "Hoppa till etikett",
step_loop: "Loop (för varje post)",
step_download: "Ladda ner",
+ step_save_attachment: "Ladda ner bilaga",
+ step_capture_pdf: "Fånga PDF (skriv ut-knapp)",
cond_element_exists: "Element finns",
cond_element_not_exists: "Element finns inte",
@@ -466,6 +580,8 @@ const I18N = {
fd_save_as_var: "Spara som variabel",
fd_var_name: "Variabelnamn",
fd_set_var_text: "Värde",
+ fd_increment_by: "Öka med (negativt = minska)",
+ fd_loop_index_var: "Variabel för varvräknare (1, 2, 3 … varje varv)",
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)",
@@ -479,7 +595,11 @@ const I18N = {
fd_screenshot_name: "Filnamn (utan .png)",
fd_list_value: "CSS-selector",
fd_loop_over: "Loopa över variabel",
+ fd_loop_item_var: "Variabel för aktuell post (används bara vid loop över en vanlig lista)",
fd_jump_target: "Hoppa till etikett",
+ fd_capture_pdf_filename: "PDF-filnamn i data/downloads/ (stödjer {variabel})",
+ fd_save_attachment_filename: "Filnamn i data/downloads/ (tomt = från filens URL; stödjer {variabel})",
+ fd_save_attachment_var: "Lägg sparad sökväg i den här listvariabeln (valfritt)",
},
};
@@ -498,10 +618,10 @@ function tf(key, params) {
}
const STEP_TYPES = [
- "goto", "wait", "wait_for_load", "wait_for_login", "wait_for_selector",
+ "goto", "wait", "wait_for_load", "go_back", "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", "if", "jump_to_label", "loop", "download",
+ "extract_text", "extract_row", "set_var", "increment", "clear_var", "list_append", "save_var", "load_var", "run_script",
+ "screenshot", "list", "if", "jump_to_label", "loop", "download", "save_attachment", "capture_pdf",
];
const SELECTOR_FIELDS = [
@@ -535,6 +655,7 @@ const FIELD_DEFS = {
goto: [{ key: "url", labelKey: "fd_goto_url", type: "text" }],
wait: [{ key: "seconds", labelKey: "fd_wait_seconds", type: "number", default: 1 }],
wait_for_load: [],
+ go_back: [],
wait_for_login: [
{ key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 180 },
{ key: "indicators", labelKey: "fd_login_indicators", type: "text" },
@@ -559,10 +680,21 @@ const FIELD_DEFS = {
{ key: "checked", labelKey: "fd_check_checked", type: "select", options: ["true", "false"], default: "true" },
],
extract_text: [...SELECTOR_FIELDS, { key: "var", labelKey: "fd_save_as_var", type: "text" }],
+ extract_row: [
+ { key: "var", labelKey: "fd_save_as_var", type: "text" },
+ { key: "scope", labelKey: "f_scope", type: "select", options: ["page", "item"], default: "item" },
+ ],
set_var: [
{ key: "var", labelKey: "fd_var_name", type: "text" },
{ key: "text", labelKey: "fd_set_var_text", type: "text" },
],
+ increment: [
+ { key: "var", labelKey: "fd_var_name", type: "text" },
+ { key: "by", labelKey: "fd_increment_by", type: "number", default: 1 },
+ ],
+ clear_var: [
+ { key: "var", labelKey: "fd_var_name", type: "text" },
+ ],
list_append: [
{ key: "var", labelKey: "fd_list_append_var", type: "text" },
{ key: "text", labelKey: "fd_list_append_text", type: "text" },
@@ -593,8 +725,24 @@ const FIELD_DEFS = {
{ key: "value", labelKey: "fd_list_value", type: "text" },
{ key: "var", labelKey: "fd_save_as_var", type: "text" },
],
- loop: [{ key: "over", labelKey: "fd_loop_over", type: "text" }],
+ loop: [
+ { key: "over", labelKey: "fd_loop_over", type: "text" },
+ { key: "item_var", labelKey: "fd_loop_item_var", type: "text", default: "item" },
+ { key: "index_var", labelKey: "fd_loop_index_var", type: "text", default: "index" },
+ ],
download: SELECTOR_FIELDS,
+ save_attachment: [
+ ...SELECTOR_FIELDS,
+ { key: "filename", labelKey: "fd_save_attachment_filename", type: "text" },
+ { key: "var", labelKey: "fd_save_attachment_var", type: "text" },
+ { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 15 },
+ ],
+ capture_pdf: [
+ ...SELECTOR_FIELDS,
+ { key: "filename", labelKey: "fd_capture_pdf_filename", type: "text", default: "utskrift.pdf" },
+ { key: "var", labelKey: "fd_save_as_var", type: "text" },
+ { key: "timeout", labelKey: "fd_timeout_seconds", type: "number", default: 15 },
+ ],
if: [],
jump_to_label: [{ key: "target", labelKey: "fd_jump_target", type: "text" }],
};
@@ -610,6 +758,7 @@ function newStep(type) {
}
if (type === "loop") step.steps = [];
if (type === "if") step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
+ if (type === "extract_row") step.fields = [{ name: "", by: "css", value: "" }];
return step;
}
@@ -718,6 +867,21 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
});
head.appendChild(labelInput);
+ const onErrorSelect = document.createElement("select");
+ onErrorSelect.title = t("ui_on_error_title");
+ onErrorSelect.style.width = "130px";
+ for (const [val, key] of [["stop", "ui_on_error_stop"], ["continue", "ui_on_error_continue"]]) {
+ const o = document.createElement("option");
+ o.value = val;
+ o.textContent = t(key);
+ onErrorSelect.appendChild(o);
+ }
+ onErrorSelect.value = step.on_error === "continue" ? "continue" : "stop";
+ onErrorSelect.addEventListener("change", () => {
+ step.on_error = onErrorSelect.value === "continue" ? "continue" : undefined;
+ });
+ head.appendChild(onErrorSelect);
+
head.appendChild(document.createElement("span")).className = "spacer";
const upBtn = document.createElement("button");
@@ -755,7 +919,7 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
fields.className = "fields";
let defs = FIELD_DEFS[step.type] || [];
if (!isItemScope) {
- defs = defs.filter((d) => d.key !== "scope" || step.type === "loop");
+ defs = defs.filter((d) => d.key !== "scope" || step.type === "loop" || step.type === "extract_row");
}
for (const def of defs) {
fields.appendChild(fieldRow(step, def, onStructuralChange));
@@ -781,6 +945,62 @@ function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
card.appendChild(nested);
}
+ if (step.type === "extract_row") {
+ if (!Array.isArray(step.fields)) step.fields = [];
+
+ const rowBlock = document.createElement("div");
+ rowBlock.className = "nested";
+
+ step.fields.forEach((field, fIdx) => {
+ const fieldWrap = document.createElement("div");
+ fieldWrap.className = "branch";
+
+ const fieldHead = document.createElement("div");
+ fieldHead.className = "step-head";
+ const nameInput = document.createElement("input");
+ nameInput.type = "text";
+ nameInput.placeholder = t("ui_field_name_placeholder");
+ nameInput.style.width = "160px";
+ nameInput.value = field.name || "";
+ nameInput.addEventListener("input", () => { field.name = nameInput.value; });
+ fieldHead.appendChild(nameInput);
+ fieldHead.appendChild(document.createElement("span")).className = "spacer";
+ const removeFieldBtn = document.createElement("button");
+ removeFieldBtn.className = "danger";
+ removeFieldBtn.textContent = t("ui_remove");
+ removeFieldBtn.addEventListener("click", () => {
+ step.fields.splice(fIdx, 1);
+ onStructuralChange();
+ });
+ fieldHead.appendChild(removeFieldBtn);
+ fieldWrap.appendChild(fieldHead);
+
+ const fieldFields = document.createElement("div");
+ fieldFields.className = "fields";
+ for (const def of [
+ { key: "by", labelKey: "f_by", type: "select", options: ["text", "css", "role"], default: "css" },
+ { key: "value", labelKey: "f_value", type: "text" },
+ { key: "role", labelKey: "f_role", type: "text" },
+ ]) {
+ fieldFields.appendChild(fieldRow(field, def, onStructuralChange));
+ }
+ fieldWrap.appendChild(fieldFields);
+
+ rowBlock.appendChild(fieldWrap);
+ });
+
+ const addFieldBtn = document.createElement("button");
+ addFieldBtn.className = "secondary add-step-row";
+ addFieldBtn.textContent = t("ui_add_field");
+ addFieldBtn.addEventListener("click", () => {
+ step.fields.push({ name: "", by: "css", value: "" });
+ onStructuralChange();
+ });
+ rowBlock.appendChild(addFieldBtn);
+
+ card.appendChild(rowBlock);
+ }
+
if (step.type === "if") {
if (!Array.isArray(step.branches) || step.branches.length === 0) {
step.branches = [{ condition: { kind: "element_exists" }, steps: [] }];
@@ -923,11 +1143,24 @@ async function loadFlowList() {
return names;
}
+function updateExportLink() {
+ const link = document.getElementById("exportLink");
+ if (currentFlowName) {
+ link.href = `/api/flows/${encodeURIComponent(currentFlowName)}/export`;
+ link.removeAttribute("aria-disabled");
+ } else {
+ link.href = "#";
+ link.setAttribute("aria-disabled", "true");
+ }
+}
+
async function loadFlow(name) {
const res = await fetch(`/api/flows/${encodeURIComponent(name)}`);
flow = await res.json();
if (!flow.steps) flow.steps = [];
+ if (!flow.settings) flow.settings = {};
currentFlowName = name;
+ updateExportLink();
render();
}
@@ -936,7 +1169,7 @@ async function saveFlow() {
await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ steps: flow.steps }),
+ body: JSON.stringify({ steps: flow.steps, settings: flow.settings || {} }),
});
await loadFlowList();
}
@@ -949,9 +1182,10 @@ document.getElementById("newFlowBtn").addEventListener("click", async () => {
const name = prompt(t("ui_new_flow_prompt"));
if (!name) return;
currentFlowName = name;
- flow = { steps: [] };
+ flow = { steps: [], settings: {} };
await saveFlow();
await loadFlowList();
+ updateExportLink();
render();
});
@@ -962,11 +1196,41 @@ document.getElementById("deleteFlowBtn").addEventListener("click", async () => {
currentFlowName = null;
const names = await loadFlowList();
if (names.length) await loadFlow(names[0]);
- else { flow = { steps: [] }; render(); }
+ else { flow = { steps: [], settings: {} }; updateExportLink(); render(); }
});
document.getElementById("saveBtn").addEventListener("click", saveFlow);
+const settingsDialog = document.getElementById("settingsDialog");
+
+function openSettingsDialog() {
+ const s = flow.settings || {};
+ document.getElementById("settingsHeadless").checked = !!s.headless;
+ document.getElementById("settingsViewportWidth").value = s.viewport_width ?? 1280;
+ document.getElementById("settingsViewportHeight").value = s.viewport_height ?? 900;
+ document.getElementById("settingsLocale").value = s.locale ?? "sv-SE";
+ document.getElementById("settingsSlowMo").value = s.slow_mo ?? 0;
+ document.getElementById("settingsNotify").checked = !!s.notify_on_complete;
+ settingsDialog.showModal();
+}
+
+document.getElementById("settingsBtn").addEventListener("click", openSettingsDialog);
+document.getElementById("settingsCancelBtn").addEventListener("click", () => settingsDialog.close());
+
+document.getElementById("settingsSaveBtn").addEventListener("click", async (e) => {
+ e.preventDefault();
+ flow.settings = {
+ headless: document.getElementById("settingsHeadless").checked,
+ viewport_width: Number(document.getElementById("settingsViewportWidth").value) || 1280,
+ viewport_height: Number(document.getElementById("settingsViewportHeight").value) || 900,
+ locale: document.getElementById("settingsLocale").value || "sv-SE",
+ slow_mo: Number(document.getElementById("settingsSlowMo").value) || 0,
+ notify_on_complete: document.getElementById("settingsNotify").checked,
+ };
+ await saveFlow();
+ settingsDialog.close();
+});
+
const logEl = document.getElementById("log");
const statusBadge = document.getElementById("statusBadge");
const runBtn = document.getElementById("runBtn");
@@ -1101,6 +1365,21 @@ function applyStaticTranslations() {
runBtn.textContent = t("ui_run");
closeBrowserBtn.textContent = t("ui_close_browser_btn");
showTraceBtn.textContent = t("ui_show_trace");
+ document.getElementById("wikiLink").textContent = t("ui_wiki_link");
+ document.getElementById("wikiLink").title = t("ui_wiki_title");
+ document.getElementById("settingsBtn").textContent = t("ui_settings_btn");
+ document.getElementById("exportLink").textContent = t("ui_export_link");
+ document.getElementById("exportLink").title = t("ui_export_title");
+ document.getElementById("settingsTitle").textContent = t("ui_settings_title");
+ document.getElementById("settingsHint").textContent = t("ui_settings_hint");
+ document.getElementById("settingsHeadlessLabel").textContent = t("ui_settings_headless");
+ document.getElementById("settingsViewportWidthLabel").textContent = t("ui_settings_viewport_width");
+ document.getElementById("settingsViewportHeightLabel").textContent = t("ui_settings_viewport_height");
+ document.getElementById("settingsLocaleLabel").textContent = t("ui_settings_locale");
+ document.getElementById("settingsSlowMoLabel").textContent = t("ui_settings_slow_mo");
+ document.getElementById("settingsNotifyLabel").textContent = t("ui_settings_notify");
+ document.getElementById("settingsCancelBtn").textContent = t("ui_settings_cancel");
+ document.getElementById("settingsSaveBtn").textContent = t("ui_settings_save");
document.getElementById("stepsHint").textContent = t("ui_steps_hint");
document.getElementById("addRootStepBtn").textContent = t("ui_add_step");
document.getElementById("logTitle").textContent = t("ui_run_log");
@@ -1136,10 +1415,11 @@ applyStaticTranslations();
flow = { steps: [
newStep("goto"),
newStep("wait_for_login"),
- ]};
+ ], settings: {} };
flow.steps[0].url = "https://www.1177.se/";
await saveFlow();
await loadFlowList();
+ updateExportLink();
render();
}
})();