diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..06878ee --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalize line endings in the repo to LF; check out as LF on all platforms. +* text=auto eol=lf + +# Windows scripts must use CRLF so they work when double-clicked or run from cmd.exe +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf + +# Shell scripts must always use LF — CRLF breaks the shebang and set -e +*.sh text eol=lf + +# Binary assets — no line-ending translation +*.icns binary +*.ico binary +*.png binary +*.jpg binary +*.gif binary +*.pdf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c914507 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + +jobs: + import-check: + name: Import check (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Verify core imports + run: | + python -c "from server import app; print('server: ok')" + python -c "import paths; print('paths:', paths.APP_DATA_DIR)" + python -c "import config; print('config: ok')" diff --git a/README.md b/README.md index e24e09d..4f0f1b9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Butterfly Effect v0.9.3-beta +# Butterfly Effect v0.9.5-beta > **Unofficial tool — not affiliated with or endorsed by Monarch Money, Inc. Use at your own risk.** @@ -27,9 +27,103 @@ Key capabilities: - **Forecast chart** — rolling account balance projection based on your Monarch recurring schedule - **Scenario Modeling** — enter upcoming real or potential income or expenses, one-time or recurring, to more accurately model your future balances - **Transaction Overrides** — correct the amount, dates, or frequency of recurring transactions, or skip/delete them altogether -- **AI Insights** (optional) — Fine-tune predictions with seasonal expense patterns, savings or transfer recommendations, and more based on your Monarch goals and existing forecast scenario +- **AI Insights** (optional) — fine-tune predictions with seasonal expense patterns, savings or transfer recommendations, and more based on your Monarch goals and existing forecast scenario - **Calendar Integration** (optional) — use public calendars to supplement Monarch's recurring or one-time transactions -- **Dark mode** - prevent safety squints and preserve your retinal health +- **Dark mode** — prevent safety squints and preserve your retinal health + +--- + +## What you'll need before starting + +- A **[Monarch Money](https://www.monarchmoney.com/)** account +- **Python 3.11 or later** (not required if using the bundled Mac app from Releases) + - **Mac (running from source):** `brew install python` or download from [python.org](https://python.org/downloads) + - **Linux:** `sudo apt install python3 python3-venv` (Debian/Ubuntu) or `sudo dnf install python3` (Fedora) + - **Windows:** Download and run the installer from [python.org/downloads](https://python.org/downloads) — check **"Add Python to PATH"** during install. Or use winget: `winget install Python.Python.3.12` + +**Optional — for AI insights:** +An API key from one of these providers (pick one). See [Setting up AI Insights](#setting-up-ai-insights) below for step-by-step instructions. +- [Anthropic (Claude)](https://console.anthropic.com/) — recommended +- [OpenAI (GPT)](https://platform.openai.com/) +- [Google (Gemini)](https://aistudio.google.com/) + +--- + +## Getting started + +### Step 1 — Get the app + +**Mac:** Go to the [Releases page](../../releases/latest) and download **`Butterfly.Effect-mac.dmg`**. Open the `.dmg`, drag the app to your Applications folder, and double-click to launch. + +> The first time you open it, macOS may warn you it's from an unidentified developer. Right-click the app → **Open** → **Open** to proceed. You only need to do this once. + +**Linux:** Go to the [Releases page](../../releases/latest) and download **`butterfly-effect-linux-x86_64.AppImage`**. Make it executable and run it: +```bash +chmod +x butterfly-effect-*-linux-x86_64.AppImage +./butterfly-effect-*-linux-x86_64.AppImage +``` + +**Windows (or running from source on any platform):** Clone or download this repository, then launch with the platform script for your OS: +``` +git clone https://github.com/vendaface/butterfly-effect.git +cd butterfly-effect +``` + +### Step 2 — Launch the app + +**Mac (bundled app):** Python is bundled — no extra installation needed. Double-click **Butterfly Effect** in your Applications folder. A startup page opens in your browser while the server starts. + +**Mac (from source) / Linux:** Run `./run.sh` from the project folder. On first run the script creates a Python virtual environment and installs all required packages (takes a couple of minutes). The startup page will guide you if Python is missing or too old. + +**Windows:** Double-click **`Start Butterfly Effect - Windows.cmd`** in the project folder, or run it from a Command Prompt. On first run it creates a Python virtual environment, installs all required packages, and downloads the Chromium browser used for Monarch data fetching — this takes a few minutes and only happens once. The startup screen shows progress and will guide you if Python is not found. + +> **Windows tip:** If Python was just installed and `Start Butterfly Effect - Windows.cmd` can't find it, open a **new** Command Prompt window before running it again — the PATH update from the Python installer only applies to new windows. + +### Step 3 — Connect to Monarch + +Once setup is complete the app takes you to the **Settings** page. The **Monarch Connection** section (highlighted with a blue border) is the only required step: + +1. Click **Connect to Monarch** — a Chrome browser window opens automatically. Log in to Monarch when prompted; the window closes on its own after a successful login. +2. Wait a moment for your Monarch accounts to populate, then select your **primary bill pay account** from the dropdown. Click **Save Monarch Settings** to complete the setup. + +Once these two steps are complete, the **Go to Dashboard →** button at the top of the page will be enabled. Click it to pull your transactions and open your forecast — this may take 30 seconds or so on the first fetch. + +> Further down the Settings page are **AI Insights** and **Forecast Settings**. These are both optional on first run and can always be customized later. + +### Step 4 — View & customize your first forecast + +The first time you open the dashboard the app fetches your transaction history from Monarch. Your forecast chart appears at the top of the left pane when it's done. After the first run, your data stays cached so the dashboard loads instantly on future visits. + +--- + +## Day-to-day use + +**Starting the app:** +- **Mac (bundled):** Double-click **Butterfly Effect** in Applications, or double-click **`Start Butterfly Effect - Mac.command`** in the project folder. +- **Mac / Linux (from source):** Run `./run.sh` from the project folder. +- **Windows:** Double-click **`Start Butterfly Effect - Windows.cmd`**, or run it from a Command Prompt. + +A startup page opens in your browser and redirects automatically once the server is ready. You can also bookmark `http://localhost:5002`. + +**Refresh Forecast** — click the button in the upper right whenever you want updated transaction data from Monarch. Takes 1–2 minutes. + +**Run AI Analysis** — generates fresh AI insights. Run this once a day or whenever you want an updated analysis. Requires an AI API key in Settings → AI Insights. + +**AI Insights drawer** — click the **AI** tab on the right edge of the window to slide the panel in over the transaction schedule. Click the **×** inside, or click the tab again, to close it. + +**Resize columns** — drag the vertical handle between the two panes to adjust how much width each side gets. + +**Settings** — click the gear icon (⚙) in the top-right to open Settings. Key options: + +| Setting | Description | +|---|---| +| Primary Account | Select the account used for bill payments — refresh the list if you don't see it | +| AI Insights | Enable AI Insights (off by default); set your preferred provider (Anthropic, OpenAI, or Google), choose your model and add your API key; set how many months of transaction history to include; customize how long before the analysis is labeled stale. Kick off an AI Analysis right from Settings and watch it run live | +| Forecast Horizon | How many days to project forward (default: 45) | +| Buffer Threshold | Get a warning when your balance drops below this dollar amount | +| User Context & AI Corrections | Hand-edit your AI corrections in free text (also stored in `user_context.md`) | +| Calendar Integration | Overlay custom calendar transaction events onto the forecast chart with Google Calendar, iCloud, or a custom ICS feed | +| App Settings | Change the default port, turn on debug mode, or reset to factory defaults | --- @@ -63,7 +157,7 @@ The right pane shows a scrollable agenda of upcoming dates with clickable transa ### Editing a transaction inline -Click any transaction in the schedule to edit it's data. Change dates, amounts, suppress the series entirely, or skip a single occurrence. The forecast updates instantly when you save. +Click any transaction in the schedule to edit its data. Change dates, amounts, suppress the series entirely, or skip a single occurrence. The forecast updates instantly when you save. Inline editing panel showing amount, skip, and suppress options @@ -79,7 +173,7 @@ Click the **AI** tab on the right edge of the window to slide the panel in from ### Scenario modeling -Add transfers or expenses to model their impact on the forecast without modifying your real data. Modeling transations can be one-time or recurring, and may be removed at any time. +Add transfers or expenses to model their impact on the forecast without modifying your real data. Modeling transactions can be one-time or recurring, and may be removed at any time. Scenario modeling panel with example transfer entry @@ -87,7 +181,7 @@ Add transfers or expenses to model their impact on the forecast without modifyin ### Dark mode -Switch between light and dark mode using the icon in the header. Avoid safety squints, crow's feet, and retinal burn-in. +Switch between light and dark mode using the icon in the header. Balance Forecast dashboard in dark mode @@ -95,7 +189,7 @@ Switch between light and dark mode using the icon in the header. Avoid safety sq ### Settings — first run -On first launch the user just needs to double-click one file in the folder and the app opens in the default browser, first with a startup splash page and then to the Settings page with the Monarch Connection section highlighted. (If no Python installation is detected the startup screen will provide instructions on how to install before continuing. +On first launch the app opens a startup splash page and then takes you directly to the Settings page with the Monarch Connection section highlighted. If no Python installation is detected the startup screen provides instructions for installing it before continuing. Settings page on first run showing Monarch Connection section highlighted with a blue border @@ -119,85 +213,6 @@ Configure your AI provider, model, and API key here. The **Last generated** line --- -## What you'll need before starting - -- A **Mac** (macOS 13+) or **Linux** computer — Windows not supported in this version -- A **[Monarch Money](https://www.monarchmoney.com/)** account -- **Python 3.11 or later** — **Linux only** (Mac users get Python bundled inside the app) - - Linux: `sudo apt install python3 python3-venv` (Debian/Ubuntu) or `sudo dnf install python3` (Fedora) - -**Optional — for AI insights:** -An API key from one of these providers (pick one). See [Setting up AI Insights](#setting-up-ai-insights) below for step-by-step instructions. -- [Anthropic (Claude)](https://console.anthropic.com/) — recommended -- [OpenAI (GPT)](https://platform.openai.com/) -- [Google (Gemini)](https://aistudio.google.com/) - ---- - -## Getting started - -### Step 1 — Download the app - -**On Mac:** Go to the [Releases page](../../releases/latest) and download **`Butterfly.Effect-mac.dmg`**. Open the `.dmg`, drag the app to your Applications folder, and double-click to launch. - -> The first time you open it, macOS may warn you it's from an unidentified developer. Right-click the app → **Open** → **Open** to proceed. You only need to do this once. - -**On Linux:** Go to the [Releases page](../../releases/latest) and download **`butterfly-effect-linux-x86_64.AppImage`**. Make it executable and run it: -```bash -chmod +x butterfly-effect-*-linux-x86_64.AppImage -./butterfly-effect-*-linux-x86_64.AppImage -``` -Or clone the repo and run `./run.sh` directly (requires Python 3.11+). - -### Step 2 — Launch the App - -**Mac:** Python is bundled inside the app — no installation needed. Just double-click **Butterfly Effect** in your Applications folder. A startup page opens in your browser and redirects to the app once it's ready. - -**Linux:** On first run, `run.sh` automatically sets up a Python virtual environment and installs all required packages. This takes a couple of minutes. The startup page will warn you if Python is missing or too old. - -### Step 3 — Connect to Monarch - -Once setup is complete the app will take you directly to the **Settings** page. The **Monarch Connection** section (highlighted with a blue border) is the only required step: - -1. Click **Connect to Monarch** — a Chrome browser window will open automatically. Log in to Monarch when prompted; the window closes on its own after a successful login (~30–60 seconds). -2. Wait a few moments for your Monarch accounts to populate, then select your **primary bill pay account** from the dropdown. Click **Save Monarch Settings** to complete the setup. - -Once these two steps are complete, the **Go to Dashboard →** button at the top of the page will be enabled. Click it to pull transactions and open your forecast — this may take 30 seconds or so on first run. - -> Down further on the Settings page are **AI Insights** and **Forecast Settings**. These are both optional on first run and can always be customized later. - -### Step 4 — View & customize your first forecast - -The first time you open the dashboard the app fetches your transaction history from Monarch. Your forecast chart will appear at the top of the left pane when it's done. After the first run, your data stays cached so the dashboard loads instantly on future visits. - ---- - -## Day-to-day use - -**Starting the app:** Double-click **`Start Balance Forecast.command`** (Mac) or run `./run.sh` (Linux). A startup page opens in your browser and redirects automatically once the server is ready. You can also bookmark `http://localhost:5002`. - -**Refresh Forecast** — click the button in the upper right whenever you want updated transaction data from Monarch. Takes 1–2 minutes. - -**Run AI Analysis** — generates fresh AI insights. Run this once a day or whenever you want an updated analysis. Requires an AI API key in Settings → AI Insights. - -**AI Insights drawer** — click the **AI** tab on the right edge of the window to slide the panel in over the transaction schedule. Click the **×** inside, or click the tab again, to close it. - -**Resize columns** — drag the vertical handle between the two panes to adjust how much width each side gets. - -**Settings** — click the butterfly icon (⚙) in the top-right to open Settings. Key options: - -| Setting | Description | -|---|---| -| Primary Account | Select the account used for bill payments — refresh the list if you don't see it | -| AI Insights | Enable AI Insights (off by default); set your preferred provider (Anthropic, OpenAI, or Google), choose your preferred model and add your API key; set the default number of transaction months to retrieve for analysis; customize how long before the analysis is labeled as stale. You can also kick off an AI Analysis right from Settings and see it run in a status window | -| Forecast Horizon | How many days to project forward (default: 45) | -| Buffer Threshold | Get a warning when your balance drops below this dollar amount | -| User Context & AI Corrections | Hand-edit your AI corrections in free text (also stored in `user_context.md`) | -| Calendar Integration | Overlay custom calendar transaction events onto the forecast chart with Google Calendar, iCloud, or a custom ICS feed | -| App Settings | Change the default port, turn on debug mode, or reset to factory defaults | - ---- - ## Setting up AI Insights AI Insights is optional but adds meaningful value — seasonal spending patterns, predicted upcoming expenses, and transfer recommendations tailored to your actual history. You need an API key from one provider. The feature is off by default; you enable it in **Settings → AI Insights**. @@ -256,7 +271,7 @@ Google AI Studio keys include a generous free tier. Usage beyond the free tier i - **Billing Day Overrides** — correct the day-of-month for any recurring payment whose billing date Monarch has learned incorrectly - **Scenario Modeling** — temporarily model one-time transfers or expenses to see how they affect your balance forecast - **Corrections & Context** — feed the AI specific facts about your finances to improve its accuracy -- **Startup page** — animated loading screen with helpful error messages if Python is missing or too old (Linux only — Mac bundles Python) +- **Startup page** — animated loading screen with live progress updates and helpful error messages if Python is missing or too old - **Dark mode** — toggle in Settings or with the moon icon in the header --- @@ -265,7 +280,9 @@ Google AI Studio keys include a generous free tier. Usage beyond the free tier i **"Setup needed" error on the dashboard** — click **→ Open Settings** in the error box and complete the Monarch Connection section (connect to Monarch and select your primary account). -**Startup page shows a Python error (Linux)** — follow the on-screen instructions to install Python 3.11 or later: `sudo apt install python3 python3-venv` (Debian/Ubuntu) or `sudo dnf install python3` (Fedora). Mac users don't see this — Python is bundled inside the app. +**Startup page shows a Python error** — follow the on-screen instructions to install Python 3.11 or later: +- **Linux:** `sudo apt install python3 python3-venv` (Debian/Ubuntu) or `sudo dnf install python3` (Fedora) +- **Windows:** Download from [python.org/downloads](https://python.org/downloads) and check "Add Python to PATH" during install, then open a **new** Command Prompt window and run `"Start Butterfly Effect - Windows.cmd"` again. **Forecast is slow** — this is normal on first run. The app opens a browser, logs into Monarch, and fetches months of transaction history. Subsequent loads use a cached session and are much faster. @@ -273,9 +290,9 @@ Google AI Studio keys include a generous free tier. Usage beyond the free tier i **Account list looks incomplete after connecting** — click **Refresh Accounts** in Settings → Monarch Connection. -**"API key is not configured"** — go to Settings → AI Insights, select your AI provider, and paste in your key. See [Setting up AI Insights](#setting-up-ai-insights) for step-by-step instructions on getting a key from each provider. +**"API key is not configured"** — go to Settings → AI Insights, select your AI provider, and paste in your key. See [Setting up AI Insights](#setting-up-ai-insights) for step-by-step instructions. -**Browser doesn't open automatically (Linux)** — navigate to `http://localhost:5002` in your browser manually. +**Browser doesn't open automatically** — navigate to `http://localhost:5002` in your browser manually. **The app was working and suddenly stopped** — Monarch occasionally updates their web app, which can break the data-fetching layer. Check the [project page on GitHub](https://github.com/vendaface/butterfly-effect) for updates. @@ -299,6 +316,7 @@ All runtime data is stored in your user data directory — never in a cloud serv - **Mac:** `~/Library/Application Support/Butterfly Effect/` - **Linux:** `~/.local/share/butterfly-effect/` +- **Windows:** `%APPDATA%\Butterfly Effect\` (typically `C:\Users\[yourname]\AppData\Roaming\Butterfly Effect\`) | File | What it contains | Leaves your device? | |---|---|---| @@ -313,7 +331,7 @@ All runtime data is stored in your user data directory — never in a cloud serv | `monarch_raw_cache.json` | Cached Monarch transaction data | **Never** | | `user_context.md` | Corrections you feed to the AI | Only if you configure an AI provider | -All sensitive files are written with owner-only permissions (`chmod 600`) so other users on the same machine cannot read them. +All sensitive files are written with owner-only permissions so other users on the same machine cannot read them. ### Security @@ -335,10 +353,11 @@ Your AI API key is stored only in `.env` on your device. It is sent only to your ## Power user reference -### Running from Terminal +### Running from source +**Mac / Linux:** ```bash -# Start (foreground — closes when you close the Terminal window) +# Start (foreground — stops when you close the terminal) ./run.sh # Start as a background daemon @@ -351,11 +370,25 @@ Your AI API key is stored only in `.env` on your device. It is sent only to your ./server.sh logs # tail the server log ``` +**Windows:** +```batch +rem Start (foreground — from Command Prompt or double-click) +"Start Butterfly Effect - Windows.cmd" + +rem Background daemon (from PowerShell) +.\server.ps1 start +.\server.ps1 stop +.\server.ps1 restart +.\server.ps1 status +.\server.ps1 logs +``` + ### File overview All runtime files live in your user data directory: - **Mac:** `~/Library/Application Support/Butterfly Effect/` - **Linux:** `~/.local/share/butterfly-effect/` +- **Windows:** `%APPDATA%\Butterfly Effect\` | File | Purpose | |---|---| @@ -373,10 +406,16 @@ All runtime files live in your user data directory: ### Resetting to a clean state +**Mac / Linux:** ```bash ./reset-for-testing.sh ``` +**Windows:** +```batch +reset.cmd +``` + Kills the running server, deletes all cached and generated files, and removes the virtual environment. You will be asked to confirm before anything is deleted. --- @@ -385,7 +424,6 @@ Kills the running server, deletes all cached and generated files, and removes th - No official Monarch API exists — this tool intercepts the same network calls the Monarch web app makes. Changes to Monarch's web app may break it without warning. - Designed for desktop use; mobile layout is not optimized. -- Windows is not supported in this version. --- diff --git a/Start Balance Forecast.command b/Start Butterfly Effect - Mac.command similarity index 100% rename from Start Balance Forecast.command rename to Start Butterfly Effect - Mac.command diff --git a/Start Butterfly Effect - Windows.cmd b/Start Butterfly Effect - Windows.cmd new file mode 100644 index 0000000..480be76 --- /dev/null +++ b/Start Butterfly Effect - Windows.cmd @@ -0,0 +1,9 @@ +@echo off +:: Butterfly Effect -- Windows launcher +:: Double-click this file or run it from cmd.exe to start the app. +:: +:: chcp 65001 sets the console to UTF-8 (code page 65001) so that Unicode +:: characters written by PowerShell and subprocesses (block chars, ellipsis, +:: box-drawing lines, etc.) render correctly instead of showing as Gua garbage. +chcp 65001 > nul +powershell.exe -ExecutionPolicy Bypass -NoProfile -File "%~dp0run.ps1" %* diff --git a/VERSION b/VERSION index 8e258dd..5763dd2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.4-beta +0.9.5-beta diff --git a/ai_advisor.py b/ai_advisor.py index ed743ee..e8a09ea 100644 --- a/ai_advisor.py +++ b/ai_advisor.py @@ -404,7 +404,7 @@ def _build_user_prompt( # Load user corrections — injected early so they take priority user_context_section = [] if _USER_CONTEXT_FILE.exists(): - ctx = _USER_CONTEXT_FILE.read_text().strip() + ctx = _USER_CONTEXT_FILE.read_text(encoding='utf-8').strip() if ctx: user_context_section = [ "", @@ -510,7 +510,7 @@ def get_ai_insights( print("Run: python ai_daily.py") sys.exit(1) - insights = json.loads(INSIGHTS_FILE.read_text()) + insights = json.loads(INSIGHTS_FILE.read_text(encoding='utf-8')) generated = insights.get("generated_at", "unknown") print(f"\n{'='*60}") print(f"AI Insights (generated: {generated})") @@ -537,13 +537,13 @@ def get_ai_insights( print(f"\nSurplus alerts:") for s in surpluses: print(f" {s.get('period', '?')}: ${s.get('estimated_surplus', 0):,.2f}") - print(f" → {s.get('suggested_allocation', '')}") + print(f" -> {s.get('suggested_allocation', '')}") flags = insights.get("risk_flags", []) if flags: print(f"\nRisk flags:") for f in flags: - print(f" ⚠ {f}") + print(f" [!] {f}") notes = insights.get("seasonal_notes", "") if notes: diff --git a/ai_daily.py b/ai_daily.py index ea044cf..489bf77 100644 --- a/ai_daily.py +++ b/ai_daily.py @@ -38,7 +38,7 @@ def _load_config() -> dict: if not _CONFIG_PATH.exists(): raise RuntimeError("config.yaml not found.") - return yaml.safe_load(_CONFIG_PATH.read_text()) + return yaml.safe_load(_CONFIG_PATH.read_text(encoding='utf-8')) def run(dry_run: bool = False) -> None: @@ -127,8 +127,8 @@ def run(dry_run: bool = False) -> None: print(f"ERROR: {e}", file=sys.stderr) sys.exit(1) - _INSIGHTS_FILE.write_text(json.dumps(insights, indent=2, default=str)) - print(f"[{datetime.now().strftime('%H:%M:%S')}] ✓ Insights written to {_INSIGHTS_FILE.name}") + _INSIGHTS_FILE.write_text(json.dumps(insights, indent=2, default=str), encoding='utf-8') + print(f"[{datetime.now().strftime('%H:%M:%S')}] [OK] Insights written to {_INSIGHTS_FILE.name}") # Print summary print(f"\nNarrative: {insights.get('narrative', '')[:200]}") @@ -142,7 +142,7 @@ def run(dry_run: bool = False) -> None: if flags: print(f"Risk flags: {len(flags)}") for f in flags: - print(f" ⚠ {f}") + print(f" [!] {f}") if __name__ == "__main__": diff --git a/butterfly-effect.spec b/butterfly-effect.spec index 61a7c5a..5642e2f 100644 --- a/butterfly-effect.spec +++ b/butterfly-effect.spec @@ -43,7 +43,10 @@ a = Analysis( hiddenimports=[ *playwright_hidden, *webview_hidden, - 'webview.platforms.cocoa', # macOS platform — not auto-detected + # Platform-specific pywebview backend — not auto-detected by PyInstaller + 'webview.platforms.cocoa' if sys.platform == 'darwin' else + 'webview.platforms.edgechromium' if sys.platform == 'win32' else + 'webview.platforms.gtk', # AI providers 'anthropic', 'openai', @@ -86,7 +89,8 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=str(SRC / 'static' / 'icon.icns') if sys.platform == 'darwin' else None, + icon=(str(SRC / 'static' / 'icon.icns') if sys.platform == 'darwin' else + str(SRC / 'static' / 'icon.ico') if sys.platform == 'win32' else None), ) coll = COLLECT( diff --git a/calendar_client.py b/calendar_client.py index 3133941..42cff41 100644 --- a/calendar_client.py +++ b/calendar_client.py @@ -31,7 +31,7 @@ def _load_ics_url() -> str: config_path = Path(__file__).parent / "config.yaml" if not config_path.exists(): raise RuntimeError("config.yaml not found.") - config = yaml.safe_load(config_path.read_text()) + config = yaml.safe_load(config_path.read_text(encoding='utf-8')) url = (config.get("calendar", {}).get("ics_url", "") or "").strip() if not url or "PASTE_YOUR" in url: raise RuntimeError( @@ -177,7 +177,7 @@ def get_events(horizon_days: int = 45) -> list[dict]: if __name__ == "__main__": config_path = Path(__file__).parent / "config.yaml" - config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {} + config = yaml.safe_load(config_path.read_text(encoding='utf-8')) if config_path.exists() else {} horizon = config.get("forecast", {}).get("horizon_days", 45) try: diff --git a/config.py b/config.py index e89a818..89113be 100644 --- a/config.py +++ b/config.py @@ -57,7 +57,7 @@ def _load_config() -> dict: raise RuntimeError( "config.yaml not found. Copy config.yaml.example and fill in values." ) - return yaml.safe_load(_CONFIG_PATH.read_text()) + return yaml.safe_load(_CONFIG_PATH.read_text(encoding='utf-8')) def _save_config(config: dict) -> None: @@ -100,7 +100,7 @@ def _env_key_status(key: str) -> str: return "configured" # Fallback: read .env file directly if _ENV_PATH.exists(): - for line in _ENV_PATH.read_text().splitlines(): + for line in _ENV_PATH.read_text(encoding='utf-8').splitlines(): if line.startswith(f"{key}="): val = line[len(f"{key}="):].strip().strip('"').strip("'") if val: @@ -111,7 +111,7 @@ def _env_key_status(key: str) -> str: def _update_env_key(key: str, value: str) -> None: """Update or add key=value in .env; also update os.environ in-memory.""" - lines = _ENV_PATH.read_text().splitlines() if _ENV_PATH.exists() else [] + lines = _ENV_PATH.read_text(encoding='utf-8').splitlines() if _ENV_PATH.exists() else [] found = False for i, line in enumerate(lines): if line.startswith(f"{key}="): @@ -130,7 +130,7 @@ def _delete_env_key(key: str) -> None: Used for privacy opt-out — leaves no empty key line behind. """ if _ENV_PATH.exists(): - lines = [l for l in _ENV_PATH.read_text().splitlines() + lines = [l for l in _ENV_PATH.read_text(encoding='utf-8').splitlines() if not l.startswith(f"{key}=")] _atomic_write(_ENV_PATH, "\n".join(lines) + ("\n" if lines else "")) os.environ.pop(key, None) @@ -142,7 +142,7 @@ def _read_env_value(key: str) -> str: if val: return val if _ENV_PATH.exists(): - for line in _ENV_PATH.read_text().splitlines(): + for line in _ENV_PATH.read_text(encoding='utf-8').splitlines(): if line.startswith(f"{key}="): return line[len(f"{key}="):].strip().strip('"').strip("'") return "" diff --git a/forecast_builder.py b/forecast_builder.py index 83bd16a..714512a 100644 --- a/forecast_builder.py +++ b/forecast_builder.py @@ -182,7 +182,7 @@ def _get_forecast_data(config: dict) -> dict: if config.get("demo_mode"): _demo = _BASE_DIR / "demo" / "forecast_data.json" if _demo.exists(): - data = json.loads(_demo.read_text()) + data = json.loads(_demo.read_text(encoding='utf-8')) _cache["data"] = data _cache["ts"] = time.time() return data @@ -352,7 +352,10 @@ def _matches_override(desc: str) -> bool: _fetched_dt = datetime.now() else: _fetched_dt = datetime.now() - result["refreshed_at"] = _fetched_dt.strftime("%A %b %-d, %Y at %-I:%M %p") + # %-d and %-I are POSIX-only (Linux/macOS); use datetime attributes for cross-platform + # zero-stripping so this works on Windows too. + _hour = _fetched_dt.strftime("%I:%M %p").lstrip("0") # "01:30 PM" → "1:30 PM" + result["refreshed_at"] = f"{_fetched_dt.strftime('%A %b')} {_fetched_dt.day}, {_fetched_dt.year} at {_hour}" result["horizon_days"] = horizon result["has_ai_predictions"] = bool(predicted_events) diff --git a/main.py b/main.py index ed30e5a..9fffbe0 100644 --- a/main.py +++ b/main.py @@ -234,10 +234,17 @@ def main(): import socket as _socket with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: if _s.connect_ex(('127.0.0.1', port)) == 0: + if sys.platform == 'win32': + _kill_hint = ( + f'netstat -ano | findstr :{port} ' + f'# note the PID, then: taskkill /PID /F' + ) + else: + _kill_hint = f'lsof -ti :{port} | xargs kill -9' print( f'\nERROR: Port {port} is already in use.\n' f'The app may already be running at http://localhost:{port}\n' - f'If not, run: lsof -ti :{port} | xargs kill -9\n', + f'If not, run: {_kill_hint}\n', file=sys.stderr, ) sys.exit(1) diff --git a/monarch_client.py b/monarch_client.py index ccb37ff..76fe0aa 100644 --- a/monarch_client.py +++ b/monarch_client.py @@ -118,7 +118,7 @@ async def _handle(response): return if self.debug: keys = list(body.keys()) if isinstance(body, dict) else type(body).__name__ - print(f" → JSON keys: {keys}") + print(f" -> JSON keys: {keys}") if isinstance(body, dict) and "data" in body: self._responses.append(body["data"]) @@ -281,7 +281,7 @@ async def _do_login(page, context): # Save browser state — all future runs will be headless await context.storage_state(path=str(BROWSER_STATE_FILE)) - print("✓ Session saved — future runs will be headless.") + print("[OK] Session saved - future runs will be headless.") async def _fetch_all(checking_account_id: str, history_days: int, _retried: bool = False): @@ -658,7 +658,7 @@ def list_accounts(debug: bool = False): args = parser.parse_args() config_path = Path(__file__).parent / "config.yaml" - config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {} + config = yaml.safe_load(config_path.read_text(encoding='utf-8')) if config_path.exists() else {} account_id = config.get("monarch", {}).get("checking_account_id", "") if args.list_accounts: diff --git a/paths.py b/paths.py index 52a4d1b..d8a6b29 100644 --- a/paths.py +++ b/paths.py @@ -15,6 +15,8 @@ if platform.system() == 'Darwin': APP_DATA_DIR = Path.home() / 'Library' / 'Application Support' / 'Butterfly Effect' +elif platform.system() == 'Windows': + APP_DATA_DIR = Path.home() / 'AppData' / 'Roaming' / 'Butterfly Effect' else: # Linux: follow XDG Base Directory specification _xdg = os.environ.get('XDG_DATA_HOME', '') diff --git a/requirements.txt b/requirements.txt index a5c1108..7e00717 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ flask -pywebview +pywebview; sys_platform == "darwin" # macOS PyInstaller bundle only (main.py/WKWebView); not used in source-run path playwright icalendar<7 # 7.x ships pre-compiled .pyc in wheels; breaks pip 25.x on Python 3.14 requests diff --git a/reset-for-testing.ps1 b/reset-for-testing.ps1 new file mode 100644 index 0000000..15f1f6e --- /dev/null +++ b/reset-for-testing.ps1 @@ -0,0 +1,145 @@ +# reset-for-testing.ps1 -- wipes all runtime files to simulate a fresh clone +# DO NOT run this on a live installation you care about. +# Mirrors reset-for-testing.sh behaviour exactly. +# +# NOTE: This file intentionally uses only ASCII characters. +# PowerShell 5.1 reads .ps1 files as Windows-1252 when no BOM is present; +# UTF-8 multi-byte sequences (em-dash, box-drawing chars, etc.) can contain +# byte 0x94 which Windows-1252 maps to a curly double-quote -- a valid PS +# string delimiter -- causing silent parse failures. + +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'SilentlyContinue' # be forgiving - deletion is best-effort + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +# -- Resolve APP_DATA_DIR (mirrors paths.py logic) ---------------------------- +$AppDataDir = Join-Path $env:APPDATA 'Butterfly Effect' +$PlaywrightCache = Join-Path $env:USERPROFILE '.cache\butterfly-effect\playwright' + +# -- Quick browser-only reset ------------------------------------------------- +if ($args.Count -gt 0 -and $args[0] -eq '--browser-only') { + if (Test-Path $PlaywrightCache) { + Remove-Item $PlaywrightCache -Recurse -Force + Write-Host "Deleted Playwright cache: $PlaywrightCache" + Write-Host "Chromium will re-download (~250 MB) on next launch or Connect to Monarch." + } else { + Write-Host "Playwright cache not found - already clean." + } + exit 0 +} + +Write-Host "This will kill the running server and delete all runtime files and the virtual environment." +Write-Host "" +Write-Host " App data dir : $AppDataDir" +Write-Host " Project dir : $ScriptDir" +Write-Host "" +Write-Host "Use this only for testing a fresh install simulation." +Write-Host "" +$delBrowser = Read-Host "Also delete the Playwright Chromium browser cache (~250 MB re-download)? (yes/no)" +Write-Host "" +$confirm = Read-Host "Are you sure you want to reset everything? (yes/no)" +if ($confirm -ne 'yes') { + Write-Host "Aborted." + exit 0 +} + +Write-Host "" + +# -- Kill running server ------------------------------------------------------ +$PidFile = Join-Path $ScriptDir '.server.pid' +if (Test-Path $PidFile) { + $pid_ = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($pid_) { + $proc = Get-Process -Id ([int]$pid_) -ErrorAction SilentlyContinue + if ($proc) { + Write-Host " killing server (pid $pid_)..." + Stop-Process -Id ([int]$pid_) -Force + Start-Sleep -Seconds 1 + } + } + Remove-Item $PidFile -Force -ErrorAction SilentlyContinue +} + +# Kill by port as well (covers run.ps1 and bundled app invocations) +$conns = Get-NetTCPConnection -LocalPort 5002 -ErrorAction SilentlyContinue +foreach ($c in $conns) { + if ($c.OwningProcess -and $c.OwningProcess -ne 0) { + Write-Host " killing process on port 5002 (pid $($c.OwningProcess))..." + Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + } +} + +# -- Delete runtime files from APP_DATA_DIR ----------------------------------- +Write-Host " deleting runtime files from: $AppDataDir" + +$AppDataFiles = @( + '.env', 'config.yaml', 'browser_state.json', 'insights.json', + 'payment_overrides.json', 'payment_monthly_amounts.json', + 'payment_skips.json', 'payment_day_overrides.json', 'scenarios.json', + 'dismissed_suggestions.json', 'monarch_accounts_cache.json', + 'monarch_raw_cache.json', 'user_context.md' +) + +foreach ($f in $AppDataFiles) { + $target = Join-Path $AppDataDir $f + if (Test-Path $target) { + Remove-Item $target -Force + Write-Host " deleted: $target" + } +} + +# -- Delete leftover runtime files from project dir (pre-migration remnants) -- +Write-Host " checking project dir for pre-migration remnants..." + +$ProjectFiles = @( + '.env', 'config.yaml', 'browser_state.json', 'insights.json', + 'payment_overrides.json', 'payment_monthly_amounts.json', + 'payment_skips.json', 'payment_day_overrides.json', 'scenarios.json', + 'dismissed_suggestions.json', 'monarch_accounts_cache.json', + 'monarch_raw_cache.json', 'user_context.md', + '.server.pid', '.server.log' +) + +foreach ($f in $ProjectFiles) { + $target = Join-Path $ScriptDir $f + if (Test-Path $target) { + Remove-Item $target -Force + Write-Host " deleted (legacy): $target" + } +} + +# -- Delete Playwright browser cache (optional) ------------------------------- +if ($delBrowser -eq 'yes') { + if (Test-Path $PlaywrightCache) { + Remove-Item $PlaywrightCache -Recurse -Force + Write-Host " deleted Playwright cache: $PlaywrightCache" + } else { + Write-Host " Playwright cache not found (already clean)" + } +} else { + Write-Host " skipping Playwright cache (will reuse existing browser)" +} + +# -- __pycache__ directories -------------------------------------------------- +Get-ChildItem $ScriptDir -Recurse -Directory -Filter '__pycache__' | ForEach-Object { + Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " deleted: $($_.FullName)" +} + +# -- Virtual environment ------------------------------------------------------ +$Venv = Join-Path $ScriptDir '.venv' +if (Test-Path $Venv) { + # Ensure all files are writable before removal (mirrors chmod -R u+w in run.sh) + Get-ChildItem $Venv -Recurse -File | ForEach-Object { + $_.Attributes = $_.Attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly) + } + Remove-Item $Venv -Recurse -Force + Write-Host " deleted: $Venv" +} + +Write-Host "" +Write-Host "Done. Run 'Start Butterfly Effect - Windows.cmd' to test a fresh install." diff --git a/reset.cmd b/reset.cmd new file mode 100644 index 0000000..08200dc --- /dev/null +++ b/reset.cmd @@ -0,0 +1,5 @@ +@echo off +:: Butterfly Effect — Windows reset / repair launcher +:: Double-click this file or run it from cmd.exe to wipe runtime files and the virtual +:: environment, simulating a fresh install. Mirrors reset-for-testing.sh on Mac/Linux. +powershell.exe -ExecutionPolicy Bypass -NoProfile -File "%~dp0reset-for-testing.ps1" %* diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..9c65512 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,356 @@ +# run.ps1 — Butterfly Effect launcher for Windows +# Usage: double-click "Start Butterfly Effect - Windows.cmd", or: powershell -ExecutionPolicy Bypass -File run.ps1 +# +# Mirrors run.sh logic exactly: +# 1. Detect Python 3.11+ +# 2. Create / verify .venv +# 3. Upgrade pip if needed; pip install -r requirements.txt +# 4. Install Playwright Chromium on first run (~250 MB, one-time) +# 5. Start Flask server and open startup.html in the default browser + +#Requires -Version 5.1 +Set-StrictMode -Version Latest +# Use 'Continue' so that native commands writing to stderr (pip warnings, py.exe launcher +# notices, playwright progress, etc.) don't trigger NativeCommandError and crash the script. +# Real failures are caught explicitly via $LASTEXITCODE checks throughout. +$ErrorActionPreference = 'Continue' +# Set UTF-8 console output so Unicode characters (ellipsis, progress bars, etc.) render correctly. +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +# ── Status file & JSONP server for startup screen progress ─────────────────── +$StatusFile = Join-Path $env:TEMP "butterfly-status-$PID.json" +$StatusServerJob = $null + +function Write-Status { + param( + [string]$Stage, + [int]$Pct, + [string]$Detail, + [int]$Step = 0, + [int]$Total = 0 + ) + $Detail = $Detail -replace '"', '\"' + $json = "{`"stage`":`"$Stage`",`"pct`":$Pct,`"detail`":`"$Detail`",`"step`":$Step,`"total`":$Total}" + [System.IO.File]::WriteAllText($StatusFile, $json) +} + +function Start-StatusServer { + # Inline Python stdlib HTTP server — no pip packages needed, runs before venv exists. + $pyCode = @" +import http.server, sys, pathlib +sf = sys.argv[1] +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_GET(self): + try: body = ('window.__BF_STATUS=' + pathlib.Path(sf).read_text(encoding='utf-8').strip() + ';').encode() + except: body = b'window.__BF_STATUS=null;' + self.send_response(200) + self.send_header('Content-Type', 'application/javascript; charset=utf-8') + self.send_header('Content-Length', len(body)) + self.end_headers() + self.wfile.write(body) +try: + http.server.HTTPServer(('127.0.0.1', 5003), H).serve_forever() +except Exception: + pass +"@ + $script:StatusServerJob = Start-Job -ScriptBlock { + param($python, $code, $sf) + & $python -c $code $sf + } -ArgumentList $script:PYTHON, $pyCode, $StatusFile +} + +function Stop-StatusServer { + if ($script:StatusServerJob) { + Stop-Job $script:StatusServerJob -ErrorAction SilentlyContinue + Remove-Job $script:StatusServerJob -ErrorAction SilentlyContinue + $script:StatusServerJob = $null + } + if (Test-Path $StatusFile) { Remove-Item $StatusFile -ErrorAction SilentlyContinue } +} + +# Ensure cleanup on exit +$null = Register-EngineEvent PowerShell.Exiting -Action { Stop-StatusServer } + +# ── Helper: open startup.html in the default browser ───────────────────────── +function Open-Startup { + param([string]$Params = '') + # Write a temp copy so we can inject window.__BF_PARAMS without modifying the source + $tmp = Join-Path $env:TEMP "butterfly-startup-$PID.html" + Copy-Item (Join-Path $ScriptDir 'startup.html') $tmp -Force + if ($Params) { + $escaped = $Params -replace "'", "''" + $inject = "" + (Get-Content $tmp -Raw -Encoding UTF8) -replace '', $inject | Set-Content $tmp -Encoding UTF8 + } + Start-Process "file:///$($tmp -replace '\\','/')" +} + +# ── Python detection ────────────────────────────────────────────────────────── +$script:PYTHON = $null +# 'py' is the Windows Python Launcher (installed to System32, never shadowed by App Execution +# Aliases). It is the most reliable way to find Python on Windows, so check it first. +foreach ($candidate in @('py', 'python', 'python3', 'python3.13', 'python3.12', 'python3.11')) { + $found = Get-Command $candidate -ErrorAction SilentlyContinue + if ($found) { + # Wrap in try/catch: Windows App Execution Aliases write to stderr ("Python was not + # found$([char]8230)") which triggers NativeCommandError under $ErrorActionPreference='Stop'. + try { + $null = & $candidate -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' 2>&1 + if ($LASTEXITCODE -eq 0) { $script:PYTHON = $candidate; break } + } catch { } + } +} + +# Allow forcing the no-python error for testing. +# Preferred: "Start Butterfly Effect - Windows.cmd" --simulate-no-python (flag passed through via %*) +# Fallback: set SIMULATE_NO_PYTHON=1 && "Start Butterfly Effect - Windows.cmd" (must be same cmd.exe window) +if ($env:SIMULATE_NO_PYTHON -eq '1' -or $args -contains '--simulate-no-python') { + $script:PYTHON = $null +} + +if (-not $script:PYTHON) { + Open-Startup 'e=nopython&os=windows' + Write-Host '' + Write-Host ('-' * 52) + Write-Host ' Python 3.11 or higher is required.' + Write-Host '' + Write-Host ' Option 1 - Direct download:' + Write-Host ' https://python.org/downloads' + Write-Host ' (Check "Add Python to PATH" during install)' + Write-Host '' + Write-Host ' Option 2 - winget (Windows 11 / updated Windows 10):' + Write-Host ' winget install Python.Python.3.12' + Write-Host '' + Write-Host ' After installing, open a new Command Prompt window' + Write-Host ' and run "Start Butterfly Effect - Windows.cmd" again.' + Write-Host ('-' * 52) + Write-Host '' + exit 1 +} + +# ── Detect which setup steps are needed (for accurate step counter) ─────────── +$Venv = Join-Path $ScriptDir '.venv' +$PlaywrightCache = Join-Path $env:USERPROFILE '.cache\butterfly-effect\playwright' + +$VenvOk = $false +$VenvPy = Join-Path $Venv 'Scripts\python.exe' +$VenvPip = Join-Path $Venv 'Scripts\pip.exe' +if ((Test-Path $VenvPy) -and (Test-Path $VenvPip)) { + $null = & $VenvPip --version 2>$null + if ($LASTEXITCODE -eq 0) { $VenvOk = $true } +} + +# Detect pip 25.x / Python 3.14 installation bug (same check as run.sh) +if ($VenvOk) { + $null = & $VenvPip show icalendar 2>$null + if ($LASTEXITCODE -eq 0) { + & $VenvPy -c 'from icalendar import Calendar' 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host ' Detected broken icalendar install (pip 25.x + Python 3.14 bug). Rebuilding venv...' + $VenvOk = $false + } + } +} +if ($VenvOk) { + $null = & $VenvPip show websockets 2>$null + if ($LASTEXITCODE -eq 0) { + & $VenvPy -c 'import websockets.frames' 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host ' Detected broken websockets install (pip 25.x + Python 3.14 bug). Rebuilding venv...' + $VenvOk = $false + } + } +} + +$NeedPlaywright = -not (Test-Path $PlaywrightCache) -or + (@(Get-ChildItem $PlaywrightCache -ErrorAction SilentlyContinue).Count -eq 0) + +$TotalSteps = 2 # pip check + start server always happen +if (-not $VenvOk) { $TotalSteps++ } +if ($NeedPlaywright) { $TotalSteps++ } +$Step = 0 + +# ── Open startup page and start live-progress status server ─────────────────── +Write-Status 'starting' 2 "Starting Butterfly Effect$([char]8230)" 0 $TotalSteps +Start-StatusServer +Start-Sleep -Milliseconds 300 # give status server a moment to bind port 5003 +Open-Startup + +# ── Application data directory ──────────────────────────────────────────────── +$AppData = Join-Path $env:APPDATA 'Butterfly Effect' +New-Item -ItemType Directory -Path $AppData -Force -ErrorAction SilentlyContinue | Out-Null + +# ── One-time migration: move existing data files to AppData ─────────────────── +$MigrateFiles = @( + 'config.yaml', '.env', 'browser_state.json', 'insights.json', 'user_context.md', + 'payment_overrides.json', 'payment_skips.json', 'payment_monthly_amounts.json', + 'payment_day_overrides.json', 'scenarios.json', 'monarch_accounts_cache.json', + 'dismissed_suggestions.json' +) +foreach ($f in $MigrateFiles) { + $src = Join-Path $ScriptDir $f + $dst = Join-Path $AppData $f + if ((Test-Path $src) -and -not (Test-Path $dst)) { + Move-Item $src $dst + Write-Host "Migrated $f to AppData" + } +} + +# ── Bootstrap config files on first run ────────────────────────────────────── +if (-not (Test-Path (Join-Path $AppData 'config.yaml'))) { + Write-Host 'First run: creating config.yaml...' + Copy-Item (Join-Path $ScriptDir 'config.yaml.example') (Join-Path $AppData 'config.yaml') +} +if (-not (Test-Path (Join-Path $AppData '.env'))) { + Write-Host 'First run: creating .env (credentials file)...' + New-Item -ItemType File -Path (Join-Path $AppData '.env') | Out-Null +} + +# ── Virtual environment ─────────────────────────────────────────────────────── +if (-not $VenvOk) { + $Step++ + Write-Status 'venv' 5 "Creating Python environment$([char]8230)" $Step $TotalSteps + Write-Host "Creating virtual environment at $Venv ..." + if (Test-Path $Venv) { Remove-Item $Venv -Recurse -Force } + & $script:PYTHON -m venv $Venv + if ($LASTEXITCODE -ne 0) { + Open-Startup 'e=novenv&os=windows' + Write-Host 'Error: could not create virtual environment. See the browser window for details.' + exit 1 + } + # Upgrade pip to avoid the pip 25.x wheel-install bug. + # Use the venv's own python (not the system launcher) so --target isn't needed + # and py.exe stderr noise can't trigger NativeCommandError. + try { + & $VenvPy -m pip install --upgrade pip --quiet 2>&1 | Out-Null + } catch { } + Write-Host " pip upgraded." +} + +# Activate venv for this session +$env:VIRTUAL_ENV = $Venv +$env:PATH = (Join-Path $Venv 'Scripts') + ';' + $env:PATH +$pip = Join-Path $Venv 'Scripts\pip.exe' +$python = Join-Path $Venv 'Scripts\python.exe' + +# ── Dependencies ────────────────────────────────────────────────────────────── +$Step++ +Write-Status 'pip' 18 "Installing Python packages$([char]8230)" $Step $TotalSteps +Write-Host -NoNewline 'Checking dependencies' + +$pipLog = Join-Path $env:TEMP "butterfly-pip-$PID.log" +$pipJob = Start-Job -ScriptBlock { + param($pip, $req, $log) + # Must set explicitly — Start-Job runspaces don't inherit the parent's preference, + # so pip stderr notices would otherwise trigger NativeCommandError and fail the job. + $ErrorActionPreference = 'Continue' + & $pip install -r $req 2>&1 | Tee-Object -FilePath $log | Out-Null + $LASTEXITCODE # emit exit code as job output, captured by Receive-Job below +} -ArgumentList $pip, (Join-Path $ScriptDir 'requirements.txt'), $pipLog + +while ($pipJob.State -eq 'Running') { + Write-Host -NoNewline '.' + Start-Sleep -Seconds 1 +} +$pipExitCode = Receive-Job $pipJob -Wait +$pipFailed = $pipJob.State -eq 'Failed' -or ($pipExitCode -ne 0) +Remove-Job $pipJob + +if ($pipFailed) { + Write-Host ' failed.' + Write-Host '' + Write-Host 'pip output:' + Get-Content $pipLog -Encoding UTF8 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } + Remove-Item $pipLog -ErrorAction SilentlyContinue + Open-Startup 'e=pipfail&os=windows' + Write-Host '' + Write-Host 'Error: dependency install failed. See above and the browser window for details.' + exit 1 +} +Remove-Item $pipLog -ErrorAction SilentlyContinue +Write-Host ' done.' +Write-Status 'pip' 42 'Python packages ready' $Step $TotalSteps + +# ── Playwright browser ──────────────────────────────────────────────────────── +$env:PLAYWRIGHT_BROWSERS_PATH = $PlaywrightCache + +if ($NeedPlaywright) { + $Step++ + Write-Status 'playwright' 47 "Downloading Chromium browser (~250 MB)$([char]8230)" $Step $TotalSteps + Write-Host '' + Write-Host 'Installing Chromium browser (first time only, ~250 MB)...' + + New-Item -ItemType Directory -Path $PlaywrightCache -Force | Out-Null + + $pwLog = Join-Path $env:TEMP "butterfly-pw-$PID.log" + '' | Set-Content $pwLog -Encoding UTF8 + + $pwJob = Start-Job -ScriptBlock { + param($python, $browsers, $log) + # Must set explicitly — Start-Job runspaces don't inherit the parent's preference. + # Node.js (used by playwright) writes deprecation warnings to stderr which would + # otherwise trigger NativeCommandError and fail the job. + $ErrorActionPreference = 'Continue' + $env:PLAYWRIGHT_BROWSERS_PATH = $browsers + & $python -m playwright install chromium *>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + } -ArgumentList $python, $PlaywrightCache, $pwLog + + Write-Host -NoNewline ' Downloading' + while ($pwJob.State -eq 'Running') { + Write-Host -NoNewline '.' + # Scan log for progress lines: "X.X MiB / Y.Y MiB" + $prog = Select-String -Path $pwLog -Pattern '(\d+\.?\d*)\s+MiB\s*/\s*(\d+\.?\d*)\s+MiB' -ErrorAction SilentlyContinue | Select-Object -Last 1 + if ($prog) { + $mb = $prog.Matches[0].Groups[1].Value + $mbt = $prog.Matches[0].Groups[2].Value + $dp = [int]([double]$mb * 100 / [double]$mbt) + if ($dp -gt 100) { $dp = 100 } + $op = 47 + [int]($dp * 44 / 100) + Write-Status 'playwright' $op "Downloading Chromium: $mb of $mbt MB" $Step $TotalSteps + } + Start-Sleep -Seconds 2 + } + Write-Host '' + + # -ErrorAction SilentlyContinue suppresses NativeCommandError records that + # Receive-Job would otherwise surface from Node.js stderr inside the job. + $null = Receive-Job $pwJob -Wait -ErrorAction SilentlyContinue + $pwExit = if ($pwJob.State -eq 'Failed') { 1 } else { 0 } + Remove-Job $pwJob + + Write-Host " [playwright] install log:" + Get-Content $pwLog -Encoding UTF8 -ErrorAction SilentlyContinue | + Where-Object { $_ -notmatch '^\s*\|' } | + Where-Object { $_ -notmatch '^\s*(At line:\d|\+\s+[~&]|\+\s+(CategoryInfo|FullyQualifiedErrorId))' } | + ForEach-Object { Write-Host " $_" } + Remove-Item $pwLog -ErrorAction SilentlyContinue + + if ($pwExit -ne 0) { + Write-Host '' + Write-Host "WARNING: Chromium browser install may have failed." + Write-Host " The app will still start, but 'Connect to Monarch' may not work." + Write-Host " To retry: `$env:PLAYWRIGHT_BROWSERS_PATH='$PlaywrightCache'; python -m playwright install chromium" + } else { + Write-Status 'playwright_done' 93 'Browser download complete' $Step $TotalSteps + Write-Host ' Browser install complete.' + } +} + +# ── Launch ──────────────────────────────────────────────────────────────────── +$Step++ +Write-Status 'starting' 96 "Starting server$([char]8230)" $Step $TotalSteps +Write-Host 'Starting Butterfly Effect at http://localhost:5002' +Stop-StatusServer # Flask server takes over; status server no longer needed + +# Force UTF-8 for all Python I/O so Unicode characters in print() statements +# (e.g. checkmarks, arrows, em-dashes in ai_daily.py and ai_advisor.py) don't +# crash with UnicodeEncodeError on Windows consoles that default to cp1252. +$env:PYTHONUTF8 = '1' + +& $python (Join-Path $ScriptDir 'server.py') diff --git a/server.ps1 b/server.ps1 new file mode 100644 index 0000000..03f7f11 --- /dev/null +++ b/server.ps1 @@ -0,0 +1,173 @@ +# server.ps1 -- Butterfly Effect server management for Windows +# Usage: .\server.ps1 [start|stop|restart|status|logs] +# Mirrors server.sh behaviour exactly. +# +# NOTE: This file intentionally uses only ASCII characters. +# PowerShell 5.1 reads .ps1 files as Windows-1252 when no BOM is present; +# UTF-8 multi-byte sequences (em-dash, box-drawing chars, etc.) can contain +# byte 0x94 which Windows-1252 maps to a curly double-quote -- a valid PS +# string delimiter -- causing silent parse failures. + +#Requires -Version 5.1 +Set-StrictMode -Version Latest +# Use 'Continue' so native commands writing to stderr (pip warnings, etc.) +# don't trigger NativeCommandError. Real failures are caught via $LASTEXITCODE. +$ErrorActionPreference = 'Continue' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +$Venv = Join-Path $ScriptDir '.venv' +$PidFile = Join-Path $ScriptDir '.server.pid' +$LogFile = Join-Path $ScriptDir '.server.log' +$Port = 5002 + +# -- Helpers ------------------------------------------------------------------ + +function Get-VenvPython { Join-Path $Venv 'Scripts\python.exe' } +function Get-VenvPip { Join-Path $Venv 'Scripts\pip.exe' } + +function Ensure-Venv { + $py = Get-VenvPython + $pip = Get-VenvPip + $healthy = (Test-Path $py) -and (Test-Path $pip) + if ($healthy) { + & $pip --version 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { $healthy = $false } + } + if (-not $healthy) { + Write-Host "Virtual environment missing or corrupted - rebuilding at $Venv ..." + if (Test-Path $Venv) { Remove-Item $Venv -Recurse -Force } + $python = $null + foreach ($c in @('py', 'python', 'python3', 'python3.13', 'python3.12', 'python3.11')) { + $found = Get-Command $c -ErrorAction SilentlyContinue + if ($found) { + try { + $null = & $c -c 'import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)' 2>&1 + if ($LASTEXITCODE -eq 0) { $python = $c; break } + } catch { } + } + } + if (-not $python) { + Write-Host 'x Python 3.11+ not found. Install from https://python.org' + exit 1 + } + & $python -m venv $Venv + & (Get-VenvPip) install -q -r (Join-Path $ScriptDir 'requirements.txt') + Write-Host 'v Virtual environment rebuilt' + } +} + +function Is-Running { + if (Test-Path $PidFile) { + $pid_ = Get-Content $PidFile -ErrorAction SilentlyContinue + if ($pid_) { + $proc = Get-Process -Id ([int]$pid_) -ErrorAction SilentlyContinue + if ($proc) { return $true } + } + Remove-Item $PidFile -ErrorAction SilentlyContinue + } + return $false +} + +function Kill-PortProcess { + param([int]$PortNum) + $conns = Get-NetTCPConnection -LocalPort $PortNum -ErrorAction SilentlyContinue + foreach ($c in $conns) { + if ($c.OwningProcess -and $c.OwningProcess -ne 0) { + Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue + Write-Host "v Killed process on port $PortNum (PID $($c.OwningProcess))" + } + } +} + +# -- Commands ----------------------------------------------------------------- + +function cmd_status { + if (Is-Running) { + $pid_ = Get-Content $PidFile + Write-Host "v Server is running (PID $pid_) at http://localhost:$Port" + } else { + Write-Host "x Server is not running" + } +} + +function cmd_start { + if (Is-Running) { + $pid_ = Get-Content $PidFile + Write-Host "Server already running (PID $pid_). Use '.\server.ps1 restart' to restart." + return + } + + Ensure-Venv + + $py = Get-VenvPython + $pip = Get-VenvPip + & $pip install -q -r (Join-Path $ScriptDir 'requirements.txt') 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host 'Error: Failed to install Python dependencies. Check your internet connection.' + exit 1 + } + + Write-Host 'Starting Butterfly Effect...' + # Force UTF-8 for all Python I/O so Unicode characters in print() statements + # (checkmarks, arrows, em-dashes) don't crash on Windows consoles using cp1252. + $env:PYTHONUTF8 = '1' + $ErrFile = $LogFile -replace '\.log$', '.err.log' + $proc = Start-Process -FilePath $py ` + -ArgumentList (Join-Path $ScriptDir 'server.py') ` + -RedirectStandardOutput $LogFile ` + -RedirectStandardError $ErrFile ` + -WindowStyle Hidden ` + -PassThru + $proc.Id | Set-Content $PidFile + Start-Sleep -Seconds 1 + + if (Is-Running) { + Write-Host "v Server started (PID $($proc.Id)) at http://localhost:$Port" + } else { + Write-Host "x Server failed to start. Check logs:" + Get-Content $LogFile -Tail 20 -ErrorAction SilentlyContinue + Get-Content $ErrFile -Tail 20 -ErrorAction SilentlyContinue + exit 1 + } +} + +function cmd_stop { + if (Is-Running) { + $pid_ = [int](Get-Content $PidFile) + Stop-Process -Id $pid_ -Force -ErrorAction SilentlyContinue + Remove-Item $PidFile -ErrorAction SilentlyContinue + Write-Host "v Server stopped (PID $pid_)" + } + # Also kill any orphaned process on the port + Kill-PortProcess -PortNum $Port +} + +function cmd_restart { + cmd_stop + Start-Sleep -Seconds 1 + cmd_start +} + +function cmd_logs { + if (-not (Test-Path $LogFile)) { + Write-Host "No log file found ($LogFile). Has the server been started?" + exit 1 + } + Get-Content $LogFile -Tail 50 -Wait +} + +# -- Dispatch ----------------------------------------------------------------- +$Cmd = if ($args.Count -gt 0) { $args[0] } else { 'status' } +switch ($Cmd) { + 'start' { cmd_start } + 'stop' { cmd_stop } + 'restart' { cmd_restart } + 'status' { cmd_status } + 'logs' { cmd_logs } + default { + Write-Host "Usage: .\server.ps1 [start|stop|restart|status|logs]" + exit 1 + } +} diff --git a/server.py b/server.py index cd33ae3..de73bed 100644 --- a/server.py +++ b/server.py @@ -127,7 +127,7 @@ def _harden_file_permissions() -> None: # ── App version (read once from VERSION file) ─────────────────────────────── _VERSION_FILE = BASE_DIR / "VERSION" -_APP_VERSION = _VERSION_FILE.read_text().strip() if _VERSION_FILE.exists() else "dev" +_APP_VERSION = _VERSION_FILE.read_text(encoding='utf-8').strip() if _VERSION_FILE.exists() else "dev" @app.context_processor @@ -285,7 +285,7 @@ def api_ai_insights(): }), 404 try: - insights = json.loads(_INSIGHTS_FILE.read_text()) + insights = json.loads(_INSIGHTS_FILE.read_text(encoding='utf-8')) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 @@ -420,7 +420,7 @@ def api_user_context(): """Return the current user_context.md content.""" if not _USER_CONTEXT_FILE.exists(): return jsonify({"content": ""}) - return jsonify({"content": _USER_CONTEXT_FILE.read_text()}) + return jsonify({"content": _USER_CONTEXT_FILE.read_text(encoding='utf-8')}) @app.route("/api/corrections", methods=["GET"]) @@ -482,7 +482,7 @@ def api_feedback(): bullet = f"- [{today}] {text}" if _USER_CONTEXT_FILE.exists(): - content = _USER_CONTEXT_FILE.read_text() + content = _USER_CONTEXT_FILE.read_text(encoding='utf-8') else: content = _USER_CONTEXT_TEMPLATE @@ -737,7 +737,7 @@ def settings(): "generated_at": insights.get("generated_at", ""), "token_usage": insights.get("token_usage"), }, - user_context=_USER_CONTEXT_FILE.read_text() if _USER_CONTEXT_FILE.exists() else "", + user_context=_USER_CONTEXT_FILE.read_text(encoding='utf-8') if _USER_CONTEXT_FILE.exists() else "", setup_mode=setup_mode, setup_status=status, ) @@ -824,7 +824,7 @@ def api_settings_monarch(): acct_name = account_id # fallback: display raw ID if _ACCOUNTS_CACHE_FILE.exists(): try: - cached_accts = json.loads(_ACCOUNTS_CACHE_FILE.read_text()) + cached_accts = json.loads(_ACCOUNTS_CACHE_FILE.read_text(encoding='utf-8')) match = next( (a for a in cached_accts if str(a.get("id", "")) == str(account_id)), None, @@ -947,6 +947,7 @@ def write(self, text): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding='utf-8', # explicit UTF-8 so Unicode in ai_daily output is decoded correctly on Windows bufsize=1, # line-buffered so output arrives incrementally cwd=str(Path(__file__).parent), ) @@ -1040,10 +1041,19 @@ def api_restart_server(): def _restart(): import time time.sleep(1.5) - subprocess.run( - [str(Path(__file__).parent / "server.sh"), "restart"], - cwd=str(Path(__file__).parent), - ) + import sys as _sys + _base = Path(__file__).parent + if _sys.platform == 'win32': + subprocess.run( + ['powershell', '-ExecutionPolicy', 'Bypass', '-File', + str(_base / 'server.ps1'), 'restart'], + cwd=str(_base), + ) + else: + subprocess.run( + [str(_base / 'server.sh'), 'restart'], + cwd=str(_base), + ) threading.Thread(target=_restart, daemon=True).start() return jsonify({"ok": True}) @@ -1061,30 +1071,35 @@ def _do_reset(): base = Path(__file__).parent - # Glob patterns catch exact names AND macOS numbered duplicates - # e.g. "config 3.yaml", "browser_state 2.json", "user_context 2.md" + # ── User data files — live in APP_DATA_DIR on every platform ────────── + # (macOS: ~/Library/Application Support/Butterfly Effect/ + # Windows: %APPDATA%\Butterfly Effect\ + # Linux: ~/.local/share/butterfly-effect/) + # Glob patterns also catch macOS numbered duplicates created by iCloud / + # Proton Drive sync conflicts, e.g. "config 3.yaml", "browser_state 2.json" # All JSON data files (payment_overrides, scenarios, accounts cache, etc.) - for f in base.glob("*.json"): + for f in APP_DATA_DIR.glob("*.json"): try: f.unlink(missing_ok=True) except Exception: pass - # config.yaml + "config 3.yaml" etc. — does NOT match config.yaml.example - for f in base.glob("config*.yaml"): + # config.yaml — does NOT match config.yaml.example (which stays in base) + for f in APP_DATA_DIR.glob("config*.yaml"): try: f.unlink(missing_ok=True) except Exception: pass - # .env + ".env 2" etc. - for f in base.glob(".env*"): + # .env credentials file + for f in APP_DATA_DIR.glob(".env*"): try: f.unlink(missing_ok=True) except Exception: pass - # user_context.md + "user_context 2.md" etc. - for f in base.glob("user_context*"): + # user_context.md AI corrections file + for f in APP_DATA_DIR.glob("user_context*"): try: f.unlink(missing_ok=True) except Exception: pass - # .server.pid, .server.log + numbered duplicates + # ── Project-root runtime files ───────────────────────────────────────── + # .server.pid and .server.log are written next to the script by server.ps1 / server.sh for f in base.glob(".server*"): try: f.unlink(missing_ok=True) except Exception: pass @@ -1106,10 +1121,18 @@ def _do_reset(): _clear_all_cache() time.sleep(1.0) - subprocess.run( - [str(base / "server.sh"), "restart"], - cwd=str(base), - ) + import sys as _sys + if _sys.platform == 'win32': + subprocess.run( + ['powershell', '-ExecutionPolicy', 'Bypass', '-File', + str(base / 'server.ps1'), 'restart'], + cwd=str(base), + ) + else: + subprocess.run( + [str(base / 'server.sh'), 'restart'], + cwd=str(base), + ) threading.Thread(target=_do_reset, daemon=True).start() return jsonify({"ok": True}) @@ -1186,7 +1209,7 @@ def api_monarch_accounts(): age = datetime.now().timestamp() - _ACCOUNTS_CACHE_FILE.stat().st_mtime if age < cache_max_age: try: - return jsonify(json.loads(_ACCOUNTS_CACHE_FILE.read_text())) + return jsonify(json.loads(_ACCOUNTS_CACHE_FILE.read_text(encoding='utf-8'))) except Exception: pass # fall through to re-fetch if cache is corrupt diff --git a/startup.html b/startup.html index 4716072..2474ded 100644 --- a/startup.html +++ b/startup.html @@ -273,7 +273,7 @@

Butterfly Effect

-
After installing, close this window and run the app again.
+
After installing, open a new Command Prompt window, then run the app again.
@@ -294,6 +294,13 @@

Butterfly Effect

+ 'brew install python@3.12' + '

Option 2 \u2014 Direct download

' + '

Visit python.org/downloads and install Python 3.12 or newer.

'; + if (os === 'windows') return '' + + '

Butterfly Effect needs Python 3.11+.

' + + '

Option 1 \u2014 Direct download

' + + '

Visit python.org/downloads and install Python 3.12 or newer.

' + + '

\u2139\ufe0f During installation, check \u201cAdd Python to PATH\u201d.

' + + '

Option 2 \u2014 winget (Windows 11 / updated Windows 10)

' + + 'winget install Python.Python.3.12'; return '' + '

Butterfly Effect needs Python 3.11+.

' + '

Ubuntu / Debian:

' @@ -307,6 +314,9 @@

Butterfly Effect

novenv: { title: '\u26a0\ufe0f Python venv module is missing', body: function (os) { + if (os === 'windows') return '' + + '

Python is installed but the virtual environment could not be created.

' + + '

Ensure Python 3.12+ was installed using the standard installer from python.org/downloads, then close this window and run "Start Butterfly Effect - Windows.cmd" again.

'; return '' + '

Python is installed but the venv module is missing. On Ubuntu, Debian, and Linux Mint it is a separate package:

' + 'sudo apt install python3-venv' @@ -316,6 +326,11 @@

Butterfly Effect

pipfail: { title: '\u26a0\ufe0f Dependency installation failed', body: function (os) { + if (os === 'windows') return '' + + '

pip could not install the required packages. This usually means a corrupted Python environment.

' + + '

Try resetting the virtual environment:

' + + 'rmdir /s /q .venv\n"Start Butterfly Effect - Windows.cmd"' + + '

If the problem persists, check your internet connection and try again.

'; return '' + '

pip could not install the required packages. This usually means a corrupted Python environment.

' + '

Try resetting the virtual environment:

' diff --git a/storage.py b/storage.py index fcc96f2..1d9c19a 100644 --- a/storage.py +++ b/storage.py @@ -79,23 +79,23 @@ def _check_list_schema( Logs a warning (to stdout) for each dropped record — never raises. """ if not isinstance(data, list): - print(f"[schema] {path.name}: expected a list, got {type(data).__name__} — ignoring file") + print(f"[schema] {path.name}: expected a list, got {type(data).__name__} - ignoring file") return [] good = [] for i, item in enumerate(data): if not isinstance(item, dict): - print(f"[schema] {path.name}[{i}]: expected dict, got {type(item).__name__} — skipping") + print(f"[schema] {path.name}[{i}]: expected dict, got {type(item).__name__} - skipping") continue bad = False for k in required_str_keys: if not isinstance(item.get(k), str) or not item[k].strip(): - print(f"[schema] {path.name}[{i}]: missing/invalid string field '{k}' — skipping") + print(f"[schema] {path.name}[{i}]: missing/invalid string field '{k}' - skipping") bad = True break if not bad: for k in required_num_keys: if not isinstance(item.get(k), (int, float)): - print(f"[schema] {path.name}[{i}]: missing/invalid numeric field '{k}' — skipping") + print(f"[schema] {path.name}[{i}]: missing/invalid numeric field '{k}' - skipping") bad = True break if not bad: @@ -114,7 +114,7 @@ def _parse_corrections() -> list[dict]: """ if not _USER_CONTEXT_FILE.exists(): return [] - lines = _USER_CONTEXT_FILE.read_text().splitlines() + lines = _USER_CONTEXT_FILE.read_text(encoding='utf-8').splitlines() results: list[dict] = [] cur_type = "Correction" # tracks section for old-format migration for i, raw in enumerate(lines): @@ -153,7 +153,7 @@ def _load_scenarios() -> list[dict]: if not _SCENARIOS_FILE.exists(): return [] try: - data = json.loads(_SCENARIOS_FILE.read_text()) + data = json.loads(_SCENARIOS_FILE.read_text(encoding='utf-8')) return _check_list_schema( _SCENARIOS_FILE, data, required_str_keys=("date", "description"), @@ -168,17 +168,17 @@ def _load_payment_overrides() -> dict: if not _PAYMENT_OVERRIDES_FILE.exists(): return {} try: - data = json.loads(_PAYMENT_OVERRIDES_FILE.read_text()) + data = json.loads(_PAYMENT_OVERRIDES_FILE.read_text(encoding='utf-8')) if not isinstance(data, dict): - print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}: expected a dict — ignoring file") + print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}: expected a dict - ignoring file") return {} good = {} for k, v in data.items(): if not isinstance(v, dict): - print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}[{k!r}]: expected dict value — skipping") + print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}[{k!r}]: expected dict value - skipping") continue if not isinstance(v.get("name"), str) or not isinstance(v.get("amount"), (int, float)): - print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}[{k!r}]: missing name/amount — skipping") + print(f"[schema] {_PAYMENT_OVERRIDES_FILE.name}[{k!r}]: missing name/amount - skipping") continue good[k] = v return good @@ -191,7 +191,7 @@ def _load_payment_skips() -> list: if not _PAYMENT_SKIPS_FILE.exists(): return [] try: - data = json.loads(_PAYMENT_SKIPS_FILE.read_text()) + data = json.loads(_PAYMENT_SKIPS_FILE.read_text(encoding='utf-8')) return _check_list_schema( _PAYMENT_SKIPS_FILE, data, required_str_keys=("name", "month"), @@ -205,7 +205,7 @@ def _load_payment_monthly_amounts() -> list: if not _PAYMENT_MONTHLY_AMOUNTS_FILE.exists(): return [] try: - data = json.loads(_PAYMENT_MONTHLY_AMOUNTS_FILE.read_text()) + data = json.loads(_PAYMENT_MONTHLY_AMOUNTS_FILE.read_text(encoding='utf-8')) return _check_list_schema( _PAYMENT_MONTHLY_AMOUNTS_FILE, data, required_str_keys=("name",), @@ -220,7 +220,7 @@ def _load_insights() -> dict | None: if not _INSIGHTS_FILE.exists(): return None try: - return json.loads(_INSIGHTS_FILE.read_text()) + return json.loads(_INSIGHTS_FILE.read_text(encoding='utf-8')) except Exception: return None @@ -230,7 +230,7 @@ def _load_dismissed_suggestions() -> list: if not _DISMISSED_SUGGESTIONS_FILE.exists(): return [] try: - data = json.loads(_DISMISSED_SUGGESTIONS_FILE.read_text()) + data = json.loads(_DISMISSED_SUGGESTIONS_FILE.read_text(encoding='utf-8')) return data if isinstance(data, list) else [] except Exception: return [] @@ -246,7 +246,7 @@ def _load_payment_day_overrides() -> dict: if not _PAYMENT_DAY_OVERRIDES_FILE.exists(): return {} try: - data = json.loads(_PAYMENT_DAY_OVERRIDES_FILE.read_text()) + data = json.loads(_PAYMENT_DAY_OVERRIDES_FILE.read_text(encoding='utf-8')) if not isinstance(data, dict): return {} good = {} @@ -275,7 +275,7 @@ def _load_monarch_raw_cache() -> dict | None: if not _MONARCH_RAW_CACHE_FILE.exists(): return None try: - data = json.loads(_MONARCH_RAW_CACHE_FILE.read_text()) + data = json.loads(_MONARCH_RAW_CACHE_FILE.read_text(encoding='utf-8')) if not isinstance(data, dict): return None required = ("fetched_at", "balance", "transactions", "recurring") diff --git a/templates/settings.html b/templates/settings.html index 6019fe0..0f13224 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -1440,9 +1440,7 @@

Privacy & Disclaimer

btn.textContent = 'Connecting\u2026'; btn.disabled = true; statusEl.innerHTML = '' - + 'Checking browser\u2026 A Chromium window will open \u2014 log in to Monarch' - + ' when prompted. The window will close automatically after a successful login' - + ' (~30\u201360 seconds). If this is your first run, Chromium may need to download first (~250\u00a0MB)\u2026'; + + 'A Chromium window will open \u2014 log in to Monarch when prompted.'; statusEl.style.display = 'block'; // First fetch: establishes the Playwright session and does an initial account pull @@ -1640,11 +1638,11 @@

Privacy & Disclaimer

statusEl.style.display = 'block'; function startPolling() { - var deadline = Date.now() + 45000; + var deadline = Date.now() + 90000; var interval = setInterval(function() { if (Date.now() > deadline) { clearInterval(interval); - statusEl.textContent = 'Server did not respond within 45 s. Try navigating to the app manually.'; + statusEl.textContent = 'Server did not respond within 90 s. Navigate manually: http://localhost:5002/'; btn.textContent = '\uD83D\uDDD1 Reset to Factory Defaults'; btn.disabled = false; return; @@ -1661,16 +1659,16 @@

Privacy & Disclaimer

}, 750); } - // Wait 4 s before polling: gives the background thread time to finish - // deleting files and call server.sh restart before the first /api/ping. + // Wait 5 s before polling: gives the background thread time to finish + // deleting files and call server.ps1/server.sh restart before the first /api/ping. // Timeline: t=0.5s files deleted, t=1.5s done, t=2.5s restart called, - // t=4.0s poll starts — server is down or coming back up ✓ + // t=5.0s poll starts — server is down or coming back up ✓ function delayedPoll() { statusEl.textContent = 'Deleting data\u2026 do not close this tab.'; setTimeout(function() { - statusEl.textContent = 'Restarting server\u2026'; + statusEl.textContent = 'Restarting server\u2026 this may take up to 30 seconds.'; startPolling(); - }, 4000); + }, 5000); } fetch('/api/factory-reset', { method: 'POST' })