diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dec9b23..d7292b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/.gitignore b/.gitignore index b7ed01c..923e480 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .DS_Store Thumbs.db .codex-gh/ +.git-codex/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4936c2f..3606344 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 8399e0a..4ccd94c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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. @@ -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 diff --git a/XML_Editor.py b/XML_Editor.py new file mode 100644 index 0000000..06b211b --- /dev/null +++ b/XML_Editor.py @@ -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() diff --git a/app.js b/app.js index 3e55298..d8c9832 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,12 @@ `; + const APP_VERSION = 'v0.1.7'; + const HEARTBEAT_INTERVAL_MS = 5000; + const HISTORY_LIMIT = 100; + const TYPING_COMMIT_DELAY_MS = 800; let idCounter = 0; + const XML_NAME_RE = /^[A-Za-z_][A-Za-z0-9_.:-]*$/; const state = { doc: null, @@ -35,6 +40,9 @@ showHelp: false, collapsed: {}, dragNodeId: null, + historyPast: [], + historyFuture: [], + pendingHistory: null, }; function generateId() { @@ -42,16 +50,60 @@ return `node_${idCounter}_${Math.random().toString(36).slice(2, 7)}`; } - function parseAttributes(attrStr) { + function isValidXmlName(name) { + return XML_NAME_RE.test(String(name || '')); + } + + function decodeXmlEntities(text) { + return String(text || '').replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (match, entity) => { + if (entity === 'amp') return '&'; + if (entity === 'lt') return '<'; + if (entity === 'gt') return '>'; + if (entity === 'quot') return '"'; + if (entity === 'apos') return "'"; + if (!entity.startsWith('#')) return match; + + const isHex = entity[1]?.toLowerCase() === 'x'; + const digits = isHex ? entity.slice(2) : entity.slice(1); + const codePoint = Number.parseInt(digits, isHex ? 16 : 10); + if (!Number.isFinite(codePoint)) return match; + + try { + return String.fromCodePoint(codePoint); + } catch { + return match; + } + }); + } + + function escapeXmlText(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>'); + } + + function escapeXmlAttribute(value) { + return escapeXmlText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function parseLooseAttributes(attrStr, { decodeValues = false } = {}) { const attrs = {}; - const regex = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + const regex = /([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g; let m; while ((m = regex.exec(attrStr)) !== null) { - attrs[m[1]] = m[2] ?? m[3] ?? ''; + const rawValue = m[2] ?? m[3] ?? ''; + attrs[m[1]] = decodeValues ? decodeXmlEntities(rawValue) : rawValue; } return attrs; } + function parseAttributes(attrStr) { + return parseLooseAttributes(attrStr, { decodeValues: true }); + } + function tokenize(xml) { const tokens = []; let i = 0; @@ -140,7 +192,7 @@ const textNode = { id: generateId(), type: 'text', - text: token.text.trim(), + text: decodeXmlEntities(token.text.trim()), children: [], }; if (stack.length > 0) { @@ -178,18 +230,18 @@ let result = ''; for (const node of nodes) { if (node.type === 'text') { - const text = (node.text || '').trim(); + const text = escapeXmlText((node.text || '').trim()); if (text) result += `${pad}${text}\n`; } else { const attrs = node.attributes - ? Object.entries(node.attributes).map(([k, v]) => ` ${k}="${v}"`).join('') + ? Object.entries(node.attributes).map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join('') : ''; if (node.children.length === 0) { result += `${pad}<${node.tag}${attrs} />\n`; } else { const hasOnlyText = node.children.length === 1 && node.children[0].type === 'text'; if (hasOnlyText) { - const text = (node.children[0].text || '').trim(); + const text = escapeXmlText((node.children[0].text || '').trim()); result += `${pad}<${node.tag}${attrs}>${text}\n`; } else { result += `${pad}<${node.tag}${attrs}>\n`; @@ -206,13 +258,13 @@ let result = ''; for (const node of nodes) { if (node.type === 'text') { - const text = (node.text || '').trim(); + const text = escapeXmlText((node.text || '').trim()); if (text) result += `${text}\n`; } else { const attrs = node.attributes ? Object.entries(node.attributes) .filter(([, v]) => v !== undefined) - .map(([k, v]) => ` ${k}="${v}"`).join('') + .map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join('') : ''; if (node.children.length === 0) { result += `<${node.tag}${attrs} />\n`; @@ -333,17 +385,124 @@ return result; } - function setDoc(doc) { + function cloneDoc(doc) { + return JSON.parse(JSON.stringify(doc)); + } + + function docsEqual(a, b) { + return JSON.stringify(a) === JSON.stringify(b); + } + + function loadDocIntoState(doc) { state.doc = doc; state.preambleVal = doc.preamble; + } + + function pushHistorySnapshot(doc) { + state.historyPast.push(cloneDoc(doc)); + if (state.historyPast.length > HISTORY_LIMIT) state.historyPast.shift(); + } + + function clearPendingHistoryTimer() { + if (state.pendingHistory?.timerId) { + window.clearTimeout(state.pendingHistory.timerId); + } + } + + function schedulePendingHistoryCommit() { + if (!state.pendingHistory) return; + clearPendingHistoryTimer(); + state.pendingHistory.timerId = window.setTimeout(() => { + commitPendingHistory(); + }, TYPING_COMMIT_DELAY_MS); + } + + function beginPendingHistorySession(key) { + if (!state.doc) return; + if (state.pendingHistory?.key === key) { + schedulePendingHistoryCommit(); + return; + } + + commitPendingHistory(); + state.pendingHistory = { + key, + snapshot: cloneDoc(state.doc), + timerId: null, + }; + schedulePendingHistoryCommit(); + } + + function commitPendingHistory() { + if (!state.pendingHistory) return; + clearPendingHistoryTimer(); + const { snapshot } = state.pendingHistory; + state.pendingHistory = null; + + if (!state.doc || docsEqual(snapshot, state.doc)) return; + pushHistorySnapshot(snapshot); + state.historyFuture = []; + } + + function setDoc(doc, opts = {}) { + const { recordHistory = true } = opts; + commitPendingHistory(); + + if (state.doc && docsEqual(state.doc, doc)) return; + if (recordHistory && state.doc) { + pushHistorySnapshot(state.doc); + state.historyFuture = []; + } + + loadDocIntoState(doc); render(); } function updateRoot(root) { - state.doc = { ...state.doc, root }; + setDoc({ ...state.doc, root }); + } + + function undo() { + commitPendingHistory(); + if (state.historyPast.length === 0 || !state.doc) return; + + state.historyFuture.push(cloneDoc(state.doc)); + const previous = state.historyPast.pop(); + loadDocIntoState(previous); + state.preambleEditing = false; + render(); + } + + function redo() { + commitPendingHistory(); + if (state.historyFuture.length === 0 || !state.doc) return; + + pushHistorySnapshot(state.doc); + const next = state.historyFuture.pop(); + loadDocIntoState(next); + state.preambleEditing = false; render(); } + function isEditableTarget(target) { + return target instanceof HTMLElement + && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + } + + function handleGlobalKeydown(event) { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + if (isEditableTarget(event.target)) return; + + const key = event.key.toLowerCase(); + if (key === 'z' && !event.shiftKey) { + event.preventDefault(); + undo(); + } else if (key === 'y' || (key === 'z' && event.shiftKey)) { + event.preventDefault(); + redo(); + } + } + function getExportContent() { return state.exportMode === 'ai' ? serializeDocumentForAI(state.doc) : serializeDocument(state.doc); } @@ -356,10 +515,95 @@ .replace(/"/g, '"'); } - function icon(text) { - const span = document.createElement('span'); - span.textContent = text; - return span; + function createTagIcon() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'tag-symbol'); + svg.setAttribute('aria-hidden', 'true'); + + const tagPath = document.createElementNS(ns, 'path'); + tagPath.setAttribute('d', 'M2.5 5.5V2.5H5.5L13.5 10.5L10.5 13.5L2.5 5.5Z'); + tagPath.setAttribute('fill', 'none'); + tagPath.setAttribute('stroke', 'currentColor'); + tagPath.setAttribute('stroke-width', '1.5'); + tagPath.setAttribute('stroke-linejoin', 'round'); + + const hole = document.createElementNS(ns, 'circle'); + hole.setAttribute('cx', '4.5'); + hole.setAttribute('cy', '4.5'); + hole.setAttribute('r', '0.9'); + hole.setAttribute('fill', 'currentColor'); + + svg.append(tagPath, hole); + return svg; + } + + function createTrashIcon() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'delete-icon'); + svg.setAttribute('aria-hidden', 'true'); + + const lid = document.createElementNS(ns, 'path'); + lid.setAttribute('d', 'M5.5 3.5H10.5'); + lid.setAttribute('fill', 'none'); + lid.setAttribute('stroke', 'currentColor'); + lid.setAttribute('stroke-width', '1.5'); + lid.setAttribute('stroke-linecap', 'round'); + + const rim = document.createElementNS(ns, 'path'); + rim.setAttribute('d', 'M3 4.5H13'); + rim.setAttribute('fill', 'none'); + rim.setAttribute('stroke', 'currentColor'); + rim.setAttribute('stroke-width', '1.5'); + rim.setAttribute('stroke-linecap', 'round'); + + const body = document.createElementNS(ns, 'path'); + body.setAttribute('d', 'M4.5 5.5L5.1 12.5C5.15 13.05 5.61 13.5 6.17 13.5H9.83C10.39 13.5 10.85 13.05 10.9 12.5L11.5 5.5'); + body.setAttribute('fill', 'none'); + body.setAttribute('stroke', 'currentColor'); + body.setAttribute('stroke-width', '1.5'); + body.setAttribute('stroke-linecap', 'round'); + body.setAttribute('stroke-linejoin', 'round'); + + const leftLine = document.createElementNS(ns, 'path'); + leftLine.setAttribute('d', 'M6.5 7V11.5'); + leftLine.setAttribute('fill', 'none'); + leftLine.setAttribute('stroke', 'currentColor'); + leftLine.setAttribute('stroke-width', '1.5'); + leftLine.setAttribute('stroke-linecap', 'round'); + + const rightLine = document.createElementNS(ns, 'path'); + rightLine.setAttribute('d', 'M9.5 7V11.5'); + rightLine.setAttribute('fill', 'none'); + rightLine.setAttribute('stroke', 'currentColor'); + rightLine.setAttribute('stroke-width', '1.5'); + rightLine.setAttribute('stroke-linecap', 'round'); + + svg.append(lid, rim, body, leftLine, rightLine); + return svg; + } + + function createArrowIcon(direction) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('class', 'arrow-icon'); + svg.setAttribute('aria-hidden', 'true'); + + const triangle = document.createElementNS(ns, 'path'); + const pathByDirection = { + up: 'M8 4L12.5 11H3.5L8 4Z', + down: 'M3.5 5L12.5 5L8 12Z', + right: 'M5 3.5L12 8L5 12.5V3.5Z', + }; + triangle.setAttribute('d', pathByDirection[direction] || pathByDirection.down); + triangle.setAttribute('fill', 'currentColor'); + + svg.appendChild(triangle); + return svg; } function btn(label, opts = {}) { @@ -396,13 +640,13 @@ input.classList.add('error'); errorLine.textContent = `⚠ ${message}`; errorLine.classList.remove('hidden'); - container.closest('.node-card')?.classList.add('error'); + container.closest('.element-frame, .node-card')?.classList.add('error'); } function clearError() { input.classList.remove('error'); errorLine.classList.add('hidden'); - container.closest('.node-card')?.classList.remove('error'); + container.closest('.element-frame, .node-card')?.classList.remove('error'); } function save() { @@ -411,6 +655,10 @@ render(); return; } + if (!isValidXmlName(name)) { + showError('Use a valid XML name. Start with a letter or underscore, then use letters, numbers, ., -, _, or :.'); + return; + } if (isDuplicate(name)) { showError(`"${name}" already exists among siblings.`); return; @@ -421,7 +669,15 @@ input.addEventListener('input', () => { const val = input.value.trim(); - if (val && isDuplicate(val)) showError(`"${val}" already exists among siblings.`); + if (!val) { + clearError(); + return; + } + if (!isValidXmlName(val)) { + showError('Use a valid XML name. Start with a letter or underscore, then use letters, numbers, ., -, _, or :.'); + return; + } + if (isDuplicate(val)) showError(`"${val}" already exists among siblings.`); else clearError(); }); input.addEventListener('blur', save); @@ -443,12 +699,7 @@ : ''; function save() { - const attrs = {}; - const regex = /(\w+)(?:\s*=\s*"([^"]*)")?/g; - let m; - while ((m = regex.exec(input.value)) !== null) { - attrs[m[1]] = m[2] ?? ''; - } + const attrs = parseLooseAttributes(input.value); updateRoot(updateNodeById(nodes, node.id, (n) => ({ ...n, attributes: attrs }))); } @@ -481,22 +732,13 @@ textarea.value = (node.text || '').trim(); textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); textarea.addEventListener('input', () => { + beginPendingHistorySession(`text:${node.id}`); textarea.rows = Math.max(1, textarea.value ? textarea.value.split('\n').length : 1); state.doc.root = updateNodeById(nodes, node.id, (n) => ({ ...n, text: textarea.value })); }); + textarea.addEventListener('blur', () => commitPendingHistory()); wrap.appendChild(textarea); - const actions = document.createElement('div'); - actions.className = 'text-actions'; - const del = document.createElement('button'); - del.type = 'button'; - del.className = 'action-btn red'; - del.title = 'Delete text node'; - del.textContent = '🗑'; - del.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); - actions.appendChild(del); - wrap.appendChild(actions); - row.appendChild(wrap); return row; } @@ -505,30 +747,46 @@ const row = document.createElement('div'); row.className = `node-row node-indent-${Math.min(depth, 10)}`; + const frame = document.createElement('div'); + frame.className = 'element-frame'; + const card = document.createElement('div'); - card.className = 'node-card'; + card.className = 'node-card element-frame-header'; card.draggable = true; + let dragDepth = 0; card.addEventListener('dragstart', (e) => { state.dragNodeId = node.id; - card.classList.add('dragging'); + frame.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', node.id); }); card.addEventListener('dragend', () => { state.dragNodeId = null; - card.classList.remove('dragging'); + dragDepth = 0; + frame.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach((el) => el.classList.remove('drag-over')); }); - card.addEventListener('dragover', (e) => { + + frame.addEventListener('dragenter', (e) => { e.preventDefault(); if (!state.dragNodeId || state.dragNodeId === node.id) return; - card.classList.add('drag-over'); + dragDepth += 1; + frame.classList.add('drag-over'); }); - card.addEventListener('dragleave', () => card.classList.remove('drag-over')); - card.addEventListener('drop', (e) => { + frame.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!state.dragNodeId || state.dragNodeId === node.id) return; + frame.classList.add('drag-over'); + }); + frame.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) frame.classList.remove('drag-over'); + }); + frame.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); - card.classList.remove('drag-over'); + dragDepth = 0; + frame.classList.remove('drag-over'); const dragged = e.dataTransfer.getData('text/plain'); if (dragged && dragged !== node.id) { updateRoot(moveNode(nodes, dragged, { parentId: node.id, index: node.children.length })); @@ -544,8 +802,10 @@ expand.type = 'button'; expand.className = 'expand-btn'; const hasChildren = node.children.length > 0; - expand.textContent = hasChildren ? (state.collapsed[node.id] ? '▸' : '▾') : ''; expand.title = state.collapsed[node.id] ? 'Expand' : 'Collapse'; + if (hasChildren) { + expand.appendChild(createArrowIcon(state.collapsed[node.id] ? 'right' : 'down')); + } expand.addEventListener('click', () => { state.collapsed[node.id] = !state.collapsed[node.id]; render(); @@ -555,10 +815,7 @@ const main = document.createElement('div'); main.className = 'node-main'; - const tagSymbol = document.createElement('span'); - tagSymbol.className = 'tag-symbol'; - tagSymbol.textContent = '🏷'; - main.appendChild(tagSymbol); + main.appendChild(createTagIcon()); const tagWrap = document.createElement('div'); const tagBtn = document.createElement('button'); @@ -602,7 +859,7 @@ moveUpBtn.type = 'button'; moveUpBtn.className = 'action-btn'; moveUpBtn.title = 'Move up'; - moveUpBtn.textContent = '▲'; + moveUpBtn.appendChild(createArrowIcon('up')); moveUpBtn.addEventListener('click', () => { const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); const idx = siblings.findIndex((n) => n.id === node.id); @@ -618,7 +875,7 @@ moveDownBtn.type = 'button'; moveDownBtn.className = 'action-btn'; moveDownBtn.title = 'Move down'; - moveDownBtn.textContent = '▼'; + moveDownBtn.appendChild(createArrowIcon('down')); moveDownBtn.addEventListener('click', () => { const { siblings, parentId } = getNodeChildrenSiblings(nodes, node.id); const idx = siblings.findIndex((n) => n.id === node.id); @@ -666,25 +923,24 @@ deleteBtn.type = 'button'; deleteBtn.className = 'action-btn red'; deleteBtn.title = 'Delete'; - deleteBtn.textContent = '🗑'; + deleteBtn.appendChild(createTrashIcon()); deleteBtn.addEventListener('click', () => updateRoot(removeNodeById(nodes, node.id))); actions.appendChild(deleteBtn); card.appendChild(actions); - row.appendChild(card); + frame.appendChild(card); const isCollapsed = !!state.collapsed[node.id]; if (!isCollapsed && hasChildren) { const childrenWrap = document.createElement('div'); - childrenWrap.className = 'children-wrap'; + childrenWrap.className = 'children-wrap element-frame-children'; for (const child of node.children) { childrenWrap.appendChild(renderNode(child, 0, nodes)); } - row.appendChild(childrenWrap); + frame.appendChild(childrenWrap); const closing = document.createElement('div'); - closing.className = 'node-card'; - closing.style.marginTop = '4px'; + closing.className = 'node-card element-frame-footer'; const grip2 = document.createElement('div'); grip2.className = 'grip'; grip2.textContent = '⋮⋮'; @@ -695,18 +951,16 @@ closing.appendChild(spacer); const main2 = document.createElement('div'); main2.className = 'node-main'; - const tagSymbol2 = document.createElement('span'); - tagSymbol2.className = 'tag-symbol'; - tagSymbol2.textContent = '🏷'; - main2.appendChild(tagSymbol2); + main2.appendChild(createTagIcon()); const closingTag = document.createElement('span'); closingTag.className = 'tag-pill-closing'; closingTag.textContent = ``; main2.appendChild(closingTag); closing.appendChild(main2); - row.appendChild(closing); + frame.appendChild(closing); } + row.appendChild(frame); return row; } @@ -737,10 +991,20 @@ iconBox.className = 'brand-icon'; iconBox.textContent = ''; brand.appendChild(iconBox); + + const brandText = document.createElement('div'); + brandText.className = 'brand-text'; const title = document.createElement('div'); title.className = 'brand-title'; title.textContent = 'XML Prompt Editor'; - brand.appendChild(title); + brandText.appendChild(title); + + const version = document.createElement('div'); + version.className = 'brand-version'; + version.textContent = APP_VERSION; + brandText.appendChild(version); + + brand.appendChild(brandText); inner.appendChild(brand); const spacer = document.createElement('div'); @@ -754,9 +1018,9 @@ className: 'btn', onClick: () => { if (!window.confirm('Start a new document? Unsaved changes will be lost.')) return; - setDoc(parseDocument('# New Prompt\n\n\n \n')); state.showRaw = false; state.preambleEditing = false; + setDoc(parseDocument('# New Prompt\n\n\n \n')); }, }); toolbar.appendChild(newBtn); @@ -772,9 +1036,9 @@ const reader = new FileReader(); reader.onload = (ev) => { const text = String(ev.target?.result || ''); - setDoc(parseDocument(text)); state.showRaw = false; state.preambleEditing = false; + setDoc(parseDocument(text)); }; reader.readAsText(file); input.value = ''; @@ -834,6 +1098,7 @@ ['+↓ button', 'add sibling below'], ['Trash icon', 'delete element'], ['Text area', 'edit text content'], + ['Ctrl+Z / Ctrl+Y', 'undo or redo document changes'], ]; for (const [strong, rest] of items) { const div = document.createElement('div'); @@ -885,9 +1150,8 @@ save.className = 'mini-btn mini-btn-primary'; save.textContent = 'Save'; save.addEventListener('click', () => { - state.doc = { ...state.doc, preamble: state.preambleVal }; state.preambleEditing = false; - render(); + setDoc({ ...state.doc, preamble: state.preambleVal }); }); actions.appendChild(save); const cancel = document.createElement('button'); @@ -1134,9 +1398,26 @@ app.appendChild(shell); } + function sendHeartbeat() { + fetch('/__heartbeat', { + method: 'POST', + cache: 'no-store', + keepalive: true, + }).catch(() => {}); + } + + function startHeartbeat() { + sendHeartbeat(); + window.setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + } + function init() { - state.doc = parseDocument(SAMPLE_DOC); - state.preambleVal = state.doc.preamble; + state.historyPast = []; + state.historyFuture = []; + state.pendingHistory = null; + loadDocIntoState(parseDocument(SAMPLE_DOC)); + window.addEventListener('keydown', handleGlobalKeydown); + startHeartbeat(); render(); } diff --git a/app.py b/app.py deleted file mode 100644 index 655642f..0000000 --- a/app.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import contextlib -import http.server -import os -import socket -import threading -import time -import webbrowser -from pathlib import Path - -BASE_DIR = Path(__file__).resolve().parent -HOST = "127.0.0.1" - - -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 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 main() -> None: - port = find_free_port() - server = http.server.ThreadingHTTPServer((HOST, port), QuietHandler) - url = f"http://{HOST}:{port}/index.html" - - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - - print("XML Prompt Editor") - print(f"Serving: {url}") - print("Press Ctrl+C to stop.") - - try: - time.sleep(0.35) - webbrowser.open(url) - while True: - time.sleep(1) - except KeyboardInterrupt: - pass - finally: - server.shutdown() - server.server_close() - - -if __name__ == "__main__": - main() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6fd63e..535bed4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,14 +4,14 @@ The application has two layers: -1. `app.py` launches a local HTTP server and opens the browser. +1. `XML_Editor.py` launches a local HTTP server and opens the browser. 2. `app.js` implements the editor as a plain JavaScript single-page app. There is no backend API, database, or framework runtime. All editing happens in memory in the browser. ## Runtime Flow -1. `app.py` finds a free local port. +1. `XML_Editor.py` finds a free local port. 2. It serves the repository directory with `http.server`. 3. It opens `index.html` in the default browser. 4. The browser loads `app.js` and initializes the editor with a sample document. @@ -80,6 +80,13 @@ The editor accepts documents with a free-form preamble followed by XML. The parser is custom and lightweight. It is built for prompt-style XML rather than strict general-purpose XML compatibility. +Current parser/serializer behavior intentionally includes a few pragmatic rules: + +- common XML entities in imported text and attribute values are decoded into the in-memory document model +- exported text and attribute values are escaped again so special characters remain valid XML +- attribute names support common XML-style characters such as `.`, `-`, `_`, and `:` +- tag rename validation uses a lightweight XML-name check to avoid exporting obviously invalid tag names + ## Export Model The app supports two export modes: @@ -91,4 +98,4 @@ The app supports two export modes: - there is currently no persistent storage layer - there are no third-party package managers or dependencies -- most future changes will happen in `app.js`, with `style.css` for UI styling and `app.py` only for launcher behavior +- most future changes will happen in `app.js`, with `style.css` for UI styling and `XML_Editor.py` only for launcher behavior diff --git a/gh-codex.ps1 b/gh-codex.ps1 index c4722ac..8170ee5 100644 --- a/gh-codex.ps1 +++ b/gh-codex.ps1 @@ -1,5 +1,6 @@ $ghPath = "C:\Program Files\GitHub CLI\gh.exe" $ghConfigDir = Join-Path $PSScriptRoot ".codex-gh" +$gitDir = Join-Path $PSScriptRoot ".git-codex" if (-not (Test-Path $ghPath)) { Write-Error "GitHub CLI not found at $ghPath" @@ -11,5 +12,7 @@ if (-not (Test-Path $ghConfigDir)) { } $env:GH_CONFIG_DIR = $ghConfigDir +$env:GIT_DIR = $gitDir +$env:GIT_WORK_TREE = $PSScriptRoot & $ghPath @args exit $LASTEXITCODE diff --git a/git-codex.ps1 b/git-codex.ps1 new file mode 100644 index 0000000..79cb25a --- /dev/null +++ b/git-codex.ps1 @@ -0,0 +1,17 @@ +$gitPath = "C:\Program Files\Git\cmd\git.exe" +$gitDir = Join-Path $PSScriptRoot ".git-codex" + +if (-not (Test-Path $gitPath)) { + Write-Error "Git not found at $gitPath" + exit 1 +} + +if (-not (Test-Path $gitDir)) { + Write-Error "Writable Git directory not found at $gitDir" + exit 1 +} + +$env:GIT_DIR = $gitDir +$env:GIT_WORK_TREE = $PSScriptRoot +& $gitPath @args +exit $LASTEXITCODE diff --git a/style.css b/style.css index c02c8fb..b7ba318 100644 --- a/style.css +++ b/style.css @@ -88,6 +88,13 @@ button { gap: 10px; } +.brand-text { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .brand-icon { width: 28px; height: 28px; @@ -106,6 +113,18 @@ button { color: #1e293b; } +.brand-version { + border: 1px solid var(--blue-200); + border-radius: 999px; + background: var(--blue-50); + color: var(--blue); + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + line-height: 1; + padding: 5px 8px; +} + .spacer { flex: 1; } @@ -154,11 +173,12 @@ button { .btn-icon { padding: 9px; - color: var(--muted-2); + color: #000; + font-size: 15px; } .btn-icon:hover { - color: var(--muted); + color: #000; } .help-panel { @@ -334,25 +354,30 @@ button { margin-bottom: 4px; } -.node-card { - display: flex; - align-items: center; - gap: 4px; +.element-frame { background: #fff; border: 1px solid var(--border); - border-radius: 12px; + border-radius: 14px; transition: border-color .15s ease, box-shadow .15s ease, opacity .15s ease, background .15s ease; + overflow: hidden; } -.node-card:hover { +.element-frame:hover { border-color: #93c5fd; box-shadow: var(--shadow); } -.node-card.error { +.element-frame.error { border-color: var(--red-300); } +.node-card { + display: flex; + align-items: center; + gap: 4px; + background: transparent; +} + .dragging { opacity: .45; } @@ -362,14 +387,26 @@ button { box-shadow: 0 0 0 3px rgba(59,130,246,.12); } +.element-frame-header, +.element-frame-footer { + min-height: 44px; +} + +.element-frame-footer { + margin-top: 4px; + border-top: 1px solid var(--border-soft); + padding-top: 4px; +} + .grip { display: flex; align-items: center; justify-content: center; width: 34px; align-self: stretch; - border-right: 1px solid var(--border-soft); - color: #cbd5e1; + color: #000; + font-size: 14px; + font-weight: 600; user-select: none; cursor: grab; } @@ -379,12 +416,12 @@ button { .action-btn { border: 0; background: transparent; - color: var(--muted-2); + color: #000; } .expand-btn { - width: 28px; - height: 28px; + width: 30px; + height: 30px; border-radius: 8px; margin-left: 4px; } @@ -393,7 +430,7 @@ button { .action-btn:hover, .icon-btn:hover { background: #f8fafc; - color: var(--muted); + color: #000; } .node-main { @@ -406,8 +443,11 @@ button { } .tag-symbol { - color: #60a5fa; - font-size: 13px; + color: #000; + width: 17px; + height: 17px; + display: block; + flex: 0 0 auto; } .tag-pill, @@ -489,10 +529,26 @@ button { } .action-btn { - width: 28px; - height: 28px; + width: 30px; + height: 30px; border-radius: 8px; - font-size: 13px; + font-size: 14px; + font-weight: 600; +} + +.arrow-icon { + width: 14px; + height: 14px; + display: block; + margin: 0 auto; + flex: 0 0 auto; +} + +.delete-icon { + width: 18px; + height: 18px; + display: block; + margin: 0 auto; } .action-btn.green:hover { @@ -521,8 +577,7 @@ button { .children-wrap { margin-top: 4px; margin-left: 24px; - padding-left: 12px; - border-left: 2px solid var(--blue-100); + padding: 4px 12px 4px 12px; } .text-row { @@ -533,10 +588,10 @@ button { } .text-icon { - color: #cbd5e1; + color: #000; padding-top: 9px; padding-left: 4px; - font-size: 12px; + font-size: 13px; } .text-node-textarea { @@ -556,10 +611,6 @@ button { border-color: var(--border); } -.text-actions { - padding-top: 6px; -} - .node-indent-1 { margin-left: 24px; } .node-indent-2 { margin-left: 48px; } .node-indent-3 { margin-left: 72px; } @@ -658,11 +709,11 @@ button { border: 0; border-radius: 12px; background: transparent; - color: var(--muted-2); - font-size: 20px; + color: #000; + font-size: 22px; } -.close-btn:hover { background: #f1f5f9; color: var(--muted); } +.close-btn:hover { background: #f1f5f9; color: #000; } .modal-tabs { display: flex;