Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
python-version: "3.11"

- name: Compile Python launcher
run: python -m py_compile app.py
run: python -m py_compile XML_Editor.py

- name: Verify required app files exist
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ __pycache__/
.DS_Store
Thumbs.db
.codex-gh/
.git-codex/
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ Suggested branch prefixes:
## Local Run

```bash
python app.py
python XML_Editor.py
```

If needed on Windows:

```bash
py app.py
py XML_Editor.py
```

## Project Expectations
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ The app runs entirely locally. Python serves the static files, and the browser h
- add root, child, and sibling elements
- reorder nodes with buttons or drag-and-drop
- edit text nodes directly
- escape XML special characters on export
- support common XML-style attribute names such as `data-id` and `xml:lang`
- preview raw XML output
- export either AI-ready XML text or full editor format

## Project Structure

- `app.py`: local launcher that serves the app and opens the browser
- `XML_Editor.py`: local launcher that serves the app and opens the browser
- `index.html`: single HTML mount point
- `app.js`: main application logic, parsing, tree editing, rendering, and export
- `style.css`: application styling
Expand All @@ -39,13 +41,13 @@ The app runs entirely locally. Python serves the static files, and the browser h
From the repository root:

```bash
python app.py
python XML_Editor.py
```

If your machine uses the Windows launcher instead of `python`, you can also use:

```bash
py app.py
py XML_Editor.py
```

The launcher will:
Expand Down Expand Up @@ -73,7 +75,7 @@ This repository includes:

The application has two parts:

1. a Python wrapper in `app.py` that starts a local HTTP server
1. a Python wrapper in `XML_Editor.py` that starts a local HTTP server
2. a plain JavaScript single-page app in `app.js` that parses mixed preamble + XML text into a tree, renders it visually, and serializes it back out for export

The editor keeps all state in memory in the browser session and does not currently save automatically.
Expand All @@ -83,4 +85,5 @@ More detail is available in `docs/ARCHITECTURE.md`.
## Notes

- the XML parsing logic is custom and currently optimized for simple prompt-style XML
- export escapes text and attribute values so characters like `&`, `<`, and `"` remain valid XML
- the app is intentionally lightweight and has no front-end framework or backend service
172 changes: 172 additions & 0 deletions XML_Editor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
from __future__ import annotations

import contextlib
import http
import http.server
import os
import socket
import subprocess
import threading
import time
import urllib.parse
import webbrowser
import shlex
from pathlib import Path

try:
import winreg
except ImportError: # pragma: no cover - Windows-only helper
winreg = None

BASE_DIR = Path(__file__).resolve().parent
HOST = "127.0.0.1"
HEARTBEAT_PATH = "/__heartbeat"
STARTUP_TIMEOUT_SECONDS = 45
HEARTBEAT_TIMEOUT_SECONDS = 12


def find_free_port(host: str = HOST) -> int:
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
sock.bind((host, 0))
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return int(sock.getsockname()[1])


class AppServer(http.server.ThreadingHTTPServer):
daemon_threads = True

def __init__(self, server_address, handler_class):
super().__init__(server_address, handler_class)
self.started_at = time.monotonic()
self.last_activity_at = self.started_at
self.seen_browser_client = False

def note_browser_activity(self) -> None:
self.last_activity_at = time.monotonic()
self.seen_browser_client = True


class QuietHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(BASE_DIR), **kwargs)

def log_message(self, format: str, *args) -> None:
return

def do_GET(self) -> None:
path = urllib.parse.urlparse(self.path).path
if path != HEARTBEAT_PATH:
self.server.note_browser_activity()
super().do_GET()

def do_POST(self) -> None:
path = urllib.parse.urlparse(self.path).path
if path != HEARTBEAT_PATH:
self.send_error(http.HTTPStatus.NOT_FOUND)
return

self.server.note_browser_activity()
self.send_response(http.HTTPStatus.NO_CONTENT)
self.send_header("Cache-Control", "no-store")
self.end_headers()


def monitor_browser_activity(server: AppServer) -> None:
while True:
time.sleep(1)
now = time.monotonic()

if server.seen_browser_client:
if now - server.last_activity_at > HEARTBEAT_TIMEOUT_SECONDS:
print("Browser window closed. Stopping server.")
server.shutdown()
return
elif now - server.started_at > STARTUP_TIMEOUT_SECONDS:
print("No browser connection detected. Stopping server.")
server.shutdown()
return


def get_default_browser_command() -> str | None:
if os.name != "nt" or winreg is None:
return None

candidates = (
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice",
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice",
)

prog_id = None
for subkey in candidates:
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, subkey) as key:
prog_id = winreg.QueryValueEx(key, "ProgId")[0]
break
except OSError:
continue

if not prog_id:
return None

try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, fr"{prog_id}\shell\open\command") as key:
return str(winreg.QueryValueEx(key, None)[0])
except OSError:
return None


def open_in_new_browser_window(url: str) -> bool:
command = get_default_browser_command()
if not command:
return webbrowser.open_new(url)

try:
parts = shlex.split(command, posix=False)
except ValueError:
return webbrowser.open_new(url)

if not parts:
return webbrowser.open_new(url)

executable = parts[0].strip('"')
browser_name = Path(executable).name.lower()

if browser_name == "firefox.exe":
extra_args = ["-new-window"]
elif browser_name in {"chrome.exe", "msedge.exe", "brave.exe", "opera.exe", "vivaldi.exe"}:
extra_args = ["--new-window"]
else:
return webbrowser.open_new(url)

try:
subprocess.Popen([executable, *extra_args, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except OSError:
return webbrowser.open_new(url)


def main() -> None:
port = find_free_port()
server = AppServer((HOST, port), QuietHandler)
url = f"http://{HOST}:{port}/index.html"

monitor = threading.Thread(target=monitor_browser_activity, args=(server,), daemon=True)
monitor.start()

print("XML Prompt Editor")
print(f"Serving: {url}")

try:
time.sleep(0.35)
open_in_new_browser_window(url)
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.shutdown()
server.server_close()


if __name__ == "__main__":
main()
Loading
Loading