diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e60acf4 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Commits listed here are ignored by `git blame` (configure once per clone with: +# git config blame.ignoreRevsFile .git-blame-ignore-revs) +# +# One-time SwiftFormat reformat of the entire Swift source tree -- purely +# mechanical, no logic changes. +4d57122a261e204a6b96c095eb9d9d64a37e9929 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd8a458..a77b7c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,27 @@ jobs: print(f"Version in sync: {py_version}") EOF - - name: Ruff (error-tier lint gate) + - name: Ruff (full lint gate) run: | pip install ruff - ruff check --select E9,F63,F7,F82 src/python tests + ruff check src/python tests + + - name: Help docs in sync (assets/*.md is canonical; regenerate, don't hand-edit the Swift copies) + run: | + python3 scripts/render_help_html.py + # Only the .md fallback copies are tracked -- Resources/*.html is gitignored + # (pure regenerated build output). If the .md copies are fresh, the HTML + # rendering from them is deterministic and therefore fresh too. + tracked=( + "src/swift/MarcutApp/Sources/MarcutApp/Resources/help.md" + "src/swift/MarcutApp/Sources/MarcutApp/Resources/forensics-guide.md" + ) + if ! git diff --quiet -- "${tracked[@]}"; then + echo "::error::assets/help.md or assets/forensics-guide.md is out of sync with the generated Swift Resources copies. Edit the assets/*.md source and run 'python3 scripts/render_help_html.py' to regenerate, then commit both the assets/ source and the Swift Resources .md copy." + git diff -- "${tracked[@]}" + exit 1 + fi + echo "Help docs in sync." smoke: runs-on: macos-14 @@ -67,3 +84,14 @@ jobs: mkdir -p src/swift/MarcutApp/Sources/MarcutApp/Frameworks mkdir -p src/swift/MarcutApp/Sources/MarcutApp/python_site swift build --package-path src/swift/MarcutApp + + - name: SwiftFormat (lint gate) + run: | + # Pinned to an exact release rather than `brew install` -- the runner image's + # pre-cached brew version silently drifts from what contributors have locally, + # and different SwiftFormat versions disagree on some rules (E4 hardening, 2026-07). + curl -sL -o /tmp/swiftformat.zip "https://github.com/nicklockwood/SwiftFormat/releases/download/0.62.1/swiftformat.zip" + unzip -o -q /tmp/swiftformat.zip -d /tmp/swiftformat + chmod +x /tmp/swiftformat/swiftformat + /tmp/swiftformat/swiftformat --version + /tmp/swiftformat/swiftformat --lint src/swift/MarcutApp/Sources src/swift/MarcutApp/Tests diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..d8c48fa --- /dev/null +++ b/.swiftformat @@ -0,0 +1,9 @@ +--swiftversion 5.9 +--indent 4 +--maxwidth 120 +--wraparguments preserve +--wrapparameters preserve +--wrapcollections preserve +--closingparen balanced +--exclude .build,**/Frameworks,**/python_site,**/python_stdlib +--disable redundantSelf diff --git a/README.md b/README.md index 947c2fc..6fa47d7 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Marcut is a native macOS application for legal and professional document redacti See [Technical Architecture](docs/TECHNICAL_ARCHITECTURE.md) for a system design overview. +

+ Marcut main window: drag-and-drop area, Browse and Settings buttons, and the redaction/scrub action bar +

+ ## πŸš€ Key Features * **Local-First & Private:** All processing happens on your device. No cloud uploads. @@ -24,6 +28,18 @@ See [Technical Architecture](docs/TECHNICAL_ARCHITECTURE.md) for a system design * **Fail-Safe by Design:** Pre-flight checks (writable destination, free disk space), clear plain-English errors, live per-document progress, and fail-closed handling if the AI can't fully analyze a document (no silently incomplete redactions). * **App Store Ready:** Fully sandboxed and code-signed architecture. +## πŸ“Έ Screenshots + +| Live AI progress | Redaction settings | +|:---:|:---:| +| Marcut mid-redaction: AI Analysis progress bar, heartbeat status, and estimated time remaining, with a Stop Processing control | Marcut Redaction Settings: processing mode (Rules Only vs Rules + AI), system notifications, output location, and quit-warning behavior | + +Redaction streams progress per chunk as the AI model works, with a live heartbeat and time estimate β€” and finishes with a clear per-document status: + +

+ Marcut after a completed redaction: Finished Processing status with per-document actions (view report, reveal in Finder, share) +

+ ## πŸ“– Documentation * **[User Guide](docs/USER_GUIDE.md)**: Installation, usage instructions, and troubleshooting. diff --git a/assets/help.md b/assets/help.md index 0b255ae..05dc2cd 100644 --- a/assets/help.md +++ b/assets/help.md @@ -357,10 +357,11 @@ Notifications 1. Click Redact Documents. 2. Choose an output folder. -3. The app checks the environment (Python runtime and model readiness). -4. Progress stages appear for each document: Loading, Detecting Data, AI Analysis, Validating, Merging, Creating Output. -5. For a batch of 3 or more documents, an estimated-time-remaining figure appears once at least 2 documents have completed, based on the observed processing rate so far in that run. With fewer completed documents there isn't enough data yet, so no estimate is shown. +3. The app runs pre-flight checks: Python runtime, model readiness, that the output folder is writable, and that there is enough free disk space. A failed check is reported upfront (for example a writability or "not enough free disk space" message) instead of failing partway through the run. +4. Progress stages appear for each document: Loading, Detecting Data, AI Analysis, Validating, Merging, Creating Output. During AI Analysis the bar advances continuously as the model streams through each chunk, not only when a whole chunk finishes. +5. For a batch of 3 or more documents, an estimated-time-remaining figure appears once at least 2 documents have completed. The estimate is weighted by each document's word count, so a queue that starts with small/fast documents doesn't produce a misleadingly short figure for larger documents still pending. With fewer completed documents there isn't enough data yet, so no estimate is shown. 6. You can cancel processing with Stop. Stopping is responsive: an active Ollama request or hanging extraction is interrupted promptly rather than waiting for the current document to finish, and no partial output is written for a cancelled document. +7. While any document is processing, Marcut keeps the Mac awake so sleep does not interrupt a long run. If the Mac does sleep and wake mid-run, the AI service is health-checked on wake: processing continues if it responds, or the in-flight document is failed cleanly (with a restart prompt) if it does not. Metadata-only option - Use Scrub Metadata to clean metadata without redacting text. @@ -892,6 +893,10 @@ Override behaviors: - Constrained LLM Overrides: AI can drop only ORG/NAME/LOC rule spans when confidence meets `llm_skip_confidence` (default 0.99 in the app; CLI default 0.95). - LLM Overrides: AI can drop rule spans across labels; use with caution. +Fail-closed on incomplete extraction: +- If the AI extraction for any chunk never succeeds (after its retries), the run fails closed with error code `AI_CHUNK_EXTRACTION_INCOMPLETE`: no output DOCX is written and the report is a failure report naming the character range(s) that were never analyzed. +- This is deliberate for a privacy tool: shipping a "redacted" document that skipped an unanalyzed range would be worse than failing the run. Retry with a smaller chunk size, a different model, or after confirming the AI service is reachable. + LLM tuning controls: - `temperature`: sampling variability (lower is more deterministic). - `seed`: helps reproducibility. @@ -1175,8 +1180,23 @@ If permission is denied, you can grant it later in macOS System Settings. - "AI processing timed out" - Use a smaller model, reduce `chunk-tokens`, or switch to Rules only. +- "The AI could not fully scan this document, so it was not redacted" (`AI_CHUNK_EXTRACTION_INCOMPLETE`) + - Part of the document could not be analyzed by the AI, so Marcut fails the run closed rather than shipping a document with an unscanned range. No output DOCX is written. Try again, reduce `chunk-tokens`, or use a different model. See AI Extraction and Validation for why this fails closed. + - "Cannot write to selected destination" - - Choose a different output folder with write permission. + - Choose a different output folder with write permission. This is checked before the run starts. + +- "Not enough free disk space..." + - Free up space or choose a location on a volume with more room. The message states roughly how much is needed versus available. Checked before a redaction run and before a model download. + +- "Another Ollama (or other) instance is running on port ..." + - A foreign process was already listening on the port Marcut expected for its embedded AI service. Marcut reports it and tries a different port. If it recurs, quit any separately launched Ollama instance and relaunch Marcut. + +- "Processing stalled - the embedded engine stopped responding" + - The embedded AI worker wedged and stopped sending progress. Marcut detects this via an internal heartbeat and fails the document instead of freezing. Restart Marcut and re-run. + +- "Processing didn't survive sleep..." + - The Mac slept and the AI service didn't respond after waking. Restart Marcut to resume processing. - "Python runtime unavailable" (macOS app) - Restart the app; reinstall if the embedded runtime failed to load. diff --git a/assets/screenshots/marcut-finished.png b/assets/screenshots/marcut-finished.png new file mode 100644 index 0000000..a3ed4ae Binary files /dev/null and b/assets/screenshots/marcut-finished.png differ diff --git a/assets/screenshots/marcut-idle.png b/assets/screenshots/marcut-idle.png new file mode 100644 index 0000000..1760784 Binary files /dev/null and b/assets/screenshots/marcut-idle.png differ diff --git a/assets/screenshots/marcut-processing.png b/assets/screenshots/marcut-processing.png new file mode 100644 index 0000000..4cc55fa Binary files /dev/null and b/assets/screenshots/marcut-processing.png differ diff --git a/assets/screenshots/marcut-settings.png b/assets/screenshots/marcut-settings.png new file mode 100644 index 0000000..db5a1d6 Binary files /dev/null and b/assets/screenshots/marcut-settings.png differ diff --git a/src/python/marcut/__init__.py b/src/python/marcut/__init__.py index 58f3ace..614d684 100644 --- a/src/python/marcut/__init__.py +++ b/src/python/marcut/__init__.py @@ -1 +1 @@ -from .version import __version__ +from .version import __version__ as __version__ diff --git a/src/python/marcut/bootstrapper.py b/src/python/marcut/bootstrapper.py index 0c66961..83fe239 100644 --- a/src/python/marcut/bootstrapper.py +++ b/src/python/marcut/bootstrapper.py @@ -4,7 +4,6 @@ import os import sys import json -import subprocess from pathlib import Path import tkinter as tk from tkinter import messagebox diff --git a/src/python/marcut/cli.py b/src/python/marcut/cli.py index 802555e..4a831cb 100644 --- a/src/python/marcut/cli.py +++ b/src/python/marcut/cli.py @@ -1,4 +1,5 @@ -import argparse, sys +import argparse +import sys import json import os import shlex @@ -185,14 +186,14 @@ def cli_progress_callback(update: ProgressUpdate): ) if result['success']: - print(f"βœ… Redaction completed successfully") + print("βœ… Redaction completed successfully") print(f" Entities detected: {result['entity_count']}") print(f" Processing time: {result['duration']:.2f}s") # Show timing breakdown if requested if (getattr(a, 'timing', False) or getattr(a, 'llm_detail', False)) and 'phase_timings' in result: total = result['duration'] - print(f"\nπŸ“Š Phase Timing Breakdown:") + print("\nπŸ“Š Phase Timing Breakdown:") print(f" {'Phase':<20} {'Time':>8} {'Pct':>6}") print(f" {'-'*20} {'-'*8} {'-'*6}") for phase, duration in result['phase_timings'].items(): @@ -207,7 +208,7 @@ def cli_progress_callback(update: ProgressUpdate): if getattr(a, 'llm_detail', False) and 'llm_timing' in result: llm = result['llm_timing'] llm_total = llm.get('http_request', 0) - print(f"\nπŸ”¬ LLM Sub-Phase Detail:") + print("\nπŸ”¬ LLM Sub-Phase Detail:") print(f" {'Sub-Phase':<22} {'Time':>8} {'Pct':>6}") print(f" {'-'*22} {'-'*8} {'-'*6}") sub_phases = [ diff --git a/src/python/marcut/cluster.py b/src/python/marcut/cluster.py index 8d4281b..8356a9b 100644 --- a/src/python/marcut/cluster.py +++ b/src/python/marcut/cluster.py @@ -10,20 +10,29 @@ def normalize(entity: str) -> str: if x.endswith(" " + suf): x = x[:-(len(suf)+1)] x = "".join(ch for ch in x if ch.isalnum() or ch.isspace()).strip() - while " " in x: x = x.replace(" "," ") + while " " in x: + x = x.replace(" ", " ") return x class ClusterTable: def __init__(self): - self.next_name = 1; self.next_org = 1; self.next_brand = 1 + self.next_name = 1 + self.next_org = 1 + self.next_brand = 1 self.clusters: Dict[str, List[Dict[str,Any]]] = {"NAME":[], "ORG":[], "BRAND":[]} def _new_id(self, label: str) -> str: if label == "NAME": - k = f"NAME_{self.next_name}"; self.next_name += 1; return k + k = f"NAME_{self.next_name}" + self.next_name += 1 + return k if label == "ORG": - k = f"ORG_{self.next_org}"; self.next_org += 1; return k - k = f"BRAND_{self.next_brand}"; self.next_brand += 1; return k + k = f"ORG_{self.next_org}" + self.next_org += 1 + return k + k = f"BRAND_{self.next_brand}" + self.next_brand += 1 + return k def link(self, label: str, surface: str) -> Tuple[str, float, bool]: n = normalize(surface) @@ -31,7 +40,8 @@ def link(self, label: str, surface: str) -> Tuple[str, float, bool]: for cl in self.clusters[label]: for alias in cl["aliases"]: s = fuzz.token_set_ratio(n, alias) / 100.0 - if s > best[1]: best = (cl, s) + if s > best[1]: + best = (cl, s) if best[0] and (best[1] >= 0.82 or n in best[0]["aliases"]): best[0]["aliases"].add(n) return best[0]["id"], best[1], False diff --git a/src/python/marcut/docx_io.py b/src/python/marcut/docx_io.py index ffc7c14..dc3c836 100644 --- a/src/python/marcut/docx_io.py +++ b/src/python/marcut/docx_io.py @@ -13,7 +13,7 @@ from docx.oxml.ns import qn from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Iterable, Tuple -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, fields def _safe_fromstring(xml_bytes: bytes): """Safe XML parsing that disables entity resolution.""" @@ -487,7 +487,6 @@ def _strip_jpeg_metadata(data: bytes) -> Tuple[bytes, bool]: if i + 1 >= len(data): break length = (data[i] << 8) + data[i + 1] - segment_start = i - 1 segment_end = min(i + length, len(data)) if marker in (0xE1, 0xED): # APP1/APP13 changed = True @@ -1383,7 +1382,6 @@ def _append_run(self, para, run): def _scan_paragraph(self, para): from docx.text.run import Run - from docx.text.paragraph import Paragraph from docx.oxml.ns import qn # Iterate over children to catch runs inside hyperlinks AND drawings (text boxes) @@ -1444,7 +1442,8 @@ def _scan_paragraph(self, para): self._append_run(para, run) self._scan_run_contents(subchild) - self.text += "\n"; self.index.append(("break", None, None)) + self.text += "\n" + self.index.append(("break", None, None)) def _scan_container(self, container): from docx.text.paragraph import Paragraph @@ -1667,7 +1666,8 @@ def _insert_after(self, run: Run, text: str, highlight: bool) -> Run: def _insert_deletion_after(self, anchor_el, text: str, rPr=None): del_el = OxmlElement('w:del') - del_el.set(qn('w:id'), str(self._rev_id)); self._rev_id += 1 + del_el.set(qn('w:id'), str(self._rev_id)) + self._rev_id += 1 del_el.set(qn('w:author'), self.author_name) del_el.set(qn('w:date'), datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) @@ -1685,7 +1685,8 @@ def _insert_deletion_after(self, anchor_el, text: str, rPr=None): def _insert_insertion_after(self, anchor_el, text: str, rPr=None): ins_el = OxmlElement('w:ins') - ins_el.set(qn('w:id'), str(self._rev_id)); self._rev_id += 1 + ins_el.set(qn('w:id'), str(self._rev_id)) + self._rev_id += 1 ins_el.set(qn('w:author'), self.author_name) ins_el.set(qn('w:date'), datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')) r = self._make_text_run(text, rPr) @@ -2054,7 +2055,7 @@ def _remove(tag: str) -> None: # Define namespace ns = {'ep': 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties'} - def _remove_elements(tag: str) -> None: + def _remove_elements(tag: str, app_xml=app_xml, ns=ns) -> None: for elem in app_xml.findall(f'.//ep:{tag}', namespaces=ns): parent = elem.getparent() if parent is not None: @@ -2363,7 +2364,8 @@ def apply_replacements(self, spans: List[Dict[str,Any]], track_changes: bool = T cis = sorted(ci for _, _, ci in chars if ci is not None) if not cis: continue - start_ci = cis[0]; end_ci = cis[-1] + start_ci = cis[0] + end_ci = cis[-1] pre = original[:start_ci] mid = original[start_ci:end_ci+1] post = original[end_ci+1:] diff --git a/src/python/marcut/docx_revisions.py b/src/python/marcut/docx_revisions.py index b016384..0dd4ed5 100644 --- a/src/python/marcut/docx_revisions.py +++ b/src/python/marcut/docx_revisions.py @@ -201,7 +201,7 @@ def accept_revisions_in_docx_bytes(input_path: str, debug: bool = False) -> Tupl try: # Check lxml availability early try: - import lxml.etree + import lxml.etree # noqa: F401 -- import itself is the availability probe except ImportError: return None, False diff --git a/src/python/marcut/gui.py b/src/python/marcut/gui.py index 3bced2c..f4818d6 100644 --- a/src/python/marcut/gui.py +++ b/src/python/marcut/gui.py @@ -11,8 +11,6 @@ import threading import subprocess from pathlib import Path -import json -import tempfile from datetime import datetime import time import requests @@ -442,7 +440,7 @@ def download(): )) except Exception as e: - self.model_status.config(text=f"❌ Error downloading model") + self.model_status.config(text="❌ Error downloading model") self.root.after(0, lambda msg=str(e): messagebox.showerror( "Download Error", f"An error occurred while downloading the model:\n{msg}" @@ -569,7 +567,7 @@ def update_progress_ui(update: ProgressUpdate): def setup_embedded_ollama(self): """Set up embedded Ollama when standard detection fails""" - print(f"[DEBUG] Setting up embedded Ollama...") + print("[DEBUG] Setting up embedded Ollama...") # Try to find and start embedded Ollama directly ollama_binary = self.get_ollama_binary_path() @@ -584,7 +582,7 @@ def setup_embedded_ollama(self): try: response = requests.get(f"{_ollama_base_url()}/api/tags", timeout=10) if response.status_code == 200: - print(f"[DEBUG] Ollama already running") + print("[DEBUG] Ollama already running") self.ollama_status.config(text="βœ… Ollama running") self.check_embedded_model() return @@ -592,7 +590,7 @@ def setup_embedded_ollama(self): pass # Start embedded Ollama - print(f"[DEBUG] Starting embedded Ollama service...") + print("[DEBUG] Starting embedded Ollama service...") self.ollama_status.config(text="⚠️ Starting AI service...") # Set up environment @@ -613,27 +611,27 @@ def setup_embedded_ollama(self): ) # Wait for service to start - print(f"[DEBUG] Waiting for Ollama service to start...") - for i in range(15): + print("[DEBUG] Waiting for Ollama service to start...") + for _ in range(15): time.sleep(1) try: response = requests.get(f"{_ollama_base_url()}/api/tags", timeout=5) if response.status_code == 200: - print(f"[DEBUG] Ollama service started successfully") + print("[DEBUG] Ollama service started successfully") self.ollama_status.config(text="βœ… Ollama running") self.check_embedded_model() return except requests.exceptions.RequestException: pass - print(f"[DEBUG] Ollama service failed to start") + print("[DEBUG] Ollama service failed to start") self.ollama_status.config(text="❌ Failed to start Ollama") except Exception as e: print(f"[DEBUG] Error setting up embedded Ollama: {e}") self.ollama_status.config(text="❌ Error starting Ollama") else: - print(f"[DEBUG] No embedded Ollama found") + print("[DEBUG] No embedded Ollama found") self.show_setup_wizard() def check_embedded_model(self): @@ -661,7 +659,7 @@ def check_embedded_model(self): def show_setup_wizard(self): """Show the first-run setup wizard for model download""" - print(f"[DEBUG] Showing setup wizard") + print("[DEBUG] Showing setup wizard") # Create a professional welcome dialog response = messagebox.askyesno( @@ -710,12 +708,12 @@ def download(): ) if result.returncode == 0: - print(f"[DEBUG] Model download successful") + print("[DEBUG] Model download successful") self.model_status.config(text=f"βœ… Model {self.model_name} ready") self.root.after(0, lambda: self.show_setup_complete()) else: print(f"[DEBUG] Model download failed: {result.stderr}") - self.model_status.config(text=f"❌ Download failed") + self.model_status.config(text="❌ Download failed") self.root.after(0, lambda: messagebox.showerror( "Download Failed", f"Failed to download {self.model_name}.\n\n" @@ -725,7 +723,7 @@ def download(): except Exception as e: print(f"[DEBUG] Exception during model download: {e}") - self.model_status.config(text=f"❌ Download error") + self.model_status.config(text="❌ Download error") self.root.after(0, lambda msg=str(e): messagebox.showerror( "Download Error", f"An error occurred while downloading the model:\n\n{msg}" diff --git a/src/python/marcut/llm_timing.py b/src/python/marcut/llm_timing.py index c305621..d0f6a52 100644 --- a/src/python/marcut/llm_timing.py +++ b/src/python/marcut/llm_timing.py @@ -3,7 +3,6 @@ """ import time import json -import sys import os import threading import requests diff --git a/src/python/marcut/model.py b/src/python/marcut/model.py index 3abe9e7..2a65fbc 100644 --- a/src/python/marcut/model.py +++ b/src/python/marcut/model.py @@ -1,6 +1,7 @@ from typing import List, Dict, Any, Optional, Set, Callable -import json, requests +import json +import requests import os import sys import re @@ -251,7 +252,8 @@ def llama_cpp_extract( entities = data["entities"] all_spans = [] for ent in entities: - if not isinstance(ent, dict): continue + if not isinstance(ent, dict): + continue etext = ent.get("text", "") etype = ent.get("type", "") label = _map_label(etype) @@ -290,13 +292,20 @@ def llama_cpp_extract( def _map_label(lbl: str) -> Optional[str]: t = (lbl or '').strip().upper() - if t in ("NAME", "PERSON", "HUMAN", "INDIVIDUAL"): return "NAME" - if t in ("ORG", "ORGANIZATION", "COMPANY", "INSTITUTION", "BUSINESS"): return "ORG" - if t in ("BRAND", "PRODUCT", "SERVICE"): return "BRAND" - if t in ("LOC", "LOCATION", "GPE", "PLACE", "ADDRESS"): return "LOC" - if t in ("MONEY", "CURRENCY"): return "MONEY" - if t in ("NUMBER", "QUANTITY", "COUNT", "AMOUNT"): return "NUMBER" - if t in ("DATE",): return "DATE" + if t in ("NAME", "PERSON", "HUMAN", "INDIVIDUAL"): + return "NAME" + if t in ("ORG", "ORGANIZATION", "COMPANY", "INSTITUTION", "BUSINESS"): + return "ORG" + if t in ("BRAND", "PRODUCT", "SERVICE"): + return "BRAND" + if t in ("LOC", "LOCATION", "GPE", "PLACE", "ADDRESS"): + return "LOC" + if t in ("MONEY", "CURRENCY"): + return "MONEY" + if t in ("NUMBER", "QUANTITY", "COUNT", "AMOUNT"): + return "NUMBER" + if t in ("DATE",): + return "DATE" # IMPORTANT: no fallback. Unknown labels are dropped. return None @@ -889,7 +898,7 @@ def _schema_fallback_allowed(err: requests.exceptions.HTTPError) -> bool: try: parsed = parse_llm_response(response_text) - except json.JSONDecodeError as first_error: + except json.JSONDecodeError: _log_app_event( "JSON parsing failed. " f"Response length={len(response_text)} chars. Raw response omitted from logs." diff --git a/src/python/marcut/model_enhanced.py b/src/python/marcut/model_enhanced.py index e960703..c750391 100644 --- a/src/python/marcut/model_enhanced.py +++ b/src/python/marcut/model_enhanced.py @@ -31,8 +31,7 @@ import requests import threading import concurrent.futures -from dataclasses import dataclass, asdict -import hashlib +from dataclasses import dataclass from .cancellation import ProcessingDeadlineExceeded, check_processing_deadline, remaining_seconds from .model import ( parse_llm_response, @@ -808,7 +807,7 @@ def ollama_validate( last_error = e print(f"Validation attempt {attempt_idx} failed: HTTP {status}") else: - raise RuntimeError(f"Ollama validation request failed: {e}") + raise RuntimeError(f"Ollama validation request failed: {e}") from e if attempt_idx < len(retry_plan): time.sleep(attempt["wait"]) @@ -973,7 +972,7 @@ def emit_mass_event(payload, progress=None, status_message=None): print(message, flush=True) except UnicodeEncodeError: print(message.encode('ascii', errors='replace').decode('ascii'), flush=True) - except Exception as e: + except Exception: pass all_entities = [] @@ -1478,8 +1477,8 @@ def _get_model(self): try: from llama_cpp import Llama - except ImportError: - raise RuntimeError("llama-cpp-python not installed. Run: pip install llama-cpp-python") + except ImportError as e: + raise RuntimeError("llama-cpp-python not installed. Run: pip install llama-cpp-python") from e # Load model if not cached or if path changed if _llama_model is None or _llama_model_path != self.model_path: @@ -1498,7 +1497,7 @@ def _get_model(self): ) _llama_model_path = self.model_path except Exception as e: - raise RuntimeError(f"Error loading model: {e}") + raise RuntimeError(f"Error loading model: {e}") from e return _llama_model @@ -1516,7 +1515,7 @@ def _generate_response(self, prompt: str, max_tokens: int = 1024) -> str: ) return response['choices'][0]['text'].strip() except Exception as e: - raise RuntimeError(f"Error during inference: {e}") + raise RuntimeError(f"Error during inference: {e}") from e def extract_entities(self, text: str, doc_context: DocumentContext) -> List[Entity]: """Extract entities using local Llama.cpp model. diff --git a/src/python/marcut/native_setup.py b/src/python/marcut/native_setup.py index a8b2b0a..53621d8 100644 --- a/src/python/marcut/native_setup.py +++ b/src/python/marcut/native_setup.py @@ -7,11 +7,9 @@ import subprocess import threading from pathlib import Path -import tempfile import webbrowser import http.server import socketserver -from urllib.parse import urlparse, parse_qs import time class SetupServer: @@ -248,7 +246,8 @@ def log_message(self, format, *args): pass # Create handler with setup instance - handler = lambda *args, **kwargs: SetupHandler(self, *args, **kwargs) + def handler(*args, **kwargs): + return SetupHandler(self, *args, **kwargs) # Start server self.server = socketserver.TCPServer(("127.0.0.1", 0), handler) diff --git a/src/python/marcut/ollama_manager.py b/src/python/marcut/ollama_manager.py index e282c6a..7194f7e 100644 --- a/src/python/marcut/ollama_manager.py +++ b/src/python/marcut/ollama_manager.py @@ -10,9 +10,7 @@ import subprocess import time import requests -import threading import signal -import hashlib from pathlib import Path from typing import Optional, Dict, Any, Callable, TextIO import logging @@ -164,7 +162,7 @@ def start_service(self, timeout: int = 60) -> bool: ) # Wait for service to start - for i in range(timeout): + for _ in range(timeout): if self.is_service_running(): self.is_running = True logger.info(f"Ollama service started (PID: {self.process.pid})") @@ -340,7 +338,7 @@ def _parse_download_progress(self, line: str) -> tuple[Optional[float], Optional return percent, None except ValueError: pass - except: + except Exception: pass # Handle other status messages @@ -476,7 +474,7 @@ def health_check(self) -> Dict[str, Any]: # Try Ollama's own registry first (more reliable for our use case) response = requests.head("https://registry.ollama.ai", timeout=5) health["network_ok"] = response.status_code in [200, 301, 302, 403] - except: + except Exception: # Network might be down, but that's OK if we have cached models health["network_ok"] = False # Only report as error if we don't have the required model diff --git a/src/python/marcut/pipeline.py b/src/python/marcut/pipeline.py index e60865d..32e85eb 100644 --- a/src/python/marcut/pipeline.py +++ b/src/python/marcut/pipeline.py @@ -1,8 +1,29 @@ import sys import io import datetime -from dataclasses import fields +import hashlib +import traceback +import warnings +import time +import os +import tempfile import logging +from dataclasses import fields +from typing import List, Dict, Any, Tuple, Optional, Callable, TypedDict +from .docx_io import DocxMap, MetadataCleaningSettings +from .docx_revisions import accept_revisions_in_docx_bytes +from .chunker import make_chunks +from .rules import run_rules, _is_excluded_combo, _is_excluded, _is_specific_org_span, ADDRESS +from .model_enhanced import ( + LlamaCppRedactionPipeline, + run_enhanced_model, + apply_llm_overrides_to_rule_spans, + LLMChunkExtractionFailed, +) +from .cluster import ClusterTable +from .confidence import combine, low_conf +from .report import write_report, write_json_file, make_private_file +import regex as re # For consistency pass boundaries # Unicode to ASCII mapping for common document characters # These are frequently found in Word documents and cause encoding issues @@ -77,31 +98,6 @@ def safe_print(*args, **kwargs): except Exception: pass -import json -import hashlib -import traceback -import warnings -import time -import os -import tempfile -from typing import List, Dict, Any, Tuple, Optional, Callable, TypedDict -from .docx_io import DocxMap, MetadataCleaningSettings -from .docx_revisions import accept_revisions_in_docx_bytes -from .chunker import make_chunks -from .rules import run_rules, _is_excluded_combo, _is_excluded, _is_specific_org_span, ADDRESS -from .model_enhanced import ( - LlamaCppRedactionPipeline, - run_enhanced_model, - apply_llm_overrides_to_rule_spans, - DocumentContext, - build_prompt_context, - LLMChunkExtractionFailed, -) -from .cluster import ClusterTable -from .confidence import combine, low_conf -from .report import write_report, write_json_file, make_private_file -import regex as re # For consistency pass boundaries - logger = logging.getLogger(__name__) def _metadata_env_enabled(name: str) -> bool: @@ -141,7 +137,7 @@ def _merge_overlaps(spans: List[Dict[str,Any]], text: str) -> List[Dict[str,Any] # Validate span data structures valid_spans = [] - for i, span in enumerate(spans): + for span in spans: if not isinstance(span, dict): continue if not all(key in span for key in ["start", "end", "label"]): @@ -686,13 +682,15 @@ def _bounded_patterns(values) -> List[str]: for match in re.finditer(pattern_str, text): matched_text = match.group(0) label = case_sensitive_map.get(matched_text) - if not label: continue + if not label: + continue s, e = match.span() if _overlaps_existing(s, e, label, matched_text): continue key = (s, e, label) - if key in existing_keys or key in new_keys: continue + if key in existing_keys or key in new_keys: + continue new_spans.append({ "start": s, "end": e, "label": label, "text": matched_text, @@ -700,7 +698,8 @@ def _bounded_patterns(values) -> List[str]: }) new_keys.add(key) except Exception as e: - if debug: print(f"Consistency Pass Error (CS): {e}") + if debug: + print(f"Consistency Pass Error (CS): {e}") # Build regex for case-insensitive (ORGs) if case_insensitive_map: @@ -712,13 +711,15 @@ def _bounded_patterns(values) -> List[str]: matched_text = match.group(0) # Lookup by lowercase label = case_insensitive_map.get(matched_text.lower()) - if not label: continue + if not label: + continue s, e = match.span() if _overlaps_existing(s, e, label, matched_text): continue key = (s, e, label) - if key in existing_keys or key in new_keys: continue + if key in existing_keys or key in new_keys: + continue new_spans.append({ "start": s, "end": e, "label": label, "text": matched_text, @@ -726,7 +727,8 @@ def _bounded_patterns(values) -> List[str]: }) new_keys.add(key) except Exception as e: - if debug: print(f"Consistency Pass Error (CI): {e}") + if debug: + print(f"Consistency Pass Error (CI): {e}") # 3. Fuzzy Scan for ORGs (Per-candidate, expensive but necessary for complex forms) # Only iterate ORG candidates @@ -753,16 +755,22 @@ def _bounded_patterns(values) -> List[str]: try: for match in re.finditer(fuzzy_pattern, text, flags=re.IGNORECASE): s, e = match.span() - if exclude_if and exclude_if(cand_text, label): continue - if s > 0 and text[s - 1].isalnum(): continue - if e < len(text) and text[e:e+1].isalnum(): continue - if _overlaps_existing(s, e, label, text[s:e]): continue + if exclude_if and exclude_if(cand_text, label): + continue + if s > 0 and text[s - 1].isalnum(): + continue + if e < len(text) and text[e:e+1].isalnum(): + continue + if _overlaps_existing(s, e, label, text[s:e]): + continue key = (s, e, label) - if key in existing_keys or key in new_keys: continue + if key in existing_keys or key in new_keys: + continue # Length check - if (e - s) < max(4, len("".join(norm_tokens)) - 1): continue + if (e - s) < max(4, len("".join(norm_tokens)) - 1): + continue new_spans.append({ "start": s, "end": e, "label": label, "text": text[s:e], @@ -1417,7 +1425,6 @@ def _finalize_and_write( spans = _drop_invalid_spans(text, spans, warnings, suppressed, debug=debug) ct = ClusterTable() - url_counter = {} # Assign entity IDs for clustering and consistent numbering # Use generic counters for exact-match types @@ -1646,7 +1653,7 @@ def _sync_dm_warnings() -> None: error_code="REPORT_SAVE_FAILED", technical_details=f"Report path: {report_path}, Error: {str(e)}", original_error=e - ) + ) from e _replace_existing_temp(report_temp_path, report_path) _replace_existing_temp(report_html_temp_path, os.path.splitext(report_path)[0] + ".html") @@ -1663,7 +1670,7 @@ def _sync_dm_warnings() -> None: error_code="ARTIFACT_FINALIZE_FAILED", technical_details=str(e), original_error=e, - ) + ) from e _cleanup_temp_artifacts(temp_paths) return 0 @@ -1906,7 +1913,7 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: error_code="DOC_LOAD_FAILED", technical_details=f"Input path: {input_path}, Error: {str(e)}", original_error=e - ) + ) from e # Enhanced error handling for rules processing try: @@ -1920,7 +1927,7 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: error_code="RULES_ENGINE_FAILED", technical_details=f"Error in rules processing: {str(e)}", original_error=e - ) + ) from e if guardrailed_mode: rule_spans = _filter_excluded_combo_spans(text, rule_spans, suppressed) @@ -1973,7 +1980,7 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: error_code="OUTPUT_SAVE_FAILED", technical_details=f"Output path: {output_path}, Error: {str(e)}", original_error=e - ) + ) from e if normalized_mode in llm_modes: # Enhanced error handling for AI processing @@ -2027,7 +2034,7 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: f"after retries. Unanalyzed ranges: {ranges}" ), original_error=e - ) + ) from e except Exception as e: # Check for specific error patterns error_str = str(e).lower() @@ -2037,28 +2044,28 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: error_code="AI_SERVICE_UNAVAILABLE", technical_details=f"Ollama service error: {str(e)}. Ensure Ollama is running and accessible.", original_error=e - ) + ) from e elif "timeout" in error_str or "deadline" in error_str: raise RedactionError( message="AI processing timed out", error_code="AI_PROCESSING_TIMEOUT", technical_details=f"Model: {model_id}, Error: {str(e)}. Try with a smaller document or different model.", original_error=e - ) + ) from e elif "model" in error_str and ("not found" in error_str or "pull" in error_str): raise RedactionError( message="AI model is not available", error_code="AI_MODEL_UNAVAILABLE", technical_details=f"Model: {model_id}, Error: {str(e)}. Ensure the model is downloaded and available.", original_error=e - ) + ) from e else: raise RedactionError( message="AI processing failed with an unexpected error", error_code="AI_PROCESSING_FAILED", technical_details=f"Model: {model_id}, Error: {str(e)}", original_error=e - ) + ) from e if guardrailed_mode: model_spans = _filter_excluded_combo_spans(text, model_spans, suppressed) @@ -2132,7 +2139,7 @@ def _normalize_confidence(value: float, fallback: float = 0.95) -> float: error_code="OUTPUT_SAVE_FAILED", technical_details=f"Output path: {output_path}, Error: {str(e)}", original_error=e - ) + ) from e except RedactionError as err: if debug: @@ -2506,7 +2513,6 @@ def _extract_text_from_part(part_blob) -> str: from docx.oxml.ns import qn from lxml import etree import posixpath - import zipfile def _iter_part_elements(): if dm.doc.element is not None: @@ -4218,7 +4224,7 @@ def scrub_metadata_only( # COPY PATH: Safe handling for None preset import shutil if debug: - print(f"[MARCUT_PIPELINE] None preset: Copying file without processed save.") + print("[MARCUT_PIPELINE] None preset: Copying file without processed save.") shutil.copy2(input_path, output_path) return (True, "", None) diff --git a/src/python/marcut/progress.py b/src/python/marcut/progress.py index ebf1b9c..01eb50b 100644 --- a/src/python/marcut/progress.py +++ b/src/python/marcut/progress.py @@ -3,7 +3,7 @@ """ import time -from typing import Dict, Any, Callable, Optional +from typing import Callable, Optional from dataclasses import dataclass from enum import Enum diff --git a/src/python/marcut/progress_widgets.py b/src/python/marcut/progress_widgets.py index 8293e56..1261a43 100644 --- a/src/python/marcut/progress_widgets.py +++ b/src/python/marcut/progress_widgets.py @@ -4,9 +4,7 @@ import tkinter as tk from tkinter import ttk -import math import time -from typing import Optional class PieProgressWidget(tk.Canvas): diff --git a/src/python/marcut/report.py b/src/python/marcut/report.py index 78345b7..0fdd807 100644 --- a/src/python/marcut/report.py +++ b/src/python/marcut/report.py @@ -9,7 +9,7 @@ import os from typing import Any, Dict, List, Optional -from .report_common import escape_html, get_base_css +from .report_common import escape_html def write_json_file(path: str, data: Dict[str, Any]) -> None: @@ -174,7 +174,6 @@ def _generate_html_audit_report(data: Dict[str, Any], input_path: str, html_path spans = data.get('spans', []) created_at = data.get('created_at', '') model = data.get('model', 'Unknown') - input_hash = data.get('input_sha256', '')[:16] warnings = data.get('warnings') or [] suppressed = data.get('suppressed') or [] diff --git a/src/python/marcut/report_common.py b/src/python/marcut/report_common.py index c8a06e2..7d486c6 100644 --- a/src/python/marcut/report_common.py +++ b/src/python/marcut/report_common.py @@ -9,7 +9,6 @@ import mimetypes import os import plistlib -import stat import subprocess from typing import Any, Dict, List, Optional diff --git a/src/python/marcut/rules.py b/src/python/marcut/rules.py index 37697ba..02caffa 100644 --- a/src/python/marcut/rules.py +++ b/src/python/marcut/rules.py @@ -757,12 +757,14 @@ def _compile_address_patterns() -> re.Pattern: def luhn_ok(s: str) -> bool: digits = [int(c) for c in re.sub(r"\D", "", s)] - if len(digits) < 13 or len(digits) > 19: return False + if len(digits) < 13 or len(digits) > 19: + return False checksum, parity = 0, len(digits) % 2 for i, d in enumerate(digits): if i % 2 == parity: d *= 2 - if d > 9: d -= 9 + if d > 9: + d -= 9 checksum += d return checksum % 10 == 0 diff --git a/src/python/marcut/setup_wizard.py b/src/python/marcut/setup_wizard.py index 8119aad..800044b 100644 --- a/src/python/marcut/setup_wizard.py +++ b/src/python/marcut/setup_wizard.py @@ -5,7 +5,6 @@ import sys import os import threading -import requests from pathlib import Path import json diff --git a/src/python/marcut/unified_redactor.py b/src/python/marcut/unified_redactor.py index db9df5a..eb1065b 100644 --- a/src/python/marcut/unified_redactor.py +++ b/src/python/marcut/unified_redactor.py @@ -58,10 +58,10 @@ if os.environ.get("MARCUT_DEBUG_PATH") == "1": print("DEBUG sys.path:", sys.path, file=sys.stderr) -import re +import re # noqa: E402 -- must follow the sys.path fixup above # Import the pipeline with strict package import (no fallbacks) -import marcut.pipeline as pipeline +import marcut.pipeline as pipeline # noqa: E402 -- must resolve against the sys.path fixup above, not the system path def validate_model_name(model: str) -> bool: @@ -391,7 +391,7 @@ def main(): # Exit with appropriate code if result['success']: - print(f"βœ… Redaction completed successfully") + print("βœ… Redaction completed successfully") print(f" Input: {result['input_file']}") print(f" Output: {result['output_file']}") print(f" Report: {result['report_file']}") diff --git a/src/swift/MarcutApp/Sources/MarcutApp/AboutView.swift b/src/swift/MarcutApp/Sources/MarcutApp/AboutView.swift index f745162..d8fcadd 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/AboutView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/AboutView.swift @@ -4,6 +4,7 @@ struct AboutView: View { private var appVersion: String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" } + private var buildNumber: String { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" } @@ -73,8 +74,16 @@ struct AboutView: View { // Links HStack(spacing: 8) { - AboutLinkButton(icon: "globe", title: "Website", url: URL(string: "https://github.com/legalmarc/marcut")!) - AboutLinkButton(icon: "person.bubble.fill", title: "Support", url: URL(string: "https://www.linkedin.com/in/marcmandel/")!) + AboutLinkButton( + icon: "globe", + title: "Website", + url: URL(string: "https://github.com/legalmarc/marcut")! + ) + AboutLinkButton( + icon: "person.bubble.fill", + title: "Support", + url: URL(string: "https://www.linkedin.com/in/marcmandel/")! + ) AboutActionButton(icon: "book.fill", title: "Help") { Task { @MainActor in LifecycleUtils.openHelpWindow(anchor: "marcutapp-help") diff --git a/src/swift/MarcutApp/Sources/MarcutApp/BundleResourceLocator.swift b/src/swift/MarcutApp/Sources/MarcutApp/BundleResourceLocator.swift index 9557d5b..9e3b41f 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/BundleResourceLocator.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/BundleResourceLocator.swift @@ -21,7 +21,8 @@ enum BundleResourceLocator { // 2. Check Package Bundle (e.g. if we are inside a Swift Package structure) if let packageURL = Bundle.main.url(forResource: "MarcutApp_MarcutApp", withExtension: "bundle"), - let packageBundle = Bundle(url: packageURL) { + let packageBundle = Bundle(url: packageURL) + { if let url = packageBundle.url(forResource: name, withExtension: ext) { return url } @@ -31,10 +32,11 @@ enum BundleResourceLocator { } #if SWIFT_PACKAGE && DEBUG - if let url = Bundle.module.url(forResource: name, withExtension: ext) ?? - Bundle.module.url(forResource: name, withExtension: ext, subdirectory: "Resources") { - return url - } + if let url = Bundle.module.url(forResource: name, withExtension: ext) ?? + Bundle.module.url(forResource: name, withExtension: ext, subdirectory: "Resources") + { + return url + } #endif // 3. Check development paths (relative to CWD or known locations) @@ -43,7 +45,7 @@ enum BundleResourceLocator { "\(name).\(ext)", "MarcutApp/Sources/MarcutApp/Resources/\(name).\(ext)", "src/swift/MarcutApp/Sources/MarcutApp/Resources/\(name).\(ext)", - "marcut/\(name).\(ext)" + "marcut/\(name).\(ext)", ] for path in candidatePaths { diff --git a/src/swift/MarcutApp/Sources/MarcutApp/ContentView.swift b/src/swift/MarcutApp/Sources/MarcutApp/ContentView.swift index e47b4be..23df417 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/ContentView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/ContentView.swift @@ -1,9 +1,10 @@ +import AppKit +import Foundation import SwiftUI import UniformTypeIdentifiers -import Foundation -import AppKit // MARK: - AttributeGraph Debugging Extensions + extension ContentView { private func debugAttributeGraph() { print("πŸ” ContentView AttributeGraph Analysis:") @@ -32,7 +33,7 @@ extension ContentView { var previousHasDocuments = viewModel.hasDocuments var previousIsEnvironmentReady = viewModel.isEnvironmentReady - for i in 0..<30 { // Monitor for 30 seconds + for i in 0 ..< 30 { // Monitor for 30 seconds try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second let currentItemsCount = viewModel.items.count @@ -40,15 +41,18 @@ extension ContentView { let currentIsEnvironmentReady = viewModel.isEnvironmentReady if currentItemsCount != previousItemsCount || - currentHasDocuments != previousHasDocuments || - currentIsEnvironmentReady != previousIsEnvironmentReady { - + currentHasDocuments != previousHasDocuments || + currentIsEnvironmentReady != previousIsEnvironmentReady + { print("πŸ”„ AttributeGraph state change detected at \(i)s:") print(" - items.count: \(previousItemsCount) β†’ \(currentItemsCount)") print(" - hasDocuments: \(previousHasDocuments) β†’ \(currentHasDocuments)") print(" - isEnvironmentReady: \(previousIsEnvironmentReady) β†’ \(currentIsEnvironmentReady)") - ContentView.logToFile("AttributeGraph state change at \(i)s - items:\(currentItemsCount) docs:\(currentHasDocuments) env:\(currentIsEnvironmentReady)") + ContentView + .logToFile( + "AttributeGraph state change at \(i)s - items:\(currentItemsCount) docs:\(currentHasDocuments) env:\(currentIsEnvironmentReady)" + ) previousItemsCount = currentItemsCount previousHasDocuments = currentHasDocuments @@ -62,8 +66,9 @@ extension ContentView { } // MARK: - Custom Color Palette (Adaptive for Light/Dark Mode) -struct CustomColors { - // Adaptive color properties that respond to color scheme + +enum CustomColors { + /// Adaptive color properties that respond to color scheme static func appBackground(for scheme: ColorScheme) -> Color { switch scheme { case .light: @@ -183,10 +188,11 @@ struct CustomColors { } // MARK: - Window Background Helper + struct WindowBackgroundView: NSViewRepresentable { @Environment(\.colorScheme) var colorScheme - func makeNSView(context: Context) -> NSView { + func makeNSView(context _: Context) -> NSView { let view = NSView() DispatchQueue.main.async { if let window = view.window { @@ -197,7 +203,7 @@ struct WindowBackgroundView: NSViewRepresentable { return view } - func updateNSView(_ nsView: NSView, context: Context) { + func updateNSView(_ nsView: NSView, context _: Context) { if let window = nsView.window { let bgColor = CustomColors.appBackground(for: colorScheme) window.backgroundColor = NSColor(bgColor) @@ -206,6 +212,7 @@ struct WindowBackgroundView: NSViewRepresentable { } // MARK: - Main Content View + struct ContentView: View { @EnvironmentObject private var viewModel: DocumentRedactionViewModel @State private var isTargeted = false @@ -218,7 +225,8 @@ struct ContentView: View { @State private var hasCheckedEnvironment = false @State private var pendingDropLoads = 0 @Environment(\.colorScheme) private var colorScheme - @AppStorage(DefaultsKey.outputSaveLocationPreference.key) private var outputSaveLocationRaw = OutputSaveLocation.alwaysAsk.rawValue + @AppStorage(DefaultsKey.outputSaveLocationPreference.key) private var outputSaveLocationRaw = OutputSaveLocation + .alwaysAsk.rawValue @AppStorage(DefaultsKey.lastExplicitOutputDirectoryPath.key) private var lastExplicitOutputDirectoryPath = "" private let actionButtonHeight: CGFloat = 52 @@ -226,11 +234,11 @@ struct ContentView: View { OutputSaveLocation(rawValue: outputSaveLocationRaw) ?? .alwaysAsk } - // Check if first run has been completed before + /// Check if first run has been completed before private var hasCompletedFirstRun: Bool { UserDefaults.standard.bool(forKey: DefaultsKey.hasCompletedFirstRun.key) } - + var body: some View { VStack(spacing: 24) { // Drop zone @@ -307,7 +315,8 @@ struct ContentView: View { ) } .buttonStyle(.plain) - .disabled(isPreparing || isStopping || viewModel.hasProcessingDocuments || currentProcessingTask != nil) + .disabled(isPreparing || isStopping || viewModel + .hasProcessingDocuments || currentProcessingTask != nil) .accessibilityIdentifier("content.retryFailed") } Spacer() @@ -372,7 +381,7 @@ struct ContentView: View { hasCheckedEnvironment = true // OPTIMIZATION: Check first-run status immediately before heavy environment checks - if !hasCompletedFirstRun && viewModel.availableModels.isEmpty { + if !hasCompletedFirstRun, viewModel.availableModels.isEmpty { ContentView.logToFile("πŸš€ Fast-path: Launching first-run setup immediately") viewModel.requestFirstRunSetup() // We still run environment check in background to populate status, but don't block UI @@ -420,7 +429,8 @@ struct ContentView: View { ContentView.logToFile("❌ Automatic recovery failed - showing detailed error") // Show detailed error with recovery options let diagnostics = viewModel.getDetailedEnvironmentDiagnostics() - let diagnosticText = diagnostics.map { "\($0.key): \($0.value)" }.joined(separator: "\n") + let diagnosticText = diagnostics.map { "\($0.key): \($0.value)" } + .joined(separator: "\n") alertInfo = AlertInfo( title: "Environment Issues Detected", @@ -493,8 +503,9 @@ struct ContentView: View { Text("Resume \(record.documentPaths.count) pending document(s) from your last session?") } } - + // MARK: - Drop Zone + private var dropZone: some View { VStack(spacing: 0) { // Drop area @@ -515,7 +526,8 @@ struct ContentView: View { .overlay( RoundedRectangle(cornerRadius: 12) .strokeBorder( - isTargeted ? CustomColors.accentColor(for: colorScheme) : CustomColors.subtleBorder(for: colorScheme).opacity(0.2), + isTargeted ? CustomColors.accentColor(for: colorScheme) : CustomColors + .subtleBorder(for: colorScheme).opacity(0.2), style: StrokeStyle(lineWidth: 2.5, dash: isTargeted ? [] : [8, 4]) ) .animation(.easeInOut(duration: 0.2), value: isTargeted) @@ -540,9 +552,9 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .accessibilityIdentifier("content.browse") - + Spacer() - + Button(action: { showSettings = true }) { HStack(spacing: 6) { Image(systemName: "gearshape.fill") @@ -567,8 +579,9 @@ struct ContentView: View { .stroke(CustomColors.subtleBorder(for: colorScheme).opacity(0.3), lineWidth: 1) ) } - + // MARK: - Action Buttons + private var actionButtons: some View { HStack(spacing: 12) { // Metadata-only report (no scrubbing) @@ -592,12 +605,12 @@ struct ContentView: View { } .disabled( !hasMetadataReportWork || - isPreparing || - isStopping || - viewModel.hasProcessingDocuments || - currentProcessingTask != nil || - viewModel.isPythonInitializing || - viewModel.pythonInitializationError != nil + isPreparing || + isStopping || + viewModel.hasProcessingDocuments || + currentProcessingTask != nil || + viewModel.isPythonInitializing || + viewModel.pythonInitializationError != nil ) .buttonStyle(.plain) .scaleEffect(hasMetadataReportWork ? 1.0 : 0.98) @@ -625,18 +638,18 @@ struct ContentView: View { } .disabled( !hasScrubWork || - isPreparing || - isStopping || - viewModel.hasProcessingDocuments || - currentProcessingTask != nil || - viewModel.isPythonInitializing || - viewModel.pythonInitializationError != nil + isPreparing || + isStopping || + viewModel.hasProcessingDocuments || + currentProcessingTask != nil || + viewModel.isPythonInitializing || + viewModel.pythonInitializationError != nil ) .buttonStyle(.plain) .scaleEffect(hasScrubWork ? 1.0 : 0.98) .animation(.easeInOut(duration: 0.2), value: hasScrubWork) .accessibilityIdentifier("content.scrubMetadata") - + // Process/Stop Button (right side) if isPreparing { // Show progress indicator while preparing @@ -681,7 +694,8 @@ struct ContentView: View { Image(systemName: "stop.fill") .font(.system(size: 16, weight: .semibold)) // If any item is in analyzing state (model download), reflect that in label - Text(viewModel.items.contains(where: { $0.status == .analyzing }) ? "Cancel Download" : "Stop Processing") + Text(viewModel.items + .contains(where: { $0.status == .analyzing }) ? "Cancel Download" : "Stop Processing") .font(.system(size: 16, weight: .semibold)) } .frame(maxWidth: .infinity) @@ -692,7 +706,11 @@ struct ContentView: View { .background( RoundedRectangle(cornerRadius: 12) .fill(CustomColors.destructiveColor(for: colorScheme)) - .shadow(color: CustomColors.destructiveColor(for: colorScheme).opacity(0.2), radius: 6, y: 3) + .shadow( + color: CustomColors.destructiveColor(for: colorScheme).opacity(0.2), + radius: 6, + y: 3 + ) ) } .buttonStyle(.plain) @@ -748,11 +766,18 @@ struct ContentView: View { .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 12) - .fill(hasReadyDocuments ? CustomColors.accentColor(for: colorScheme) : Color.gray.opacity(0.4)) - .shadow(color: hasReadyDocuments ? CustomColors.accentColor(for: colorScheme).opacity(0.2) : Color.clear, radius: 6, y: 3) + .fill(hasReadyDocuments ? CustomColors.accentColor(for: colorScheme) : Color.gray + .opacity(0.4)) + .shadow( + color: hasReadyDocuments ? CustomColors.accentColor(for: colorScheme) + .opacity(0.2) : Color.clear, + radius: 6, + y: 3 + ) ) } - .disabled(!hasReadyDocuments || !viewModel.isEnvironmentReady || isPreparing || viewModel.isPythonInitializing || viewModel.pythonInitializationError != nil) + .disabled(!hasReadyDocuments || !viewModel.isEnvironmentReady || isPreparing || viewModel + .isPythonInitializing || viewModel.pythonInitializationError != nil) .buttonStyle(.plain) .scaleEffect(hasReadyDocuments ? 1.0 : 0.98) .animation(.easeInOut(duration: 0.2), value: hasReadyDocuments) @@ -760,8 +785,9 @@ struct ContentView: View { } } } - + // MARK: - Actions + private func handleDrop(providers: [NSItemProvider]) { ContentView.logToFile("=== HANDLING FILE DROP ===") ContentView.logToFile("Number of providers: \(providers.count)") @@ -776,13 +802,14 @@ struct ContentView: View { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in DispatchQueue.main.async { defer { finishDropLoad() } - if let error = error { + if let error { ContentView.logToFile("Error loading item: \(error.localizedDescription)") return } if let data = item as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil) { + let url = URL(dataRepresentation: data, relativeTo: nil) + { ContentView.logToFile("Adding file: \(url.lastPathComponent)") viewModel.add(urls: [url]) ContentView.logToFile("After adding - hasValidDocuments: \(viewModel.hasValidDocuments)") @@ -796,14 +823,14 @@ struct ContentView: View { } } } - + private func openPanel() { // Permissions are handled centrally by FileAccessCoordinator - no need to check on every browse ContentView.logToFile("πŸ” File browser - using centralized permission system") let panel = NSOpenPanel() panel.allowsMultipleSelection = true - panel.allowedContentTypes = [UTType.init(filenameExtension: "docx") ?? UTType.data] + panel.allowedContentTypes = [UTType(filenameExtension: "docx") ?? UTType.data] panel.message = "Select Microsoft Word documents (.docx) to redact" NSApp.activate(ignoringOtherApps: true) @@ -812,7 +839,7 @@ struct ContentView: View { Task { await viewModel.refreshEnvironmentStatus() } } } - + private func startProcessing() { // Log that the button was clicked ContentView.logToFile("=== REDACT DOCUMENTS BUTTON CLICKED ===") @@ -1007,7 +1034,7 @@ struct ContentView: View { private static func logToFile(_ message: String) { DebugLogger.shared.log(message, component: "ContentView") } - + private func stopProcessing() { isStopping = true currentProcessingTask?.cancel() @@ -1144,10 +1171,10 @@ struct ContentView: View { // Show preparing state isStopping = false isPreparing = true - + Task { try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 second - + await MainActor.run { if outputSaveLocation.requiresPrompt { let panel = NSOpenPanel() @@ -1159,11 +1186,11 @@ struct ContentView: View { panel.prompt = "Save Here" isPreparing = false - + if panel.runModal() == .OK, let destination = panel.url { ContentView.logToFile("Metadata scrub destination: \(destination.path)") rememberExplicitDestination(destination) - + // Validate destination if let error = viewModel.validateDestination(destination) { alertInfo = AlertInfo(title: "Destination Error", message: error) @@ -1185,7 +1212,7 @@ struct ContentView: View { ) return } - + // Start metadata-only processing currentProcessingTask = Task { await viewModel.scrubMetadataOnly(to: destination) @@ -1218,7 +1245,7 @@ struct ContentView: View { } } } - + isPreparing = false } } @@ -1241,7 +1268,9 @@ struct ContentView: View { guard !trimmed.isEmpty else { return nil } let candidate = URL(fileURLWithPath: trimmed, isDirectory: true).standardizedFileURL var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue else { + guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { return nil } return candidate @@ -1298,13 +1327,14 @@ struct ContentView: View { } // MARK: - Document List View + struct DocumentListView: View { @ObservedObject var viewModel: DocumentRedactionViewModel @Binding var alertInfo: AlertInfo? @Environment(\.colorScheme) private var colorScheme let onRetry: (DocumentItem) -> Void private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - + var body: some View { ScrollView { LazyVStack(spacing: 12) { @@ -1329,7 +1359,7 @@ struct DocumentListView: View { } } } - + private func handleTap(on item: DocumentItem) { switch item.status { case .failed: @@ -1350,6 +1380,7 @@ struct DocumentListView: View { } // MARK: - Document Row + struct DocumentRow: View { @ObservedObject var item: DocumentItem @ObservedObject var viewModel: DocumentRedactionViewModel @@ -1357,7 +1388,7 @@ struct DocumentRow: View { @Environment(\.colorScheme) private var colorScheme private let heartbeatTimeout: TimeInterval = 30.0 let onRetry: (DocumentItem) -> Void - + var body: some View { HStack(spacing: 12) { // Trash button to remove this document @@ -1372,13 +1403,13 @@ struct DocumentRow: View { .opacity(item.status.isProcessing ? 0.3 : 1.0) .help("Remove from list") .accessibilityIdentifier("document.remove.\(item.id.uuidString)") - + // Status icon Image(systemName: iconName) .font(.system(size: 20, weight: .medium)) .foregroundColor(iconColor) .frame(width: 24, height: 24) - + // Document info and progress VStack(alignment: .leading, spacing: 6) { // Document name @@ -1403,7 +1434,7 @@ struct DocumentRow: View { } } } - + // Enhanced status display for processing documents if item.status.isProcessing { VStack(alignment: .leading, spacing: 8) { @@ -1429,34 +1460,51 @@ struct DocumentRow: View { } } } - + Spacer(minLength: 12) - + if item.status.isComplete { // Action buttons for completed items with enhanced tooltips HStack(spacing: 8) { TooltipButton( - action: { performUserAction(failureMessage: "The document is not available yet.") { viewModel.openRedactedDocument(item) } }, + action: { + performUserAction(failureMessage: "The document is not available yet.") { + viewModel.openRedactedDocument(item) + } + }, icon: "doc.text.fill", - tooltip: item.reportOutputURL != nil ? "Open Redacted Document" : "Open Metadata Scrubbed Document", - description: item.reportOutputURL != nil ? "Opens the redacted .docx file with sensitive information removed" : "Opens the metadata-cleaned .docx file", + tooltip: item + .reportOutputURL != nil ? "Open Redacted Document" : "Open Metadata Scrubbed Document", + description: item + .reportOutputURL != nil ? + "Opens the redacted .docx file with sensitive information removed" : + "Opens the metadata-cleaned .docx file", isEnabled: item.redactedOutputURL != nil, accessibilityId: "document.openRedacted.\(item.id.uuidString)" ) TooltipButton( - action: { performUserAction(failureMessage: "Output files could not be revealed in Finder.") { viewModel.revealInFinder(item) } }, + action: { + performUserAction(failureMessage: "Output files could not be revealed in Finder.") { + viewModel.revealInFinder(item) + } + }, icon: "folder.fill", tooltip: "Show in Finder", description: "Reveals output files in Finder for easy access", - isEnabled: item.redactedOutputURL != nil || item.reportOutputURL != nil || item.scrubReportOutputURL != nil, + isEnabled: item.redactedOutputURL != nil || item.reportOutputURL != nil || item + .scrubReportOutputURL != nil, accessibilityId: "document.revealInFinder.\(item.id.uuidString)" ) - + // Show redaction report button if available if item.reportOutputURL != nil { TooltipButton( - action: { performUserAction(failureMessage: "The audit report is not available yet.") { viewModel.openReport(item) } }, + action: { + performUserAction(failureMessage: "The audit report is not available yet.") { + viewModel.openReport(item) + } + }, icon: "doc.text.magnifyingglass", tooltip: "View Audit Report", description: "Shows detailed report of all detected entities and redactions", @@ -1464,19 +1512,28 @@ struct DocumentRow: View { accessibilityId: "document.openReport.\(item.id.uuidString)" ) } - + // Show scrub report button (always available, enabled if URL exists) TooltipButton( - action: { performUserAction(failureMessage: "The scrub report is not available yet.") { viewModel.openScrubReport(item) } }, + action: { + performUserAction(failureMessage: "The scrub report is not available yet.") { + viewModel.openScrubReport(item) + } + }, icon: "magnifyingglass.circle.fill", tooltip: "View Scrub Report", description: "Shows metadata report (before/after or observed) with raw JSON link", - isEnabled: item.scrubReportOutputURL != nil || item.metadataReportOutputURL != nil || item.reportOutputURL != nil, + isEnabled: item.scrubReportOutputURL != nil || item.metadataReportOutputURL != nil || item + .reportOutputURL != nil, accessibilityId: "document.openScrubReport.\(item.id.uuidString)" ) TooltipButton( - action: { performUserAction(failureMessage: "The document is not available yet.") { viewModel.shareDocument(item) } }, + action: { + performUserAction(failureMessage: "The document is not available yet.") { + viewModel.shareDocument(item) + } + }, icon: "square.and.arrow.up", tooltip: "Send Document", description: "Choose between a final redacted copy or a review copy with Track Changes", @@ -1484,11 +1541,15 @@ struct DocumentRow: View { accessibilityId: "document.share.\(item.id.uuidString)" ) } - } else if metadataReportAvailable { - VStack(alignment: .trailing, spacing: 6) { - HStack(spacing: 8) { - TooltipButton( - action: { performUserAction(failureMessage: "The metadata report is not available yet.") { viewModel.openMetadataReport(item) } }, + } else if metadataReportAvailable { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 8) { + TooltipButton( + action: { + performUserAction(failureMessage: "The metadata report is not available yet.") { + viewModel.openMetadataReport(item) + } + }, icon: "magnifyingglass.circle.fill", tooltip: "View Metadata Report", description: "Opens the metadata-only report (HTML preferred)", @@ -1496,7 +1557,11 @@ struct DocumentRow: View { accessibilityId: "document.openMetadataReport.\(item.id.uuidString)" ) TooltipButton( - action: { performUserAction(failureMessage: "The metadata report is not available yet.") { viewModel.saveMetadataReport(item) } }, + action: { + performUserAction(failureMessage: "The metadata report is not available yet.") { + viewModel.saveMetadataReport(item) + } + }, icon: "square.and.arrow.down", tooltip: "Save metadata report (HTML + JSON)", description: "Saves the HTML report and JSON data to a location you choose", @@ -1524,50 +1589,55 @@ struct DocumentRow: View { .background( RoundedRectangle(cornerRadius: 12) .fill(CustomColors.cardBackground(for: colorScheme)) - .shadow(color: colorScheme == .dark ? Color.black.opacity(0.2) : Color.black.opacity(0.03), radius: 6, x: 0, y: 1) + .shadow( + color: colorScheme == .dark ? Color.black.opacity(0.2) : Color.black.opacity(0.03), + radius: 6, + x: 0, + y: 1 + ) ) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(CustomColors.subtleBorder(for: colorScheme), lineWidth: 1) ) } - + private var metadataReportAvailable: Bool { item.metadataReportOutputURL != nil || item.metadataReportHTMLOutputURL != nil } - + private var iconName: String { switch item.status { - case .checking: return "doc.text.magnifyingglass" - case .validDocument: return "doc.text.fill" - case .invalidDocument: return "doc.badge.exclamationmark" - case .processing, .analyzing, .redacting: return "doc.text.magnifyingglass" - case .completed: return "checkmark.circle.fill" - case .failed: return "xmark.circle.fill" - case .cancelled: return "stop.circle.fill" + case .checking: "doc.text.magnifyingglass" + case .validDocument: "doc.text.fill" + case .invalidDocument: "doc.badge.exclamationmark" + case .processing, .analyzing, .redacting: "doc.text.magnifyingglass" + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .cancelled: "stop.circle.fill" } } - + private var iconColor: Color { switch item.status { - case .validDocument, .completed: return CustomColors.accentColor(for: colorScheme) - case .invalidDocument, .failed: return CustomColors.destructiveColor(for: colorScheme) - case .cancelled: return CustomColors.secondaryText(for: colorScheme) - default: return CustomColors.accentColor(for: colorScheme) + case .validDocument, .completed: CustomColors.accentColor(for: colorScheme) + case .invalidDocument, .failed: CustomColors.destructiveColor(for: colorScheme) + case .cancelled: CustomColors.secondaryText(for: colorScheme) + default: CustomColors.accentColor(for: colorScheme) } } - + private func getStageColor(_ stage: ProcessingStage) -> Color { switch stage { - case .preflight: return .blue - case .ruleDetection: return .green - case .llmValidation: return .orange - case .enhancedDetection: return .purple - case .merging: return .indigo - case .outputGeneration: return .teal + case .preflight: .blue + case .ruleDetection: .green + case .llmValidation: .orange + case .enhancedDetection: .purple + case .merging: .indigo + case .outputGeneration: .teal } } - + private func performUserAction(failureMessage: String, action: () -> Bool) { guard action() else { alertInfo = AlertInfo(title: "Action Unavailable", message: failureMessage) @@ -1577,10 +1647,11 @@ struct DocumentRow: View { } // MARK: - Status View + struct StatusView: View { let status: RedactionStatus @Environment(\.colorScheme) private var colorScheme - + var body: some View { Group { switch status { @@ -1649,6 +1720,7 @@ struct StatusView: View { } // MARK: - Footer View + struct FooterView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { @@ -1661,17 +1733,18 @@ struct FooterView: View { .lineLimit(2) .multilineTextAlignment(.center) .font(.system(size: 13, weight: .medium)) - + HStack(spacing: 4) { Text("Got bugs? Message me at") .foregroundColor(CustomColors.secondaryText(for: colorScheme)) - Link("linkedin.com/in/marcmandel/", destination: URL(string: "https://www.linkedin.com/in/marcmandel/")!) + Link( + "linkedin.com/in/marcmandel/", + destination: URL(string: "https://www.linkedin.com/in/marcmandel/")! + ) } .lineLimit(2) .multilineTextAlignment(.center) .font(.system(size: 13, weight: .medium)) - - } .multilineTextAlignment(.center) .padding(.top, 8) @@ -1696,15 +1769,15 @@ struct EnvironmentStatusBanner: View { ? ("bolt.circle.fill", .green, "Ollama running") : ("bolt.slash.fill", .orange, "Ollama offline") } - + private var environmentStatusComponents: (String, String?) { let baseStatus = viewModel.environmentStatus if baseStatus.contains("Starting") { let dots = String(repeating: ".", count: animationDotCount) // Clean status text (remove any existing dots just in case) let cleanStatus = baseStatus.replacingOccurrences(of: "...", with: "") - .replacingOccurrences(of: "..", with: "") - .trimmingCharacters(in: .punctuationCharacters) + .replacingOccurrences(of: "..", with: "") + .trimmingCharacters(in: .punctuationCharacters) return (cleanStatus, dots) } return (baseStatus, nil) @@ -1714,22 +1787,26 @@ struct EnvironmentStatusBanner: View { HStack(spacing: 16) { StatusLabel(icon: frameworkStatus.0, color: frameworkStatus.1, text: frameworkStatus.2) StatusLabel(icon: ollamaStatus.0, color: ollamaStatus.1, text: ollamaStatus.2) - StatusLabel(icon: "shippingbox.fill", color: viewModel.availableModels.isEmpty ? .orange : .blue, text: "\(viewModel.availableModels.count) models") + StatusLabel( + icon: "shippingbox.fill", + color: viewModel.availableModels.isEmpty ? .orange : .blue, + text: "\(viewModel.availableModels.count) models" + ) Spacer() - + // Simplified ready status - just checkmark and "Ready with AI" if viewModel.isEnvironmentReady { StatusLabel( - icon: "checkmark.circle.fill", - color: .green, + icon: "checkmark.circle.fill", + color: .green, text: viewModel.settings.mode == .rules ? "Ready (Rules Only)" : "Ready with AI", animatingDots: nil ) } else { let (statusText, dots) = environmentStatusComponents StatusLabel( - icon: "hourglass", - color: .orange, + icon: "hourglass", + color: .orange, text: statusText, animatingDots: dots ) @@ -1752,7 +1829,7 @@ struct EnvironmentStatusBanner: View { stopAnimation() } } - + private func startAnimation() { stopAnimation() timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in @@ -1767,7 +1844,7 @@ struct EnvironmentStatusBanner: View { } } } - + private func stopAnimation() { timer?.invalidate() timer = nil @@ -1777,13 +1854,13 @@ struct EnvironmentStatusBanner: View { let icon: String let color: Color let text: String - var animatingDots: String? = nil + var animatingDots: String? var body: some View { HStack(spacing: 6) { Image(systemName: icon) .foregroundColor(color) - + HStack(spacing: 0) { Text(text) if let dots = animatingDots { @@ -1864,7 +1941,7 @@ extension DocumentRow { private func formatTimeRemaining(_ timeInterval: TimeInterval) -> String { let minutes = Int(timeInterval) / 60 let seconds = Int(timeInterval) % 60 - + if minutes > 60 { let hours = minutes / 60 let remainingMinutes = minutes % 60 @@ -1877,9 +1954,8 @@ extension DocumentRow { } } - -// Batch-level "time remaining" indicator shown above the action buttons during a multi-document -// run, once BatchETACalculator has enough completed-document samples to produce an estimate. +/// Batch-level "time remaining" indicator shown above the action buttons during a multi-document +/// run, once BatchETACalculator has enough completed-document samples to produce an estimate. struct BatchETAView: View { let remainingSeconds: TimeInterval @Environment(\.colorScheme) private var colorScheme @@ -1913,59 +1989,57 @@ struct BatchETAView: View { } } -// Countdown timer component +/// Countdown timer component struct CountdownTimerView: View { let remainingSeconds: TimeInterval let isActive: Bool @Environment(\.colorScheme) private var colorScheme - + var body: some View { - Group { - if isActive { - HStack(spacing: 2) { - Image(systemName: remainingSeconds > 0 ? "clock" : "hourglass") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(CustomColors.accentColor(for: colorScheme)) + if isActive { + HStack(spacing: 2) { + Image(systemName: remainingSeconds > 0 ? "clock" : "hourglass") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(CustomColors.accentColor(for: colorScheme)) - Text(remainingSeconds > 0 ? formatTime(remainingSeconds) : "Processing...") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundColor(CustomColors.accentColor(for: colorScheme)) - } - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - Capsule() - .fill(CustomColors.accentColor(for: colorScheme).opacity(0.1)) - .overlay( - Capsule() - .stroke(CustomColors.accentColor(for: colorScheme).opacity(0.2), lineWidth: 0.5) - ) - ) - } else if !isActive { - HStack(spacing: 2) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.green) - - Text("Done") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.green) - } - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background( - Capsule() - .fill(Color.green.opacity(0.1)) - ) + Text(remainingSeconds > 0 ? formatTime(remainingSeconds) : "Processing...") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(CustomColors.accentColor(for: colorScheme)) } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(CustomColors.accentColor(for: colorScheme).opacity(0.1)) + .overlay( + Capsule() + .stroke(CustomColors.accentColor(for: colorScheme).opacity(0.2), lineWidth: 0.5) + ) + ) + } else if !isActive { + HStack(spacing: 2) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.green) + + Text("Done") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.green) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.green.opacity(0.1)) + ) } } - + private func formatTime(_ timeInterval: TimeInterval) -> String { let totalSeconds = Int(timeInterval) let minutes = totalSeconds / 60 let seconds = totalSeconds % 60 - + if minutes > 60 { let hours = minutes / 60 let remainingMinutes = minutes % 60 @@ -2041,12 +2115,13 @@ struct HeartbeatStatusView: View { } // MARK: - Enhanced Tooltip Button + struct TooltipButton: View { let action: () -> Void let iconView: (Color) -> AnyView let tooltip: String let description: String - var isEnabled: Bool = true // Default to enabled + var isEnabled: Bool = true // Default to enabled let iconName: String? let accessibilityId: String? diff --git a/src/swift/MarcutApp/Sources/MarcutApp/DefaultsKey.swift b/src/swift/MarcutApp/Sources/MarcutApp/DefaultsKey.swift index 8804272..4f059ac 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/DefaultsKey.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/DefaultsKey.swift @@ -34,5 +34,7 @@ enum DefaultsKey: String { case metadataCleaningSettings = "MetadataCleaningSettings" /// Convenience for call sites that need the raw string (e.g. `@AppStorage`, `defaults.object(forKey:)`). - var key: String { rawValue } + var key: String { + rawValue + } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/DiskSpaceCheck.swift b/src/swift/MarcutApp/Sources/MarcutApp/DiskSpaceCheck.swift index 75d42a1..00343d9 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/DiskSpaceCheck.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/DiskSpaceCheck.swift @@ -27,11 +27,16 @@ enum DiskSpaceCheck { let cleaned = label.trimmingCharacters(in: .whitespaces) let pattern = #"(\d+\.?\d*)\s*([KMGT]B)"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), - let match = regex.firstMatch(in: cleaned, options: [], range: NSRange(cleaned.startIndex..., in: cleaned)), + let match = regex.firstMatch( + in: cleaned, + options: [], + range: NSRange(cleaned.startIndex..., in: cleaned) + ), let valueRange = Range(match.range(at: 1), in: cleaned), let unitRange = Range(match.range(at: 2), in: cleaned), let value = Double(cleaned[valueRange]), - value > 0 else { + value > 0 + else { return nil } @@ -66,7 +71,8 @@ enum DiskSpaceCheck { ) -> String? { guard estimatedBytesNeeded > 0, let available = freeSpaceProvider(directory), - available < estimatedBytesNeeded else { + available < estimatedBytesNeeded + else { return nil } return "Not enough free disk space to \(subject) (needs ~\(formatGB(estimatedBytesNeeded)), " diff --git a/src/swift/MarcutApp/Sources/MarcutApp/DocumentModels.swift b/src/swift/MarcutApp/Sources/MarcutApp/DocumentModels.swift index d1b17aa..ab88e13 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/DocumentModels.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/DocumentModels.swift @@ -1,6 +1,6 @@ +import Foundation import SwiftUI import UniformTypeIdentifiers -import Foundation // MARK: - Data Models @@ -20,7 +20,7 @@ final class DocumentItem: Identifiable, ObservableObject { @Published var redactedOutputURL: URL? = nil @Published var reportOutputURL: URL? = nil @Published var reportHTMLOutputURL: URL? = nil - + // Metadata scrubbing results @Published var scrubOutputURL: URL? = nil @Published var scrubReportOutputURL: URL? = nil @@ -33,8 +33,8 @@ final class DocumentItem: Identifiable, ObservableObject { @Published var lastOperation: DocumentOperation? = nil // File paths for Python processing - var outputPath: String? = nil - var reportPath: String? = nil + var outputPath: String? + var reportPath: String? // Enhanced progress tracking @Published var currentStage: ProcessingStage = .preflight @@ -42,48 +42,49 @@ final class DocumentItem: Identifiable, ObservableObject { @Published var estimatedTimeRemaining: TimeInterval = 0.0 @Published var startTime: Date? = nil @Published var documentComplexity: DocumentComplexity = .unknown - @Published var lastTimeUpdate: Date = Date() + @Published var lastTimeUpdate: Date = .init() @Published var allStages: [ProcessingStage] = ProcessingStage.allCases - private var stageStartTime: Date? = nil + private var stageStartTime: Date? private var stageExpectedDuration: TimeInterval = 0.0 private var stageBaseDuration: TimeInterval = 0.0 - private var progressRange: ClosedRange = 0.0...1.0 + private var progressRange: ClosedRange = 0.0 ... 1.0 @Published var wordCount: Int? = nil @Published var lastHeartbeat: Date? = nil @Published var lastHeartbeatChunk: Int = 0 @Published var totalHeartbeatChunks: Int = 0 @Published var lastDestinationURL: URL? = nil - + // Mass-based progress tracking @Published var totalMass: Int = 0 @Published var processedMass: Int = 0 private var currentChunkMass: Int = 0 - private var massStartTime: Date? = nil - private var pendingTotalMass: Int? = nil - private var chunkStartTime: Date? = nil - private var smoothedCharsPerSecond: Double? = nil + private var massStartTime: Date? + private var pendingTotalMass: Int? + private var chunkStartTime: Date? + private var smoothedCharsPerSecond: Double? - // File access management + /// File access management private(set) var hasSecurityScope: Bool = false // MARK: - Smooth Progress Animation Properties + private var smoothProgressTimer: Timer? private var targetProgress: Double = 0.0 private var currentVelocity: Double = 0.0 - private var lastProgressUpdate: Date = Date() + private var lastProgressUpdate: Date = .init() private var microProgressActive: Bool = false private var microProgressTimer: Timer? // Animation parameters - private let smoothingFactor: Double = 0.15 // Higher = smoother but slower response - private let maxVelocity: Double = 0.8 // Max progress units per second - private let acceleration: Double = 0.3 // Acceleration rate - private let microProgressSpeed: Double = 0.02 // Small forward movement per tick - private let microProgressInterval: TimeInterval = 0.1 // 100ms micro-updates + private let smoothingFactor: Double = 0.15 // Higher = smoother but slower response + private let maxVelocity: Double = 0.8 // Max progress units per second + private let acceleration: Double = 0.3 // Acceleration rate + private let microProgressSpeed: Double = 0.02 // Small forward movement per tick + private let microProgressInterval: TimeInterval = 0.1 // 100ms micro-updates - // Computed property for file path + /// Computed property for file path var path: String { - return url.path + url.path } init(url: URL) { @@ -97,8 +98,8 @@ final class DocumentItem: Identifiable, ObservableObject { url.stopAccessingSecurityScopedResource() } } - - // Smart time estimation that can adjust up or down + + /// Smart time estimation that can adjust up or down func updateTimeEstimate(_ newEstimate: TimeInterval) { let now = Date() let clampedEstimate = max(0.0, newEstimate) @@ -119,7 +120,7 @@ final class DocumentItem: Identifiable, ObservableObject { lastTimeUpdate = now } - + func beginStage(_ stage: ProcessingStage) { // Stop any existing animations before stage transition stopMicroProgress() @@ -195,7 +196,8 @@ final class DocumentItem: Identifiable, ObservableObject { func applyStageProgressFraction(_ fraction: Double) { let clamped = min(max(fraction, 0.0), 1.0) stageProgress = clamped - let targetStageProgress = progressRange.lowerBound + clamped * (progressRange.upperBound - progressRange.lowerBound) + let targetStageProgress = progressRange + .lowerBound + clamped * (progressRange.upperBound - progressRange.lowerBound) setTargetProgress(targetStageProgress, isChunkUpdate: false) startMicroProgress() } @@ -257,7 +259,7 @@ final class DocumentItem: Identifiable, ObservableObject { @MainActor private func startMomentumAnimation() { smoothProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] timer in - guard let self = self else { + guard let self else { timer.invalidate() return } @@ -315,7 +317,7 @@ final class DocumentItem: Identifiable, ObservableObject { let startTime = Date() smoothProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] timer in - guard let self = self else { + guard let self else { timer.invalidate() return } @@ -323,7 +325,8 @@ final class DocumentItem: Identifiable, ObservableObject { let elapsed = Date().timeIntervalSince(startTime) let progress = min(elapsed * 3.0, 1.0) // 0.33 second animation - self.progress = initialProgress + (self.targetProgress - initialProgress) * self.applyAngularEasing(progress) + self.progress = initialProgress + (self.targetProgress - initialProgress) * self + .applyAngularEasing(progress) if progress >= 1.0 { self.progress = self.targetProgress @@ -342,33 +345,34 @@ final class DocumentItem: Identifiable, ObservableObject { microProgressActive = true microProgressTimer?.invalidate() - microProgressTimer = Timer.scheduledTimer(withTimeInterval: microProgressInterval, repeats: true) { [weak self] timer in - guard let self = self else { - timer.invalidate() - return - } - Task { @MainActor in - // Only add forward movement, never backward - let forwardSpace = self.targetProgress - self.progress - if forwardSpace > 0.001 { - // Take smaller steps as we get closer to target - let microIncrement = min(self.microProgressSpeed, forwardSpace * 0.5) - let newProgress = self.progress + microIncrement - - // Ensure we don't exceed target - if newProgress < self.targetProgress { - self.progress = newProgress + microProgressTimer = Timer + .scheduledTimer(withTimeInterval: microProgressInterval, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + Task { @MainActor in + // Only add forward movement, never backward + let forwardSpace = self.targetProgress - self.progress + if forwardSpace > 0.001 { + // Take smaller steps as we get closer to target + let microIncrement = min(self.microProgressSpeed, forwardSpace * 0.5) + let newProgress = self.progress + microIncrement + + // Ensure we don't exceed target + if newProgress < self.targetProgress { + self.progress = newProgress + } else { + self.progress = self.targetProgress + self.stopMicroProgress() + } } else { + // We're at or very close to target self.progress = self.targetProgress self.stopMicroProgress() } - } else { - // We're at or very close to target - self.progress = self.targetProgress - self.stopMicroProgress() } } - } } /// Stops micro-progress animation @@ -404,11 +408,11 @@ final class DocumentItem: Identifiable, ObservableObject { func updateEstimatesForCurrentTime() { // If using mass-based tracking during extraction phase, use specialized logic - if currentStage == .enhancedDetection && totalMass > 0 { + if currentStage == .enhancedDetection, totalMass > 0 { updateMassBasedEstimate() return } - + guard let start = stageStartTime else { return } let elapsed = Date().timeIntervalSince(start) @@ -444,11 +448,12 @@ final class DocumentItem: Identifiable, ObservableObject { guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let type = json["type"] as? String else { + let type = json["type"] as? String + else { return false } - if currentStage != .enhancedDetection && type == "mass_total" { + if currentStage != .enhancedDetection, type == "mass_total" { pendingTotalMass = intValue(from: json["value"]) return true } @@ -477,14 +482,15 @@ final class DocumentItem: Identifiable, ObservableObject { // Keep-alive signals carry chunk info for progress updates during LLM calls // This ensures the progress bar advances even when waiting on long LLM responses if let chunk = intValue(from: json["chunk"]), - let total = intValue(from: json["total"]) { + let total = intValue(from: json["total"]) + { lastHeartbeat = Date() lastHeartbeatChunk = chunk totalHeartbeatChunks = total - + // Update progress based on chunk ratio if we don't have mass tracking // or if mass tracking underestimates progress - if totalMass == 0 && total > 0 { + if totalMass == 0, total > 0 { let chunkFraction = min(max(Double(chunk) / Double(total), 0.0), 1.0) let stageLower = progressRange.lowerBound let stageUpper = progressRange.upperBound @@ -502,7 +508,7 @@ final class DocumentItem: Identifiable, ObservableObject { private func updateMassBasedEstimate() { guard totalMass > 0 else { return } - + if massStartTime == nil { massStartTime = Date() } @@ -521,20 +527,20 @@ final class DocumentItem: Identifiable, ObservableObject { progressThroughChunk = min(chunkElapsed / expected, 0.95) interpolatedChunkMass = chunkContribution * progressThroughChunk } - + let effectiveProcessed = Double(processedMass) + interpolatedChunkMass let totalFraction = min(max(effectiveProcessed / Double(totalMass), 0.0), 1.0) - + // Apply to valid range for this stage let stageLower = progressRange.lowerBound let stageUpper = progressRange.upperBound let globalProgress = stageLower + (totalFraction * (stageUpper - stageLower)) - + if globalProgress > targetProgress { setTargetProgress(globalProgress, isChunkUpdate: false) } - - if overallElapsed > 0.0 && effectiveProcessed > 0.0 { + + if overallElapsed > 0.0, effectiveProcessed > 0.0 { let rate = effectiveProcessed / overallElapsed let remainingMass = max(Double(totalMass) - effectiveProcessed, 0.0) let remainingCurrentStage = remainingMass / max(rate, 1.0) @@ -572,7 +578,7 @@ final class DocumentItem: Identifiable, ObservableObject { if let start = chunkStartTime, size > 0 { let duration = max(Date().timeIntervalSince(start), 0.5) let rate = Double(size) / duration - if rate.isFinite && rate > 0 { + if rate.isFinite, rate > 0 { if let existing = smoothedCharsPerSecond { smoothedCharsPerSecond = existing * 0.7 + rate * 0.3 } else { @@ -678,66 +684,66 @@ enum ProcessingStage: CaseIterable { case enhancedDetection case merging case outputGeneration - + var displayName: String { switch self { - case .preflight: return "Loading Document" - case .ruleDetection: return "Detecting Data" - case .llmValidation: return "Validating Entities" - case .enhancedDetection: return "AI Analysis" - case .merging: return "Merging Results" - case .outputGeneration: return "Creating Output" + case .preflight: "Loading Document" + case .ruleDetection: "Detecting Data" + case .llmValidation: "Validating Entities" + case .enhancedDetection: "AI Analysis" + case .merging: "Merging Results" + case .outputGeneration: "Creating Output" } } var expectedDuration: TimeInterval { switch self { - case .preflight: return 5 - case .ruleDetection: return 15 - case .llmValidation: return 20 - case .enhancedDetection: return 120 // Most time-consuming - case .merging: return 12 - case .outputGeneration: return 18 + case .preflight: 5 + case .ruleDetection: 15 + case .llmValidation: 20 + case .enhancedDetection: 120 // Most time-consuming + case .merging: 12 + case .outputGeneration: 18 } } var icon: String { switch self { - case .preflight: return "checkmark.shield" - case .ruleDetection: return "text.magnifyingglass" - case .llmValidation: return "brain" - case .enhancedDetection: return "sparkles" - case .merging: return "arrow.triangle.merge" - case .outputGeneration: return "doc.badge.gearshape" + case .preflight: "checkmark.shield" + case .ruleDetection: "text.magnifyingglass" + case .llmValidation: "brain" + case .enhancedDetection: "sparkles" + case .merging: "arrow.triangle.merge" + case .outputGeneration: "doc.badge.gearshape" } } var progressRange: ClosedRange { switch self { - case .preflight: return 0.0...0.12 - case .ruleDetection: return 0.12...0.25 - case .llmValidation: return 0.25...0.40 - case .enhancedDetection: return 0.40...0.80 - case .merging: return 0.80...0.92 - case .outputGeneration: return 0.92...1.0 + case .preflight: 0.0 ... 0.12 + case .ruleDetection: 0.12 ... 0.25 + case .llmValidation: 0.25 ... 0.40 + case .enhancedDetection: 0.40 ... 0.80 + case .merging: 0.80 ... 0.92 + case .outputGeneration: 0.92 ... 1.0 } } } enum DocumentComplexity { case unknown - case simple // < 10 pages - case moderate // 10-50 pages - case complex // 50-100 pages - case massive // 100+ pages + case simple // < 10 pages + case moderate // 10-50 pages + case complex // 50-100 pages + case massive // 100+ pages var multiplier: Double { switch self { - case .unknown: return 1.0 - case .simple: return 0.5 - case .moderate: return 1.0 - case .complex: return 2.0 - case .massive: return 4.0 + case .unknown: 1.0 + case .simple: 0.5 + case .moderate: 1.0 + case .complex: 2.0 + case .massive: 4.0 } } @@ -761,13 +767,13 @@ enum DocumentComplexity { static func fallback(forFileSize size: Int64) -> DocumentComplexity { switch size { case ..<150_000: // <150 KB - return .simple + .simple case ..<600_000: // <600 KB - return .moderate + .moderate case ..<2_500_000: // <2.5 MB - return .complex + .complex default: - return .massive + .massive } } } @@ -778,7 +784,7 @@ struct AlertInfo: Identifiable { let message: String } -enum RedactionStatus: Sendable { +enum RedactionStatus { case checking case validDocument case invalidDocument @@ -788,65 +794,65 @@ enum RedactionStatus: Sendable { case completed case failed case cancelled - + var displayText: String { switch self { - case .checking: return "[Checking...]" - case .validDocument: return "[Valid DOCX]" - case .invalidDocument: return "[Invalid File]" - case .processing: return "[Processing...]" - case .analyzing: return "[Analyzing...]" - case .redacting: return "[Redacting...]" - case .completed: return "[Completed]" - case .failed: return "[Failed]" - case .cancelled: return "[Cancelled]" - } - } - + case .checking: "[Checking...]" + case .validDocument: "[Valid DOCX]" + case .invalidDocument: "[Invalid File]" + case .processing: "[Processing...]" + case .analyzing: "[Analyzing...]" + case .redacting: "[Redacting...]" + case .completed: "[Completed]" + case .failed: "[Failed]" + case .cancelled: "[Cancelled]" + } + } + var isProcessing: Bool { switch self { case .checking, .processing, .analyzing, .redacting: - return true + true default: - return false + false } } - + var isComplete: Bool { switch self { case .completed: - return true + true default: - return false + false } } - + var isPendingReview: Bool { switch self { case .validDocument, .checking: - return true + true default: - return false + false } } - + var canRetry: Bool { switch self { case .failed, .cancelled: - return true + true default: - return false + false } } } -enum DocumentOperation: String, Sendable { +enum DocumentOperation: String { case redaction case scrub } enum RedactionMode: String, CaseIterable, Codable { - case rules = "rules" + case rules case rulesOverride = "rules_override" case constrainedOverrides = "constrained_overrides" case llmOverrides = "llm_overrides" @@ -854,26 +860,26 @@ enum RedactionMode: String, CaseIterable, Codable { var displayName: String { switch self { case .rules: - return "Rules Only" + "Rules Only" case .rulesOverride: - return "Rules + AI (Rules Override)" + "Rules + AI (Rules Override)" case .constrainedOverrides: - return "Rules + AI (Constrained LLM Overrides)" + "Rules + AI (Constrained LLM Overrides)" case .llmOverrides: - return "Rules + AI (LLM Overrides)" + "Rules + AI (LLM Overrides)" } } var description: String { switch self { case .rules: - return "Fast rule-based detection for structured PII" + "Fast rule-based detection for structured PII" case .rulesOverride: - return "AI expands coverage, but deterministic rules always win" + "AI expands coverage, but deterministic rules always win" case .constrainedOverrides: - return "LLM can veto rules for ORG/NAME/LOC with high confidence" + "LLM can veto rules for ORG/NAME/LOC with high confidence" case .llmOverrides: - return "LLM can override any rule when confident" + "LLM can override any rule when confident" } } @@ -900,63 +906,65 @@ enum RedactionRule: String, CaseIterable, Identifiable, Codable, Hashable { case signatureNames = "SIGNATURE" case images = "IMAGES" - var id: String { rawValue } + var id: String { + rawValue + } var displayName: String { switch self { - case .email: return "EMAIL" - case .phone: return "PHONE" - case .ssn: return "SSN" - case .money: return "MONEY" - case .percent: return "PERCENT" - case .number: return "NUMBER" - case .date: return "DATE" - case .account: return "ACCOUNT" - case .swift: return "SWIFT/BIC" - case .card: return "CARD" - case .url: return "URL" - case .ip: return "IP" - case .org: return "ORG" - case .loc: return "Address (LOC)" - case .signatureNames: return "Signature Names" - case .images: return "IMAGES" + case .email: "EMAIL" + case .phone: "PHONE" + case .ssn: "SSN" + case .money: "MONEY" + case .percent: "PERCENT" + case .number: "NUMBER" + case .date: "DATE" + case .account: "ACCOUNT" + case .swift: "SWIFT/BIC" + case .card: "CARD" + case .url: "URL" + case .ip: "IP" + case .org: "ORG" + case .loc: "Address (LOC)" + case .signatureNames: "Signature Names" + case .images: "IMAGES" } } var description: String { switch self { case .email: - return "RFC-like email addresses: sample123@domain.tld (case-insensitive)." + "RFC-like email addresses: sample123@domain.tld (case-insensitive)." case .phone: - return "U.S./international phone numbers with country codes, separators, or parentheses." + "U.S./international phone numbers with country codes, separators, or parentheses." case .ssn: - return "U.S. Social Security numbers in ###-##-#### format." + "U.S. Social Security numbers in ###-##-#### format." case .money: - return "Currency amounts, ISO codes, bracketed dollars, or spelled-out figures." + "Currency amounts, ISO codes, bracketed dollars, or spelled-out figures." case .percent: - return "Numeric percentages (0.06%) and spelled-out (six-hundredths of one percent)." + "Numeric percentages (0.06%) and spelled-out (six-hundredths of one percent)." case .number: - return "Bracketed numeric quantities like [1,200] when not preceded by currency." + "Bracketed numeric quantities like [1,200] when not preceded by currency." case .date: - return "Numeric, ISO, written date formats, plus placeholders like \"June ___, 2025\"." + "Numeric, ISO, written date formats, plus placeholders like \"June ___, 2025\"." case .account: - return "Bank/account-style digit sequences (8–20 digits)." + "Bank/account-style digit sequences (8–20 digits)." case .swift: - return "SWIFT/BIC codes (8 or 11 characters)." + "SWIFT/BIC codes (8 or 11 characters)." case .card: - return "Credit/debit card numbers (13–19 digits) with Luhn validation." + "Credit/debit card numbers (13–19 digits) with Luhn validation." case .url: - return "HTTP/HTTPS/FTP URLs, mailto links, www hosts, bare domains with paths." + "HTTP/HTTPS/FTP URLs, mailto links, www hosts, bare domains with paths." case .ip: - return "IPv4 addresses." + "IPv4 addresses." case .org: - return "Company names ending with legal suffixes (Inc., LLC, Ltd., etc.)." + "Company names ending with legal suffixes (Inc., LLC, Ltd., etc.)." case .loc: - return "Street addresses (US/Intl) including PO Boxes and explicit address labels." + "Street addresses (US/Intl) including PO Boxes and explicit address labels." case .signatureNames: - return "Names extracted from signature blocks (Name: John Q. Public …)." + "Names extracted from signature blocks (Name: John Q. Public …)." case .images: - return "Delete ALL images in the document (if disabled, only thumbnails are deleted)." + "Delete ALL images in the document (if disabled, only thumbnails are deleted)." } } @@ -966,7 +974,7 @@ enum RedactionRule: String, CaseIterable, Identifiable, Codable, Hashable { static func serializedList(from set: Set) -> String { set.sorted { $0.rawValue < $1.rawValue } - .map { $0.rawValue } + .map(\.rawValue) .joined(separator: ",") } } @@ -981,13 +989,13 @@ struct RedactionSettings: Codable, Equatable { var mode: RedactionMode = RedactionSettings.standardNormalMode var model: String = ModelCatalog.shared.defaultModelId var backend: String = "ollama" // 'ollama' or 'mock' (for fast tests) - var debug: Bool = false // Default to false for production + var debug: Bool = false // Default to false for production var temperature: Double = RedactionSettings.standardNormalModeTemperature var seed: Int = 42 var chunkTokens: Int = RedactionSettings.standardNormalModeChunkTokens var overlap: Int = RedactionSettings.standardNormalModeOverlap var llmConcurrency: Int = 2 - var processingTimeoutSeconds: Int = 7200 // 120 minutes + var processingTimeoutSeconds: Int = 7200 // 120 minutes var enabledRules: Set = RedactionRule.defaultSelection var llmConfidenceThreshold: Int = RedactionSettings.standardNormalModeConfidence @@ -1038,10 +1046,10 @@ struct RedactionProfile: Codable, Equatable { var errorDescription: String? { switch self { - case .unsupportedSchemaVersion(let found, let supported): - return "This profile was created with an unsupported schema version (\(found)). This version of Marcut supports schema version \(supported)." - case .malformed(let detail): - return "This profile file could not be read: \(detail)" + case let .unsupportedSchemaVersion(found, supported): + "This profile was created with an unsupported schema version (\(found)). This version of Marcut supports schema version \(supported)." + case let .malformed(detail): + "This profile file could not be read: \(detail)" } } } @@ -1103,18 +1111,25 @@ enum DebugPreferences { class DebugLogger { static let shared = DebugLogger() - private var isDebugEnabled: Bool = false // Default to false for production + private var isDebugEnabled: Bool = false // Default to false for production private let logDirectoryURL: URL private let writeQueue = DispatchQueue(label: "com.marclaw.marcutapp.debuglogger", qos: .utility) - // Centralized log location under Application Support - // ~/Library/Application Support/MarcutApp/logs/marcut.log - var logURL: URL { logDirectoryURL.appendingPathComponent("marcut.log") } - var logPath: String { logURL.path } + /// Centralized log location under Application Support + /// ~/Library/Application Support/MarcutApp/logs/marcut.log + var logURL: URL { + logDirectoryURL.appendingPathComponent("marcut.log") + } + + var logPath: String { + logURL.path + } /// Directory containing this instance's log files (`~/Library/Application Support/MarcutApp/logs`, /// or a fallback under the temporary directory if Application Support is unavailable/sandboxed). - var logsDirectoryURL: URL { logDirectoryURL } + var logsDirectoryURL: URL { + logDirectoryURL + } private init() { logDirectoryURL = DebugLogger.resolveLogDirectory() @@ -1134,7 +1149,9 @@ class DebugLogger { } } - if let groupBase = fm.containerURL(forSecurityApplicationGroupIdentifier: "QG85EMCQ75.group.com.marclaw.marcutapp") { + if let groupBase = fm + .containerURL(forSecurityApplicationGroupIdentifier: "QG85EMCQ75.group.com.marclaw.marcutapp") + { let dir = groupBase .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) @@ -1162,7 +1179,7 @@ class DebugLogger { } } - public func ensureLogInitialized() { + func ensureLogInitialized() { writeQueue.async { [logURL = self.logURL, logDirectoryURL = self.logDirectoryURL] in DebugLogger.ensureLogInitializedAsync(logURL: logURL, logDirectoryURL: logDirectoryURL) } @@ -1173,12 +1190,12 @@ class DebugLogger { let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" let build = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "?" let header = "=== MarcutApp Log Started (v\(version) b\(build)) ===\n" - + // Ensure directory exists first if !FileManager.default.fileExists(atPath: logDirectoryURL.path) { _ = ensureDirectoryExists(logDirectoryURL, fileManager: FileManager.default) } - + if !FileManager.default.fileExists(atPath: logURL.path) { try? header.write(to: logURL, atomically: true, encoding: .utf8) } @@ -1227,7 +1244,10 @@ class DebugLogger { let logURL = self.logURL writeQueue.async { // Ensure directory exists before writing - try? FileManager.default.createDirectory(at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? FileManager.default.createDirectory( + at: logURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) if FileManager.default.fileExists(atPath: logURL.path) { if let fileHandle = try? FileHandle(forWritingTo: logURL) { @@ -1262,8 +1282,10 @@ class DebugLogger { let logFiles = entries.filter { $0.pathExtension.lowercased() == "log" } return logFiles.sorted { lhs, rhs in - let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast - let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? .distantPast + let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]))? + .contentModificationDate ?? .distantPast if lhsDate != rhsDate { return lhsDate > rhsDate } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/DocumentRedactionViewModel.swift b/src/swift/MarcutApp/Sources/MarcutApp/DocumentRedactionViewModel.swift index df8226e..8ce1ecf 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/DocumentRedactionViewModel.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/DocumentRedactionViewModel.swift @@ -1,7 +1,7 @@ -import SwiftUI -import UniformTypeIdentifiers import AppKit import Foundation +import SwiftUI +import UniformTypeIdentifiers enum ProcessingState { static var isProcessing = false @@ -17,7 +17,7 @@ final class DocumentRedactionViewModel: ObservableObject { if parts.count >= 3 { relevant = Array(parts.suffix(2)) } - if relevant.count == 2 && relevant.first == "library" { + if relevant.count == 2, relevant.first == "library" { return String(relevant[1]).lowercased() } return relevant.map { String($0).lowercased() }.joined(separator: "/") @@ -49,6 +49,7 @@ final class DocumentRedactionViewModel: ObservableObject { ofItemAtPath: url.path ) } + @Published var items: [DocumentItem] = [] @Published var hasDocuments: Bool = false @Published var hasValidDocuments: Bool = false @@ -93,6 +94,7 @@ final class DocumentRedactionViewModel: ObservableObject { private var didJustResumePendingJob = false // MARK: - Initialization & Cleanup + private var pythonInitObservers: [NSObjectProtocol] = [] /// `NSWorkspace` sleep/wake observer tokens (B5). Kept separate from `pythonInitObservers` /// since they're registered on a different notification center. @@ -125,30 +127,35 @@ final class DocumentRedactionViewModel: ObservableObject { await self?.refreshEnvironmentStatus(triggerFirstRunCheck: true) } } - let failed = center.addObserver(forName: .pythonRunnerFailed, object: nil, queue: .main) { [weak self] notification in - let failureMessage: String? = { - if let message = notification.userInfo?["error"] as? String { - return message - } - if let message = notification.object as? String { - return message - } - return nil - }() - Task { @MainActor [weak self] in - guard let self else { return } - self.isPythonInitializing = false - if let message = failureMessage { - self.pythonInitializationError = message - } else { - self.pythonInitializationError = "Failed to initialize the AI engine. Please restart Marcut." + let failed = center + .addObserver(forName: .pythonRunnerFailed, object: nil, queue: .main) { [weak self] notification in + let failureMessage: String? = { + if let message = notification.userInfo?["error"] as? String { + return message + } + if let message = notification.object as? String { + return message + } + return nil + }() + Task { @MainActor [weak self] in + guard let self else { return } + self.isPythonInitializing = false + if let message = failureMessage { + self.pythonInitializationError = message + } else { + self.pythonInitializationError = "Failed to initialize the AI engine. Please restart Marcut." + } } } - } pythonInitObservers = [ready, failed] let workspaceCenter = NSWorkspace.shared.notificationCenter - let wakeObserver = workspaceCenter.addObserver(forName: NSWorkspace.didWakeNotification, object: nil, queue: .main) { [weak self] _ in + let wakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in Task { @MainActor [weak self] in await self?.handleSystemWake() } @@ -163,7 +170,7 @@ final class DocumentRedactionViewModel: ObservableObject { // Fast-path: Check for models asynchronously. // The service method handles off-main-thread I/O and MainActor state updates internally. Task { @MainActor [weak self] in - guard let self = self else { return } + guard let self else { return } if await self.pythonBridge.populateInstalledModelsFromDisk() { self.hasPrefetchedModels = true if !self.hasCompletedFirstRun { @@ -221,6 +228,7 @@ final class DocumentRedactionViewModel: ObservableObject { private var activeAttemptTokens: [UUID: UUID] = [:] // MARK: - Batch ETA Tracking + /// Wall-clock time each document started processing in the current run, keyed by item id. /// Used to compute a per-document duration sample once the document reaches a terminal state. private var batchProcessingStartTimes: [UUID: Date] = [:] @@ -237,7 +245,8 @@ final class DocumentRedactionViewModel: ObservableObject { /// embedded engine was already marked unavailable by an earlier stall" -- restarting the /// app is the only recovery in either case (see `PythonBridgeError`). static let processingStalledMessage = "Processing stalled - the embedded engine stopped responding. Restart Marcut to resume processing." - // Note: retryCounts was removed along with retry logic - heartbeat stalls now immediately fail + + /// Note: retryCounts was removed along with retry logic - heartbeat stalls now immediately fail private var hasPrefetchedModels = false enum FirstRunEntryPoint: Equatable { @@ -263,21 +272,21 @@ final class DocumentRedactionViewModel: ObservableObject { private func markMetadataScrubUsed() { UserDefaults.standard.set(true, forKey: DefaultsKey.hasUsedMetadataScrub.key) } - + // MARK: - Document Management - + func add(urls: [URL]) { for url in urls where !items.contains(where: { $0.url == url }) { let item = DocumentItem(url: url) items.append(item) Task { [weak self, weak item] in - guard let self = self, let item = item else { return } + guard let self, let item else { return } await self.checkDocument(item) } } updateState() } - + private func checkDocument(_ item: DocumentItem) async { item.status = .checking @@ -354,9 +363,9 @@ final class DocumentRedactionViewModel: ObservableObject { item.status = .validDocument updateState() } - + // MARK: - Batch Processing - + func processAllDocuments(to destination: URL? = nil, includeRetryItems: Bool = false) async { // Clear any lingering cancellation flags from previous operations AppDelegate.pythonRunner?.clearCancellationRequest() @@ -391,19 +400,31 @@ final class DocumentRedactionViewModel: ObservableObject { var processedIDs = Set() - while let item = items.first(where: { needsRedaction($0, includeRetryItems: includeRetryItems) && !processedIDs.contains($0.id) }) { + while let item = items + .first(where: { needsRedaction($0, includeRetryItems: includeRetryItems) && !processedIDs.contains($0.id) + }) + { guard items.contains(where: { $0.id == item.id }) else { - DebugLogger.shared.log("Skipping removed document: \(item.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Skipping removed document: \(item.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) continue } processedIDs.insert(item.id) // CRITICAL: Clear any lingering cancellation state before starting new document // This prevents race conditions where the previous document's cleanup affects the next AppDelegate.pythonRunner?.clearCancellationRequest() - DebugLogger.shared.log("πŸ”„ Cleared cancellation state before processing: \(item.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ”„ Cleared cancellation state before processing: \(item.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) if Task.isCancelled { - DebugLogger.shared.log("processAllDocuments cancelled before processing next item", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "processAllDocuments cancelled before processing next item", + component: "DocumentRedactionViewModel" + ) updateState() return } @@ -439,10 +460,16 @@ final class DocumentRedactionViewModel: ObservableObject { // Clear cancellation again after document completion to ensure clean state AppDelegate.pythonRunner?.clearCancellationRequest() - DebugLogger.shared.log("πŸ”„ Cleared cancellation state after completing: \(item.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ”„ Cleared cancellation state after completing: \(item.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) if Task.isCancelled { - DebugLogger.shared.log("processAllDocuments cancelled during processing", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "processAllDocuments cancelled during processing", + component: "DocumentRedactionViewModel" + ) updateState() return } @@ -458,7 +485,7 @@ final class DocumentRedactionViewModel: ObservableObject { fileHandle.closeFile() } } - + // Send completion notification with accurate counts let processedItems = items.filter { processedIDs.contains($0.id) } let succeeded = processedItems.filter { $0.status == .completed }.count @@ -467,7 +494,7 @@ final class DocumentRedactionViewModel: ObservableObject { let body = "Finished processing \(processedItems.count) document(s). Success: \(succeeded), Failed: \(failed)." PermissionManager.shared.sendSystemNotification(title: title, body: body) } - + /// Scrub metadata only - no rules or LLM redaction /// This is a fast operation that only applies metadata cleaning based on saved preferences func scrubMetadataOnly(to destination: URL? = nil, includeRetryItems: Bool = true) async { @@ -479,7 +506,7 @@ final class DocumentRedactionViewModel: ObservableObject { DebugLogger.shared.log("Metadata scrub cancelled before start", component: "DocumentRedactionViewModel") return } - + let validItems = items.filter { item in if item.status.canRetry && !includeRetryItems { return false @@ -487,15 +514,18 @@ final class DocumentRedactionViewModel: ObservableObject { let eligibleStatus = item.status == .validDocument || item.status.canRetry || item.status.isComplete return eligibleStatus && item.scrubOutputURL == nil } - + guard !validItems.isEmpty else { DebugLogger.shared.log("No valid items for metadata scrub", component: "DocumentRedactionViewModel") return } - + for item in validItems { if Task.isCancelled { - DebugLogger.shared.log("Metadata scrub cancelled while processing queue", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Metadata scrub cancelled while processing queue", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.status = .cancelled updateState() @@ -519,9 +549,9 @@ final class DocumentRedactionViewModel: ObservableObject { await scrubDocumentMetadataOnly(item, destination: resolvedDestination) } - + updateState() - + // Send completion notification let succeeded = items.filter { $0.status == .completed }.count PermissionManager.shared.sendSystemNotification( @@ -558,16 +588,25 @@ final class DocumentRedactionViewModel: ObservableObject { applyMetadataSettingsEnvironment(metadataSettings, context: "report only") guard let runner = AppDelegate.pythonRunner else { - DebugLogger.shared.log("❌ Python runtime unavailable for metadata report", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Python runtime unavailable for metadata report", + component: "DocumentRedactionViewModel" + ) await MainActor.run { - setMetadataReportError("Metadata reports are unavailable until the AI engine finishes starting.", needsPermissionRetry: false) + setMetadataReportError( + "Metadata reports are unavailable until the AI engine finishes starting.", + needsPermissionRetry: false + ) } return } for item in validItems { if Task.isCancelled { - DebugLogger.shared.log("Metadata report cancelled while processing queue", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Metadata report cancelled while processing queue", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.status = .cancelled updateState() @@ -651,7 +690,10 @@ final class DocumentRedactionViewModel: ObservableObject { item.lastDestinationURL = destination } if result.success { - DebugLogger.shared.log("βœ… Metadata report generated in-place: \(reportURL.path)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "βœ… Metadata report generated in-place: \(reportURL.path)", + component: "DocumentRedactionViewModel" + ) item.metadataReport = result.report item.metadataReportOutputURL = reportURL item.errorMessage = nil @@ -663,14 +705,24 @@ final class DocumentRedactionViewModel: ObservableObject { let message = "Metadata report HTML was not generated. Please retry." item.errorMessage = message setMetadataReportError(message, needsPermissionRetry: false, item: item) - DebugLogger.shared.log("❌ Metadata report HTML missing for: \(reportURL.path)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Metadata report HTML missing for: \(reportURL.path)", + component: "DocumentRedactionViewModel" + ) } } else { let rawError = result.error ?? "Metadata report failed." let payload = metadataReportErrorPayload(for: item, error: rawError) item.errorMessage = payload.message - DebugLogger.shared.log("❌ Metadata report failed: \(rawError)", component: "DocumentRedactionViewModel") - setMetadataReportError(payload.message, needsPermissionRetry: payload.needsPermissionRetry, item: item) + DebugLogger.shared.log( + "❌ Metadata report failed: \(rawError)", + component: "DocumentRedactionViewModel" + ) + setMetadataReportError( + payload.message, + needsPermissionRetry: payload.needsPermissionRetry, + item: item + ) } updateState() } @@ -682,12 +734,17 @@ final class DocumentRedactionViewModel: ObservableObject { setMetadataReportError(payload.message, needsPermissionRetry: payload.needsPermissionRetry, item: item) updateState() } - DebugLogger.shared.log("❌ Metadata report exception: \(error.localizedDescription)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Metadata report exception: \(error.localizedDescription)", + component: "DocumentRedactionViewModel" + ) } item.releaseSecurityScope() } - private func metadataReportErrorPayload(for item: DocumentItem, error: String) -> (message: String, needsPermissionRetry: Bool) { + private func metadataReportErrorPayload(for item: DocumentItem, + error: String) -> (message: String, needsPermissionRetry: Bool) + { let lowercased = error.lowercased() let isPermissionIssue = lowercased.contains("operation not permitted") || lowercased.contains("permission denied") || @@ -719,12 +776,14 @@ final class DocumentRedactionViewModel: ObservableObject { } private func metadataReportPermissionMessage(for item: DocumentItem) -> String { - return "\(item.url.lastPathComponent): Couldn't save the metadata report. Retry file access permissions." + "\(item.url.lastPathComponent): Couldn't save the metadata report. Retry file access permissions." } var outputSaveLocationPreference: OutputSaveLocation { let defaults = UserDefaults.standard - let rawValue = defaults.object(forKey: DefaultsKey.outputSaveLocationPreference.key) as? Int ?? OutputSaveLocation.alwaysAsk.rawValue + let rawValue = defaults + .object(forKey: DefaultsKey.outputSaveLocationPreference.key) as? Int ?? OutputSaveLocation.alwaysAsk + .rawValue return OutputSaveLocation(rawValue: rawValue) ?? .alwaysAsk } @@ -732,7 +791,12 @@ final class DocumentRedactionViewModel: ObservableObject { "Output location unavailable. Retry file access permissions or choose another output location." } - private func setOutputAccessError(_ message: String, isMetadataOperation: Bool, needsPermissionRetry: Bool, item: DocumentItem? = nil) { + private func setOutputAccessError( + _ message: String, + isMetadataOperation: Bool, + needsPermissionRetry: Bool, + item: DocumentItem? = nil + ) { if isMetadataOperation { setMetadataReportError(message, needsPermissionRetry: needsPermissionRetry, item: item) } else { @@ -743,7 +807,10 @@ final class DocumentRedactionViewModel: ObservableObject { private func resolveTemporaryReportDirectory() -> URL? { let fm = FileManager.default guard let cacheDir = FileAccessCoordinator.shared.metadataReportCacheDirectory() else { - DebugLogger.shared.log("❌ Failed to resolve metadata report cache directory", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Failed to resolve metadata report cache directory", + component: "DocumentRedactionViewModel" + ) return nil } do { @@ -751,7 +818,10 @@ final class DocumentRedactionViewModel: ObservableObject { try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: cacheDir.path) return cacheDir } catch { - DebugLogger.shared.log("❌ Failed to create temporary report directory: \(error)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Failed to create temporary report directory: \(error)", + component: "DocumentRedactionViewModel" + ) return nil } } @@ -766,29 +836,54 @@ final class DocumentRedactionViewModel: ObservableObject { switch preference { case .alwaysAsk: guard let baseDestination else { - setOutputAccessError(outputLocationErrorMessage(), isMetadataOperation: isMetadataOperation, needsPermissionRetry: false, item: item) + setOutputAccessError( + outputLocationErrorMessage(), + isMetadataOperation: isMetadataOperation, + needsPermissionRetry: false, + item: item + ) return nil } if let error = validateDestination(baseDestination, estimatedBytesNeeded: estimatedBytesNeeded) { - setOutputAccessError(error, isMetadataOperation: isMetadataOperation, needsPermissionRetry: false, item: item) + setOutputAccessError( + error, + isMetadataOperation: isMetadataOperation, + needsPermissionRetry: false, + item: item + ) return nil } return baseDestination case .sameAsOriginal: let destination = item.url.deletingLastPathComponent() if let error = validateDestination(destination, estimatedBytesNeeded: estimatedBytesNeeded) { - setOutputAccessError(error, isMetadataOperation: isMetadataOperation, needsPermissionRetry: false, item: item) + setOutputAccessError( + error, + isMetadataOperation: isMetadataOperation, + needsPermissionRetry: false, + item: item + ) return nil } return destination case .downloads: let granted = await FileAccessCoordinator.shared.requestDownloadsAccessForReports() guard granted, let downloadsURL = FileAccessCoordinator.shared.downloadsDirectoryForReports() else { - setOutputAccessError(outputLocationErrorMessage(), isMetadataOperation: isMetadataOperation, needsPermissionRetry: true, item: item) + setOutputAccessError( + outputLocationErrorMessage(), + isMetadataOperation: isMetadataOperation, + needsPermissionRetry: true, + item: item + ) return nil } if let error = validateDestination(downloadsURL, estimatedBytesNeeded: estimatedBytesNeeded) { - setOutputAccessError(error, isMetadataOperation: isMetadataOperation, needsPermissionRetry: false, item: item) + setOutputAccessError( + error, + isMetadataOperation: isMetadataOperation, + needsPermissionRetry: false, + item: item + ) return nil } return downloadsURL @@ -848,7 +943,7 @@ final class DocumentRedactionViewModel: ObservableObject { func clearReportError() { reportErrorMessage = nil } - + /// Process a single document for metadata-only scrubbing private func scrubDocumentMetadataOnly(_ item: DocumentItem, destination: URL) async { defer { @@ -871,20 +966,20 @@ final class DocumentRedactionViewModel: ObservableObject { } return } - + DebugLogger.shared.log("Metadata scrub for: \(item.path)", component: "DocumentRedactionViewModel") - + let inputPath = item.path let inputURL = URL(fileURLWithPath: inputPath) - + let formatter = DateFormatter() formatter.dateFormat = "M-d-yy hmma" let timestamp = formatter.string(from: Date()) let label = "(metadata-scrubbed \(timestamp))" - + let outputFileName = inputURL.deletingPathExtension().lastPathComponent + " " + label + ".docx" let outputPath = destination.appendingPathComponent(outputFileName).path - + guard let runner = AppDelegate.pythonRunner else { DebugLogger.shared.log("❌ Python runtime unavailable", component: "DocumentRedactionViewModel") await MainActor.run { @@ -894,24 +989,24 @@ final class DocumentRedactionViewModel: ObservableObject { } return } - + // Load metadata settings and set environment variable let metadataSettings = MetadataCleaningSettings.load() applyMetadataSettingsEnvironment(metadataSettings, context: "metadata scrub") - + // Also set flag to skip rules and LLM setenv("MARCUT_METADATA_ONLY", "1", 1) defer { unsetenv("MARCUT_METADATA_ONLY") } - + // Process using Python do { let result = try await runner.scrubMetadataOnlyAsync( inputPath: inputPath, outputPath: outputPath ) - + if Task.isCancelled { await MainActor.run { item.status = .cancelled @@ -933,46 +1028,73 @@ final class DocumentRedactionViewModel: ObservableObject { let cleaned = summary?["total_cleaned"] as? Int ?? 0 let preserved = summary?["total_preserved"] as? Int ?? 0 let embedded = (report["embedded_docs_found"] as? [String])?.count ?? 0 - - DebugLogger.shared.log("βœ… Metadata scrub complete: \(outputPath)", component: "DocumentRedactionViewModel") - DebugLogger.shared.log("πŸ“Š Report: \(cleaned) cleaned, \(preserved) preserved, \(embedded) embedded docs", component: "DocumentRedactionViewModel") - + + DebugLogger.shared.log( + "βœ… Metadata scrub complete: \(outputPath)", + component: "DocumentRedactionViewModel" + ) + DebugLogger.shared.log( + "πŸ“Š Report: \(cleaned) cleaned, \(preserved) preserved, \(embedded) embedded docs", + component: "DocumentRedactionViewModel" + ) + // Save report JSON file matching redaction report naming convention let reportFileName = inputURL.deletingPathExtension().lastPathComponent + " \(label)_scrub_report.json" let reportOutputPath = destination.appendingPathComponent(reportFileName) - - do { - let reportData = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) - try reportData.write(to: reportOutputPath) - Self.makeSensitiveReportFilePrivate(reportOutputPath) - scrubReportURL = reportOutputPath - item.metadataReportOutputURL = reportOutputPath - - // Check for HTML report (generated by Python alongside JSON) - let htmlReportURL = reportOutputPath.deletingPathExtension().appendingPathExtension("html") - if FileManager.default.fileExists(atPath: htmlReportURL.path) { - Self.makeSensitiveReportFilePrivate(htmlReportURL) - scrubHTMLURL = htmlReportURL - item.metadataReportHTMLOutputURL = htmlReportURL - DebugLogger.shared.log("πŸ“„ HTML Report found: \(htmlReportURL.path)", component: "DocumentRedactionViewModel") - } else if let generatedHTML = await generateScrubHTMLIfMissing(at: reportOutputPath) { - Self.makeSensitiveReportFilePrivate(generatedHTML) - scrubHTMLURL = generatedHTML - item.metadataReportHTMLOutputURL = generatedHTML - DebugLogger.shared.log("πŸ“„ Generated HTML report: \(generatedHTML.path)", component: "DocumentRedactionViewModel") - } - - DebugLogger.shared.log("πŸ“„ Report saved: \(reportOutputPath.path)", component: "DocumentRedactionViewModel") + + do { + let reportData = try JSONSerialization.data( + withJSONObject: report, + options: [.prettyPrinted, .sortedKeys] + ) + try reportData.write(to: reportOutputPath) + Self.makeSensitiveReportFilePrivate(reportOutputPath) + scrubReportURL = reportOutputPath + item.metadataReportOutputURL = reportOutputPath + + // Check for HTML report (generated by Python alongside JSON) + let htmlReportURL = reportOutputPath.deletingPathExtension().appendingPathExtension("html") + if FileManager.default.fileExists(atPath: htmlReportURL.path) { + Self.makeSensitiveReportFilePrivate(htmlReportURL) + scrubHTMLURL = htmlReportURL + item.metadataReportHTMLOutputURL = htmlReportURL + DebugLogger.shared.log( + "πŸ“„ HTML Report found: \(htmlReportURL.path)", + component: "DocumentRedactionViewModel" + ) + } else if let generatedHTML = await generateScrubHTMLIfMissing(at: reportOutputPath) { + Self.makeSensitiveReportFilePrivate(generatedHTML) + scrubHTMLURL = generatedHTML + item.metadataReportHTMLOutputURL = generatedHTML + DebugLogger.shared.log( + "πŸ“„ Generated HTML report: \(generatedHTML.path)", + component: "DocumentRedactionViewModel" + ) + } + + DebugLogger.shared.log( + "πŸ“„ Report saved: \(reportOutputPath.path)", + component: "DocumentRedactionViewModel" + ) } catch { - DebugLogger.shared.log("⚠️ Failed to save report: \(error)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Failed to save report: \(error)", + component: "DocumentRedactionViewModel" + ) } - + // Log embedded docs warning if any if let embeddedDocs = report["embedded_docs_found"] as? [String], !embeddedDocs.isEmpty { - DebugLogger.shared.log("⚠️ Embedded documents found (need recursive cleaning): \(embeddedDocs)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Embedded documents found (need recursive cleaning): \(embeddedDocs)", + component: "DocumentRedactionViewModel" + ) } } else { - DebugLogger.shared.log("βœ… Metadata scrub complete: \(outputPath)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "βœ… Metadata scrub complete: \(outputPath)", + component: "DocumentRedactionViewModel" + ) } } @@ -980,7 +1102,7 @@ final class DocumentRedactionViewModel: ObservableObject { if outputValid { item.status = .completed markMetadataScrubUsed() - + // Set output URLs for document and report item.redactedOutputURL = outputURL item.scrubOutputURL = outputURL @@ -996,7 +1118,10 @@ final class DocumentRedactionViewModel: ObservableObject { } else { item.status = .failed item.errorMessage = "Scrubbed file appears to be a corrupt DOCX package" - DebugLogger.shared.log("❌ Metadata scrub output failed validation: \(outputPath)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Metadata scrub output failed validation: \(outputPath)", + component: "DocumentRedactionViewModel" + ) } updateState() } @@ -1007,7 +1132,10 @@ final class DocumentRedactionViewModel: ObservableObject { // `scrubMetadataOnlyAsync`) is logged below, not shown to the user -- // see `FailureMessagePresenter`. item.errorMessage = FailureMessagePresenter.message(forCode: nil) - DebugLogger.shared.log("❌ Metadata scrub failed: \(result.error ?? "Unknown")", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Metadata scrub failed: \(result.error ?? "Unknown")", + component: "DocumentRedactionViewModel" + ) updateState() } } @@ -1019,7 +1147,7 @@ final class DocumentRedactionViewModel: ObservableObject { } } } - + func processDocument(_ item: DocumentItem, destination: URL) async { // Show immediate progress indication on main thread await MainActor.run { @@ -1031,23 +1159,26 @@ final class DocumentRedactionViewModel: ObservableObject { } // Add logging at ViewModel level - DebugLogger.shared.log("ViewModel.processDocument called for: \(item.path)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "ViewModel.processDocument called for: \(item.path)", + component: "DocumentRedactionViewModel" + ) // Prepare file paths for PythonKit processing let inputPath = item.path let inputURL = URL(fileURLWithPath: inputPath) - + let formatter = DateFormatter() formatter.dateFormat = "M-d-yy hmma" // e.g., 12-20-25 1130PM let timestamp = formatter.string(from: Date()) let label = "(redacted \(timestamp))" - + // Output format: Filename (redacted M-d-yy hmma).docx let outputFileName = inputURL.deletingPathExtension().lastPathComponent + " " + label + ".docx" // Report format: Filename (redacted M-d-yy hmma)_report.json let reportFileName = inputURL.deletingPathExtension().lastPathComponent + " " + label + "_report.json" let scrubReportFileName = inputURL.deletingPathExtension().lastPathComponent + " " + label + "_scrub_report.json" - + let outputPath = destination.appendingPathComponent(outputFileName).path let reportPath = destination.appendingPathComponent(reportFileName).path let scrubReportPath = destination.appendingPathComponent(scrubReportFileName).path @@ -1057,18 +1188,24 @@ final class DocumentRedactionViewModel: ObservableObject { let modelName = settings.model let backend = settings.backend.lowercased() let runnerStatus = AppDelegate.pythonRunner == nil ? "nil" : "ready" - DebugLogger.shared.log("Pre-flight: runner=\(runnerStatus) backend=\(backend)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Pre-flight: runner=\(runnerStatus) backend=\(backend)", + component: "DocumentRedactionViewModel" + ) logAdvancedSettingsSnapshot(useEnhanced: useEnhanced, modelName: modelName, backend: backend) guard let runner = AppDelegate.pythonRunner else { - DebugLogger.shared.log("❌ Python runtime unavailable; cannot process document", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Python runtime unavailable; cannot process document", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.status = .failed item.errorMessage = "Processing unavailable: embedded Python runtime not initialized. Please restart the app." updateState() } PermissionManager.shared.sendSystemNotification( - title: "Processing Failed", + title: "Processing Failed", body: "Fatal Error: Embedded AI runtime could not be initialized." ) return @@ -1076,7 +1213,10 @@ final class DocumentRedactionViewModel: ObservableObject { if settings.mode.usesLLM { guard backend == "ollama" else { - DebugLogger.shared.log("❌ Unsupported backend for App Store-safe build: \(backend)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Unsupported backend for App Store-safe build: \(backend)", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.status = .failed item.errorMessage = "Unsupported backend. Use Ollama in Settings and restart." @@ -1090,7 +1230,10 @@ final class DocumentRedactionViewModel: ObservableObject { if useEnhanced { let ready = await pythonBridge.ensureOllamaReadyForPythonKit(requiredModel: modelName) if !ready { - DebugLogger.shared.log("❌ Pre-flight failed: Ollama service or model \(modelName) unavailable", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Pre-flight failed: Ollama service or model \(modelName) unavailable", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.status = .failed item.errorMessage = "AI service is not ready (missing model or offline). Please restart the app or redownload the model. Check App Log in Settings." @@ -1100,7 +1243,10 @@ final class DocumentRedactionViewModel: ObservableObject { } } - DebugLogger.shared.log("πŸš€ Using in-process PythonKit pipeline (\(useEnhanced ? "LLM" : "Rules") mode) -> output=\(outputPath), report=\(reportPath)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸš€ Using in-process PythonKit pipeline (\(useEnhanced ? "LLM" : "Rules") mode) -> output=\(outputPath), report=\(reportPath)", + component: "DocumentRedactionViewModel" + ) await processDocumentWithPythonKit( item, outputPath: outputPath, @@ -1128,7 +1274,8 @@ final class DocumentRedactionViewModel: ObservableObject { private func applyAdvancedSettingsEnvironment() { let defaults = UserDefaults.standard let advancedEnabled = defaults.bool(forKey: DefaultsKey.advancedModeEnabled.key) - let advancedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride.rawValue + let advancedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride + .rawValue let advancedConfidence = defaults.integer(forKey: DefaultsKey.advancedLLMConfidence.key) setenv("MARCUT_ADVANCED_MODE_ENABLED", advancedEnabled ? "1" : "0", 1) setenv("MARCUT_ADVANCED_AI_MODE", advancedModeRaw, 1) @@ -1186,16 +1333,28 @@ final class DocumentRedactionViewModel: ObservableObject { item.scrubReportHTMLOutputURL = htmlURL item.metadataReportHTMLOutputURL = htmlURL } - DebugLogger.shared.log("πŸ“„ Found scrub report at alternate path: \(foundScrubReport.path)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ“„ Found scrub report at alternate path: \(foundScrubReport.path)", + component: "DocumentRedactionViewModel" + ) } else { - DebugLogger.shared.log("⚠️ Scrub report missing: \(scrubReportPath)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Scrub report missing: \(scrubReportPath)", + component: "DocumentRedactionViewModel" + ) } } } else { - DebugLogger.shared.log("ℹ️ Metadata cleaning disabled; skipping scrub report lookup.", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "ℹ️ Metadata cleaning disabled; skipping scrub report lookup.", + component: "DocumentRedactionViewModel" + ) } - DebugLogger.shared.log("πŸ“„ Set output URLs - DOCX: \(outputPath), JSON: \(reportPath)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ“„ Set output URLs - DOCX: \(outputPath), JSON: \(reportPath)", + component: "DocumentRedactionViewModel" + ) } private func processDocumentWithPythonKit( @@ -1212,7 +1371,10 @@ final class DocumentRedactionViewModel: ObservableObject { // completion from an earlier, abandoned attempt on this same item can't clobber it. let attemptToken = UUID() activeAttemptTokens[item.id] = attemptToken - DebugLogger.shared.log("πŸ”„ processDocumentWithPythonKit started for item.id=\(item.id) (\(item.url.lastPathComponent))", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ”„ processDocumentWithPythonKit started for item.id=\(item.id) (\(item.url.lastPathComponent))", + component: "DocumentRedactionViewModel" + ) if settings.debug { setenv("MARCUT_LOG_PATH", DebugLogger.shared.logPath, 1) } else { @@ -1224,13 +1386,19 @@ final class DocumentRedactionViewModel: ObservableObject { item.beginStage(.preflight) let debug = settings.debug let cancellationChecker: () -> Bool = { [weak self] in - guard let self = self else { - DebugLogger.shared.log("⚠️ cancellationChecker: self is nil, returning true", component: "CancellationCheck") + guard let self else { + DebugLogger.shared.log( + "⚠️ cancellationChecker: self is nil, returning true", + component: "CancellationCheck" + ) return true } let isCancelled = self.processingTasks[item.id]?.isCancelled ?? false if isCancelled { - DebugLogger.shared.log("⚠️ cancellationChecker: task for item.id=\(item.id) is cancelled", component: "CancellationCheck") + DebugLogger.shared.log( + "⚠️ cancellationChecker: task for item.id=\(item.id) is cancelled", + component: "CancellationCheck" + ) } return isCancelled } @@ -1244,7 +1412,10 @@ final class DocumentRedactionViewModel: ObservableObject { if !modelReady { item.status = .failed item.errorMessage = "Model \(modelName) is not ready yet. Please try again in a moment." - DebugLogger.shared.log("❌ Model readiness check failed for \(modelName)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Model readiness check failed for \(modelName)", + component: "DocumentRedactionViewModel" + ) finalizeProcessing(for: item) return } @@ -1253,8 +1424,8 @@ final class DocumentRedactionViewModel: ObservableObject { setenv("MARCUT_SCRUB_REPORT_PATH", scrubReportPath, 1) DebugLogger.shared.log("πŸ“„ Scrub report path: \(scrubReportPath)", component: "DocumentRedactionViewModel") - let streamAndResult: (AsyncStream, Task) = { - runner.runEnhancedOllamaWithProgress( + let streamAndResult: (AsyncStream, Task) = runner + .runEnhancedOllamaWithProgress( inputPath: item.path, outputPath: outputPath, reportPath: reportPath, @@ -1267,22 +1438,28 @@ final class DocumentRedactionViewModel: ObservableObject { overlap: settings.overlap, temperature: settings.temperature, seed: settings.seed, - processingStepTimeout: useEnhanced && settings.processingTimeoutSeconds != Int.max ? TimeInterval(settings.processingTimeoutSeconds) : nil, + processingStepTimeout: useEnhanced && settings.processingTimeoutSeconds != Int + .max ? TimeInterval(settings.processingTimeoutSeconds) : nil, cancellationChecker: cancellationChecker ) - }() let progressTask = Task.detached { [weak self] in - guard let self = self else { + guard let self else { DebugLogger.shared.log("⚠️ Progress task: self is nil", component: "ProgressMonitor") return } - DebugLogger.shared.log("πŸ”„ Progress task started for item.id=\(item.id) (\(item.url.lastPathComponent))", component: "ProgressMonitor") + DebugLogger.shared.log( + "πŸ”„ Progress task started for item.id=\(item.id) (\(item.url.lastPathComponent))", + component: "ProgressMonitor" + ) var updateCount = 0 for await update in streamAndResult.0 { updateCount += 1 if Task.isCancelled { - DebugLogger.shared.log("⚠️ Progress task cancelled after \(updateCount) updates for \(item.url.lastPathComponent)", component: "ProgressMonitor") + DebugLogger.shared.log( + "⚠️ Progress task cancelled after \(updateCount) updates for \(item.url.lastPathComponent)", + component: "ProgressMonitor" + ) break } let updateIndex = updateCount @@ -1291,20 +1468,26 @@ final class DocumentRedactionViewModel: ObservableObject { let updateSnapshot = update let enhancedMode = useEnhanced await MainActor.run { [weak self] in - guard let self = self else { return } + guard let self else { return } guard let currentItem = self.items.first(where: { $0.id == itemId }) else { - DebugLogger.shared.log("⚠️ Progress update #\(updateIndex) dropped: item.id=\(itemId) not found in items", component: "ProgressMonitor") + DebugLogger.shared.log( + "⚠️ Progress update #\(updateIndex) dropped: item.id=\(itemId) not found in items", + component: "ProgressMonitor" + ) return } - + // Always update heartbeat timestamp to prevent false stall detection // This is critical during status transitions (processing β†’ completed) currentItem.lastHeartbeat = Date() - + // Allow progress updates during processing OR completed (to handle race at completion) // Block only for failed/cancelled states where updates are meaningless guard currentItem.status == .processing || currentItem.status == .completed else { - DebugLogger.shared.log("⚠️ Progress update #\(updateIndex) dropped: item status=\(currentItem.status) (expected .processing or .completed) for \(itemName)", component: "ProgressMonitor") + DebugLogger.shared.log( + "⚠️ Progress update #\(updateIndex) dropped: item status=\(currentItem.status) (expected .processing or .completed) for \(itemName)", + component: "ProgressMonitor" + ) return } @@ -1313,12 +1496,15 @@ final class DocumentRedactionViewModel: ObservableObject { self.applyPythonKitProgress(updateSnapshot, to: currentItem, isEnhanced: enhancedMode) } } - DebugLogger.shared.log("πŸ”„ Progress task finished for \(item.url.lastPathComponent) after \(updateCount) updates", component: "ProgressMonitor") + DebugLogger.shared.log( + "πŸ”„ Progress task finished for \(item.url.lastPathComponent) after \(updateCount) updates", + component: "ProgressMonitor" + ) } let completionTask = Task.detached { [weak self] in defer { unsetenv("MARCUT_SCRUB_REPORT_PATH") } - guard let self = self else { return } + guard let self else { return } let outcome = await self.awaitPythonOutcome(streamAndResult.1, runner: runner) progressTask.cancel() @@ -1329,11 +1515,17 @@ final class DocumentRedactionViewModel: ObservableObject { // user retried it. If a newer attempt has since taken over `item.id`, this // result is stale: apply nothing. guard self.activeAttemptTokens[item.id] == attemptToken else { - DebugLogger.shared.log("⚠️ Ignoring stale completion for \(item.url.lastPathComponent) (superseded by a newer attempt or abandoned)", component: "CompletionTask") + DebugLogger.shared.log( + "⚠️ Ignoring stale completion for \(item.url.lastPathComponent) (superseded by a newer attempt or abandoned)", + component: "CompletionTask" + ) return } guard let currentItem = self.items.first(where: { $0.id == item.id }) else { - DebugLogger.shared.log("⚠️ Completion task: item.id=\(item.id) not found in items", component: "CompletionTask") + DebugLogger.shared.log( + "⚠️ Completion task: item.id=\(item.id) not found in items", + component: "CompletionTask" + ) return } @@ -1348,11 +1540,14 @@ final class DocumentRedactionViewModel: ObservableObject { scrubReportPath: scrubReportPath ) currentItem.errorMessage = nil - DebugLogger.shared.log("βœ… PythonKit processing completed for \(currentItem.url.lastPathComponent) (prevStatus=\(previousStatus))", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "βœ… PythonKit processing completed for \(currentItem.url.lastPathComponent) (prevStatus=\(previousStatus))", + component: "DocumentRedactionViewModel" + ) case .cancelled: let outputExists = FileManager.default.fileExists(atPath: outputPath) let reportExists = FileManager.default.fileExists(atPath: reportPath) - if outputExists && reportExists { + if outputExists, reportExists { currentItem.status = .completed self.applyOutputArtifacts( to: currentItem, @@ -1361,10 +1556,16 @@ final class DocumentRedactionViewModel: ObservableObject { scrubReportPath: scrubReportPath ) currentItem.errorMessage = nil - DebugLogger.shared.log("⚠️ Cancellation received after outputs were written; marking completed for \(currentItem.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Cancellation received after outputs were written; marking completed for \(currentItem.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) } else { currentItem.status = .cancelled - DebugLogger.shared.log("⏹️ PythonKit processing cancelled for \(currentItem.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⏹️ PythonKit processing cancelled for \(currentItem.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) } case .failure: currentItem.status = .failed @@ -1374,20 +1575,29 @@ final class DocumentRedactionViewModel: ObservableObject { // (see `FailureMessagePresenter`) -- never the bare pipeline error_code // or message as the headline. currentItem.errorMessage = FailureMessagePresenter.message(forCode: failure.code) - DebugLogger.shared.log("❌ PythonKit processing failed for \(currentItem.url.lastPathComponent) code=\(failure.code) message=\(failure.message) details=\(failure.details)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ PythonKit processing failed for \(currentItem.url.lastPathComponent) code=\(failure.code) message=\(failure.message) details=\(failure.details)", + component: "DocumentRedactionViewModel" + ) // Dump Ollama logs to see why the runner crashed PythonBridgeService.shared.dumpOllamaLogs() } else { if currentItem.errorMessage == nil { currentItem.errorMessage = FailureMessagePresenter.message(forCode: nil) } - DebugLogger.shared.log("❌ PythonKit processing failed for \(currentItem.url.lastPathComponent) (no failure report found)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ PythonKit processing failed for \(currentItem.url.lastPathComponent) (no failure report found)", + component: "DocumentRedactionViewModel" + ) } self.assignFailureMessageIfNeeded(currentItem) case .stalled: currentItem.status = .failed currentItem.errorMessage = Self.processingStalledMessage - DebugLogger.shared.log("❌ PythonKit processing stalled (bridge watchdog abandoned the worker) for \(currentItem.url.lastPathComponent)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ PythonKit processing stalled (bridge watchdog abandoned the worker) for \(currentItem.url.lastPathComponent)", + component: "DocumentRedactionViewModel" + ) } self.finalizeProcessing(for: currentItem) } @@ -1452,7 +1662,7 @@ final class DocumentRedactionViewModel: ObservableObject { func retryDocument(_ item: DocumentItem, destination: URL? = nil, operation: DocumentOperation) { item.errorMessage = nil Task { [weak self, weak item] in - guard let self = self, let item = item else { return } + guard let self, let item else { return } guard let resolvedDestination = await self.resolveOutputDirectory( for: item, baseDestination: destination, @@ -1462,7 +1672,11 @@ final class DocumentRedactionViewModel: ObservableObject { await MainActor.run { item.status = .failed item.errorMessage = message - self.setOutputAccessError(message, isMetadataOperation: operation == .scrub, needsPermissionRetry: true) + self.setOutputAccessError( + message, + isMetadataOperation: operation == .scrub, + needsPermissionRetry: true + ) self.updateState() } return @@ -1528,7 +1742,7 @@ final class DocumentRedactionViewModel: ObservableObject { } return false } - + private func updateState() { hasDocuments = !items.isEmpty hasValidDocuments = items.contains { $0.status == .validDocument } @@ -1554,7 +1768,8 @@ final class DocumentRedactionViewModel: ObservableObject { let hasPendingDocuments = items.contains { $0.status.isPendingReview } let hasPendingRedaction = items.contains { needsRedaction($0) } - hasFinishedProcessing = hasCompletedDocuments && !hasProcessingDocuments && !hasPendingDocuments && !hasPendingRedaction + hasFinishedProcessing = hasCompletedDocuments && !hasProcessingDocuments && !hasPendingDocuments && + !hasPendingRedaction ProcessingState.isProcessing = hasProcessingDocuments persistPendingBatchJobIfNeeded() @@ -1566,7 +1781,7 @@ final class DocumentRedactionViewModel: ObservableObject { private var pendingBatchJobPaths: [String] { items .filter { $0.status == .validDocument || $0.status.isProcessing } - .map { $0.url.path } + .map(\.url.path) } /// Persists the current pending/in-progress document set whenever it changes, or clears the @@ -1654,9 +1869,9 @@ final class DocumentRedactionViewModel: ObservableObject { items.removeAll { $0.id == item.id } updateState() } - + // MARK: - Output Management - + @discardableResult func openRedactedDocument(_ item: DocumentItem) -> Bool { guard let url = item.redactedOutputURL else { return false } @@ -1672,13 +1887,9 @@ final class DocumentRedactionViewModel: ObservableObject { let alert = NSAlert() alert.messageText = "Send Document" - alert.informativeText = """ - Choose the DOCX you want to send. - - Final redacted copy accepts Marcut's redaction Track Changes in a new copy and scrubs metadata before sending. - - Review copy sends the current review artifact with Track Changes and metadata exactly as they are in that file. - """ + alert.informativeText = "Choose the DOCX you want to send.\n\n" + + "Final redacted copy accepts Marcut's redaction Track Changes in a new copy and scrubs metadata before sending.\n\n" + + "Review copy sends the current review artifact with Track Changes and metadata exactly as they are in that file." alert.alertStyle = .warning alert.addButton(withTitle: "Send Final Redacted Copy") alert.addButton(withTitle: "Send Review Copy") @@ -1773,7 +1984,7 @@ final class DocumentRedactionViewModel: ObservableObject { picker.show(relativeTo: .zero, of: view, preferredEdge: .minY) return true } - + @discardableResult func openReport(_ item: DocumentItem) -> Bool { let title = "Audit Report β€” \(item.url.lastPathComponent)" @@ -1787,13 +1998,14 @@ final class DocumentRedactionViewModel: ObservableObject { } return false } - + @discardableResult func openScrubReport(_ item: DocumentItem) -> Bool { let title = "Scrub Report β€” \(item.url.lastPathComponent)" if item.scrubReportOutputURL == nil { let baseName = item.url.deletingPathExtension().lastPathComponent - let searchDir = item.reportOutputURL?.deletingLastPathComponent() ?? item.redactedOutputURL?.deletingLastPathComponent() + let searchDir = item.reportOutputURL?.deletingLastPathComponent() ?? item.redactedOutputURL? + .deletingLastPathComponent() if let directory = searchDir, let found = findScrubReport(in: directory, matching: baseName) { item.scrubReportOutputURL = found item.metadataReportOutputURL = found @@ -1824,7 +2036,10 @@ final class DocumentRedactionViewModel: ObservableObject { } return true } - if let htmlURL = resolvedHTMLURL(preferred: item.metadataReportHTMLOutputURL, from: item.metadataReportOutputURL) { + if let htmlURL = resolvedHTMLURL( + preferred: item.metadataReportHTMLOutputURL, + from: item.metadataReportOutputURL + ) { return presentReport(url: htmlURL, title: "Metadata Report β€” \(item.url.lastPathComponent)") } if let jsonURL = item.metadataReportOutputURL { @@ -1850,7 +2065,10 @@ final class DocumentRedactionViewModel: ObservableObject { @discardableResult func openMetadataReport(_ item: DocumentItem) -> Bool { let title = "Metadata Report β€” \(item.url.lastPathComponent)" - if let htmlURL = resolvedHTMLURL(preferred: item.metadataReportHTMLOutputURL, from: item.metadataReportOutputURL) { + if let htmlURL = resolvedHTMLURL( + preferred: item.metadataReportHTMLOutputURL, + from: item.metadataReportOutputURL + ) { return presentReport(url: htmlURL, title: title) } if let jsonURL = item.metadataReportOutputURL { @@ -1895,7 +2113,8 @@ final class DocumentRedactionViewModel: ObservableObject { } let existingHTML = item.metadataReportHTMLOutputURL - let htmlURL = (existingHTML != nil && FileManager.default.fileExists(atPath: existingHTML!.path)) ? existingHTML : nil + let htmlURL = (existingHTML != nil && FileManager.default.fileExists(atPath: existingHTML!.path)) ? + existingHTML : nil switch outputSaveLocationPreference { case .downloads: @@ -1987,7 +2206,12 @@ final class DocumentRedactionViewModel: ObservableObject { await saveMetadataReportToDirectory(downloadsURL, htmlURL: htmlURL, jsonURL: jsonURL, item: item) } - private func saveMetadataReportToDirectory(_ destinationDir: URL, htmlURL: URL?, jsonURL: URL, item: DocumentItem) async { + private func saveMetadataReportToDirectory( + _ destinationDir: URL, + htmlURL: URL?, + jsonURL: URL, + item: DocumentItem + ) async { let estimatedBytesNeeded = estimatedOutputBytes(for: item, isMetadataOperation: true) if let error = validateDestination(destinationDir, estimatedBytesNeeded: estimatedBytesNeeded) { await MainActor.run { @@ -2028,11 +2252,18 @@ final class DocumentRedactionViewModel: ObservableObject { // 1. Copy HTML if requested if let finalHTMLURL, let htmlURL { guard fm.fileExists(atPath: htmlURL.path) else { - throw NSError(domain: "MarcutApp", code: 404, userInfo: [NSLocalizedDescriptionKey: "Source HTML report not found at \(htmlURL.path)"]) + throw NSError( + domain: "MarcutApp", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Source HTML report not found at \(htmlURL.path)"] + ) } - + if htmlURL.standardizedFileURL == finalHTMLURL.standardizedFileURL { - DebugLogger.shared.log("⚠️ Skip copy: source and destination HTML are identical (\(htmlURL.path))", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Skip copy: source and destination HTML are identical (\(htmlURL.path))", + component: "DocumentRedactionViewModel" + ) } else { if fm.fileExists(atPath: finalHTMLURL.path) { try fm.removeItem(at: finalHTMLURL) @@ -2046,13 +2277,20 @@ final class DocumentRedactionViewModel: ObservableObject { let destinationJSONURL = htmlURL != nil ? destinationDir.appendingPathComponent(jsonURL.lastPathComponent) : destinationURL - + guard fm.fileExists(atPath: jsonURL.path) else { - throw NSError(domain: "MarcutApp", code: 404, userInfo: [NSLocalizedDescriptionKey: "Source JSON report not found at \(jsonURL.path)"]) + throw NSError( + domain: "MarcutApp", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Source JSON report not found at \(jsonURL.path)"] + ) } if jsonURL.standardizedFileURL == destinationJSONURL.standardizedFileURL { - DebugLogger.shared.log("⚠️ Skip copy: source and destination JSON are identical (\(jsonURL.path))", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Skip copy: source and destination JSON are identical (\(jsonURL.path))", + component: "DocumentRedactionViewModel" + ) } else { if fm.fileExists(atPath: destinationJSONURL.path) { try fm.removeItem(at: destinationJSONURL) @@ -2061,7 +2299,10 @@ final class DocumentRedactionViewModel: ObservableObject { } Self.makeSensitiveReportFilePrivate(destinationJSONURL) - DebugLogger.shared.log("βœ… Saved metadata report to \(destinationURL.path)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "βœ… Saved metadata report to \(destinationURL.path)", + component: "DocumentRedactionViewModel" + ) await MainActor.run { item.metadataReportErrorMessage = nil item.metadataReportNeedsPermissionRetry = false @@ -2069,21 +2310,27 @@ final class DocumentRedactionViewModel: ObservableObject { } catch { DebugLogger.shared.log("❌ Save metadata report failed: \(error)", component: "DocumentRedactionViewModel") await MainActor.run { - setMetadataReportError("Save failed: \(error.localizedDescription)", needsPermissionRetry: false, item: item) + setMetadataReportError( + "Save failed: \(error.localizedDescription)", + needsPermissionRetry: false, + item: item + ) } } } - + @discardableResult func revealInFinder(_ item: DocumentItem) -> Bool { - if let url = item.redactedOutputURL ?? item.reportOutputURL ?? item.scrubReportOutputURL ?? item.metadataReportOutputURL { + if let url = item.redactedOutputURL ?? item.reportOutputURL ?? item.scrubReportOutputURL ?? item + .metadataReportOutputURL + { return NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "") } return false } - + // MARK: - Settings Management - + func updateSettings(_ newSettings: RedactionSettings) { settings = newSettings // Sync debug setting with global logger @@ -2110,14 +2357,19 @@ final class DocumentRedactionViewModel: ObservableObject { } if defaults.object(forKey: DefaultsKey.advancedLLMConfidenceMigratedTo99.key) == nil { if let storedConfidence = defaults.object(forKey: DefaultsKey.advancedLLMConfidence.key) as? NSNumber, - storedConfidence.intValue == 95 { - defaults.set(RedactionSettings.standardNormalModeConfidence, forKey: DefaultsKey.advancedLLMConfidence.key) + storedConfidence.intValue == 95 + { + defaults.set( + RedactionSettings.standardNormalModeConfidence, + forKey: DefaultsKey.advancedLLMConfidence.key + ) } defaults.set(true, forKey: DefaultsKey.advancedLLMConfidenceMigratedTo99.key) } let advancedEnabled = defaults.bool(forKey: DefaultsKey.advancedModeEnabled.key) - let storedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride.rawValue + let storedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride + .rawValue let storedMode = RedactionMode(rawValue: storedModeRaw) ?? .rulesOverride let normalizedMode = storedMode == .rules ? .rulesOverride : storedMode let storedConfidence = defaults.integer(forKey: DefaultsKey.advancedLLMConfidence.key) @@ -2140,8 +2392,11 @@ final class DocumentRedactionViewModel: ObservableObject { } func requestFirstRunSetup(entryPoint: FirstRunEntryPoint = .onboarding) { - if entryPoint == .onboarding && shouldSuppressModelSetupPrompt { - DebugLogger.shared.log("🧽 Skipping setup prompt (metadata-only usage, no models installed)", component: "DocumentRedactionViewModel") + if entryPoint == .onboarding, shouldSuppressModelSetupPrompt { + DebugLogger.shared.log( + "🧽 Skipping setup prompt (metadata-only usage, no models installed)", + component: "DocumentRedactionViewModel" + ) shouldShowFirstRunSetup = false return } @@ -2225,12 +2480,13 @@ final class DocumentRedactionViewModel: ObservableObject { return Int64(words) } guard let attributes = try? FileManager.default.attributesOfItem(atPath: item.url.path), - let size = attributes[.size] as? Int64 else { + let size = attributes[.size] as? Int64 + else { return 0 } return size } - + private func loadFailureReport(at path: String) -> (code: String, message: String, details: String)? { let url = URL(fileURLWithPath: path) guard FileManager.default.fileExists(atPath: url.path) else { return nil } @@ -2244,7 +2500,10 @@ final class DocumentRedactionViewModel: ObservableObject { let details = (json["technical_details"] as? String) ?? "" return (code: code, message: message, details: details) } catch { - DebugLogger.shared.log("⚠️ Failed to parse failure report at \(path): \(error)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Failed to parse failure report at \(path): \(error)", + component: "DocumentRedactionViewModel" + ) return nil } } @@ -2262,9 +2521,11 @@ final class DocumentRedactionViewModel: ObservableObject { specificError = "AI service unreachable - check if Ollama is running" } else if logContent.contains("AI_MODEL_UNAVAILABLE") { specificError = "AI model missing - please download it in Settings" - } else if logContent.contains("PK_PROCESSING_TIMEOUT") || logContent.contains("AI_PROCESSING_TIMEOUT") || logContent.contains("Step timeout") { + } else if logContent.contains("PK_PROCESSING_TIMEOUT") || logContent + .contains("AI_PROCESSING_TIMEOUT") || logContent.contains("Step timeout") + { specificError = "AI processing timed out - try increasing Processing Timeout or reducing Chunk Size/Overlap" - } else if logContent.contains("OLLAMA_HOST") && logContent.contains("empty") && settings.mode.usesLLM { + } else if logContent.contains("OLLAMA_HOST"), logContent.contains("empty"), settings.mode.usesLLM { specificError = "Ollama service not configured - check settings" } else if logContent.contains("KeyboardInterrupt") { specificError = "Processing was interrupted" @@ -2279,15 +2540,15 @@ final class DocumentRedactionViewModel: ObservableObject { item.errorMessage = "\(specificError). See \(URL(fileURLWithPath: DebugLogger.shared.logPath).lastPathComponent) for details." } - + // MARK: - Environment Status - + var isEnvironmentReady: Bool { // In Rules Only mode, we don't need Ollama or models if settings.mode == .rules { return frameworkAvailable } - + // Environment is only truly ready when the Ollama service is confirmed to be running. // The UI will reflect the startup process until this is true. // In LLM modes, we also require at least one model to be installed. @@ -2300,12 +2561,15 @@ final class DocumentRedactionViewModel: ObservableObject { private func getOllamaPath() -> String? { // Correctly check Contents/MacOS for the binary if let executableURL = Bundle.main.executableURL { - let macosOllamaURL = executableURL.deletingLastPathComponent().appendingPathComponent("ollama", isDirectory: false) + let macosOllamaURL = executableURL.deletingLastPathComponent().appendingPathComponent( + "ollama", + isDirectory: false + ) if FileManager.default.fileExists(atPath: macosOllamaURL.path) { return macosOllamaURL.path } } - + // Fallback to legacy Resources location (just in case) if let bundledPath = Bundle.main.path(forResource: "ollama", ofType: nil) { return bundledPath @@ -2313,7 +2577,7 @@ final class DocumentRedactionViewModel: ObservableObject { return nil } - + var environmentStatus: String { let supportedModels = availableModels @@ -2321,7 +2585,7 @@ final class DocumentRedactionViewModel: ObservableObject { if !frameworkAvailable { return "❌ Python framework missing - Please reinstall MarcutApp" } - + // In Rules Only mode, we bypass AI checks if settings.mode == .rules { return "βœ… Ready (Rules Only Mode)" @@ -2330,8 +2594,8 @@ final class DocumentRedactionViewModel: ObservableObject { if let launchError = pythonBridge.ollamaLaunchError, !pythonBridge.isOllamaRunning { return "❌ \(launchError)" } - - if !pythonBridge.isOllamaRunning && getOllamaPath() == nil { + + if !pythonBridge.isOllamaRunning, getOllamaPath() == nil { return "❌ Ollama service not found - Check installation" } else if !pythonBridge.isOllamaRunning { return "Starting Ollama service..." @@ -2357,7 +2621,10 @@ final class DocumentRedactionViewModel: ObservableObject { // 1. Try to refresh environment status let refreshSuccess = await refreshEnvironmentStatus(triggerFirstRunCheck: false) recoveryAttempts += 1 - DebugLogger.shared.log("Recovery attempt \(recoveryAttempts): Environment refresh - \(refreshSuccess ? "βœ…" : "❌")", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Recovery attempt \(recoveryAttempts): Environment refresh - \(refreshSuccess ? "βœ…" : "❌")", + component: "DocumentRedactionViewModel" + ) if refreshSuccess { return true @@ -2365,7 +2632,10 @@ final class DocumentRedactionViewModel: ObservableObject { // 2. If framework is missing, we can't recover without reinstall if !frameworkAvailable { - DebugLogger.shared.log("❌ Cannot recover - Python framework missing", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Cannot recover - Python framework missing", + component: "DocumentRedactionViewModel" + ) return false } @@ -2382,12 +2652,18 @@ final class DocumentRedactionViewModel: ObservableObject { // Check again await pythonBridge.checkOllamaStatus() - DebugLogger.shared.log("Recovery attempt \(recoveryAttempts): Ollama restart - \(pythonBridge.isOllamaRunning ? "βœ…" : "❌")", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "Recovery attempt \(recoveryAttempts): Ollama restart - \(pythonBridge.isOllamaRunning ? "βœ…" : "❌")", + component: "DocumentRedactionViewModel" + ) } // Final status check let finalStatus = isEnvironmentReady - DebugLogger.shared.log("🏁 Recovery completed - Final status: \(finalStatus ? "βœ… Ready" : "❌ Still not ready")", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "🏁 Recovery completed - Final status: \(finalStatus ? "βœ… Ready" : "❌ Still not ready")", + component: "DocumentRedactionViewModel" + ) return finalStatus } @@ -2419,14 +2695,14 @@ final class DocumentRedactionViewModel: ObservableObject { var lastModelDownloadError: String? { pythonBridge.lastModelDownloadError } - + func checkEnvironment() { pythonBridge.checkEnvironment() Task { @MainActor [weak self] in self?.frameworkAvailable = Bundle.main.path(forResource: "python_launcher", ofType: "sh") != nil } } - + func downloadModel(_ modelName: String, progress: @escaping (Double) -> Void) async -> Bool { let result = await pythonBridge.downloadModel(modelName, progress: progress) if result { @@ -2458,7 +2734,7 @@ final class DocumentRedactionViewModel: ObservableObject { settings.mode == .rules || (hasUsedMetadataScrub && availableModels.isEmpty) } - @discardableResult + @discardableResult func refreshEnvironmentStatus(triggerFirstRunCheck: Bool = true) async -> Bool { DebugLogger.shared.log("=== REFRESHING ENVIRONMENT STATUS ===", component: "DocumentRedactionViewModel") @@ -2467,7 +2743,10 @@ final class DocumentRedactionViewModel: ObservableObject { let initStart = Date() while isPythonInitializing && pythonInitializationError == nil { if Date().timeIntervalSince(initStart) > 30 { - DebugLogger.shared.log("⚠️ Python initialization timeout in refreshEnvironmentStatus", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Python initialization timeout in refreshEnvironmentStatus", + component: "DocumentRedactionViewModel" + ) break } try? await Task.sleep(nanoseconds: 100_000_000) // 100ms @@ -2485,7 +2764,10 @@ final class DocumentRedactionViewModel: ObservableObject { } else if cliScriptAvailable { DebugLogger.shared.log("βœ… CLI subprocess launcher available", component: "DocumentRedactionViewModel") } else { - DebugLogger.shared.log("❌ Neither PythonKit nor CLI launcher available", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "❌ Neither PythonKit nor CLI launcher available", + component: "DocumentRedactionViewModel" + ) } // Check Python bridge with error handling @@ -2497,10 +2779,16 @@ final class DocumentRedactionViewModel: ObservableObject { let ready = isEnvironmentReady let hasSupportedModels = !availableModels.isEmpty - if hasSupportedModels && !availableModels.contains(where: { Self.normalizeModelIdentifier($0) == Self.normalizeModelIdentifier(settings.model) }) { + if hasSupportedModels, + !availableModels + .contains(where: { Self.normalizeModelIdentifier($0) == Self.normalizeModelIdentifier(settings.model) }) + { if let first = availableModels.first { settings.model = first - DebugLogger.shared.log("🎯 Auto-selected available model \(first) as default", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "🎯 Auto-selected available model \(first) as default", + component: "DocumentRedactionViewModel" + ) } } @@ -2510,29 +2798,47 @@ final class DocumentRedactionViewModel: ObservableObject { if triggerFirstRunCheck { if shouldSuppressModelSetupPrompt { shouldShowFirstRunSetup = false - DebugLogger.shared.log("🧽 Metadata-only usage detected with no models; skipping setup prompt", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "🧽 Metadata-only usage detected with no models; skipping setup prompt", + component: "DocumentRedactionViewModel" + ) return ready } if !hasCompletedFirstRun { - if ready && hasSupportedModels { - DebugLogger.shared.log("βœ… Detected configured environment with \(availableModels.count) model(s); auto-completing onboarding", component: "DocumentRedactionViewModel") + if ready, hasSupportedModels { + DebugLogger.shared.log( + "βœ… Detected configured environment with \(availableModels.count) model(s); auto-completing onboarding", + component: "DocumentRedactionViewModel" + ) markFirstRunComplete() shouldShowFirstRunSetup = false } else { firstRunEntryPoint = .onboarding shouldShowFirstRunSetup = true - DebugLogger.shared.log("πŸ”§ Showing first-time setup (onboarding not completed)", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "πŸ”§ Showing first-time setup (onboarding not completed)", + component: "DocumentRedactionViewModel" + ) } } else if !ready { // Provide specific guidance based on what's missing if !frameworkAvailable { - DebugLogger.shared.log("⚠️ Framework missing - prompting setup", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Framework missing - prompting setup", + component: "DocumentRedactionViewModel" + ) firstRunEntryPoint = .onboarding } else if !pythonBridge.isOllamaRunning { - DebugLogger.shared.log("⚠️ Ollama not running - prompting setup", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Ollama not running - prompting setup", + component: "DocumentRedactionViewModel" + ) firstRunEntryPoint = .manageModels } else { - DebugLogger.shared.log("⚠️ Other environment issue - prompting setup", component: "DocumentRedactionViewModel") + DebugLogger.shared.log( + "⚠️ Other environment issue - prompting setup", + component: "DocumentRedactionViewModel" + ) firstRunEntryPoint = .onboarding } shouldShowFirstRunSetup = true @@ -2544,7 +2850,7 @@ final class DocumentRedactionViewModel: ObservableObject { return ready } - + // MARK: - Pre-flight Validation /// Rough multiplier applied to an input document's size to estimate the disk space a full @@ -2563,7 +2869,8 @@ final class DocumentRedactionViewModel: ObservableObject { /// source file's size can't be determined. private func estimatedOutputBytes(for item: DocumentItem, isMetadataOperation: Bool) -> Int64 { guard let attributes = try? FileManager.default.attributesOfItem(atPath: item.url.path), - let sizeNumber = attributes[.size] as? NSNumber else { + let sizeNumber = attributes[.size] as? NSNumber + else { return 0 } let inputBytes = sizeNumber.int64Value @@ -2616,7 +2923,7 @@ final class DocumentRedactionViewModel: ObservableObject { let fallback = jsonURL.deletingPathExtension().appendingPathExtension("html") return FileManager.default.fileExists(atPath: fallback.path) ? fallback : nil } - + /// Search for scrub report file in directory matching document basename /// Handles both naming conventions: "(scrub-report DATE).json" and "_scrub_report.json" /// Find scrub report file in directory matching the document base name. @@ -2627,9 +2934,9 @@ final class DocumentRedactionViewModel: ObservableObject { guard let contents = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return nil } - + func normalizeForMatch(_ value: String) -> String { - return value.lowercased() + value.lowercased() .replacingOccurrences(of: " ", with: "_") .replacingOccurrences(of: "-", with: "_") } @@ -2667,10 +2974,10 @@ final class DocumentRedactionViewModel: ObservableObject { return nil } - } // MARK: - Heartbeat Monitoring (B1 watchdog) + // // Detects a genuinely wedged embedded Python call: one where not even the keepalive signal // Python emits roughly every 3s during a long LLM call (`send_keepalive` in @@ -2740,21 +3047,25 @@ extension DocumentRedactionViewModel { item.errorMessage = Self.processingStalledMessage activeAttemptTokens.removeValue(forKey: item.id) AppDelegate.pythonRunner?.cancelCurrentOperation(source: "heartbeat_watchdog_stall") - DebugLogger.shared.log("❌ Heartbeat watchdog marked \(item.url.lastPathComponent) failed (processing stalled)", component: "HeartbeatWatchdog") + DebugLogger.shared.log( + "❌ Heartbeat watchdog marked \(item.url.lastPathComponent) failed (processing stalled)", + component: "HeartbeatWatchdog" + ) finalizeProcessing(for: item) } } // MARK: - Sleep/Wake Handling (B5) -// -// `PowerAssertionGuard` (held/released via `updateState()` above) prevents *idle* sleep while -// processing, but not a forced lid-close or an explicit Apple-menu "Sleep" -- so processing can -// still be interrupted by a real sleep/wake cycle. The wall-clock time spent asleep counts toward -// the heartbeat watchdog's silence window (`heartbeatTimeout`) even though nothing was actually -// stalled, which would otherwise surface the generic, misleading `processingStalledMessage` a few -// heartbeat-poll cycles after wake. This gets ahead of that with an immediate, wake-specific -// health check so the user gets a clearer signal and, when the engine did survive, doesn't lose -// the run to a false positive. + +/// +/// `PowerAssertionGuard` (held/released via `updateState()` above) prevents *idle* sleep while +/// processing, but not a forced lid-close or an explicit Apple-menu "Sleep" -- so processing can +/// still be interrupted by a real sleep/wake cycle. The wall-clock time spent asleep counts toward +/// the heartbeat watchdog's silence window (`heartbeatTimeout`) even though nothing was actually +/// stalled, which would otherwise surface the generic, misleading `processingStalledMessage` a few +/// heartbeat-poll cycles after wake. This gets ahead of that with an immediate, wake-specific +/// health check so the user gets a clearer signal and, when the engine did survive, doesn't lose +/// the run to a false positive. extension DocumentRedactionViewModel { /// Shown when a document was mid-processing when the Mac slept, and the health check taken /// immediately on wake found the embedded engine unresponsive. @@ -2784,7 +3095,10 @@ extension DocumentRedactionViewModel { for item in processingItems { item.lastHeartbeat = now } - DebugLogger.shared.log("βœ… Wake health check passed; resuming in-flight processing", component: "PowerAssertion") + DebugLogger.shared.log( + "βœ… Wake health check passed; resuming in-flight processing", + component: "PowerAssertion" + ) return } @@ -2800,6 +3114,7 @@ extension DocumentRedactionViewModel { } // MARK: - Progress Mapping + private extension DocumentRedactionViewModel { func applyPythonKitProgress( _ update: PythonRunnerProgressUpdate, @@ -2825,7 +3140,7 @@ private extension DocumentRedactionViewModel { } if let chunkInfo = extractChunkInfo(from: update) { - if item.isMassTrackingActive && stage == .enhancedDetection { + if item.isMassTrackingActive, stage == .enhancedDetection { item.recordHeartbeatOnly(chunkIndex: chunkInfo.chunk, totalChunks: chunkInfo.total) } else { item.recordHeartbeat(chunkIndex: chunkInfo.chunk, totalChunks: chunkInfo.total) @@ -2868,14 +3183,16 @@ private extension DocumentRedactionViewModel { } if let message = update.message, - let match = message.range(of: #"Processing chunk\s+(\d+)\s*/\s*(\d+)"#, options: .regularExpression) { + let match = message.range(of: #"Processing chunk\s+(\d+)\s*/\s*(\d+)"#, options: .regularExpression) + { let substring = message[match] let numbers = substring.replacingOccurrences(of: "Processing chunk", with: "") let parts = numbers.split(separator: "/").map { $0.trimmingCharacters(in: .whitespaces) } if parts.count == 2, let chunk = Int(parts[0]), let total = Int(parts[1]), - total > 0 { + total > 0 + { return (chunk, total) } } @@ -2885,6 +3202,7 @@ private extension DocumentRedactionViewModel { } // MARK: - DOCX Validation + extension DocumentRedactionViewModel { /// Deep validation of DOCX structure to catch corrupt files before processing func validateDocxStructure(at url: URL) async -> Bool { @@ -2893,7 +3211,8 @@ extension DocumentRedactionViewModel { guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? Int64, fileSize > 4, - let handle = try? FileHandle(forReadingFrom: url) else { + let handle = try? FileHandle(forReadingFrom: url) + else { DebugLogger.shared.log("DOCX validation: File too small or unreadable", component: "DocValidation") return false } @@ -2901,7 +3220,8 @@ extension DocumentRedactionViewModel { // Step 2: Check ZIP signature (PK\x03\x04) without loading the whole file guard let header = try? handle.read(upToCount: 4), - header.count == 4 else { + header.count == 4 + else { DebugLogger.shared.log("DOCX validation: Unable to read ZIP signature", component: "DocValidation") return false } @@ -2911,18 +3231,23 @@ extension DocumentRedactionViewModel { return false } - // Step 3: Try to open as ZIP archive and extract entries - do { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { + // Step 3: Try to open as ZIP archive and extract entries + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { // Secure deletion: zero all files before removing to prevent recovery - if let enumerator = FileManager.default.enumerator(at: tempDir, includingPropertiesForKeys: [.isRegularFileKey]) { + if let enumerator = FileManager.default.enumerator( + at: tempDir, + includingPropertiesForKeys: [.isRegularFileKey] + ) { while let fileURL = enumerator.nextObject() as? URL { if let attrs = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), attrs.isRegularFile == true, - let size = try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int, - size > 0 { + let size = try? FileManager.default + .attributesOfItem(atPath: fileURL.path)[.size] as? Int, + size > 0 + { // Overwrite with zeros if let handle = try? FileHandle(forWritingTo: fileURL) { let zeros = Data(repeating: 0, count: size) @@ -2935,21 +3260,24 @@ extension DocumentRedactionViewModel { } try? FileManager.default.removeItem(at: tempDir) } - + // IMPORTANT: In sandboxed apps, Process() cannot access security-scoped URLs directly. // Copy the file to temp directory first so unzip can access it. let tempCopy = tempDir.appendingPathComponent("validate.docx") try FileManager.default.copyItem(at: url, to: tempCopy) - + func runProcess(_ process: Process, timeout: TimeInterval, label: String) -> Bool { do { try process.run() } catch { - DebugLogger.shared.log("DOCX validation: Failed to start \(label): \(error.localizedDescription)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Failed to start \(label): \(error.localizedDescription)", + component: "DocValidation" + ) return false } let deadline = Date().addingTimeInterval(timeout) - while process.isRunning && Date() < deadline { + while process.isRunning, Date() < deadline { Thread.sleep(forTimeInterval: 0.1) } if process.isRunning { @@ -2964,15 +3292,18 @@ extension DocumentRedactionViewModel { // Use unzip to extract and verify integrity let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = ["-t", "-q", tempCopy.path] // Test archive integrity + process.arguments = ["-t", "-q", tempCopy.path] // Test archive integrity process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice guard runProcess(process, timeout: 15, label: "unzip -t") else { - DebugLogger.shared.log("DOCX validation: ZIP archive corrupt (unzip test failed)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: ZIP archive corrupt (unzip test failed)", + component: "DocValidation" + ) return false } - + // Step 3.5: List zip entries for duplicate/relationship validation var zipEntries: [String] = [] let listProcess = Process() @@ -2982,7 +3313,10 @@ extension DocumentRedactionViewModel { listProcess.standardOutput = listPipe listProcess.standardError = FileHandle.nullDevice guard runProcess(listProcess, timeout: 15, label: "unzip -Z") else { - DebugLogger.shared.log("DOCX validation: Failed to list ZIP entries (unzip -Z)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Failed to list ZIP entries (unzip -Z)", + component: "DocValidation" + ) return false } let data = listPipe.fileHandleForReading.readDataToEndOfFile() @@ -3003,8 +3337,8 @@ extension DocumentRedactionViewModel { "_rels/.rels", "word/document.xml", "word/_rels/document.xml.rels", - "\\[Content_Types\\].xml", // Escape brackets for unzip glob pattern - "-d", tempDir.path + "\\[Content_Types\\].xml", // Escape brackets for unzip glob pattern + "-d", tempDir.path, ] extractProcess.standardOutput = FileHandle.nullDevice extractProcess.standardError = FileHandle.nullDevice @@ -3014,13 +3348,12 @@ extension DocumentRedactionViewModel { return false } - // Check required files were extracted let rootRelsPath = tempDir.appendingPathComponent("_rels/.rels") let documentXmlPath = tempDir.appendingPathComponent("word/document.xml") let relsPath = tempDir.appendingPathComponent("word/_rels/document.xml.rels") let contentTypesPath = tempDir.appendingPathComponent("[Content_Types].xml") - + guard FileManager.default.fileExists(atPath: rootRelsPath.path) else { DebugLogger.shared.log("DOCX validation: Missing _rels/.rels", component: "DocValidation") return false @@ -3030,63 +3363,71 @@ extension DocumentRedactionViewModel { DebugLogger.shared.log("DOCX validation: Missing word/document.xml", component: "DocValidation") return false } - + guard FileManager.default.fileExists(atPath: contentTypesPath.path) else { DebugLogger.shared.log("DOCX validation: Missing [Content_Types].xml", component: "DocValidation") return false } guard FileManager.default.fileExists(atPath: relsPath.path) else { - DebugLogger.shared.log("DOCX validation: Missing word/_rels/document.xml.rels", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Missing word/_rels/document.xml.rels", + component: "DocValidation" + ) return false } - + // Step 5: Verify document.xml is valid XML with correct namespace and relationships guard let xmlData = try? Data(contentsOf: documentXmlPath) else { DebugLogger.shared.log("DOCX validation: Cannot read document.xml", component: "DocValidation") return false } - + // Parse XML to verify it's well-formed do { let xmlDoc = try XMLDocument(data: xmlData) - + // Check for WordprocessingML namespace let rootElement = xmlDoc.rootElement() let namespaceURI = rootElement?.uri ?? "" - - guard namespaceURI.contains("wordprocessingml") || - rootElement?.name == "document" else { - DebugLogger.shared.log("DOCX validation: Invalid WordprocessingML namespace: \(namespaceURI)", component: "DocValidation") + + guard namespaceURI.contains("wordprocessingml") || + rootElement?.name == "document" + else { + DebugLogger.shared.log( + "DOCX validation: Invalid WordprocessingML namespace: \(namespaceURI)", + component: "DocValidation" + ) return false } - + // Step 6: Relationship Integrity Check // Corrupt files often have elements referring to missing relationships if FileManager.default.fileExists(atPath: relsPath.path), let relsData = try? Data(contentsOf: relsPath), - let relsDoc = try? XMLDocument(data: relsData) { - + let relsDoc = try? XMLDocument(data: relsData) + { // Collect valid Relationship IDs var validRelIds = Set() if let relsRoot = relsDoc.rootElement() { for child in relsRoot.children ?? [] { if let element = child as? XMLElement, - let id = element.attribute(forName: "Id")?.stringValue { + let id = element.attribute(forName: "Id")?.stringValue + { validRelIds.insert(id) } } } - + // Scan document.xml for relationship references (r:id) // Using XPath to find elements with r:id attributes would be ideal but complex with namespaces // Simple recursive scan var orphanedRels: [String] = [] - + func scanForRels(_ element: XMLElement) { if let attributes = element.attributes { for attr in attributes { - if attr.localName == "id" && (attr.prefix == "r" || attr.name == "r:id") { + if attr.localName == "id", attr.prefix == "r" || attr.name == "r:id" { if let val = attr.stringValue, !validRelIds.contains(val) { orphanedRels.append(val) } @@ -3099,15 +3440,18 @@ extension DocumentRedactionViewModel { } } } - + if let root = xmlDoc.rootElement() { scanForRels(root) } - + if !orphanedRels.isEmpty { - DebugLogger.shared.log("⚠️ DOCX validation warning: Found \(orphanedRels.count) orphaned relationships (e.g., \(orphanedRels.first ?? "?")) - Proceeding despite warnings", component: "DocValidation") + DebugLogger.shared.log( + "⚠️ DOCX validation warning: Found \(orphanedRels.count) orphaned relationships (e.g., \(orphanedRels.first ?? "?")) - Proceeding despite warnings", + component: "DocValidation" + ) // Relaxed validation: Allow files with orphaned relationships to proceed - // return false + // return false } } @@ -3118,14 +3462,19 @@ extension DocumentRedactionViewModel { for name in zipEntries { entryCounts[name, default: 0] += 1 } - let duplicateEntries = Set(entryCounts.filter { $0.value > 1 }.map { $0.key }) + let duplicateEntries = Set(entryCounts.filter { $0.value > 1 }.map(\.key)) if !duplicateEntries.isEmpty { - DebugLogger.shared.log("DOCX validation: Duplicate ZIP entries detected (e.g., \(duplicateEntries.first ?? "?"))", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Duplicate ZIP entries detected (e.g., \(duplicateEntries.first ?? "?"))", + component: "DocValidation" + ) return false } func relsSourceDir(_ relsPath: String) -> String { - if relsPath == "_rels/.rels" { return "" } + if relsPath == "_rels/.rels" { + return "" + } if !relsPath.contains("/_rels/") { return (relsPath as NSString).deletingLastPathComponent } @@ -3140,9 +3489,13 @@ extension DocumentRedactionViewModel { var parts: [String] = [] for raw in path.split(separator: "/") { let part = String(raw) - if part.isEmpty || part == "." { continue } + if part.isEmpty || part == "." { + continue + } if part == ".." { - if !parts.isEmpty { parts.removeLast() } + if !parts.isEmpty { + parts.removeLast() + } continue } parts.append(part) @@ -3162,18 +3515,27 @@ extension DocumentRedactionViewModel { func validateTargets(relsPath: String, relsData: Data) -> Bool { guard let relsDoc = try? XMLDocument(data: relsData), - let relsRoot = relsDoc.rootElement() else { - DebugLogger.shared.log("DOCX validation: Unable to parse \(relsPath)", component: "DocValidation") + let relsRoot = relsDoc.rootElement() + else { + DebugLogger.shared.log( + "DOCX validation: Unable to parse \(relsPath)", + component: "DocValidation" + ) return false } for child in relsRoot.children ?? [] { guard let element = child as? XMLElement else { continue } let targetMode = element.attribute(forName: "TargetMode")?.stringValue ?? "" - if targetMode == "External" { continue } + if targetMode == "External" { + continue + } guard let target = element.attribute(forName: "Target")?.stringValue else { continue } let resolved = resolveTarget(relsPath: relsPath, target: target) if !entrySet.contains(resolved) { - DebugLogger.shared.log("DOCX validation: Missing relationship target \(resolved) from \(relsPath)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Missing relationship target \(resolved) from \(relsPath)", + component: "DocValidation" + ) return false } } @@ -3191,17 +3553,23 @@ extension DocumentRedactionViewModel { } } } - + DebugLogger.shared.log("DOCX validation: Document passes all checks", component: "DocValidation") return true - + } catch { - DebugLogger.shared.log("DOCX validation: XML parsing failed: \(error.localizedDescription)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: XML parsing failed: \(error.localizedDescription)", + component: "DocValidation" + ) return false } - + } catch { - DebugLogger.shared.log("DOCX validation: Process error: \(error.localizedDescription)", component: "DocValidation") + DebugLogger.shared.log( + "DOCX validation: Process error: \(error.localizedDescription)", + component: "DocValidation" + ) return false } }.value @@ -3225,7 +3593,8 @@ extension DocumentRedactionViewModel { func extractWordCount(at url: URL) async -> Int? { await Task.detached(priority: .utility) { () -> Int? in let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - guard (try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)) != nil else { + guard (try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)) != nil + else { return nil } defer { try? FileManager.default.removeItem(at: tempDir) } @@ -3259,7 +3628,8 @@ extension DocumentRedactionViewModel { let documentXmlPath = tempDir.appendingPathComponent("word/document.xml") guard let xmlData = try? Data(contentsOf: documentXmlPath), let xmlDoc = try? XMLDocument(data: xmlData), - let root = xmlDoc.rootElement() else { + let root = xmlDoc.rootElement() + else { return nil } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/ExcludedWordMatcher.swift b/src/swift/MarcutApp/Sources/MarcutApp/ExcludedWordMatcher.swift index 1aa57d7..438fe9e 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/ExcludedWordMatcher.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/ExcludedWordMatcher.swift @@ -126,7 +126,8 @@ enum ExcludedWordMatcher { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let range = NSRange(trimmed.startIndex..., in: trimmed) guard let match = determinerPrefixRegex.firstMatch(in: trimmed, options: [], range: range), - let matchRange = Range(match.range, in: trimmed) else { + let matchRange = Range(match.range, in: trimmed) + else { return trimmed } return String(trimmed[matchRange.upperBound...]) @@ -154,9 +155,9 @@ enum ExcludedWordMatcher { return normalized .replacingOccurrences(of: "\\(s\\)\\s*$", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) - } else if normalized.hasSuffix("ies") && normalized.count > 3 { + } else if normalized.hasSuffix("ies"), normalized.count > 3 { return String(normalized.dropLast(3)) + "y" - } else if normalized.hasSuffix("s") && normalized.count > 1 { + } else if normalized.hasSuffix("s"), normalized.count > 1 { return String(normalized.dropLast()).trimmingTrailingWhitespace() } return nil diff --git a/src/swift/MarcutApp/Sources/MarcutApp/FileAccessCoordinator.swift b/src/swift/MarcutApp/Sources/MarcutApp/FileAccessCoordinator.swift index a0f9fa1..acf74db 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/FileAccessCoordinator.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/FileAccessCoordinator.swift @@ -11,6 +11,7 @@ class FileAccessCoordinator: ObservableObject { private let desktopBookmarkKey = "MarcutApp_DesktopBookmark" // MARK: - Permission State Management + private let permissionVersionKey = "MarcutApp_PermissionVersion" private let onboardingCompletedKey = "MarcutApp_OnboardingCompleted" private let comprehensivePermissionKey = "MarcutApp_ComprehensivePermission" @@ -27,6 +28,7 @@ class FileAccessCoordinator: ObservableObject { private var activeScopedURLs: Set = [] // MARK: - Session Permission Management + private var hasRequestedPermissionsThisSession = false private var sessionPermissionsEstablished = false private let permissionSessionKey = "MarcutApp_PermissionSessionUUID" @@ -35,8 +37,8 @@ class FileAccessCoordinator: ObservableObject { private var downloadsPromptInProgress = false private init() { - initializePermissionState() // Initialize state first - initializeSessionTracking() // Track new app session + initializePermissionState() // Initialize state first + initializeSessionTracking() // Track new app session // DEFERRED: restoreAuthorizedDirectories() // Then restore bookmarks (may update state) } @@ -46,7 +48,10 @@ class FileAccessCoordinator: ObservableObject { private func initializePermissionState() { isOnboardingCompleted = userDefaults.bool(forKey: onboardingCompletedKey) needsPermissionRequest = shouldRequestPermissions() - DebugLogger.shared.log("πŸ” Permission state - Onboarding: \(isOnboardingCompleted), Needs Request: \(needsPermissionRequest)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Permission state - Onboarding: \(isOnboardingCompleted), Needs Request: \(needsPermissionRequest)", + component: "FileAccessCoordinator" + ) } /// Initializes session-based permission tracking to detect new app launches @@ -59,9 +64,15 @@ class FileAccessCoordinator: ObservableObject { hasRequestedPermissionsThisSession = false sessionPermissionsEstablished = false userDefaults.set(currentSessionUUID, forKey: permissionSessionKey) - DebugLogger.shared.log("πŸ†” New app session detected: \(currentSessionUUID)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ†” New app session detected: \(currentSessionUUID)", + component: "FileAccessCoordinator" + ) } else { - DebugLogger.shared.log("πŸ†” Continuing existing session: \(currentSessionUUID)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ†” Continuing existing session: \(currentSessionUUID)", + component: "FileAccessCoordinator" + ) } } @@ -69,7 +80,10 @@ class FileAccessCoordinator: ObservableObject { private func shouldRequestPermissions() -> Bool { // If onboarding never completed, always request guard isOnboardingCompleted else { - DebugLogger.shared.log("πŸ” Onboarding not completed - permission request needed", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Onboarding not completed - permission request needed", + component: "FileAccessCoordinator" + ) return true } @@ -78,13 +92,19 @@ class FileAccessCoordinator: ObservableObject { let storedVersion = userDefaults.string(forKey: permissionVersionKey) if currentVersion != storedVersion { - DebugLogger.shared.log("πŸ” Version mismatch - current: \(currentVersion), stored: \(storedVersion ?? "none")", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Version mismatch - current: \(currentVersion), stored: \(storedVersion ?? "none")", + component: "FileAccessCoordinator" + ) return true } // Check if we have valid bookmarks guard hasValidBookmarks() else { - DebugLogger.shared.log("πŸ” Invalid or missing bookmarks - permission request needed", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Invalid or missing bookmarks - permission request needed", + component: "FileAccessCoordinator" + ) return true } @@ -93,7 +113,7 @@ class FileAccessCoordinator: ObservableObject { /// Gets the current app version private func getCurrentAppVersion() -> String { - return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" + Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" } /// Establish permissions proactively at app startup (optional, can be called once) @@ -101,13 +121,19 @@ class FileAccessCoordinator: ObservableObject { DebugLogger.shared.log("πŸš€ Startup permission establishment check", component: "FileAccessCoordinator") // If permissions are already valid from previous sessions, just mark session as established - if !needsPermissionRequest && (hasValidBookmarks() || hasEntitlementAccess()) { + if !needsPermissionRequest, hasValidBookmarks() || hasEntitlementAccess() { sessionPermissionsEstablished = true - DebugLogger.shared.log("βœ… Startup: Existing permissions are valid, session established", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Startup: Existing permissions are valid, session established", + component: "FileAccessCoordinator" + ) return } - DebugLogger.shared.log("⚠️ Startup: No existing permissions found, will request on first file access", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Startup: No existing permissions found, will request on first file access", + component: "FileAccessCoordinator" + ) } /// Checks if we have valid bookmarks and accessible directories @@ -119,7 +145,7 @@ class FileAccessCoordinator: ObservableObject { let comprehensiveBookmark = userDefaults.data(forKey: comprehensivePermissionKey) let hasAnyBookmark = documentsBookmark != nil || downloadsBookmark != nil || - desktopBookmark != nil || comprehensiveBookmark != nil + desktopBookmark != nil || comprehensiveBookmark != nil guard hasAnyBookmark else { DebugLogger.shared.log("πŸ” No bookmarks found", component: "FileAccessCoordinator") @@ -143,7 +169,10 @@ class FileAccessCoordinator: ObservableObject { } let isValid = accessibleDirectories > 0 - DebugLogger.shared.log("πŸ” Bookmark validation: \(accessibleDirectories) directories accessible", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Bookmark validation: \(accessibleDirectories) directories accessible", + component: "FileAccessCoordinator" + ) return isValid } @@ -157,7 +186,10 @@ class FileAccessCoordinator: ObservableObject { isOnboardingCompleted = true needsPermissionRequest = false - DebugLogger.shared.log("βœ… Onboarding completed for version \(getCurrentAppVersion())", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Onboarding completed for version \(getCurrentAppVersion())", + component: "FileAccessCoordinator" + ) } /// Checks if silent entitlement access works @@ -172,7 +204,10 @@ class FileAccessCoordinator: ObservableObject { // Test read access without file creation let isReadable = fileManager.isReadableFile(atPath: url.path) results[directory] = isReadable - DebugLogger.shared.log("πŸ” Entitlement test for \(directory): \(isReadable ? "βœ… PASS" : "❌ FAIL")", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Entitlement test for \(directory): \(isReadable ? "βœ… PASS" : "❌ FAIL")", + component: "FileAccessCoordinator" + ) } } @@ -190,13 +225,19 @@ class FileAccessCoordinator: ObservableObject { let hasEntitlementAccess = entitlementResults.values.allSatisfy { $0 } if hasEntitlementAccess { - DebugLogger.shared.log("βœ… All directories accessible via enhanced entitlements", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… All directories accessible via enhanced entitlements", + component: "FileAccessCoordinator" + ) await setupFromEntitlements() markOnboardingCompleted() return true } - DebugLogger.shared.log("⚠️ Entitlements insufficient, requesting comprehensive permission", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Entitlements insufficient, requesting comprehensive permission", + component: "FileAccessCoordinator" + ) // Fall back to unified permission request let success = await requestComprehensivePermission() @@ -218,24 +259,33 @@ class FileAccessCoordinator: ObservableObject { } // If permissions are already established for this session, trust them - if sessionPermissionsEstablished && (hasValidBookmarks() || hasEntitlementAccess()) { + if sessionPermissionsEstablished, hasValidBookmarks() || hasEntitlementAccess() { return true } // If we already tried to get permissions this session and failed, don't try again if hasRequestedPermissionsThisSession { - DebugLogger.shared.log("πŸ” Permissions already requested this session - returning cached result", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Permissions already requested this session - returning cached result", + component: "FileAccessCoordinator" + ) return hasValidBookmarks() || hasEntitlementAccess() } // If we already have valid permissions from previous sessions, use them immediately - if !needsPermissionRequest && (hasValidBookmarks() || hasEntitlementAccess()) { + if !needsPermissionRequest, hasValidBookmarks() || hasEntitlementAccess() { sessionPermissionsEstablished = true - DebugLogger.shared.log("βœ… Using existing valid permissions for this session", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Using existing valid permissions for this session", + component: "FileAccessCoordinator" + ) return true } - DebugLogger.shared.log("πŸ” First file access request this session - establishing permissions", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” First file access request this session - establishing permissions", + component: "FileAccessCoordinator" + ) // Mark that we're requesting permissions for this session hasRequestedPermissionsThisSession = true @@ -245,9 +295,15 @@ class FileAccessCoordinator: ObservableObject { if success { sessionPermissionsEstablished = true - DebugLogger.shared.log("βœ… File access permissions established for this session", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… File access permissions established for this session", + component: "FileAccessCoordinator" + ) } else { - DebugLogger.shared.log("❌ File access permissions denied for this session", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ File access permissions denied for this session", + component: "FileAccessCoordinator" + ) } return success @@ -346,24 +402,33 @@ class FileAccessCoordinator: ObservableObject { // Try to derive Downloads and Desktop from the base await deriveRelatedDirectories(from: baseURL) - DebugLogger.shared.log("βœ… Created comprehensive bookmark for base directory: \(baseURL.lastPathComponent)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Created comprehensive bookmark for base directory: \(baseURL.lastPathComponent)", + component: "FileAccessCoordinator" + ) } else { DebugLogger.shared.log("❌ Failed to access comprehensive bookmark", component: "FileAccessCoordinator") } } catch { - DebugLogger.shared.log("❌ Failed to create comprehensive bookmark: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to create comprehensive bookmark: \(error)", + component: "FileAccessCoordinator" + ) } } /// Attempts to derive Downloads and Desktop access from Documents bookmark - private func deriveRelatedDirectories(from baseURL: URL) async { + private func deriveRelatedDirectories(from _: URL) async { let fileManager = FileManager.default // Try to access Downloads directory if let downloadsURL = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first { if fileManager.isReadableFile(atPath: downloadsURL.path) { authorizedDownloadsURL = downloadsURL - DebugLogger.shared.log("βœ… Downloads access derived from comprehensive permission", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Downloads access derived from comprehensive permission", + component: "FileAccessCoordinator" + ) } } @@ -371,7 +436,10 @@ class FileAccessCoordinator: ObservableObject { if let desktopURL = fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first { if fileManager.isReadableFile(atPath: desktopURL.path) { authorizedDesktopURL = desktopURL - DebugLogger.shared.log("βœ… Desktop access derived from comprehensive permission", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Desktop access derived from comprehensive permission", + component: "FileAccessCoordinator" + ) } } } @@ -387,12 +455,18 @@ class FileAccessCoordinator: ObservableObject { if let docsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { do { try fileManager.createDirectory(at: docsURL.appendingPathComponent(".marcut_test"), - withIntermediateDirectories: true) + withIntermediateDirectories: true) try fileManager.removeItem(at: docsURL.appendingPathComponent(".marcut_test")) authorizedDocumentsURL = docsURL - DebugLogger.shared.log("βœ… Documents directory authorized via entitlement", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Documents directory authorized via entitlement", + component: "FileAccessCoordinator" + ) } catch { - DebugLogger.shared.log("⚠️ Documents directory requires explicit authorization", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Documents directory requires explicit authorization", + component: "FileAccessCoordinator" + ) // Fall back to bookmark system await createBookmarkForDirectory(.documentDirectory, key: documentsBookmarkKey) } @@ -403,12 +477,18 @@ class FileAccessCoordinator: ObservableObject { if let downloadsURL = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first { do { try fileManager.createDirectory(at: downloadsURL.appendingPathComponent(".marcut_test"), - withIntermediateDirectories: true) + withIntermediateDirectories: true) try fileManager.removeItem(at: downloadsURL.appendingPathComponent(".marcut_test")) authorizedDownloadsURL = downloadsURL - DebugLogger.shared.log("βœ… Downloads directory authorized via entitlement", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Downloads directory authorized via entitlement", + component: "FileAccessCoordinator" + ) } catch { - DebugLogger.shared.log("⚠️ Downloads directory requires explicit authorization", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Downloads directory requires explicit authorization", + component: "FileAccessCoordinator" + ) await createBookmarkForDirectory(.downloadsDirectory, key: downloadsBookmarkKey) } } @@ -418,12 +498,18 @@ class FileAccessCoordinator: ObservableObject { if let desktopURL = fileManager.urls(for: .desktopDirectory, in: .userDomainMask).first { do { try fileManager.createDirectory(at: desktopURL.appendingPathComponent(".marcut_test"), - withIntermediateDirectories: true) + withIntermediateDirectories: true) try fileManager.removeItem(at: desktopURL.appendingPathComponent(".marcut_test")) authorizedDesktopURL = desktopURL - DebugLogger.shared.log("βœ… Desktop directory authorized via entitlement", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Desktop directory authorized via entitlement", + component: "FileAccessCoordinator" + ) } catch { - DebugLogger.shared.log("⚠️ Desktop directory requires explicit authorization", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Desktop directory requires explicit authorization", + component: "FileAccessCoordinator" + ) await createBookmarkForDirectory(.desktopDirectory, key: desktopBookmarkKey) } } @@ -434,7 +520,8 @@ class FileAccessCoordinator: ObservableObject { private func createBookmarkForDirectory(_ directory: FileManager.SearchPathDirectory, key: String) async -> Bool { guard let url = FileManager.default.urls(for: directory, in: .userDomainMask).first else { return false } - let directoryName = directory == .documentDirectory ? "Documents" : directory == .downloadsDirectory ? "Downloads" : "Desktop" + let directoryName = directory == .documentDirectory ? "Documents" : directory == .downloadsDirectory ? + "Downloads" : "Desktop" let selectedURL = await promptForDirectoryAccess( message: "Authorize access to \(directoryName) folder", prompt: "Authorize", @@ -468,7 +555,10 @@ class FileAccessCoordinator: ObservableObject { } return success } catch { - DebugLogger.shared.log("❌ Failed to create bookmark for \(directory): \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to create bookmark for \(directory): \(error)", + component: "FileAccessCoordinator" + ) return false } } @@ -512,7 +602,10 @@ class FileAccessCoordinator: ObservableObject { let newState = shouldRequestPermissions() if needsPermissionRequest != newState { needsPermissionRequest = newState - DebugLogger.shared.log("πŸ” Permission state updated after restoration: needsRequest=\(newState)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ” Permission state updated after restoration: needsRequest=\(newState)", + component: "FileAccessCoordinator" + ) } } @@ -521,9 +614,9 @@ class FileAccessCoordinator: ObservableObject { do { var isStale = false let url = try URL(resolvingBookmarkData: bookmark, - options: .withSecurityScope, - relativeTo: nil, - bookmarkDataIsStale: &isStale) + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale) guard !isStale else { userDefaults.removeObject(forKey: comprehensivePermissionKey) @@ -540,13 +633,19 @@ class FileAccessCoordinator: ObservableObject { await deriveRelatedDirectories(from: url) } - DebugLogger.shared.log("βœ… Restored comprehensive permission from bookmark: \(url.lastPathComponent)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Restored comprehensive permission from bookmark: \(url.lastPathComponent)", + component: "FileAccessCoordinator" + ) return true } DebugLogger.shared.log("❌ Failed to access comprehensive bookmark", component: "FileAccessCoordinator") return false } catch { - DebugLogger.shared.log("❌ Failed to restore comprehensive bookmark: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to restore comprehensive bookmark: \(error)", + component: "FileAccessCoordinator" + ) // Remove invalid bookmark userDefaults.removeObject(forKey: comprehensivePermissionKey) return false @@ -560,9 +659,9 @@ class FileAccessCoordinator: ObservableObject { do { var isStale = false let url = try URL(resolvingBookmarkData: bookmark, - options: .withSecurityScope, - relativeTo: nil, - bookmarkDataIsStale: &isStale) + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale) guard !isStale else { userDefaults.removeObject(forKey: key) @@ -572,10 +671,16 @@ class FileAccessCoordinator: ObservableObject { let success = startAccessingScopedResource(url) if success { completion(url) - DebugLogger.shared.log("βœ… Restored directory access from bookmark: \(url.lastPathComponent)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Restored directory access from bookmark: \(url.lastPathComponent)", + component: "FileAccessCoordinator" + ) } } catch { - DebugLogger.shared.log("❌ Failed to restore directory from bookmark: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to restore directory from bookmark: \(error)", + component: "FileAccessCoordinator" + ) } } @@ -606,7 +711,10 @@ class FileAccessCoordinator: ObservableObject { do { try fm.createDirectory(at: container, withIntermediateDirectories: true) } catch { - DebugLogger.shared.log("❌ Failed to prepare Application Support container: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to prepare Application Support container: \(error)", + component: "FileAccessCoordinator" + ) } return container } @@ -635,7 +743,10 @@ class FileAccessCoordinator: ObservableObject { let localCopy = processingDir.appendingPathComponent(uniqueName) try FileManager.default.copyItem(at: originalURL, to: localCopy) - DebugLogger.shared.log("βœ… Copied file to app support container: \(originalURL.lastPathComponent)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Copied file to app support container: \(originalURL.lastPathComponent)", + component: "FileAccessCoordinator" + ) return localCopy } @@ -658,7 +769,10 @@ class FileAccessCoordinator: ObservableObject { } try FileManager.default.copyItem(at: processedURL, to: userURL) - DebugLogger.shared.log("βœ… Copied processed file to user location: \(userURL.path)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Copied processed file to user location: \(userURL.path)", + component: "FileAccessCoordinator" + ) } /// Cleans up temporary files from the Application Support container @@ -672,10 +786,16 @@ class FileAccessCoordinator: ObservableObject { do { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) - DebugLogger.shared.log("🧹 Cleaned up directory: \(url.lastPathComponent)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "🧹 Cleaned up directory: \(url.lastPathComponent)", + component: "FileAccessCoordinator" + ) } } catch { - DebugLogger.shared.log("⚠️ Failed to cleanup directory \(url.lastPathComponent): \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Failed to cleanup directory \(url.lastPathComponent): \(error)", + component: "FileAccessCoordinator" + ) } } @@ -696,7 +816,10 @@ class FileAccessCoordinator: ObservableObject { try fm.createDirectory(at: cacheURL, withIntermediateDirectories: true) try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: cacheURL.path) } catch { - DebugLogger.shared.log("❌ Failed to prepare metadata report cache: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Failed to prepare metadata report cache: \(error)", + component: "FileAccessCoordinator" + ) return nil } } @@ -705,7 +828,7 @@ class FileAccessCoordinator: ObservableObject { /// Location for temporary metadata reports (cleared on app open/close). func metadataReportCacheDirectory() -> URL? { - return metadataReportCacheURL(createIfMissing: true) + metadataReportCacheURL(createIfMissing: true) } /// Clear temporary metadata reports from the app cache. @@ -715,9 +838,15 @@ class FileAccessCoordinator: ObservableObject { guard fm.fileExists(atPath: cacheURL.path) else { return } do { try fm.removeItem(at: cacheURL) - DebugLogger.shared.log("🧹 Cleared metadata report cache: \(cacheURL.path)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "🧹 Cleared metadata report cache: \(cacheURL.path)", + component: "FileAccessCoordinator" + ) } catch { - DebugLogger.shared.log("⚠️ Failed to clear metadata report cache: \(error)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "⚠️ Failed to clear metadata report cache: \(error)", + component: "FileAccessCoordinator" + ) } } @@ -732,8 +861,13 @@ class FileAccessCoordinator: ObservableObject { ] for dir in candidateDirs where fm.fileExists(atPath: dir.path) { - if let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]), - !contents.isEmpty { + if let contents = try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ), + !contents.isEmpty + { return true } } @@ -745,7 +879,8 @@ class FileAccessCoordinator: ObservableObject { func downloadsDirectoryForReports() -> URL? { restoreDownloadsBookmarkIfNeeded() guard let downloadsURL = authorizedDownloadsURL, - FileManager.default.isWritableFile(atPath: downloadsURL.path) else { + FileManager.default.isWritableFile(atPath: downloadsURL.path) + else { return nil } return downloadsURL @@ -854,7 +989,10 @@ class FileAccessCoordinator: ObservableObject { } try FileManager.default.copyItem(at: source, to: destination) - DebugLogger.shared.log("βœ… Copied file: \(source.path) -> \(destination.path)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Copied file: \(source.path) -> \(destination.path)", + component: "FileAccessCoordinator" + ) } /// Safe file move within authorized directories @@ -868,7 +1006,10 @@ class FileAccessCoordinator: ObservableObject { } try FileManager.default.moveItem(at: source, to: destination) - DebugLogger.shared.log("βœ… Moved file: \(source.path) -> \(destination.path)", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "βœ… Moved file: \(source.path) -> \(destination.path)", + component: "FileAccessCoordinator" + ) } /// Safe file removal within authorized directories @@ -885,7 +1026,10 @@ class FileAccessCoordinator: ObservableObject { func findDocxFiles() async -> [URL] { // Ensure we have permissions before searching guard await ensurePermissionsForFileAccess() else { - DebugLogger.shared.log("❌ Cannot find DOCX files - permissions not available", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "❌ Cannot find DOCX files - permissions not available", + component: "FileAccessCoordinator" + ) return [] } @@ -905,7 +1049,10 @@ class FileAccessCoordinator: ObservableObject { } } - DebugLogger.shared.log("πŸ“„ Found \(docxFiles.count) DOCX files in authorized directories", component: "FileAccessCoordinator") + DebugLogger.shared.log( + "πŸ“„ Found \(docxFiles.count) DOCX files in authorized directories", + component: "FileAccessCoordinator" + ) return docxFiles } } @@ -916,10 +1063,10 @@ enum FileAccessError: LocalizedError { var errorDescription: String? { switch self { - case .unauthorizedLocation(let path): - return "Access to location not authorized: \(path)" + case let .unauthorizedLocation(path): + "Access to location not authorized: \(path)" case .appSupportContainerUnavailable: - return "Application Support container is not available. Please check your app configuration." + "Application Support container is not available. Please check your app configuration." } } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/HelpHTMLView.swift b/src/swift/MarcutApp/Sources/MarcutApp/HelpHTMLView.swift index dd3ce71..c44fc7a 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/HelpHTMLView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/HelpHTMLView.swift @@ -19,7 +19,7 @@ struct HelpHTMLView: NSViewRepresentable { return webView } - func updateNSView(_ webView: WKWebView, context: Context) { + func updateNSView(_: WKWebView, context: Context) { if context.coordinator.lastHTML != htmlContent { context.coordinator.load(html: htmlContent, baseURL: Bundle.main.resourceURL) } @@ -55,13 +55,17 @@ struct HelpHTMLView: NSViewRepresentable { pendingAnchor = nil } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + func webView(_: WKWebView, didFinish _: WKNavigation!) { if let anchor = pendingAnchor { scrollTo(anchor: anchor) } } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { guard let url = navigationAction.request.url else { decisionHandler(.allow) return diff --git a/src/swift/MarcutApp/Sources/MarcutApp/LaunchDiagnostics.swift b/src/swift/MarcutApp/Sources/MarcutApp/LaunchDiagnostics.swift index 716a9ad..6691ed2 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/LaunchDiagnostics.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/LaunchDiagnostics.swift @@ -1,6 +1,6 @@ -import SwiftUI import AppKit import OSLog +import SwiftUI enum LaunchStage: String { case delegateLaunched = "delegate_launched" @@ -57,7 +57,7 @@ final class LaunchDiagnostics: ObservableObject { DispatchQueue.main.async { self.statusLines.append(message) LaunchDiagnosticsWindow.shared.update(statusLines: self.statusLines) - if self.forceDiagnosticWindow && self.guiModeEnabled { + if self.forceDiagnosticWindow, self.guiModeEnabled { LaunchDiagnosticsWindow.shared.present(reason: "Force flag") } } @@ -112,7 +112,7 @@ final class LaunchDiagnostics: ObservableObject { func activationDidChange(isActive: Bool) { guard diagnosticsEnabled else { return } mark(.activationStateChanged, extra: "active=\(isActive)") - if guiModeEnabled && isActive && !contentViewConfirmed { + if guiModeEnabled, isActive, !contentViewConfirmed { LaunchDiagnosticsWindow.shared.present(reason: "Activation changed") } } @@ -159,7 +159,7 @@ final class LaunchDiagnosticsWindow { } } - func update(statusLines: [String]) { + func update(statusLines _: [String]) { DispatchQueue.main.async { if let hosting = self.window?.contentView as? NSHostingView { hosting.rootView = LaunchDiagnosticsPanel(statusProvider: LaunchDiagnostics.shared) diff --git a/src/swift/MarcutApp/Sources/MarcutApp/LogViewerSheet.swift b/src/swift/MarcutApp/Sources/MarcutApp/LogViewerSheet.swift index 19b7329..236dff4 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/LogViewerSheet.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/LogViewerSheet.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI /// Sheet that lets users view app log files in-app instead of navigating Finder to /// `~/Library/Application Support/MarcutApp/logs`. Shows the most recently modified log by @@ -52,7 +52,6 @@ struct LogViewerSheet: View { } } - @ViewBuilder private var emptyState: some View { VStack(spacing: 12) { Spacer() diff --git a/src/swift/MarcutApp/Sources/MarcutApp/Main.swift b/src/swift/MarcutApp/Sources/MarcutApp/Main.swift index 0180abd..5846095 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/Main.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/Main.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI @main struct MarcutMain { @@ -17,13 +17,13 @@ struct MarcutMain { LaunchDiagnostics.shared.configure(forceDiagnosticWindow: forceDiagnosticsWindow) LaunchDiagnostics.shared.mark(.delegateLaunched, extra: "args=\(redactedLaunchArguments(args))") - let cliFlags: Set = [ + let cliFlags: Set = [ "--cli", "--test", "--diagnose", "--help", "--redact", - "--download-model" + "--download-model", ] let isCLIMode = args.contains { cliFlags.contains($0) } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/MarcutApp.swift b/src/swift/MarcutApp/Sources/MarcutApp/MarcutApp.swift index 8d0750a..15a4180 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/MarcutApp.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/MarcutApp.swift @@ -1,14 +1,14 @@ -import SwiftUI import AppKit -import OSLog import Foundation +import OSLog import PythonKit +import SwiftUI func redactedLaunchArguments(_ args: [String]) -> [String] { - let pathValuedFlags: Set = [ + let pathValuedFlags: Set = [ "--in", "--out", "--outdir", "--report", "--metadata-report", "--metadata-settings-json", "--metadata-args", "--llama-gguf", - "--format-schema", "--log" + "--format-schema", "--log", ] var redacted: [String] = [] var redactNext = false @@ -40,7 +40,7 @@ func redactedLaunchArguments(_ args: [String]) -> [String] { return redacted } -// App Delegate for proper menu handling +/// App Delegate for proper menu handling class AppDelegate: NSObject, NSApplicationDelegate { // Global Python runtime - initialized once at app launch static var pythonRunner: PythonKitRunner? @@ -49,7 +49,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private let pythonInitStateQueue = DispatchQueue(label: "com.marclaw.marcutapp.pythonInitState") @MainActor - func applicationDidFinishLaunching(_ notification: Notification) { + func applicationDidFinishLaunching(_: Notification) { if DebugPreferences.isEnabled() { // Enable SwiftUI debugging for AttributeGraph cycles enableSwiftUIDebugging() @@ -71,7 +71,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { registerActivationObservers() FileAccessCoordinator.shared.clearMetadataReportCache() - if args.contains("--cli") || args.contains("--test") || args.contains("--diagnose") || args.contains("--help") || args.contains("--redact") || args.contains("--diag-launcher") { + if args.contains("--cli") || args.contains("--test") || args.contains("--diagnose") || args + .contains("--help") || args.contains("--redact") || args.contains("--diag-launcher") + { // Run in CLI mode logToFile("Running in CLI mode") NSApp.setActivationPolicy(.accessory) @@ -105,23 +107,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Establish permissions proactively at startup to prevent repeated dialogs // DEFERRED: We now lazy-load permissions on first use to avoid startup prompts - /*Task { - await FileAccessCoordinator.shared.establishPermissionsAtStartup() - }*/ + /* Task { + await FileAccessCoordinator.shared.establishPermissionsAtStartup() + } */ } } - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return !ProcessingState.isProcessing + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + !ProcessingState.isProcessing } - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { guard FileAccessCoordinator.shared.hasUnsavedReportFiles() else { return .terminateNow } let defaults = UserDefaults.standard - let behavior = UnsavedReportQuitBehavior(rawValue: defaults.integer(forKey: DefaultsKey.unsavedReportQuitBehavior.key)) ?? .warn + let behavior = UnsavedReportQuitBehavior(rawValue: defaults + .integer(forKey: DefaultsKey.unsavedReportQuitBehavior.key)) ?? .warn switch behavior { case .alwaysQuit: return .terminateNow @@ -137,7 +140,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { alert.addButton(withTitle: "Always Quit") let response = alert.runModal() if response == .alertThirdButtonReturn { - defaults.set(UnsavedReportQuitBehavior.alwaysQuit.rawValue, forKey: DefaultsKey.unsavedReportQuitBehavior.key) + defaults.set( + UnsavedReportQuitBehavior.alwaysQuit.rawValue, + forKey: DefaultsKey.unsavedReportQuitBehavior.key + ) return .terminateNow } if response == .alertSecondButtonReturn { @@ -148,7 +154,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @MainActor - func applicationWillTerminate(_ notification: Notification) { + func applicationWillTerminate(_: Notification) { // Clean up any running Ollama processes // This will be handled by PythonBridgeService deinit activationObservers.forEach { NotificationCenter.default.removeObserver($0) } @@ -180,7 +186,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { DebugLogger.shared.log(msg, component: "PythonRuntime") print("PythonRuntime: \(msg)") }) - + // Update shared state on main thread DispatchQueue.main.async { AppDelegate.pythonRunner = runner @@ -239,13 +245,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { let timestamp = formatter.string(from: Date()) let pid = getpid() let banner = """ -========================================================== -πŸͺͺ MarcutApp launch -πŸ•’ \(timestamp) -πŸ”§ PID: \(pid) -🚩 Args: \(args.joined(separator: " ")) -========================================================== -""" + ========================================================== + πŸͺͺ MarcutApp launch + πŸ•’ \(timestamp) + πŸ”§ PID: \(pid) + 🚩 Args: \(args.joined(separator: " ")) + ========================================================== + """ print(banner) DebugLogger.shared.log(banner, component: "AppDelegate") } @@ -300,8 +306,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let initStart = Date() while AppDelegate.pythonRunner == nil { if Date().timeIntervalSince(initStart) > 30 { - print("❌ Python integration timed out") - return false + print("❌ Python integration timed out") + return false } try? await Task.sleep(nanoseconds: 100_000_000) // 100ms } @@ -363,13 +369,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - if let outStr = String(data: outData, encoding: .utf8), !outStr.isEmpty { print(outStr) } - if let errStr = String(data: errData, encoding: .utf8), !errStr.isEmpty { fputs(errStr, stderr) } + if let outStr = String(data: outData, encoding: .utf8), !outStr.isEmpty { + print(outStr) + } + if let errStr = String(data: errData, encoding: .utf8), !errStr.isEmpty { + fputs(errStr, stderr) + } let ok = (process.terminationStatus == 0) - print(ok ? "βœ… CLI launcher verified" : "❌ CLI launcher returned non-zero exit code \(process.terminationStatus)") + print(ok ? "βœ… CLI launcher verified" : + "❌ CLI launcher returned non-zero exit code \(process.terminationStatus)") return ok } else if args.contains("--redact") { - // Headless redact: --redact --in [--out ] [--report ] [--outdir ] [--mode rules|enhanced|rules_override|constrained_overrides|llm_overrides] [--model name] [--llm-skip-confidence ] + // Headless redact: --redact --in [--out ] [--report ] [--outdir ] [--mode + // rules|enhanced|rules_override|constrained_overrides|llm_overrides] [--model name] [--llm-skip-confidence + // ] guard let inIdx = args.firstIndex(of: "--in"), inIdx + 1 < args.count else { print("❌ Missing --in argument") printCLIHelp() @@ -378,24 +391,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { let inPath = args[inIdx + 1] // Optional exact output/report paths let outPathOpt: String? = { - if let idx = args.firstIndex(of: "--out"), idx + 1 < args.count { return args[idx + 1] } + if let idx = args.firstIndex(of: "--out"), idx + 1 < args.count { + return args[idx + 1] + } return nil }() let reportPathOpt: String? = { - if let idx = args.firstIndex(of: "--report"), idx + 1 < args.count { return args[idx + 1] } + if let idx = args.firstIndex(of: "--report"), idx + 1 < args.count { + return args[idx + 1] + } return nil }() // Optional outdir (fallback if exact paths not provided) let outDirPath: String? = { - if let idx = args.firstIndex(of: "--outdir"), idx + 1 < args.count { return args[idx + 1] } + if let idx = args.firstIndex(of: "--outdir"), idx + 1 < args.count { + return args[idx + 1] + } return nil }() let mode: String = { - if let mIdx = args.firstIndex(of: "--mode"), mIdx + 1 < args.count { return args[mIdx + 1] } + if let mIdx = args.firstIndex(of: "--mode"), mIdx + 1 < args.count { + return args[mIdx + 1] + } return "enhanced" }() let modelName: String = { - if let mIdx = args.firstIndex(of: "--model"), mIdx + 1 < args.count { return args[mIdx + 1] } + if let mIdx = args.firstIndex(of: "--model"), mIdx + 1 < args.count { + return args[mIdx + 1] + } return ModelCatalog.shared.defaultModelId }() let llmSkipConfidence: Double = { @@ -416,7 +439,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let outExact = outPathOpt, let repExact = reportPathOpt { outputPath = outExact reportPath = repExact - } else if let outDirPath = outDirPath { + } else if let outDirPath { let outDir = URL(fileURLWithPath: outDirPath, isDirectory: true) let outputFileName = inputURL.deletingPathExtension().lastPathComponent + "_redacted.docx" let reportFileName = inputURL.deletingPathExtension().lastPathComponent + "_report.json" @@ -488,7 +511,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { return ok } else if args.contains("--download-model") { if let modelIndex = args.firstIndex(of: "--download-model"), - modelIndex + 1 < args.count { + modelIndex + 1 < args.count + { let model = args[modelIndex + 1] print("\nπŸ“₯ Downloading model: \(model)") await downloadModelWithPythonKit(model: model) @@ -561,7 +585,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @MainActor - func runTests(bridge: PythonBridgeService) async { + func runTests(bridge _: PythonBridgeService) async { // Add comprehensive tests here print("Running test suite...") } @@ -615,11 +639,11 @@ struct MarcutAppScene: App { var body: some Scene { #if arch(arm64) - marcutAppScene + marcutAppScene #else - WindowGroup("Unsupported Architecture") { - UnsupportedArchitectureView() - } + WindowGroup("Unsupported Architecture") { + UnsupportedArchitectureView() + } #endif } @@ -663,7 +687,6 @@ struct MarcutAppScene: App { // Tools menu removed - // Help menu CommandGroup(replacing: .help) { Button("MarcutApp Help") { @@ -696,7 +719,6 @@ struct MarcutAppScene: App { private func applyAppTheme() { NSApp.appearance = appTheme.appearance } - } enum LifecycleUtils { @@ -822,10 +844,10 @@ struct HelpView: View { self.initialAnchor = initialAnchor self.resourceName = resourceName } - + var body: some View { VStack(alignment: .leading, spacing: 0) { - if htmlContent.isEmpty && markdownContent.isEmpty { + if htmlContent.isEmpty, markdownContent.isEmpty { ProgressView("Loading help...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !htmlContent.isEmpty { @@ -833,7 +855,7 @@ struct HelpView: View { } else { MarkdownTextView(markdownContent: markdownContent, scrollToAnchor: $pendingAnchor) } - + HStack { Spacer() Button("Close") { @@ -857,7 +879,7 @@ struct HelpView: View { } } } - + private func loadHelpContent() { if let html = loadBundledResource(extension: "html") { htmlContent = html @@ -886,7 +908,8 @@ struct HelpView: View { var bundles: [Bundle] = [Bundle.main] if let resourceBundleURL = Bundle.main.resourceURL? .appendingPathComponent("MarcutApp_MarcutApp.bundle"), - let resourceBundle = Bundle(url: resourceBundleURL) { + let resourceBundle = Bundle(url: resourceBundleURL) + { bundles.append(resourceBundle) } @@ -894,11 +917,13 @@ struct HelpView: View { for bundle in bundles { for name in names { if let url = bundle.url(forResource: name, withExtension: ext), - let content = try? String(contentsOf: url, encoding: .utf8) { + let content = try? String(contentsOf: url, encoding: .utf8) + { return content } if let url = bundle.url(forResource: name, withExtension: ext, subdirectory: "Resources"), - let content = try? String(contentsOf: url, encoding: .utf8) { + let content = try? String(contentsOf: url, encoding: .utf8) + { return content } } @@ -908,7 +933,8 @@ struct HelpView: View { for possibleURL in fallbackPaths { guard let url = possibleURL else { continue } if FileManager.default.fileExists(atPath: url.path), - let content = try? String(contentsOf: url, encoding: .utf8) { + let content = try? String(contentsOf: url, encoding: .utf8) + { return content } } @@ -918,7 +944,7 @@ struct HelpView: View { private func resourceNameCandidates(for ext: String) -> [String] { var names = [resourceName] - if resourceName == "help" && ext.lowercased() == "md" { + if resourceName == "help", ext.lowercased() == "md" { names.append("Help") } return names @@ -943,11 +969,8 @@ struct HelpView: View { } return paths } - } - - extension Notification.Name { static let pythonRunnerReady = Notification.Name("com.marclaw.marcutapp.pythonRunnerReady") static let pythonRunnerFailed = Notification.Name("com.marclaw.marcutapp.pythonRunnerFailed") diff --git a/src/swift/MarcutApp/Sources/MarcutApp/MarkdownTextView.swift b/src/swift/MarcutApp/Sources/MarcutApp/MarkdownTextView.swift index 1c9ef66..7ad3fba 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/MarkdownTextView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/MarkdownTextView.swift @@ -1,5 +1,5 @@ -import SwiftUI import AppKit +import SwiftUI /// Custom attribute key for anchor IDs private extension NSAttributedString.Key { @@ -10,7 +10,7 @@ private extension NSAttributedString.Key { struct MarkdownTextView: NSViewRepresentable { let markdownContent: String @Binding var scrollToAnchor: String? - + func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true @@ -18,29 +18,32 @@ struct MarkdownTextView: NSViewRepresentable { scrollView.autohidesScrollers = true scrollView.borderType = .noBorder scrollView.drawsBackground = false - + let textView = NSTextView() textView.isEditable = false textView.isSelectable = true textView.drawsBackground = false textView.textContainerInset = NSSize(width: 16, height: 16) - textView.isAutomaticLinkDetectionEnabled = false // We handle links manually + textView.isAutomaticLinkDetectionEnabled = false // We handle links manually textView.delegate = context.coordinator - + // Enable Find panel (Cmd+F) textView.isIncrementalSearchingEnabled = true textView.usesFindPanel = true textView.usesFindBar = true // Potentially fixes in-window search - + // Set up text container for proper wrapping textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.autoresizingMask = [.width] - + scrollView.documentView = textView - + // Set the attributed string content let anchorPositions = updateContent(textView: textView) context.coordinator.anchorPositions = anchorPositions @@ -52,10 +55,10 @@ struct MarkdownTextView: NSViewRepresentable { scrollToAnchor = nil } } - + return scrollView } - + func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? NSTextView else { return } let anchorPositions = updateContent(textView: textView) @@ -69,10 +72,10 @@ struct MarkdownTextView: NSViewRepresentable { } } } - + /// Generate an anchor ID from header text (GitHub-style slugification) private func generateAnchorID(from text: String) -> String { - return text + text .lowercased() .replacingOccurrences(of: " ", with: "-") .replacingOccurrences(of: "(", with: "") @@ -82,34 +85,34 @@ struct MarkdownTextView: NSViewRepresentable { .replacingOccurrences(of: "'", with: "") .replacingOccurrences(of: "\"", with: "") } - + @discardableResult private func updateContent(textView: NSTextView) -> [String: Int] { // Build styled attributed string from markdown let result = NSMutableAttributedString() var anchorPositions: [String: Int] = [:] - + let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 4 paragraphStyle.paragraphSpacing = 8 - + let baseFont = NSFont.systemFont(ofSize: 14) let boldFont = NSFont.boldSystemFont(ofSize: 14) let h1Font = NSFont.boldSystemFont(ofSize: 24) let h2Font = NSFont.boldSystemFont(ofSize: 20) let h3Font = NSFont.boldSystemFont(ofSize: 16) let codeFont = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - + let baseAttributes: [NSAttributedString.Key: Any] = [ .font: baseFont, .foregroundColor: NSColor.textColor, - .paragraphStyle: paragraphStyle + .paragraphStyle: paragraphStyle, ] - + let lines = markdownContent.components(separatedBy: "\n") var inCodeBlock = false var codeBlockContent = "" - + for (index, line) in lines.enumerated() { // Handle code blocks if line.hasPrefix("```") { @@ -121,7 +124,7 @@ struct MarkdownTextView: NSViewRepresentable { .font: codeFont, .foregroundColor: NSColor.textColor, .backgroundColor: NSColor.textBackgroundColor.withAlphaComponent(0.3), - .paragraphStyle: codeStyle + .paragraphStyle: codeStyle, ] result.append(NSAttributedString(string: codeBlockContent + "\n\n", attributes: codeAttrs)) codeBlockContent = "" @@ -131,18 +134,18 @@ struct MarkdownTextView: NSViewRepresentable { } continue } - + if inCodeBlock { codeBlockContent += line + "\n" continue } - + // Handle headers - record anchor positions if line.hasPrefix("# ") { let text = String(line.dropFirst(2)) let anchorID = generateAnchorID(from: text) anchorPositions[anchorID] = result.length - + var attrs = baseAttributes attrs[.font] = h1Font attrs[.anchorID] = anchorID @@ -151,7 +154,7 @@ struct MarkdownTextView: NSViewRepresentable { let text = String(line.dropFirst(3)) let anchorID = generateAnchorID(from: text) anchorPositions[anchorID] = result.length - + var attrs = baseAttributes attrs[.font] = h2Font attrs[.anchorID] = anchorID @@ -160,7 +163,7 @@ struct MarkdownTextView: NSViewRepresentable { let text = String(line.dropFirst(4)) let anchorID = generateAnchorID(from: text) anchorPositions[anchorID] = result.length - + var attrs = baseAttributes attrs[.font] = h3Font attrs[.anchorID] = anchorID @@ -168,7 +171,12 @@ struct MarkdownTextView: NSViewRepresentable { } else if line.hasPrefix("- ") { // Bullet list - process inline markdown for the content let bulletContent = String(line.dropFirst(2)) - let processed = processInlineMarkdown(bulletContent, baseAttributes: baseAttributes, boldFont: boldFont, codeFont: codeFont) + let processed = processInlineMarkdown( + bulletContent, + baseAttributes: baseAttributes, + boldFont: boldFont, + codeFont: codeFont + ) let bulletResult = NSMutableAttributedString(string: "β€’ ", attributes: baseAttributes) bulletResult.append(processed) bulletResult.append(NSAttributedString(string: "\n", attributes: baseAttributes)) @@ -177,7 +185,12 @@ struct MarkdownTextView: NSViewRepresentable { result.append(NSAttributedString(string: "\n", attributes: baseAttributes)) } else { // Regular paragraph - process inline markdown - let processed = processInlineMarkdown(line, baseAttributes: baseAttributes, boldFont: boldFont, codeFont: codeFont) + let processed = processInlineMarkdown( + line, + baseAttributes: baseAttributes, + boldFont: boldFont, + codeFont: codeFont + ) result.append(processed) // Add newline unless next line continues paragraph if index < lines.count - 1 { @@ -185,14 +198,19 @@ struct MarkdownTextView: NSViewRepresentable { } } } - + textView.textStorage?.setAttributedString(result) return anchorPositions } - - private func processInlineMarkdown(_ text: String, baseAttributes: [NSAttributedString.Key: Any], boldFont: NSFont, codeFont: NSFont) -> NSAttributedString { + + private func processInlineMarkdown( + _ text: String, + baseAttributes: [NSAttributedString.Key: Any], + boldFont: NSFont, + codeFont: NSFont + ) -> NSAttributedString { let result = NSMutableAttributedString(string: text, attributes: baseAttributes) - + // Process markdown links: [text](url) - including anchor links [text](#anchor) let linkPattern = #"\[([^\]]+)\]\(([^)]+)\)"# if let regex = try? NSRegularExpression(pattern: linkPattern) { @@ -203,14 +221,14 @@ struct MarkdownTextView: NSViewRepresentable { let textRange = Range(match.range(at: 1), in: text), let urlRange = Range(match.range(at: 2), in: text), let fullRange = Range(match.range, in: text) else { continue } - + let linkText = String(text[textRange]) let urlString = String(text[urlRange]) - + var linkAttrs = baseAttributes linkAttrs[.foregroundColor] = NSColor.linkColor linkAttrs[.underlineStyle] = NSUnderlineStyle.single.rawValue - + // Handle anchor links (#section-name) vs external URLs if urlString.hasPrefix("#") { // Internal anchor link @@ -218,12 +236,12 @@ struct MarkdownTextView: NSViewRepresentable { } else if let url = URL(string: urlString) { linkAttrs[.link] = url } - + let replacement = NSAttributedString(string: linkText, attributes: linkAttrs) result.replaceCharacters(in: NSRange(fullRange, in: text), with: replacement) } } - + // Process plain URLs (https:// or http://) - but only in current text, not modified result let urlPattern = #"https?://[^\s\)\]\>]+"# if let regex = try? NSRegularExpression(pattern: urlPattern) { @@ -233,7 +251,7 @@ struct MarkdownTextView: NSViewRepresentable { for match in matches.reversed() { guard let range = Range(match.range, in: currentText) else { continue } let urlString = String(currentText[range]) - + // Check if this URL is already a link (to avoid double-processing) var isAlreadyLink = false result.enumerateAttribute(.link, in: match.range, options: []) { value, _, stop in @@ -242,18 +260,18 @@ struct MarkdownTextView: NSViewRepresentable { stop.pointee = true } } - + if !isAlreadyLink, let url = URL(string: urlString) { var linkAttrs = baseAttributes linkAttrs[.link] = url linkAttrs[.foregroundColor] = NSColor.linkColor linkAttrs[.underlineStyle] = NSUnderlineStyle.single.rawValue - + result.setAttributes(linkAttrs, range: match.range) } } } - + // Process bold: **text** or __text__ let boldPattern = #"\*\*([^*]+)\*\*|__([^_]+)__"# if let regex = try? NSRegularExpression(pattern: boldPattern) { @@ -262,19 +280,22 @@ struct MarkdownTextView: NSViewRepresentable { for match in matches { let captureIdx = match.range(at: 1).location != NSNotFound ? 1 : 2 guard let contentRange = Range(match.range(at: captureIdx), in: text) else { continue } - + let boldText = String(text[contentRange]) var boldAttrs = baseAttributes boldAttrs[.font] = boldFont - + let nsFullRange = NSRange(location: match.range.location + offset, length: match.range.length) if nsFullRange.location + nsFullRange.length <= result.length { - result.replaceCharacters(in: nsFullRange, with: NSAttributedString(string: boldText, attributes: boldAttrs)) + result.replaceCharacters( + in: nsFullRange, + with: NSAttributedString(string: boldText, attributes: boldAttrs) + ) offset -= (match.range.length - boldText.count) } } } - + // Process inline code: `text` let codePattern = #"`([^`]+)`"# if let regex = try? NSRegularExpression(pattern: codePattern) { @@ -282,33 +303,36 @@ struct MarkdownTextView: NSViewRepresentable { let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) for match in matches { guard let contentRange = Range(match.range(at: 1), in: text) else { continue } - + let codeText = String(text[contentRange]) var codeAttrs = baseAttributes codeAttrs[.font] = codeFont codeAttrs[.backgroundColor] = NSColor.textBackgroundColor.withAlphaComponent(0.3) - + let nsFullRange = NSRange(location: match.range.location + offset, length: match.range.length) if nsFullRange.location + nsFullRange.length <= result.length { - result.replaceCharacters(in: nsFullRange, with: NSAttributedString(string: codeText, attributes: codeAttrs)) + result.replaceCharacters( + in: nsFullRange, + with: NSAttributedString(string: codeText, attributes: codeAttrs) + ) offset -= (match.range.length - codeText.count) } } } - + return result } - + func makeCoordinator() -> Coordinator { Coordinator() } - + class Coordinator: NSObject, NSTextViewDelegate { var anchorPositions: [String: Int] = [:] weak var textView: NSTextView? weak var scrollView: NSScrollView? - - func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + + func textView(_: NSTextView, clickedOnLink link: Any, at _: Int) -> Bool { if let url = link as? URL { // Handle internal anchor links if url.scheme == "marcut-anchor" { @@ -332,19 +356,23 @@ struct MarkdownTextView: NSViewRepresentable { } return false } - + func scrollToAnchor(_ anchorID: String) { - guard let textView = textView, - let position = anchorPositions[anchorID] else { + guard let textView, + let position = anchorPositions[anchorID] + else { return } - + // Get the rect for this character position let layoutManager = textView.layoutManager! let textContainer = textView.textContainer! - let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: position, length: 1), actualCharacterRange: nil) + let glyphRange = layoutManager.glyphRange( + forCharacterRange: NSRange(location: position, length: 1), + actualCharacterRange: nil + ) let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - + // Scroll to the position with some padding at the top let scrollPoint = NSPoint(x: 0, y: rect.origin.y - 20) textView.scroll(scrollPoint) @@ -352,25 +380,25 @@ struct MarkdownTextView: NSViewRepresentable { } } -/// Preview provider for MarkdownTextView +// Preview provider for MarkdownTextView #if DEBUG -struct MarkdownTextView_Previews: PreviewProvider { - static var previews: some View { - MarkdownTextView(markdownContent: """ - # Sample Markdown - - This is **bold** and this is *italic*. - - - [Jump to section](#another-section) - - Item 2 - - [Link](https://example.com) - - ## Another Section - - This is the section we jumped to. - """, scrollToAnchor: .constant(nil)) - .frame(width: 400, height: 300) + struct MarkdownTextView_Previews: PreviewProvider { + static var previews: some View { + MarkdownTextView(markdownContent: """ + # Sample Markdown + + This is **bold** and this is *italic*. + + - [Jump to section](#another-section) + - Item 2 + + [Link](https://example.com) + + ## Another Section + + This is the section we jumped to. + """, scrollToAnchor: .constant(nil)) + .frame(width: 400, height: 300) + } } -} #endif diff --git a/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSettings.swift b/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSettings.swift index 5da005c..f19be9c 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSettings.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSettings.swift @@ -2,28 +2,30 @@ import SwiftUI /// Represents the preset cleaning profiles for metadata enum MetadataCleaningPreset: String, CaseIterable, Identifiable { - case maximum = "maximum" - case balanced = "balanced" - case none = "none" - case custom = "custom" - - var id: String { rawValue } - + case maximum + case balanced + case none + case custom + + var id: String { + rawValue + } + var displayName: String { switch self { - case .maximum: return "Maximum Privacy" - case .balanced: return "Balanced" - case .none: return "None" - case .custom: return "Custom" + case .maximum: "Maximum Privacy" + case .balanced: "Balanced" + case .none: "None" + case .custom: "Custom" } } - + var description: String { switch self { - case .maximum: return "Clears all metadata fields for maximum privacy" - case .balanced: return "Clears identifying info while preserving document formatting" - case .none: return "No metadata cleaning (keep all original metadata)" - case .custom: return "Use your custom selection below" + case .maximum: "Clears all metadata fields for maximum privacy" + case .balanced: "Clears identifying info while preserving document formatting" + case .none: "No metadata cleaning (keep all original metadata)" + case .custom: "Use your custom selection below" } } } @@ -31,6 +33,7 @@ enum MetadataCleaningPreset: String, CaseIterable, Identifiable { /// Settings for controlling which metadata fields are cleaned during redaction struct MetadataCleaningSettings: Codable, Equatable { // MARK: - App Properties (docProps/app.xml) + var cleanCompany: Bool = true var cleanManager: Bool = true var cleanTotalEditingTime: Bool = true @@ -44,8 +47,9 @@ struct MetadataCleaningSettings: Codable, Equatable { var cleanLinksUpToDate: Bool = true var cleanSharedDoc: Bool = true var cleanHyperlinksChanged: Bool = true - + // MARK: - Core Properties (docProps/core.xml) + var cleanAuthor: Bool = true var cleanLastModifiedBy: Bool = true var cleanTitle: Bool = true @@ -61,11 +65,13 @@ struct MetadataCleaningSettings: Codable, Equatable { var cleanIdentifier: Bool = true var cleanLanguage: Bool = true var cleanVersion: Bool = true - + // MARK: - Custom Properties + var cleanCustomProperties: Bool = true - + // MARK: - Document Structure + var cleanReviewCommentsVisible: Bool = true var cleanReviewCommentsHidden: Bool = true var cleanTrackChanges: Bool = true @@ -81,8 +87,9 @@ struct MetadataCleaningSettings: Codable, Equatable { var cleanInvisibleObjects: Bool = true var cleanHeadersFooters: Bool = true var cleanWatermarks: Bool = true - + // MARK: - Embedded Content + var cleanThumbnail: Bool = true var cleanHyperlinkURLs: Bool = true var cleanAltText: Bool = true @@ -93,38 +100,43 @@ struct MetadataCleaningSettings: Codable, Equatable { var cleanEmbeddedFonts: Bool = true var cleanGlossary: Bool = true var cleanFastSaveData: Bool = true - + // MARK: - Advanced Hardening (Path Leakage) + var cleanExternalLinks: Bool = true var cleanUNCPaths: Bool = true var cleanUserPaths: Bool = true var cleanInternalURLs: Bool = true var cleanOLESources: Bool = true - + // MARK: - Image EXIF + var cleanImageEXIF: Bool = true - + // MARK: - Style and Formatting (can affect document appearance) + var cleanStyleNames: Bool = true var cleanChartLabels: Bool = true var cleanFormDefaults: Bool = true var cleanLanguageSettings: Bool = true - + // MARK: - ActiveX and Controls + var cleanActiveX: Bool = true // MARK: - Nuclear Options (Non-Standard / Hidden Data) + var cleanCustomXMLParts: Bool = true var cleanNonstandardXML: Bool = true var cleanMicrosoftExtensionXML: Bool = true var cleanUnknownRelationships: Bool = true var cleanOrphanedParts: Bool = true var cleanAlternateContent: Bool = true - + // MARK: - Preset Application init() {} - + /// Returns settings for the Maximum Privacy preset (all ON) static var maximumPrivacy: MetadataCleaningSettings { var settings = MetadataCleaningSettings() @@ -133,31 +145,31 @@ struct MetadataCleaningSettings: Codable, Equatable { settings.cleanModifiedDate = true return settings } - + /// Returns settings for the Balanced preset /// Clears identifying metadata but preserves document formatting/hierarchy static var balanced: MetadataCleaningSettings { var settings = MetadataCleaningSettings() - + // === KEEP OFF (preserve document formatting) === settings.cleanStatistics = false settings.cleanCreatedDate = false settings.cleanModifiedDate = false settings.cleanLinksUpToDate = true settings.cleanHyperlinksChanged = false - + // Preserve visual formatting - settings.cleanAltText = true // Accessibility - settings.cleanEmbeddedFonts = false // Document appearance - settings.cleanStyleNames = false // Style hierarchy - settings.cleanChartLabels = false // Chart readability - settings.cleanFormDefaults = false // Form functionality - settings.cleanLanguageSettings = false // Locale/proofing - settings.cleanHyperlinkURLs = false // Link functionality - settings.cleanLanguage = false // Proofing language - + settings.cleanAltText = true // Accessibility + settings.cleanEmbeddedFonts = false // Document appearance + settings.cleanStyleNames = false // Style hierarchy + settings.cleanChartLabels = false // Chart readability + settings.cleanFormDefaults = false // Form functionality + settings.cleanLanguageSettings = false // Locale/proofing + settings.cleanHyperlinkURLs = false // Link functionality + settings.cleanLanguage = false // Proofing language + // Keep structure intact - settings.cleanGlossary = false // Building blocks + settings.cleanGlossary = false // Building blocks settings.cleanScaleCrop = false // Visual appearance settings.cleanSharedDoc = true // Document state settings.cleanSpellGrammarState = false // Proofing state @@ -179,7 +191,7 @@ struct MetadataCleaningSettings: Codable, Equatable { settings.cleanReviewCommentsVisible = false settings.cleanReviewCommentsHidden = true settings.cleanTrackChanges = false - + // === LEAVE ON (identify info - cleaned) === // Author, company, manager, last modified by - ON (cleaned) // Custom properties, GUID, RSIDs - ON (cleaned) @@ -187,10 +199,10 @@ struct MetadataCleaningSettings: Codable, Equatable { // Digital signatures, VBA macros - ON (cleaned) // EXIF data, external links, UNC paths - ON (cleaned) // User paths, internal URLs - ON (cleaned) - + return settings } - + /// Returns settings for the None preset (all OFF - no metadata cleaning) static var none: MetadataCleaningSettings { var settings = MetadataCleaningSettings() @@ -268,22 +280,22 @@ struct MetadataCleaningSettings: Codable, Equatable { settings.cleanAlternateContent = false return settings } - + /// Default settings - Balanced preset (identifying info ON, dates/stats OFF) static var `default`: MetadataCleaningSettings { - return .balanced + .balanced } - + /// Detects which preset matches the current settings func detectPreset() -> MetadataCleaningPreset { if self == .maximumPrivacy { - return .maximum + .maximum } else if self == .balanced { - return .balanced + .balanced } else if self == .none { - return .none + .none } else { - return .custom + .custom } } @@ -368,35 +380,48 @@ struct MetadataCleaningSettings: Codable, Equatable { cleanCompany = try container.decodeIfPresent(Bool.self, forKey: .cleanCompany) ?? defaults.cleanCompany cleanManager = try container.decodeIfPresent(Bool.self, forKey: .cleanManager) ?? defaults.cleanManager - cleanTotalEditingTime = try container.decodeIfPresent(Bool.self, forKey: .cleanTotalEditingTime) ?? defaults.cleanTotalEditingTime - cleanApplication = try container.decodeIfPresent(Bool.self, forKey: .cleanApplication) ?? defaults.cleanApplication + cleanTotalEditingTime = try container.decodeIfPresent(Bool.self, forKey: .cleanTotalEditingTime) ?? defaults + .cleanTotalEditingTime + cleanApplication = try container.decodeIfPresent(Bool.self, forKey: .cleanApplication) ?? defaults + .cleanApplication cleanAppVersion = try container.decodeIfPresent(Bool.self, forKey: .cleanAppVersion) ?? defaults.cleanAppVersion cleanTemplate = try container.decodeIfPresent(Bool.self, forKey: .cleanTemplate) ?? defaults.cleanTemplate - cleanHyperlinkBase = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinkBase) ?? defaults.cleanHyperlinkBase + cleanHyperlinkBase = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinkBase) ?? defaults + .cleanHyperlinkBase cleanStatistics = try container.decodeIfPresent(Bool.self, forKey: .cleanStatistics) ?? defaults.cleanStatistics - cleanDocSecurity = try container.decodeIfPresent(Bool.self, forKey: .cleanDocSecurity) ?? defaults.cleanDocSecurity + cleanDocSecurity = try container.decodeIfPresent(Bool.self, forKey: .cleanDocSecurity) ?? defaults + .cleanDocSecurity cleanScaleCrop = try container.decodeIfPresent(Bool.self, forKey: .cleanScaleCrop) ?? defaults.cleanScaleCrop - cleanLinksUpToDate = try container.decodeIfPresent(Bool.self, forKey: .cleanLinksUpToDate) ?? defaults.cleanLinksUpToDate + cleanLinksUpToDate = try container.decodeIfPresent(Bool.self, forKey: .cleanLinksUpToDate) ?? defaults + .cleanLinksUpToDate cleanSharedDoc = try container.decodeIfPresent(Bool.self, forKey: .cleanSharedDoc) ?? defaults.cleanSharedDoc - cleanHyperlinksChanged = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinksChanged) ?? defaults.cleanHyperlinksChanged + cleanHyperlinksChanged = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinksChanged) ?? defaults + .cleanHyperlinksChanged cleanAuthor = try container.decodeIfPresent(Bool.self, forKey: .cleanAuthor) ?? defaults.cleanAuthor - cleanLastModifiedBy = try container.decodeIfPresent(Bool.self, forKey: .cleanLastModifiedBy) ?? defaults.cleanLastModifiedBy + cleanLastModifiedBy = try container.decodeIfPresent(Bool.self, forKey: .cleanLastModifiedBy) ?? defaults + .cleanLastModifiedBy cleanTitle = try container.decodeIfPresent(Bool.self, forKey: .cleanTitle) ?? defaults.cleanTitle cleanSubject = try container.decodeIfPresent(Bool.self, forKey: .cleanSubject) ?? defaults.cleanSubject cleanKeywords = try container.decodeIfPresent(Bool.self, forKey: .cleanKeywords) ?? defaults.cleanKeywords cleanComments = try container.decodeIfPresent(Bool.self, forKey: .cleanComments) ?? defaults.cleanComments cleanCategory = try container.decodeIfPresent(Bool.self, forKey: .cleanCategory) ?? defaults.cleanCategory - cleanContentStatus = try container.decodeIfPresent(Bool.self, forKey: .cleanContentStatus) ?? defaults.cleanContentStatus - cleanCreatedDate = try container.decodeIfPresent(Bool.self, forKey: .cleanCreatedDate) ?? defaults.cleanCreatedDate - cleanModifiedDate = try container.decodeIfPresent(Bool.self, forKey: .cleanModifiedDate) ?? defaults.cleanModifiedDate - cleanLastPrinted = try container.decodeIfPresent(Bool.self, forKey: .cleanLastPrinted) ?? defaults.cleanLastPrinted - cleanRevisionNumber = try container.decodeIfPresent(Bool.self, forKey: .cleanRevisionNumber) ?? defaults.cleanRevisionNumber + cleanContentStatus = try container.decodeIfPresent(Bool.self, forKey: .cleanContentStatus) ?? defaults + .cleanContentStatus + cleanCreatedDate = try container.decodeIfPresent(Bool.self, forKey: .cleanCreatedDate) ?? defaults + .cleanCreatedDate + cleanModifiedDate = try container.decodeIfPresent(Bool.self, forKey: .cleanModifiedDate) ?? defaults + .cleanModifiedDate + cleanLastPrinted = try container.decodeIfPresent(Bool.self, forKey: .cleanLastPrinted) ?? defaults + .cleanLastPrinted + cleanRevisionNumber = try container.decodeIfPresent(Bool.self, forKey: .cleanRevisionNumber) ?? defaults + .cleanRevisionNumber cleanIdentifier = try container.decodeIfPresent(Bool.self, forKey: .cleanIdentifier) ?? defaults.cleanIdentifier cleanLanguage = try container.decodeIfPresent(Bool.self, forKey: .cleanLanguage) ?? defaults.cleanLanguage cleanVersion = try container.decodeIfPresent(Bool.self, forKey: .cleanVersion) ?? defaults.cleanVersion - cleanCustomProperties = try container.decodeIfPresent(Bool.self, forKey: .cleanCustomProperties) ?? defaults.cleanCustomProperties + cleanCustomProperties = try container.decodeIfPresent(Bool.self, forKey: .cleanCustomProperties) ?? defaults + .cleanCustomProperties let legacyReviewComments = try container.decodeIfPresent(Bool.self, forKey: .cleanReviewComments) cleanReviewCommentsVisible = try container.decodeIfPresent(Bool.self, forKey: .cleanReviewCommentsVisible) @@ -405,48 +430,73 @@ struct MetadataCleaningSettings: Codable, Equatable { cleanReviewCommentsHidden = try container.decodeIfPresent(Bool.self, forKey: .cleanReviewCommentsHidden) ?? legacyReviewComments ?? defaults.cleanReviewCommentsHidden - cleanTrackChanges = try container.decodeIfPresent(Bool.self, forKey: .cleanTrackChanges) ?? defaults.cleanTrackChanges + cleanTrackChanges = try container.decodeIfPresent(Bool.self, forKey: .cleanTrackChanges) ?? defaults + .cleanTrackChanges cleanRSIDs = try container.decodeIfPresent(Bool.self, forKey: .cleanRSIDs) ?? defaults.cleanRSIDs - cleanDocumentGUID = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentGUID) ?? defaults.cleanDocumentGUID - cleanSpellGrammarState = try container.decodeIfPresent(Bool.self, forKey: .cleanSpellGrammarState) ?? defaults.cleanSpellGrammarState - cleanDocumentVariables = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentVariables) ?? defaults.cleanDocumentVariables + cleanDocumentGUID = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentGUID) ?? defaults + .cleanDocumentGUID + cleanSpellGrammarState = try container.decodeIfPresent(Bool.self, forKey: .cleanSpellGrammarState) ?? defaults + .cleanSpellGrammarState + cleanDocumentVariables = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentVariables) ?? defaults + .cleanDocumentVariables cleanMailMerge = try container.decodeIfPresent(Bool.self, forKey: .cleanMailMerge) ?? defaults.cleanMailMerge - cleanDataBindings = try container.decodeIfPresent(Bool.self, forKey: .cleanDataBindings) ?? defaults.cleanDataBindings - cleanDocumentVersions = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentVersions) ?? defaults.cleanDocumentVersions - cleanInkAnnotations = try container.decodeIfPresent(Bool.self, forKey: .cleanInkAnnotations) ?? defaults.cleanInkAnnotations + cleanDataBindings = try container.decodeIfPresent(Bool.self, forKey: .cleanDataBindings) ?? defaults + .cleanDataBindings + cleanDocumentVersions = try container.decodeIfPresent(Bool.self, forKey: .cleanDocumentVersions) ?? defaults + .cleanDocumentVersions + cleanInkAnnotations = try container.decodeIfPresent(Bool.self, forKey: .cleanInkAnnotations) ?? defaults + .cleanInkAnnotations cleanHiddenText = try container.decodeIfPresent(Bool.self, forKey: .cleanHiddenText) ?? defaults.cleanHiddenText - cleanInvisibleObjects = try container.decodeIfPresent(Bool.self, forKey: .cleanInvisibleObjects) ?? defaults.cleanInvisibleObjects - cleanHeadersFooters = try container.decodeIfPresent(Bool.self, forKey: .cleanHeadersFooters) ?? defaults.cleanHeadersFooters + cleanInvisibleObjects = try container.decodeIfPresent(Bool.self, forKey: .cleanInvisibleObjects) ?? defaults + .cleanInvisibleObjects + cleanHeadersFooters = try container.decodeIfPresent(Bool.self, forKey: .cleanHeadersFooters) ?? defaults + .cleanHeadersFooters cleanWatermarks = try container.decodeIfPresent(Bool.self, forKey: .cleanWatermarks) ?? defaults.cleanWatermarks cleanThumbnail = try container.decodeIfPresent(Bool.self, forKey: .cleanThumbnail) ?? defaults.cleanThumbnail - cleanHyperlinkURLs = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinkURLs) ?? defaults.cleanHyperlinkURLs + cleanHyperlinkURLs = try container.decodeIfPresent(Bool.self, forKey: .cleanHyperlinkURLs) ?? defaults + .cleanHyperlinkURLs cleanAltText = try container.decodeIfPresent(Bool.self, forKey: .cleanAltText) ?? defaults.cleanAltText cleanOLEObjects = try container.decodeIfPresent(Bool.self, forKey: .cleanOLEObjects) ?? defaults.cleanOLEObjects cleanVBAMacros = try container.decodeIfPresent(Bool.self, forKey: .cleanVBAMacros) ?? defaults.cleanVBAMacros - cleanDigitalSignatures = try container.decodeIfPresent(Bool.self, forKey: .cleanDigitalSignatures) ?? defaults.cleanDigitalSignatures - cleanPrinterSettings = try container.decodeIfPresent(Bool.self, forKey: .cleanPrinterSettings) ?? defaults.cleanPrinterSettings - cleanEmbeddedFonts = try container.decodeIfPresent(Bool.self, forKey: .cleanEmbeddedFonts) ?? defaults.cleanEmbeddedFonts + cleanDigitalSignatures = try container.decodeIfPresent(Bool.self, forKey: .cleanDigitalSignatures) ?? defaults + .cleanDigitalSignatures + cleanPrinterSettings = try container.decodeIfPresent(Bool.self, forKey: .cleanPrinterSettings) ?? defaults + .cleanPrinterSettings + cleanEmbeddedFonts = try container.decodeIfPresent(Bool.self, forKey: .cleanEmbeddedFonts) ?? defaults + .cleanEmbeddedFonts cleanGlossary = try container.decodeIfPresent(Bool.self, forKey: .cleanGlossary) ?? defaults.cleanGlossary - cleanFastSaveData = try container.decodeIfPresent(Bool.self, forKey: .cleanFastSaveData) ?? defaults.cleanFastSaveData + cleanFastSaveData = try container.decodeIfPresent(Bool.self, forKey: .cleanFastSaveData) ?? defaults + .cleanFastSaveData - cleanExternalLinks = try container.decodeIfPresent(Bool.self, forKey: .cleanExternalLinks) ?? defaults.cleanExternalLinks + cleanExternalLinks = try container.decodeIfPresent(Bool.self, forKey: .cleanExternalLinks) ?? defaults + .cleanExternalLinks cleanUNCPaths = try container.decodeIfPresent(Bool.self, forKey: .cleanUNCPaths) ?? defaults.cleanUNCPaths cleanUserPaths = try container.decodeIfPresent(Bool.self, forKey: .cleanUserPaths) ?? defaults.cleanUserPaths - cleanInternalURLs = try container.decodeIfPresent(Bool.self, forKey: .cleanInternalURLs) ?? defaults.cleanInternalURLs + cleanInternalURLs = try container.decodeIfPresent(Bool.self, forKey: .cleanInternalURLs) ?? defaults + .cleanInternalURLs cleanOLESources = try container.decodeIfPresent(Bool.self, forKey: .cleanOLESources) ?? defaults.cleanOLESources cleanImageEXIF = try container.decodeIfPresent(Bool.self, forKey: .cleanImageEXIF) ?? defaults.cleanImageEXIF cleanStyleNames = try container.decodeIfPresent(Bool.self, forKey: .cleanStyleNames) ?? defaults.cleanStyleNames - cleanChartLabels = try container.decodeIfPresent(Bool.self, forKey: .cleanChartLabels) ?? defaults.cleanChartLabels - cleanFormDefaults = try container.decodeIfPresent(Bool.self, forKey: .cleanFormDefaults) ?? defaults.cleanFormDefaults - cleanLanguageSettings = try container.decodeIfPresent(Bool.self, forKey: .cleanLanguageSettings) ?? defaults.cleanLanguageSettings + cleanChartLabels = try container.decodeIfPresent(Bool.self, forKey: .cleanChartLabels) ?? defaults + .cleanChartLabels + cleanFormDefaults = try container.decodeIfPresent(Bool.self, forKey: .cleanFormDefaults) ?? defaults + .cleanFormDefaults + cleanLanguageSettings = try container.decodeIfPresent(Bool.self, forKey: .cleanLanguageSettings) ?? defaults + .cleanLanguageSettings cleanActiveX = try container.decodeIfPresent(Bool.self, forKey: .cleanActiveX) ?? defaults.cleanActiveX - cleanCustomXMLParts = try container.decodeIfPresent(Bool.self, forKey: .cleanCustomXMLParts) ?? defaults.cleanCustomXMLParts - cleanNonstandardXML = try container.decodeIfPresent(Bool.self, forKey: .cleanNonstandardXML) ?? defaults.cleanNonstandardXML - cleanMicrosoftExtensionXML = try container.decodeIfPresent(Bool.self, forKey: .cleanMicrosoftExtensionXML) ?? defaults.cleanMicrosoftExtensionXML - cleanUnknownRelationships = try container.decodeIfPresent(Bool.self, forKey: .cleanUnknownRelationships) ?? defaults.cleanUnknownRelationships - cleanOrphanedParts = try container.decodeIfPresent(Bool.self, forKey: .cleanOrphanedParts) ?? defaults.cleanOrphanedParts - cleanAlternateContent = try container.decodeIfPresent(Bool.self, forKey: .cleanAlternateContent) ?? defaults.cleanAlternateContent + cleanCustomXMLParts = try container.decodeIfPresent(Bool.self, forKey: .cleanCustomXMLParts) ?? defaults + .cleanCustomXMLParts + cleanNonstandardXML = try container.decodeIfPresent(Bool.self, forKey: .cleanNonstandardXML) ?? defaults + .cleanNonstandardXML + cleanMicrosoftExtensionXML = try container + .decodeIfPresent(Bool.self, forKey: .cleanMicrosoftExtensionXML) ?? defaults.cleanMicrosoftExtensionXML + cleanUnknownRelationships = try container + .decodeIfPresent(Bool.self, forKey: .cleanUnknownRelationships) ?? defaults.cleanUnknownRelationships + cleanOrphanedParts = try container.decodeIfPresent(Bool.self, forKey: .cleanOrphanedParts) ?? defaults + .cleanOrphanedParts + cleanAlternateContent = try container.decodeIfPresent(Bool.self, forKey: .cleanAlternateContent) ?? defaults + .cleanAlternateContent } func encode(to encoder: Encoder) throws { @@ -528,12 +578,13 @@ struct MetadataCleaningSettings: Codable, Equatable { try container.encode(cleanOrphanedParts, forKey: .cleanOrphanedParts) try container.encode(cleanAlternateContent, forKey: .cleanAlternateContent) } - + // MARK: - Persistence - + static func load() -> MetadataCleaningSettings { guard let data = UserDefaults.standard.data(forKey: DefaultsKey.metadataCleaningSettings.key), - let settings = try? JSONDecoder().decode(MetadataCleaningSettings.self, from: data) else { + let settings = try? JSONDecoder().decode(MetadataCleaningSettings.self, from: data) + else { return .default } return settings @@ -544,95 +595,237 @@ struct MetadataCleaningSettings: Codable, Equatable { UserDefaults.standard.set(data, forKey: DefaultsKey.metadataCleaningSettings.key) } } - + // MARK: - CLI Arguments - + /// Generates CLI arguments for passing to Python func toCLIArguments() -> [String] { var args: [String] = [] - + // Explicitly signal 'None' preset for robust handling if self == MetadataCleaningSettings.none { args.append("--preset-none") } - + // Only pass fields that are OFF (to minimize arg list) - if !cleanCompany { args.append("--no-clean-company") } - if !cleanManager { args.append("--no-clean-manager") } - if !cleanTotalEditingTime { args.append("--no-clean-editing-time") } - if !cleanApplication { args.append("--no-clean-application") } - if !cleanAppVersion { args.append("--no-clean-app-version") } - if !cleanTemplate { args.append("--no-clean-template") } - if !cleanHyperlinkBase { args.append("--no-clean-hyperlink-base") } - if !cleanStatistics { args.append("--no-clean-statistics") } - if !cleanDocSecurity { args.append("--no-clean-doc-security") } - if !cleanAuthor { args.append("--no-clean-author") } - if !cleanLastModifiedBy { args.append("--no-clean-last-modified-by") } - if !cleanTitle { args.append("--no-clean-title") } - if !cleanSubject { args.append("--no-clean-subject") } - if !cleanKeywords { args.append("--no-clean-keywords") } - if !cleanComments { args.append("--no-clean-comments") } - if !cleanCategory { args.append("--no-clean-category") } - if !cleanContentStatus { args.append("--no-clean-content-status") } - if !cleanCreatedDate { args.append("--no-clean-created-date") } - if !cleanModifiedDate { args.append("--no-clean-modified-date") } - if !cleanLastPrinted { args.append("--no-clean-last-printed") } - if !cleanRevisionNumber { args.append("--no-clean-revision") } - if !cleanIdentifier { args.append("--no-clean-identifier") } - if !cleanLanguage { args.append("--no-clean-language") } - if !cleanVersion { args.append("--no-clean-version") } - if !cleanCustomProperties { args.append("--no-clean-custom-props") } - if !cleanReviewCommentsVisible { args.append("--no-clean-review-comments-visible") } - if !cleanReviewCommentsHidden { args.append("--no-clean-review-comments-hidden") } - if !cleanTrackChanges { args.append("--no-clean-track-changes") } - if !cleanRSIDs { args.append("--no-clean-rsids") } - if !cleanDocumentGUID { args.append("--no-clean-guid") } - if !cleanMailMerge { args.append("--no-clean-mail-merge") } - if !cleanDataBindings { args.append("--no-clean-data-bindings") } - if !cleanDocumentVersions { args.append("--no-clean-doc-versions") } - if !cleanInkAnnotations { args.append("--no-clean-ink-annotations") } - if !cleanHiddenText { args.append("--no-clean-hidden-text") } - if !cleanInvisibleObjects { args.append("--no-clean-invisible-objects") } - if !cleanHeadersFooters { args.append("--no-clean-headers-footers") } - if !cleanWatermarks { args.append("--no-clean-watermarks") } - if !cleanThumbnail { args.append("--no-clean-thumbnail") } - if !cleanHyperlinkURLs { args.append("--no-clean-hyperlinks") } - if !cleanAltText { args.append("--no-clean-alt-text") } - if !cleanOLEObjects { args.append("--no-clean-ole") } - if !cleanVBAMacros { args.append("--no-clean-macros") } - if !cleanDigitalSignatures { args.append("--no-clean-signatures") } - if !cleanPrinterSettings { args.append("--no-clean-printer") } - if !cleanEmbeddedFonts { args.append("--no-clean-fonts") } - if !cleanGlossary { args.append("--no-clean-glossary") } - if !cleanFastSaveData { args.append("--no-clean-fast-save") } - + if !cleanCompany { + args.append("--no-clean-company") + } + if !cleanManager { + args.append("--no-clean-manager") + } + if !cleanTotalEditingTime { + args.append("--no-clean-editing-time") + } + if !cleanApplication { + args.append("--no-clean-application") + } + if !cleanAppVersion { + args.append("--no-clean-app-version") + } + if !cleanTemplate { + args.append("--no-clean-template") + } + if !cleanHyperlinkBase { + args.append("--no-clean-hyperlink-base") + } + if !cleanStatistics { + args.append("--no-clean-statistics") + } + if !cleanDocSecurity { + args.append("--no-clean-doc-security") + } + if !cleanAuthor { + args.append("--no-clean-author") + } + if !cleanLastModifiedBy { + args.append("--no-clean-last-modified-by") + } + if !cleanTitle { + args.append("--no-clean-title") + } + if !cleanSubject { + args.append("--no-clean-subject") + } + if !cleanKeywords { + args.append("--no-clean-keywords") + } + if !cleanComments { + args.append("--no-clean-comments") + } + if !cleanCategory { + args.append("--no-clean-category") + } + if !cleanContentStatus { + args.append("--no-clean-content-status") + } + if !cleanCreatedDate { + args.append("--no-clean-created-date") + } + if !cleanModifiedDate { + args.append("--no-clean-modified-date") + } + if !cleanLastPrinted { + args.append("--no-clean-last-printed") + } + if !cleanRevisionNumber { + args.append("--no-clean-revision") + } + if !cleanIdentifier { + args.append("--no-clean-identifier") + } + if !cleanLanguage { + args.append("--no-clean-language") + } + if !cleanVersion { + args.append("--no-clean-version") + } + if !cleanCustomProperties { + args.append("--no-clean-custom-props") + } + if !cleanReviewCommentsVisible { + args.append("--no-clean-review-comments-visible") + } + if !cleanReviewCommentsHidden { + args.append("--no-clean-review-comments-hidden") + } + if !cleanTrackChanges { + args.append("--no-clean-track-changes") + } + if !cleanRSIDs { + args.append("--no-clean-rsids") + } + if !cleanDocumentGUID { + args.append("--no-clean-guid") + } + if !cleanMailMerge { + args.append("--no-clean-mail-merge") + } + if !cleanDataBindings { + args.append("--no-clean-data-bindings") + } + if !cleanDocumentVersions { + args.append("--no-clean-doc-versions") + } + if !cleanInkAnnotations { + args.append("--no-clean-ink-annotations") + } + if !cleanHiddenText { + args.append("--no-clean-hidden-text") + } + if !cleanInvisibleObjects { + args.append("--no-clean-invisible-objects") + } + if !cleanHeadersFooters { + args.append("--no-clean-headers-footers") + } + if !cleanWatermarks { + args.append("--no-clean-watermarks") + } + if !cleanThumbnail { + args.append("--no-clean-thumbnail") + } + if !cleanHyperlinkURLs { + args.append("--no-clean-hyperlinks") + } + if !cleanAltText { + args.append("--no-clean-alt-text") + } + if !cleanOLEObjects { + args.append("--no-clean-ole") + } + if !cleanVBAMacros { + args.append("--no-clean-macros") + } + if !cleanDigitalSignatures { + args.append("--no-clean-signatures") + } + if !cleanPrinterSettings { + args.append("--no-clean-printer") + } + if !cleanEmbeddedFonts { + args.append("--no-clean-fonts") + } + if !cleanGlossary { + args.append("--no-clean-glossary") + } + if !cleanFastSaveData { + args.append("--no-clean-fast-save") + } + // Advanced hardening - if !cleanExternalLinks { args.append("--no-clean-ext-links") } - if !cleanUNCPaths { args.append("--no-clean-unc-paths") } - if !cleanUserPaths { args.append("--no-clean-user-paths") } - if !cleanInternalURLs { args.append("--no-clean-internal-urls") } - if !cleanOLESources { args.append("--no-clean-ole-sources") } - if !cleanImageEXIF { args.append("--no-clean-exif") } - if !cleanStyleNames { args.append("--no-clean-style-names") } - if !cleanChartLabels { args.append("--no-clean-chart-labels") } - if !cleanFormDefaults { args.append("--no-clean-form-defaults") } - if !cleanLanguageSettings { args.append("--no-clean-language-settings") } - if !cleanActiveX { args.append("--no-clean-activex") } - if !cleanCustomXMLParts { args.append("--no-clean-custom-xml-parts") } - if !cleanNonstandardXML { args.append("--no-clean-nonstandard-xml") } - if !cleanMicrosoftExtensionXML { args.append("--no-clean-microsoft-extensions") } - if !cleanUnknownRelationships { args.append("--no-clean-unknown-rels") } - if !cleanOrphanedParts { args.append("--no-clean-orphaned-parts") } - if !cleanAlternateContent { args.append("--no-clean-alternate-content") } - + if !cleanExternalLinks { + args.append("--no-clean-ext-links") + } + if !cleanUNCPaths { + args.append("--no-clean-unc-paths") + } + if !cleanUserPaths { + args.append("--no-clean-user-paths") + } + if !cleanInternalURLs { + args.append("--no-clean-internal-urls") + } + if !cleanOLESources { + args.append("--no-clean-ole-sources") + } + if !cleanImageEXIF { + args.append("--no-clean-exif") + } + if !cleanStyleNames { + args.append("--no-clean-style-names") + } + if !cleanChartLabels { + args.append("--no-clean-chart-labels") + } + if !cleanFormDefaults { + args.append("--no-clean-form-defaults") + } + if !cleanLanguageSettings { + args.append("--no-clean-language-settings") + } + if !cleanActiveX { + args.append("--no-clean-activex") + } + if !cleanCustomXMLParts { + args.append("--no-clean-custom-xml-parts") + } + if !cleanNonstandardXML { + args.append("--no-clean-nonstandard-xml") + } + if !cleanMicrosoftExtensionXML { + args.append("--no-clean-microsoft-extensions") + } + if !cleanUnknownRelationships { + args.append("--no-clean-unknown-rels") + } + if !cleanOrphanedParts { + args.append("--no-clean-orphaned-parts") + } + if !cleanAlternateContent { + args.append("--no-clean-alternate-content") + } + // Hidden settings (App Props / structure) - if !cleanScaleCrop { args.append("--no-clean-scale-crop") } - if !cleanLinksUpToDate { args.append("--no-clean-links-up-to-date") } - if !cleanSharedDoc { args.append("--no-clean-shared-doc") } - if !cleanHyperlinksChanged { args.append("--no-clean-hyperlinks-changed") } - if !cleanSpellGrammarState { args.append("--no-clean-spell-grammar") } - if !cleanDocumentVariables { args.append("--no-clean-doc-vars") } - + if !cleanScaleCrop { + args.append("--no-clean-scale-crop") + } + if !cleanLinksUpToDate { + args.append("--no-clean-links-up-to-date") + } + if !cleanSharedDoc { + args.append("--no-clean-shared-doc") + } + if !cleanHyperlinksChanged { + args.append("--no-clean-hyperlinks-changed") + } + if !cleanSpellGrammarState { + args.append("--no-clean-spell-grammar") + } + if !cleanDocumentVariables { + args.append("--no-clean-doc-vars") + } + return args } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSheet.swift b/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSheet.swift index d4ce15c..0ec0696 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSheet.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/MetadataCleaningSheet.swift @@ -8,18 +8,32 @@ struct MetadataCleaningSheet: View { @State private var collapsedStates: [String: [MetadataCleaningPreset: Bool]] = [:] // Per-preset collapse states @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme - - private let groupNames = ["appProperties", "coreProperties", "customProperties", "documentStructure", "embeddedContent", "advancedHardening"] - + + private let groupNames = [ + "appProperties", + "coreProperties", + "customProperties", + "documentStructure", + "embeddedContent", + "advancedHardening", + ] + init(settings: Binding) { self._settings = settings let currentPreset = settings.wrappedValue.detectPreset() self._selectedPreset = State(initialValue: currentPreset) self._customSettings = State(initialValue: settings.wrappedValue) - + // Initialize default collapsed states (all expanded) var defaultStates: [String: [MetadataCleaningPreset: Bool]] = [:] - for name in ["appProperties", "coreProperties", "customProperties", "documentStructure", "embeddedContent", "advancedHardening"] { + for name in [ + "appProperties", + "coreProperties", + "customProperties", + "documentStructure", + "embeddedContent", + "advancedHardening", + ] { defaultStates[name] = [:] for preset in MetadataCleaningPreset.allCases { defaultStates[name]?[preset] = false // false = expanded @@ -27,7 +41,7 @@ struct MetadataCleaningSheet: View { } self._collapsedStates = State(initialValue: defaultStates) } - + var body: some View { VStack(spacing: 0) { // Header @@ -35,20 +49,20 @@ struct MetadataCleaningSheet: View { Text("Metadata Cleaning Options") .font(.title2) .fontWeight(.semibold) - + Text("Select which metadata fields to remove during redaction") .font(.body) .foregroundColor(.secondary) } .padding(.top, 24) .padding(.bottom, 16) - + // Preset Picker with label HStack { Text("Preset:") .font(.system(size: 13, weight: .medium)) .foregroundColor(.secondary) - + Picker("", selection: $selectedPreset) { ForEach(MetadataCleaningPreset.allCases) { preset in Text(preset.displayName).tag(preset) @@ -60,12 +74,12 @@ struct MetadataCleaningSheet: View { .onChange(of: selectedPreset) { _, newPreset in applyPreset(newPreset) } - + Spacer() } .padding(.horizontal, 24) .padding(.bottom, 16) - + // Scrollable checkbox groups ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -77,27 +91,79 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "Company", description: "Organization name (often auto-filled from system)", binding: makeCustomBinding($settings.cleanCompany)), - MetadataItem(label: "Manager", description: "Manager's name field", binding: makeCustomBinding($settings.cleanManager)), - MetadataItem(label: "Total Editing Time", description: "Cumulative minutes spent editing", binding: makeCustomBinding($settings.cleanTotalEditingTime)), - MetadataItem(label: "Application", description: "Application name (e.g., \"Microsoft Office Word\")", binding: makeCustomBinding($settings.cleanApplication)), - MetadataItem(label: "App Version", description: "Specific Office version number", binding: makeCustomBinding($settings.cleanAppVersion)), - MetadataItem(label: "Template", description: "Template name used (e.g., \"Normal.dotm\")", binding: makeCustomBinding($settings.cleanTemplate)), - MetadataItem(label: "Hyperlink Base", description: "Base URL for resolving relative links", binding: makeCustomBinding($settings.cleanHyperlinkBase)), - MetadataItem(label: "Document Statistics", description: "Character, word, line, paragraph, and page counts", binding: makeCustomBinding($settings.cleanStatistics)), - MetadataItem(label: "Document Security", description: "Security settings value", binding: makeCustomBinding($settings.cleanDocSecurity)), - MetadataItem(label: "Thumbnail Settings", description: "Scale/crop display settings", binding: makeCustomBinding($settings.cleanScaleCrop)), - MetadataItem(label: "Shared Document Flag", description: "Whether document was shared", binding: makeCustomBinding($settings.cleanSharedDoc)), - MetadataItem(label: "Links Up-to-Date Flag", description: "Status of external link updates", binding: makeCustomBinding($settings.cleanLinksUpToDate)), - MetadataItem(label: "Hyperlinks Changed Flag", description: "Status of hyperlink modifications", binding: makeCustomBinding($settings.cleanHyperlinksChanged)), + MetadataItem( + label: "Company", + description: "Organization name (often auto-filled from system)", + binding: makeCustomBinding($settings.cleanCompany) + ), + MetadataItem( + label: "Manager", + description: "Manager's name field", + binding: makeCustomBinding($settings.cleanManager) + ), + MetadataItem( + label: "Total Editing Time", + description: "Cumulative minutes spent editing", + binding: makeCustomBinding($settings.cleanTotalEditingTime) + ), + MetadataItem( + label: "Application", + description: "Application name (e.g., \"Microsoft Office Word\")", + binding: makeCustomBinding($settings.cleanApplication) + ), + MetadataItem( + label: "App Version", + description: "Specific Office version number", + binding: makeCustomBinding($settings.cleanAppVersion) + ), + MetadataItem( + label: "Template", + description: "Template name used (e.g., \"Normal.dotm\")", + binding: makeCustomBinding($settings.cleanTemplate) + ), + MetadataItem( + label: "Hyperlink Base", + description: "Base URL for resolving relative links", + binding: makeCustomBinding($settings.cleanHyperlinkBase) + ), + MetadataItem( + label: "Document Statistics", + description: "Character, word, line, paragraph, and page counts", + binding: makeCustomBinding($settings.cleanStatistics) + ), + MetadataItem( + label: "Document Security", + description: "Security settings value", + binding: makeCustomBinding($settings.cleanDocSecurity) + ), + MetadataItem( + label: "Thumbnail Settings", + description: "Scale/crop display settings", + binding: makeCustomBinding($settings.cleanScaleCrop) + ), + MetadataItem( + label: "Shared Document Flag", + description: "Whether document was shared", + binding: makeCustomBinding($settings.cleanSharedDoc) + ), + MetadataItem( + label: "Links Up-to-Date Flag", + description: "Status of external link updates", + binding: makeCustomBinding($settings.cleanLinksUpToDate) + ), + MetadataItem( + label: "Hyperlinks Changed Flag", + description: "Status of hyperlink modifications", + binding: makeCustomBinding($settings.cleanHyperlinksChanged) + ), ], onSelectAll: { switchToCustomAndApply { selectAllInGroup(appProperties: true) } }, onClearAll: { switchToCustomAndApply { clearAllInGroup(appProperties: true) } }, onInvert: { switchToCustomAndApply { invertGroup(appProperties: true) } } ) - + Divider() - + // Group 2: Core Properties MetadataGroupView( title: "Core Properties", @@ -106,29 +172,89 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "Author", description: "Document creator's name", binding: makeCustomBinding($settings.cleanAuthor)), - MetadataItem(label: "Last Modified By", description: "Last person who edited", binding: makeCustomBinding($settings.cleanLastModifiedBy)), - MetadataItem(label: "Title", description: "Document title", binding: makeCustomBinding($settings.cleanTitle)), - MetadataItem(label: "Subject", description: "Document subject", binding: makeCustomBinding($settings.cleanSubject)), - MetadataItem(label: "Keywords", description: "Search keywords/tags", binding: makeCustomBinding($settings.cleanKeywords)), - MetadataItem(label: "Comments", description: "Document description field", binding: makeCustomBinding($settings.cleanComments)), - MetadataItem(label: "Category", description: "Document category", binding: makeCustomBinding($settings.cleanCategory)), - MetadataItem(label: "Content Status", description: "Status (Draft, Final, etc.)", binding: makeCustomBinding($settings.cleanContentStatus)), - MetadataItem(label: "Created Date", description: "Creation timestamp", binding: makeCustomBinding($settings.cleanCreatedDate)), - MetadataItem(label: "Modified Date", description: "Last modification timestamp", binding: makeCustomBinding($settings.cleanModifiedDate)), - MetadataItem(label: "Last Printed", description: "Last print timestamp", binding: makeCustomBinding($settings.cleanLastPrinted)), - MetadataItem(label: "Revision Number", description: "Document revision count", binding: makeCustomBinding($settings.cleanRevisionNumber)), - MetadataItem(label: "Identifier", description: "Unique document identifier", binding: makeCustomBinding($settings.cleanIdentifier)), - MetadataItem(label: "Language", description: "Document language code", binding: makeCustomBinding($settings.cleanLanguage)), - MetadataItem(label: "Version", description: "Document version string", binding: makeCustomBinding($settings.cleanVersion)), + MetadataItem( + label: "Author", + description: "Document creator's name", + binding: makeCustomBinding($settings.cleanAuthor) + ), + MetadataItem( + label: "Last Modified By", + description: "Last person who edited", + binding: makeCustomBinding($settings.cleanLastModifiedBy) + ), + MetadataItem( + label: "Title", + description: "Document title", + binding: makeCustomBinding($settings.cleanTitle) + ), + MetadataItem( + label: "Subject", + description: "Document subject", + binding: makeCustomBinding($settings.cleanSubject) + ), + MetadataItem( + label: "Keywords", + description: "Search keywords/tags", + binding: makeCustomBinding($settings.cleanKeywords) + ), + MetadataItem( + label: "Comments", + description: "Document description field", + binding: makeCustomBinding($settings.cleanComments) + ), + MetadataItem( + label: "Category", + description: "Document category", + binding: makeCustomBinding($settings.cleanCategory) + ), + MetadataItem( + label: "Content Status", + description: "Status (Draft, Final, etc.)", + binding: makeCustomBinding($settings.cleanContentStatus) + ), + MetadataItem( + label: "Created Date", + description: "Creation timestamp", + binding: makeCustomBinding($settings.cleanCreatedDate) + ), + MetadataItem( + label: "Modified Date", + description: "Last modification timestamp", + binding: makeCustomBinding($settings.cleanModifiedDate) + ), + MetadataItem( + label: "Last Printed", + description: "Last print timestamp", + binding: makeCustomBinding($settings.cleanLastPrinted) + ), + MetadataItem( + label: "Revision Number", + description: "Document revision count", + binding: makeCustomBinding($settings.cleanRevisionNumber) + ), + MetadataItem( + label: "Identifier", + description: "Unique document identifier", + binding: makeCustomBinding($settings.cleanIdentifier) + ), + MetadataItem( + label: "Language", + description: "Document language code", + binding: makeCustomBinding($settings.cleanLanguage) + ), + MetadataItem( + label: "Version", + description: "Document version string", + binding: makeCustomBinding($settings.cleanVersion) + ), ], onSelectAll: { switchToCustomAndApply { selectAllInGroup(coreProperties: true) } }, onClearAll: { switchToCustomAndApply { clearAllInGroup(coreProperties: true) } }, onInvert: { switchToCustomAndApply { invertGroup(coreProperties: true) } } ) - + Divider() - + // Group 3: Custom Properties MetadataGroupView( title: "Custom Properties", @@ -137,15 +263,19 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "Custom Properties & Custom XML", description: "User-defined properties and custom XML parts", binding: makeCustomBinding($settings.cleanCustomProperties)), + MetadataItem( + label: "Custom Properties & Custom XML", + description: "User-defined properties and custom XML parts", + binding: makeCustomBinding($settings.cleanCustomProperties) + ), ], onSelectAll: { switchToCustomAndApply { settings.cleanCustomProperties = true } }, onClearAll: { switchToCustomAndApply { settings.cleanCustomProperties = false } }, onInvert: { switchToCustomAndApply { settings.cleanCustomProperties.toggle() } } ) - + Divider() - + // Group 4: Document Structure MetadataGroupView( title: "Document Structure", @@ -154,29 +284,89 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "Visible Review Comments", description: "Active comment annotations", binding: makeCustomBinding($settings.cleanReviewCommentsVisible)), - MetadataItem(label: "Hidden/Resolved Review Comments", description: "Resolved or hidden comment annotations", binding: makeCustomBinding($settings.cleanReviewCommentsHidden)), - MetadataItem(label: "Track Changes", description: "Insertions, deletions, formatting changes", binding: makeCustomBinding($settings.cleanTrackChanges)), - MetadataItem(label: "RSIDs", description: "Revision Save IDs (fingerprinting data)", binding: makeCustomBinding($settings.cleanRSIDs)), - MetadataItem(label: "Document GUID", description: "Unique document identifier in settings", binding: makeCustomBinding($settings.cleanDocumentGUID)), - MetadataItem(label: "Spell/Grammar State", description: "Proofing state markers", binding: makeCustomBinding($settings.cleanSpellGrammarState)), - MetadataItem(label: "Document Variables", description: "Programmatic variables", binding: makeCustomBinding($settings.cleanDocumentVariables)), - MetadataItem(label: "Mail Merge Data", description: "Data source bindings and merge fields", binding: makeCustomBinding($settings.cleanMailMerge)), - MetadataItem(label: "Data Bindings", description: "Content control XML bindings", binding: makeCustomBinding($settings.cleanDataBindings)), - MetadataItem(label: "Document Versions", description: "Legacy version history parts", binding: makeCustomBinding($settings.cleanDocumentVersions)), - MetadataItem(label: "Ink Annotations", description: "Pen/ink markup data", binding: makeCustomBinding($settings.cleanInkAnnotations)), - MetadataItem(label: "Hidden Text", description: "Runs marked as hidden text", binding: makeCustomBinding($settings.cleanHiddenText)), - MetadataItem(label: "Invisible Objects", description: "Shapes marked as hidden", binding: makeCustomBinding($settings.cleanInvisibleObjects)), - MetadataItem(label: "Headers & Footers", description: "Remove all header/footer parts", binding: makeCustomBinding($settings.cleanHeadersFooters)), - MetadataItem(label: "Watermarks", description: "Remove watermark shapes in headers", binding: makeCustomBinding($settings.cleanWatermarks)), + MetadataItem( + label: "Visible Review Comments", + description: "Active comment annotations", + binding: makeCustomBinding($settings.cleanReviewCommentsVisible) + ), + MetadataItem( + label: "Hidden/Resolved Review Comments", + description: "Resolved or hidden comment annotations", + binding: makeCustomBinding($settings.cleanReviewCommentsHidden) + ), + MetadataItem( + label: "Track Changes", + description: "Insertions, deletions, formatting changes", + binding: makeCustomBinding($settings.cleanTrackChanges) + ), + MetadataItem( + label: "RSIDs", + description: "Revision Save IDs (fingerprinting data)", + binding: makeCustomBinding($settings.cleanRSIDs) + ), + MetadataItem( + label: "Document GUID", + description: "Unique document identifier in settings", + binding: makeCustomBinding($settings.cleanDocumentGUID) + ), + MetadataItem( + label: "Spell/Grammar State", + description: "Proofing state markers", + binding: makeCustomBinding($settings.cleanSpellGrammarState) + ), + MetadataItem( + label: "Document Variables", + description: "Programmatic variables", + binding: makeCustomBinding($settings.cleanDocumentVariables) + ), + MetadataItem( + label: "Mail Merge Data", + description: "Data source bindings and merge fields", + binding: makeCustomBinding($settings.cleanMailMerge) + ), + MetadataItem( + label: "Data Bindings", + description: "Content control XML bindings", + binding: makeCustomBinding($settings.cleanDataBindings) + ), + MetadataItem( + label: "Document Versions", + description: "Legacy version history parts", + binding: makeCustomBinding($settings.cleanDocumentVersions) + ), + MetadataItem( + label: "Ink Annotations", + description: "Pen/ink markup data", + binding: makeCustomBinding($settings.cleanInkAnnotations) + ), + MetadataItem( + label: "Hidden Text", + description: "Runs marked as hidden text", + binding: makeCustomBinding($settings.cleanHiddenText) + ), + MetadataItem( + label: "Invisible Objects", + description: "Shapes marked as hidden", + binding: makeCustomBinding($settings.cleanInvisibleObjects) + ), + MetadataItem( + label: "Headers & Footers", + description: "Remove all header/footer parts", + binding: makeCustomBinding($settings.cleanHeadersFooters) + ), + MetadataItem( + label: "Watermarks", + description: "Remove watermark shapes in headers", + binding: makeCustomBinding($settings.cleanWatermarks) + ), ], onSelectAll: { switchToCustomAndApply { selectAllInGroup(documentStructure: true) } }, onClearAll: { switchToCustomAndApply { clearAllInGroup(documentStructure: true) } }, onInvert: { switchToCustomAndApply { invertGroup(documentStructure: true) } } ) - + Divider() - + // Group 5: Embedded Content MetadataGroupView( title: "Embedded Content", @@ -185,24 +375,64 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "Thumbnail Image", description: "Document preview image", binding: makeCustomBinding($settings.cleanThumbnail)), - MetadataItem(label: "Hyperlink URLs", description: "External links (converted to plain text)", binding: makeCustomBinding($settings.cleanHyperlinkURLs)), - MetadataItem(label: "Alt Text on Images", description: "Descriptive text on embedded images", binding: makeCustomBinding($settings.cleanAltText)), - MetadataItem(label: "OLE Objects", description: "Embedded Excel, PDFs, etc.", binding: makeCustomBinding($settings.cleanOLEObjects)), - MetadataItem(label: "VBA Macros", description: "Embedded code (security risk)", binding: makeCustomBinding($settings.cleanVBAMacros)), - MetadataItem(label: "Digital Signatures", description: "Document signing data", binding: makeCustomBinding($settings.cleanDigitalSignatures)), - MetadataItem(label: "Printer Settings", description: "Print configuration data", binding: makeCustomBinding($settings.cleanPrinterSettings)), - MetadataItem(label: "Embedded Fonts", description: "Embedded font information", binding: makeCustomBinding($settings.cleanEmbeddedFonts)), - MetadataItem(label: "Glossary/AutoText", description: "Auto-complete entries", binding: makeCustomBinding($settings.cleanGlossary)), - MetadataItem(label: "Fast Save Data", description: "Incremental save fragments", binding: makeCustomBinding($settings.cleanFastSaveData)), + MetadataItem( + label: "Thumbnail Image", + description: "Document preview image", + binding: makeCustomBinding($settings.cleanThumbnail) + ), + MetadataItem( + label: "Hyperlink URLs", + description: "External links (converted to plain text)", + binding: makeCustomBinding($settings.cleanHyperlinkURLs) + ), + MetadataItem( + label: "Alt Text on Images", + description: "Descriptive text on embedded images", + binding: makeCustomBinding($settings.cleanAltText) + ), + MetadataItem( + label: "OLE Objects", + description: "Embedded Excel, PDFs, etc.", + binding: makeCustomBinding($settings.cleanOLEObjects) + ), + MetadataItem( + label: "VBA Macros", + description: "Embedded code (security risk)", + binding: makeCustomBinding($settings.cleanVBAMacros) + ), + MetadataItem( + label: "Digital Signatures", + description: "Document signing data", + binding: makeCustomBinding($settings.cleanDigitalSignatures) + ), + MetadataItem( + label: "Printer Settings", + description: "Print configuration data", + binding: makeCustomBinding($settings.cleanPrinterSettings) + ), + MetadataItem( + label: "Embedded Fonts", + description: "Embedded font information", + binding: makeCustomBinding($settings.cleanEmbeddedFonts) + ), + MetadataItem( + label: "Glossary/AutoText", + description: "Auto-complete entries", + binding: makeCustomBinding($settings.cleanGlossary) + ), + MetadataItem( + label: "Fast Save Data", + description: "Incremental save fragments", + binding: makeCustomBinding($settings.cleanFastSaveData) + ), ], onSelectAll: { switchToCustomAndApply { selectAllInGroup(embeddedContent: true) } }, onClearAll: { switchToCustomAndApply { clearAllInGroup(embeddedContent: true) } }, onInvert: { switchToCustomAndApply { invertGroup(embeddedContent: true) } } ) - + Divider() - + // Group 6: Advanced Hardening MetadataGroupView( title: "Advanced Hardening", @@ -211,17 +441,61 @@ struct MetadataCleaningSheet: View { collapsedStates: $collapsedStates, currentPreset: selectedPreset, items: [ - MetadataItem(label: "External Link Paths", description: "File paths in external links", binding: makeCustomBinding($settings.cleanExternalLinks)), - MetadataItem(label: "Network (UNC) Paths", description: "\\\\server\\share style paths", binding: makeCustomBinding($settings.cleanUNCPaths)), - MetadataItem(label: "User Profile Paths", description: "Home directory paths (~/name or %USERPROFILE%)", binding: makeCustomBinding($settings.cleanUserPaths)), - MetadataItem(label: "Internal URLs", description: "Internal site URLs (intranet, etc.)", binding: makeCustomBinding($settings.cleanInternalURLs)), - MetadataItem(label: "OLE Source Paths", description: "Source paths embedded in OLE objects", binding: makeCustomBinding($settings.cleanOLESources)), - MetadataItem(label: "Image EXIF Data", description: "GPS, camera info, author in JPEG/PNG", binding: makeCustomBinding($settings.cleanImageEXIF)), - MetadataItem(label: "Custom Style Names", description: "Rename identifying style names", binding: makeCustomBinding($settings.cleanStyleNames)), - MetadataItem(label: "Chart Labels", description: "Clean identifying chart text", binding: makeCustomBinding($settings.cleanChartLabels)), - MetadataItem(label: "Form Field Defaults", description: "Clear pre-filled form values", binding: makeCustomBinding($settings.cleanFormDefaults)), - MetadataItem(label: "Language Settings", description: "Locale and language fingerprints", binding: makeCustomBinding($settings.cleanLanguageSettings)), - MetadataItem(label: "ActiveX Controls", description: "Remove embedded ActiveX", binding: makeCustomBinding($settings.cleanActiveX)), + MetadataItem( + label: "External Link Paths", + description: "File paths in external links", + binding: makeCustomBinding($settings.cleanExternalLinks) + ), + MetadataItem( + label: "Network (UNC) Paths", + description: "\\\\server\\share style paths", + binding: makeCustomBinding($settings.cleanUNCPaths) + ), + MetadataItem( + label: "User Profile Paths", + description: "Home directory paths (~/name or %USERPROFILE%)", + binding: makeCustomBinding($settings.cleanUserPaths) + ), + MetadataItem( + label: "Internal URLs", + description: "Internal site URLs (intranet, etc.)", + binding: makeCustomBinding($settings.cleanInternalURLs) + ), + MetadataItem( + label: "OLE Source Paths", + description: "Source paths embedded in OLE objects", + binding: makeCustomBinding($settings.cleanOLESources) + ), + MetadataItem( + label: "Image EXIF Data", + description: "GPS, camera info, author in JPEG/PNG", + binding: makeCustomBinding($settings.cleanImageEXIF) + ), + MetadataItem( + label: "Custom Style Names", + description: "Rename identifying style names", + binding: makeCustomBinding($settings.cleanStyleNames) + ), + MetadataItem( + label: "Chart Labels", + description: "Clean identifying chart text", + binding: makeCustomBinding($settings.cleanChartLabels) + ), + MetadataItem( + label: "Form Field Defaults", + description: "Clear pre-filled form values", + binding: makeCustomBinding($settings.cleanFormDefaults) + ), + MetadataItem( + label: "Language Settings", + description: "Locale and language fingerprints", + binding: makeCustomBinding($settings.cleanLanguageSettings) + ), + MetadataItem( + label: "ActiveX Controls", + description: "Remove embedded ActiveX", + binding: makeCustomBinding($settings.cleanActiveX) + ), MetadataItem( label: "Nuclear Option: Custom XML Parts", description: "Remove all /customXml package parts", @@ -267,9 +541,9 @@ struct MetadataCleaningSheet: View { .padding(.horizontal, 24) .padding(.bottom, 24) } - + Divider() - + // Footer buttons HStack(spacing: 12) { Button("Cancel") { @@ -278,9 +552,9 @@ struct MetadataCleaningSheet: View { .buttonStyle(.bordered) .controlSize(.large) .accessibilityIdentifier("metadata.cancel") - + Spacer() - + Button("Save Preferences") { settings.save() dismiss() @@ -303,7 +577,7 @@ struct MetadataCleaningSheet: View { selectedPreset = newSettings.detectPreset() } } - + private func applyPreset(_ preset: MetadataCleaningPreset) { switch preset { case .maximum: @@ -317,7 +591,7 @@ struct MetadataCleaningSheet: View { settings = customSettings } } - + /// Apply action and switch to Custom preset private func switchToCustomAndApply(_ action: () -> Void) { if selectedPreset != .custom { @@ -326,7 +600,7 @@ struct MetadataCleaningSheet: View { } action() } - + /// Creates a binding that switches to Custom when modified private func makeCustomBinding(_ binding: Binding) -> Binding { Binding( @@ -340,8 +614,14 @@ struct MetadataCleaningSheet: View { } ) } - - private func selectAllInGroup(appProperties: Bool = false, coreProperties: Bool = false, documentStructure: Bool = false, embeddedContent: Bool = false, advancedHardening: Bool = false) { + + private func selectAllInGroup( + appProperties: Bool = false, + coreProperties: Bool = false, + documentStructure: Bool = false, + embeddedContent: Bool = false, + advancedHardening: Bool = false + ) { if appProperties { settings.cleanCompany = true settings.cleanManager = true @@ -414,8 +694,14 @@ struct MetadataCleaningSheet: View { settings.cleanAlternateContent = true } } - - private func clearAllInGroup(appProperties: Bool = false, coreProperties: Bool = false, documentStructure: Bool = false, embeddedContent: Bool = false, advancedHardening: Bool = false) { + + private func clearAllInGroup( + appProperties: Bool = false, + coreProperties: Bool = false, + documentStructure: Bool = false, + embeddedContent: Bool = false, + advancedHardening: Bool = false + ) { if appProperties { settings.cleanCompany = false settings.cleanManager = false @@ -488,8 +774,14 @@ struct MetadataCleaningSheet: View { settings.cleanAlternateContent = false } } - - private func invertGroup(appProperties: Bool = false, coreProperties: Bool = false, documentStructure: Bool = false, embeddedContent: Bool = false, advancedHardening: Bool = false) { + + private func invertGroup( + appProperties: Bool = false, + coreProperties: Bool = false, + documentStructure: Bool = false, + embeddedContent: Bool = false, + advancedHardening: Bool = false + ) { if appProperties { settings.cleanCompany.toggle() settings.cleanManager.toggle() @@ -569,7 +861,7 @@ struct MetadataCleaningSheet: View { struct MetadataItem { let label: String let description: String - var warning: String? = nil + var warning: String? var binding: Binding } @@ -583,18 +875,18 @@ struct MetadataGroupView: View { let onSelectAll: () -> Void let onClearAll: () -> Void let onInvert: () -> Void - + private var isExpanded: Bool { !(collapsedStates[groupKey]?[currentPreset] ?? false) } - + private func setExpanded(_ expanded: Bool) { if collapsedStates[groupKey] == nil { collapsedStates[groupKey] = [:] } collapsedStates[groupKey]?[currentPreset] = !expanded } - + var body: some View { VStack(alignment: .leading, spacing: 8) { // Header with expand/collapse @@ -609,10 +901,10 @@ struct MetadataGroupView: View { .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) .frame(width: 16) - + Text(title) .font(.system(size: 14, weight: .semibold)) - + Text("(\(subtitle))") .font(.system(size: 12)) .foregroundColor(.secondary) @@ -620,25 +912,25 @@ struct MetadataGroupView: View { } .buttonStyle(.plain) .accessibilityIdentifier("metadata.\(groupKey).toggleGroup") - + Spacer() - + // Only show All/None/Invert buttons in Custom mode - if isExpanded && currentPreset == .custom { + if isExpanded, currentPreset == .custom { Button("All") { onSelectAll() } .buttonStyle(.bordered) .controlSize(.mini) .accessibilityIdentifier("metadata.\(groupKey).selectAll") - + Button("None") { onClearAll() } .buttonStyle(.bordered) .controlSize(.mini) .accessibilityIdentifier("metadata.\(groupKey).selectNone") - + Button("Invert") { onInvert() } @@ -647,7 +939,7 @@ struct MetadataGroupView: View { .accessibilityIdentifier("metadata.\(groupKey).invert") } } - + if isExpanded { VStack(alignment: .leading, spacing: 6) { ForEach(items.indices, id: \.self) { index in diff --git a/src/swift/MarcutApp/Sources/MarcutApp/ModelCatalog.swift b/src/swift/MarcutApp/Sources/MarcutApp/ModelCatalog.swift index 9968295..ff3f797 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/ModelCatalog.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/ModelCatalog.swift @@ -21,15 +21,15 @@ struct ModelCatalogEntry: Codable, Equatable, Identifiable { func resolvedAccentColor(for colorScheme: ColorScheme) -> Color { switch accentColor { case "accent": - return CustomColors.accentColor(for: colorScheme) + CustomColors.accentColor(for: colorScheme) case "orange": - return Color.orange + Color.orange case "green": - return Color.green + Color.green case "purple": - return Color.purple + Color.purple default: - return CustomColors.accentColor(for: colorScheme) + CustomColors.accentColor(for: colorScheme) } } } @@ -58,7 +58,7 @@ final class ModelCatalog { /// All supported model identifiers, in catalog order. var modelIds: Set { - Set(models.map { $0.id }) + Set(models.map(\.id)) } private init() { diff --git a/src/swift/MarcutApp/Sources/MarcutApp/OutputSaveLocation.swift b/src/swift/MarcutApp/Sources/MarcutApp/OutputSaveLocation.swift index eedc21b..fddb8c2 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/OutputSaveLocation.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/OutputSaveLocation.swift @@ -5,16 +5,18 @@ enum OutputSaveLocation: Int, CaseIterable, Identifiable { case sameAsOriginal = 1 case alwaysAsk = 2 - var id: Int { rawValue } + var id: Int { + rawValue + } var label: String { switch self { case .downloads: - return "Downloads" + "Downloads" case .sameAsOriginal: - return "Same as Original" + "Same as Original" case .alwaysAsk: - return "Always Ask" + "Always Ask" } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/PermissionAuthorizationView.swift b/src/swift/MarcutApp/Sources/MarcutApp/PermissionAuthorizationView.swift index 2e002c0..4f5049f 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/PermissionAuthorizationView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/PermissionAuthorizationView.swift @@ -98,7 +98,7 @@ struct PermissionAuthorizationView: View { } // Action Buttons - if !isAuthorizing && authorizationError == nil { + if !isAuthorizing, authorizationError == nil { VStack(spacing: 16) { Button(action: { authorizePermissions() @@ -261,7 +261,7 @@ struct PermissionExplanationView: View { "Read Microsoft Word documents that you select", "Create new redacted documents with track changes", "Generate audit reports in JSON format", - "Access files in your Downloads, Documents, and Desktop folders" + "Access files in your Downloads, Documents, and Desktop folders", ] ) @@ -274,7 +274,7 @@ struct PermissionExplanationView: View { "Run AI models like Llama 3.1 8B locally", "No internet connection required for processing", "All AI computation happens on your Mac", - "Complete privacy - no data sent to external servers" + "Complete privacy - no data sent to external servers", ] ) @@ -287,7 +287,7 @@ struct PermissionExplanationView: View { "One-time access to your Documents folder", "Access only to files you explicitly select", "Maintains security through sandboxing", - "You can revoke this permission at any time in System Preferences" + "You can revoke this permission at any time in System Preferences", ] ) } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/PermissionManager.swift b/src/swift/MarcutApp/Sources/MarcutApp/PermissionManager.swift index a3b9de2..d00610f 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/PermissionManager.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/PermissionManager.swift @@ -6,29 +6,28 @@ import UserNotifications static let shared = PermissionManager() @Published var notificationStatus: UNAuthorizationStatus = .notDetermined - - // Local preference: Should we send notifications? - // This allows the user to "Disable" them in-app without revoking OS permission. + + /// Local preference: Should we send notifications? + /// This allows the user to "Disable" them in-app without revoking OS permission. @Published var userEnabledNotifications: Bool { didSet { UserDefaults.standard.set(userEnabledNotifications, forKey: DefaultsKey.userEnabledNotifications.key) } } - private override init() { + override private init() { // Init local preference (Default to TRUE if not set) let savedPref = UserDefaults.standard.object(forKey: DefaultsKey.userEnabledNotifications.key) as? Bool self.userEnabledNotifications = savedPref ?? true - + super.init() - + // CRITICAL FIX: Ensure notification delegate is set immediately. // This allows banners to appear even if the app is in the foreground. UNUserNotificationCenter.current().delegate = self // DEFERRED: We no longer check automatically on init to avoid startup prompts. } - } enum PermissionError: LocalizedError { @@ -37,23 +36,20 @@ enum PermissionError: LocalizedError { var errorDescription: String? { switch self { case .notificationPermissionDenied: - return "Notification permission was denied" + "Notification permission was denied" } } } - - extension PermissionManager: UNUserNotificationCenterDelegate { - - // Step 1.5: Request Notification Permissions + /// Step 1.5: Request Notification Permissions func requestNotificationPermission() async throws { let center = UNUserNotificationCenter.current() // Delegate needed to show notifications while app is in foreground center.delegate = self - + let settings = await center.notificationSettings() - + // If already authorized, just return success (no nag) // Also update the published status await MainActor.run { self.notificationStatus = settings.authorizationStatus } @@ -62,16 +58,16 @@ extension PermissionManager: UNUserNotificationCenterDelegate { DebugLogger.shared.log("βœ… Notifications already authorized", component: "PermissionManager") return } - + // If explicitly denied, do not pester the user if settings.authorizationStatus == .denied { DebugLogger.shared.log("⚠️ Notification permission was previously denied", component: "PermissionManager") throw PermissionError.notificationPermissionDenied } - + // Only request if status is .notDetermined let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) - + // Update status again after request let newSettings = await center.notificationSettings() await MainActor.run { self.notificationStatus = newSettings.authorizationStatus } @@ -83,9 +79,9 @@ extension PermissionManager: UNUserNotificationCenterDelegate { throw PermissionError.notificationPermissionDenied } } - - // Helper to send notifications - // Helper to send notifications + + /// Helper to send notifications + /// Helper to send notifications func sendSystemNotification(title: String, body: String, force: Bool = false) { // 1. Check Local Preference first (unless forced) guard userEnabledNotifications || force else { @@ -97,29 +93,35 @@ extension PermissionManager: UNUserNotificationCenterDelegate { content.title = title content.body = body content.sound = UNNotificationSound.default - + // Create a unique ID or reuse? Unique for history. let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) - + UNUserNotificationCenter.current().add(request) { error in - if let error = error { - DebugLogger.shared.log("❌ Failed to schedule notification: \(error.localizedDescription)", component: "PermissionManager") + if let error { + DebugLogger.shared.log( + "❌ Failed to schedule notification: \(error.localizedDescription)", + component: "PermissionManager" + ) } else { - DebugLogger.shared.log("βœ… Notification successfully scheduled into UNUserNotificationCenter", component: "PermissionManager") + DebugLogger.shared.log( + "βœ… Notification successfully scheduled into UNUserNotificationCenter", + component: "PermissionManager" + ) } } } - - // Force request without checks (User triggered) + + /// Force request without checks (User triggered) func forceRequestNotificationPermission() async throws { let center = UNUserNotificationCenter.current() center.delegate = self - + DebugLogger.shared.log("🚨 Force-requesting notification permission...", component: "PermissionManager") - + // Always request, ignoring previous status let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) - + // Update status immediately let newSettings = await center.notificationSettings() await MainActor.run { self.notificationStatus = newSettings.authorizationStatus } @@ -132,17 +134,21 @@ extension PermissionManager: UNUserNotificationCenterDelegate { throw PermissionError.notificationPermissionDenied } } - - // Explicit refresh of status (called on view appear) + + /// Explicit refresh of status (called on view appear) func refreshNotificationStatus() async { let settings = await UNUserNotificationCenter.current().notificationSettings() await MainActor.run { - self.notificationStatus = settings.authorizationStatus + self.notificationStatus = settings.authorizationStatus } } - - // Delegate method: Present notification even if app is in foreground - nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + + /// Delegate method: Present notification even if app is in foreground + nonisolated func userNotificationCenter( + _: UNUserNotificationCenter, + willPresent _: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { completionHandler([.banner, .sound, .list]) } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/PowerAssertionGuard.swift b/src/swift/MarcutApp/Sources/MarcutApp/PowerAssertionGuard.swift index 5d24d40..adb8a62 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/PowerAssertionGuard.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/PowerAssertionGuard.swift @@ -114,5 +114,7 @@ final class PowerAssertionGuard { /// Whether an OS-level assertion is currently held. `false` if the count is zero, or if the /// count is positive but the OS refused every `acquire()` attempt so far. - var isHoldingAssertion: Bool { assertionID != nil } + var isHoldingAssertion: Bool { + assertionID != nil + } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/PythonBridge.swift b/src/swift/MarcutApp/Sources/MarcutApp/PythonBridge.swift index 24ce5b4..f1e4e4b 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/PythonBridge.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/PythonBridge.swift @@ -1,8 +1,8 @@ -import Foundation import Combine import Darwin +import Foundation -// Helper functions outside of actor context +/// Helper functions outside of actor context private let pythonBridgeLogQueue = DispatchQueue(label: "com.marclaw.marcutapp.pythonbridge.log", qos: .utility) private func logToFile(_ path: String, _ message: String) { @@ -35,14 +35,14 @@ private func logToFile(_ path: String, _ message: String) { private func removeANSIEscapeCodes(_ input: String) -> String { // Remove ANSI escape sequences using regex let patterns = [ - "\\x1B\\[[0-9;]*[mGKH]", // Common color and cursor codes - "\\x1B\\[\\?[0-9]+[hl]", // Mode settings - "\\x1B\\[[0-9]*[ABCD]", // Cursor movement - "\\x1B\\[[0-9;]*[Jm]", // Clear and color - "\\[\\?2026[hl]", // Bracketed paste mode - "\\[\\?25[hl]", // Cursor visibility - "\\[[0-9]+G", // Cursor column - "\\[K" // Clear line + "\\x1B\\[[0-9;]*[mGKH]", // Common color and cursor codes + "\\x1B\\[\\?[0-9]+[hl]", // Mode settings + "\\x1B\\[[0-9]*[ABCD]", // Cursor movement + "\\x1B\\[[0-9;]*[Jm]", // Clear and color + "\\[\\?2026[hl]", // Bracketed paste mode + "\\[\\?25[hl]", // Cursor visibility + "\\[[0-9]+G", // Cursor column + "\\[K", // Clear line ] var result = input @@ -85,8 +85,7 @@ private func parseOllamaPullProgressLine(_ line: String) -> Double? { if let match = cleaned.range(of: #"[0-9]+(\.[0-9]+)?%"#, options: .regularExpression) { let raw = cleaned[match].replacingOccurrences(of: "%", with: "") if let percent = Double(raw) { - let scaled = min(95.0, 5.0 + percent * 0.9) - return scaled + return min(95.0, 5.0 + percent * 0.9) } } @@ -110,7 +109,7 @@ private enum ModelPromotion { private static let fileManager = FileManager.default static func safeFileName(for modelName: String) -> String { - return modelName + modelName .replacingOccurrences(of: ":", with: "-") .replacingOccurrences(of: "/", with: "-") } @@ -129,7 +128,8 @@ private enum ModelPromotion { return true } guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), - let size = (attributes[.size] as? NSNumber)?.int64Value else { + let size = (attributes[.size] as? NSNumber)?.int64Value + else { return false } return size == expectedSize @@ -154,52 +154,81 @@ private enum ModelPromotion { return models } - if let registries = try? fileManager.contentsOfDirectory(at: manifestsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { + if let registries = try? fileManager.contentsOfDirectory( + at: manifestsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { for registry in registries { - bridgeLog("MODEL_DISCOVERY: Searching registry \(registry.lastPathComponent)", component: "MODEL_PROMOTION") - guard let libraries = try? fileManager.contentsOfDirectory(at: registry, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { continue } + bridgeLog( + "MODEL_DISCOVERY: Searching registry \(registry.lastPathComponent)", + component: "MODEL_PROMOTION" + ) + guard let libraries = try? fileManager.contentsOfDirectory( + at: registry, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { continue } for library in libraries { bridgeLog("MODEL_DISCOVERY: -> Library \(library.lastPathComponent)", component: "MODEL_PROMOTION") - guard let modelDirs = try? fileManager.contentsOfDirectory(at: library, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { continue } + guard let modelDirs = try? fileManager.contentsOfDirectory( + at: library, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { continue } for modelDir in modelDirs { - bridgeLog("MODEL_DISCOVERY: -> ModelDir \(modelDir.lastPathComponent)", component: "MODEL_PROMOTION") + bridgeLog( + "MODEL_DISCOVERY: -> ModelDir \(modelDir.lastPathComponent)", + component: "MODEL_PROMOTION" + ) if modelDir.lastPathComponent.contains("sha256") { continue } - + // Check for manifest.json (legacy) OR tag files (modern) // We iterate all files in the model directory - if let files = try? fileManager.contentsOfDirectory(at: modelDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { + if let files = try? fileManager.contentsOfDirectory( + at: modelDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { for file in files { // Skip system files - if file.lastPathComponent.hasPrefix(".") { continue } - + if file.lastPathComponent.hasPrefix(".") { + continue + } + // If it's a JSON file or just a tag name (no extension) // We assume it's a valid model tag if it parses as a manifest let libraryName = library.lastPathComponent let modelName = modelDir.lastPathComponent - let tagName = file.lastPathComponent == "manifest.json" ? "latest" : file.lastPathComponent - + let tagName = file.lastPathComponent == "manifest.json" ? "latest" : file + .lastPathComponent + // Construct the full model ID: library/model:tag - // But Marcut uses library:model format mostly? - // Actually, discoverModels returns a Set. - // The existing code returned "library:model". + // But Marcut uses library:model format mostly? + // Actually, discoverModels returns a Set. + // The existing code returned "library:model". // Let's stick to "library:model" if tag is latest, or "library:model:tag" if not? - // The user selects "llama3.2:3b". - // If we find "library/llama3.2/3b", we should probably return "llama3.2:3b" (omitting library if it's 'library'?) - + // The user selects "llama3.2:3b". + // If we find "library/llama3.2/3b", we should probably return "llama3.2:3b" (omitting + // library if it's 'library'?) + // Let's try to match what the user expects: "llama3.2:3b" var identifier = "\(modelName)" - if tagName != "latest" && tagName != "manifest.json" { + if tagName != "latest", tagName != "manifest.json" { identifier += ":\(tagName)" } - + // If library is NOT 'library', prepend it? if libraryName != "library" { identifier = "\(libraryName)/\(identifier)" } - - bridgeLog("MODEL_DISCOVERY: Found model candidate: \(identifier) at \(file.path)", component: "MODEL_PROMOTION") + + bridgeLog( + "MODEL_DISCOVERY: Found model candidate: \(identifier) at \(file.path)", + component: "MODEL_PROMOTION" + ) models.insert(identifier) } } @@ -236,7 +265,10 @@ private enum ModelPromotion { } do { - try fileManager.createDirectory(at: canonicalURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory( + at: canonicalURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) if fileManager.fileExists(atPath: canonicalURL.path) { try fileManager.removeItem(at: canonicalURL) @@ -259,8 +291,11 @@ private enum ModelPromotion { if let expectedSize = manifest.size, let attributes = try? fileManager.attributesOfItem(atPath: canonicalURL.path), let actualSize = (attributes[.size] as? NSNumber)?.int64Value, - actualSize != expectedSize { - log("Canonical file size mismatch for \(modelName). Expected \(expectedSize), got \(actualSize). Removing corrupted file.") + actualSize != expectedSize + { + log( + "Canonical file size mismatch for \(modelName). Expected \(expectedSize), got \(actualSize). Removing corrupted file." + ) try? fileManager.removeItem(at: canonicalURL) return false } @@ -277,18 +312,18 @@ private enum ModelPromotion { // Parse model base name and tag // Input: "llama3.2:3b" -> library="library", model="llama3.2", tag="3b" // Input: "library/llama3.2:3b" -> library="library", model="llama3.2", tag="3b" - + var libraryName = "library" var modelBaseName = modelName var modelTag = "latest" - + // Check for library prefix (e.g. "user/model") if modelName.contains("/") { let parts = modelName.split(separator: "/", maxSplits: 1) libraryName = String(parts[0]) modelBaseName = String(parts[1]) } - + // Check for tag suffix (e.g. "model:tag") if modelBaseName.contains(":") { let parts = modelBaseName.split(separator: ":", maxSplits: 1) @@ -305,7 +340,11 @@ private enum ModelPromotion { log("MANIFEST_INFO: Searching registries in \(manifestsDir.path)...") log("MANIFEST_INFO: Model parsed as library='\(libraryName)', baseName='\(modelBaseName)', tag='\(modelTag)'") - if let registries = try? fileManager.contentsOfDirectory(at: manifestsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { + if let registries = try? fileManager.contentsOfDirectory( + at: manifestsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { for registry in registries { let modelDir = registry .appendingPathComponent(libraryName, isDirectory: true) @@ -332,7 +371,11 @@ private enum ModelPromotion { } // Final fallback: look for any JSON file in the model directory - if let contents = try? fileManager.contentsOfDirectory(at: modelDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { + if let contents = try? fileManager.contentsOfDirectory( + at: modelDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { for file in contents { if file.pathExtension == "json" || file.lastPathComponent == modelTag { log("MANIFEST_INFO: -> Attempting fallback file: \(file.path)") @@ -348,7 +391,11 @@ private enum ModelPromotion { log("MANIFEST_INFO: Direct path failed. Starting fallback enumeration...") // Final fallback: search entire manifests tree for any manifest - let enumerator = fileManager.enumerator(at: manifestsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + let enumerator = fileManager.enumerator( + at: manifestsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) while let next = enumerator?.nextObject() as? URL { let fileName = next.lastPathComponent let modelDir = next.deletingLastPathComponent() @@ -357,12 +404,14 @@ private enum ModelPromotion { // Check if this could be our manifest (either tag-named or manifest.json) let isManifestCandidate = (fileName == "manifest.json" || - fileName == modelTag || - (fileName as NSString).pathExtension == "json") + fileName == modelTag || + (fileName as NSString).pathExtension == "json") if isManifestCandidate { - log("MANIFEST_INFO: -> Found candidate manifest. Checking if '\(libraryComponent)/\(modelNameComponent)' matches '\(libraryName)/\(modelBaseName)' with file '\(fileName)'") - if libraryComponent == libraryName && modelNameComponent == modelBaseName { + log( + "MANIFEST_INFO: -> Found candidate manifest. Checking if '\(libraryComponent)/\(modelNameComponent)' matches '\(libraryName)/\(modelBaseName)' with file '\(fileName)'" + ) + if libraryComponent == libraryName, modelNameComponent == modelBaseName { if let parsed = parseManifest(at: next) { log("MANIFEST_INFO: βœ… Found manifest via enumeration") return parsed @@ -387,7 +436,8 @@ private enum ModelPromotion { .data(using: .utf8) ?? data guard let object = try? JSONSerialization.jsonObject(with: cleanedData, options: [.fragmentsAllowed]), - let json = object as? [String: Any] else { + let json = object as? [String: Any] + else { return nil } @@ -397,7 +447,8 @@ private enum ModelPromotion { for layer in sorted { if let mediaType = layer["mediaType"] as? String, mediaType.lowercased().contains("gguf"), - let digest = layer["digest"] as? String { + let digest = layer["digest"] as? String + { return ManifestData(digest: digest, size: value(from: layer["size"])) } } @@ -408,7 +459,8 @@ private enum ModelPromotion { } if let config = json["config"] as? [String: Any], - let digest = config["digest"] as? String { + let digest = config["digest"] as? String + { return ManifestData(digest: digest, size: value(from: config["size"])) } @@ -417,7 +469,9 @@ private enum ModelPromotion { private static func blobFileName(for digest: String) -> String? { var trimmed = digest.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } + if trimmed.isEmpty { + return nil + } if trimmed.hasPrefix("sha256:") { trimmed.removeFirst("sha256:".count) @@ -468,7 +522,7 @@ final class PythonBridgeService: ObservableObject { private var launchedOllamaPort: Int32? private var ollamaStartTask: Task? // XPC removed - CLI subprocess only approach - fail-fast direct execution - + // Store active processes by document ID to allow cancellation private var activeProcesses: [UUID: Process] = [:] private var activeModelDownloadSession: URLSession? @@ -491,7 +545,6 @@ final class PythonBridgeService: ObservableObject { ) } - /// Public getter for the current Ollama host (including dynamic port) /// Reads from MARCUT_OLLAMA_HOST environment variable which is set when port changes var currentOllamaHost: String { @@ -521,10 +574,12 @@ final class PythonBridgeService: ObservableObject { } let parts = hostPort.split(separator: ":") if let last = parts.last, parts.count >= 2, - let parsed = Int(last), (1...65535).contains(parsed) { + let parsed = Int(last), (1 ... 65535).contains(parsed) + { port = parsed } else if parts.count == 1, - let parsed = Int(parts[0]), (1...65535).contains(parsed) { + let parsed = Int(parts[0]), (1 ... 65535).contains(parsed) + { port = parsed } } @@ -534,26 +589,28 @@ final class PythonBridgeService: ObservableObject { private var promotionsInFlight: Set = [] - // Shared Application Support container (sandbox-safe) + /// Shared Application Support container (sandbox-safe) private lazy var appSupportURL: URL = { // Force fallback to standard Application Support (Sandbox Safe) let fallback = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("MarcutApp", isDirectory: true) - + do { try FileManager.default.createDirectory(at: fallback, withIntermediateDirectories: true) bridgeLog("Using Application Support container: \(fallback.path)", component: "STORAGE") } catch { - bridgeLog("Failed to prepare Application Support directory: \(fallback.path) - \(error)", component: "STORAGE") + bridgeLog( + "Failed to prepare Application Support directory: \(fallback.path) - \(error)", + component: "STORAGE" + ) } return fallback }() - // Local, writable Application Support root for Ollama runtime artifacts - private lazy var localAppSupportURL: URL = { - // Align local storage with the shared appSupportURL so XPC helper can access the same paths - return appSupportURL - }() + /// Local, writable Application Support root for Ollama runtime artifacts + private lazy var localAppSupportURL: URL = // Align local storage with the shared appSupportURL so XPC helper can + // access the same paths + appSupportURL private func prepareOllamaTmpDir() -> URL? { guard let tmpDir = prepareAppContainerTempDir() else { @@ -567,17 +624,21 @@ final class PythonBridgeService: ObservableObject { private func prepareAppContainerTempDir() -> URL? { let fm = FileManager.default - + // Candidates for temp directory (checked in order) let candidates: [URL] = [ localAppSupportURL.appendingPathComponent("ollama_tmp", isDirectory: true), - localAppSupportURL.appendingPathComponent("tmp", isDirectory: true) + localAppSupportURL.appendingPathComponent("tmp", isDirectory: true), ] for tmpDir in candidates { bridgeLog("Checking Temp Dir Candidate: \(tmpDir.path)", component: "Ollama") do { - try fm.createDirectory(at: tmpDir, withIntermediateDirectories: true, attributes: [.posixPermissions: NSNumber(value: Int16(0o700))]) + try fm.createDirectory( + at: tmpDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: tmpDir.path) bridgeLog("βœ… Using temp dir: \(tmpDir.path)", component: "Ollama") return tmpDir @@ -598,14 +659,20 @@ final class PythonBridgeService: ObservableObject { return (fs.f_flags & UInt32(MNT_NOEXEC)) == 0 } - nonisolated private static func secureEraseDirectory(_ dir: URL) { + private nonisolated static func secureEraseDirectory(_ dir: URL) { let fm = FileManager.default guard fm.fileExists(atPath: dir.path) else { return } - if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil) { + if let enumerator = fm.enumerator( + at: dir, + includingPropertiesForKeys: [.isRegularFileKey], + options: [], + errorHandler: nil + ) { for case let fileURL as URL in enumerator { if let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), - values.isRegularFile == true { + values.isRegularFile == true + { secureEraseFile(fileURL) } } @@ -618,7 +685,7 @@ final class PythonBridgeService: ObservableObject { } } - nonisolated private static func secureEraseFile(_ url: URL) { + private nonisolated static func secureEraseFile(_ url: URL) { let fm = FileManager.default // Secure erase zero-out removed: // APFS copy-on-write makes zeroing files ineffective and increases SSD wear. @@ -631,7 +698,7 @@ final class PythonBridgeService: ObservableObject { bridgeLog("ℹ️ No ollama log file found at \(ollamaLogFileURL.path)", component: "Ollama") return } - + do { let data = try Data(contentsOf: ollamaLogFileURL) if let content = String(data: data, encoding: .utf8) { @@ -648,9 +715,9 @@ final class PythonBridgeService: ObservableObject { localAppSupportURL.appendingPathComponent("ollama", isDirectory: true) } - // Models directory + /// Models directory private var modelsDirectory: URL { - return localAppSupportURL.appendingPathComponent("models", isDirectory: true) + localAppSupportURL.appendingPathComponent("models", isDirectory: true) } private var workDirectory: URL { @@ -687,7 +754,6 @@ final class PythonBridgeService: ObservableObject { FileManager.default.createFile(atPath: logURL.path, contents: nil) } - private let autoStartOllama: Bool private let allowOllamaService: Bool private var ruleFilterValue: String = RedactionRule.serializedList(from: RedactionRule.defaultSelection) @@ -698,7 +764,6 @@ final class PythonBridgeService: ObservableObject { /// verify acquire/release counts without touching real IOKit state. private let powerAssertion: PowerAssertionGuard - init(autoStartOllama: Bool = true, allowOllamaService: Bool = true, powerAssertion: PowerAssertionGuard? = nil) { self.isDebugLoggingEnabled = DebugPreferences.isEnabled() self.autoStartOllama = autoStartOllama @@ -710,27 +775,26 @@ final class PythonBridgeService: ObservableObject { // Dynamic port selection self.ollamaPort = PythonBridgeService.findFreePort(start: 11434, maxAttempts: 20) bridgeLog("Selected Ollama port: \(self.ollamaPort)", component: "Ollama") - + setupEnvironment() applyRuleFilter(RedactionRule.defaultSelection) checkEnvironment(autoStart: autoStartOllama) } - + /// Updates the MARCUT_OLLAMA_HOST/OLLAMA_HOST process environment variables with the current port private func updateOllamaPortEnvironment() { let host = "127.0.0.1:\(ollamaPort)" - setenv("MARCUT_OLLAMA_HOST", host, 1) // used by Python clients (expects host:port) - setenv("OLLAMA_HOST", host, 1) // Ollama server/CLI expects host:port (no scheme) + setenv("MARCUT_OLLAMA_HOST", host, 1) // used by Python clients (expects host:port) + setenv("OLLAMA_HOST", host, 1) // Ollama server/CLI expects host:port (no scheme) bridgeLog("Set MARCUT_OLLAMA_HOST/OLLAMA_HOST=\(host)", component: "Ollama") } - - + /// Finds a free port starting from `start`, trying up to `maxAttempts` times. /// Returns the first available port, or the `start` port if all fail (fallback). private static func findFreePort(start: Int32, maxAttempts: Int) -> Int32 { - for _ in 0.. Int32 in return pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - return bind(socketFileDescriptor, sockaddrPointer, socklen_t(MemoryLayout.size)) + bind(socketFileDescriptor, sockaddrPointer, socklen_t(MemoryLayout.size)) } } bindResult = result @@ -827,7 +891,7 @@ final class PythonBridgeService: ObservableObject { if parts.count >= 3 { relevant = Array(parts.suffix(2)) } - if relevant.count == 2 && relevant.first == "library" { + if relevant.count == 2, relevant.first == "library" { return String(relevant[1]) } return relevant.map { String($0) }.joined(separator: "/") @@ -883,7 +947,10 @@ final class PythonBridgeService: ObservableObject { bridgeLog("MODEL_PROMOTION: Found \(appModels.count) models in app container", component: "MODEL_PROMOTION") guard !allModels.isEmpty else { - bridgeLog("MODEL_PROMOTION: No models found in either app or system directories", component: "MODEL_PROMOTION") + bridgeLog( + "MODEL_PROMOTION: No models found in either app or system directories", + component: "MODEL_PROMOTION" + ) return } @@ -904,7 +971,10 @@ final class PythonBridgeService: ObservableObject { // Priority 1: Try bundled Ollama binary in Contents/MacOS (sibling to main executable) // This is the correct location for executables in a Mac App Store bundle if let executableURL = Bundle.main.executableURL { - let macosOllamaURL = executableURL.deletingLastPathComponent().appendingPathComponent("ollama", isDirectory: false) + let macosOllamaURL = executableURL.deletingLastPathComponent().appendingPathComponent( + "ollama", + isDirectory: false + ) bridgeLog("Checking for Ollama at: \(macosOllamaURL.path)", component: "Ollama") if fileManager.fileExists(atPath: macosOllamaURL.path) { bridgeLog("βœ… Found Ollama at Contents/MacOS", component: "Ollama") @@ -913,7 +983,7 @@ final class PythonBridgeService: ObservableObject { bridgeLog("ℹ️ Ollama not in Contents/MacOS (checking Resources)", component: "Ollama") } } - + // Priority 2: Fallback to Resources (legacy location) if let resourceURL = Bundle.main.resourceURL { let resourceOllamaURL = resourceURL.appendingPathComponent("ollama", isDirectory: false) @@ -928,16 +998,16 @@ final class PythonBridgeService: ObservableObject { // System Ollama fallback removed to ensure App Store compliance and robustness. // We must rely only on the bundled binary. - + bridgeLog("❌ CRITICAL: No Ollama executable found in bundled resources.", component: "Ollama") return nil } - + /// Async helper to prepare binary for execution (fix permissions) private func prepareOllamaBinary() async -> String? { guard let path = resolveOllamaPath() else { return nil } await fixBinaryPermissions(at: URL(fileURLWithPath: path)) - + // After fixing permissions, verify it's executable if FileManager.default.isExecutableFile(atPath: path) { bridgeLog("Found bundled Ollama binary and confirmed executable: \(path)", component: "Ollama") @@ -957,7 +1027,7 @@ final class PythonBridgeService: ObservableObject { bridgeLog("Skipping permission fix for bundled binary: \(path)", component: "Ollama") return } - + await Task.detached(priority: .userInitiated) { // 1. Remove quarantine attribute let xattrProcess = Process() @@ -965,7 +1035,7 @@ final class PythonBridgeService: ObservableObject { xattrProcess.arguments = ["-d", "com.apple.quarantine", path] try? xattrProcess.run() xattrProcess.waitUntilExit() - + // 2. Ensure executable permission (chmod +x) let chmodProcess = Process() chmodProcess.executableURL = URL(fileURLWithPath: "/bin/chmod") @@ -973,7 +1043,7 @@ final class PythonBridgeService: ObservableObject { try? chmodProcess.run() chmodProcess.waitUntilExit() }.value - + bridgeLog("Attempted permission fix for: \(path)", component: "Ollama") } @@ -982,7 +1052,7 @@ final class PythonBridgeService: ObservableObject { stripUnsafeRemoteOllamaOverrides(from: &env) let proxyKeys = [ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", - "http_proxy", "https_proxy", "all_proxy", "no_proxy" + "http_proxy", "https_proxy", "all_proxy", "no_proxy", ] for key in proxyKeys { env[key] = nil @@ -991,7 +1061,7 @@ final class PythonBridgeService: ObservableObject { // Aggressively strip any OLLAMA_ variables to prevent leaks from host environment (e.g. LM Studio config) for key in Array(env.keys) { if key.uppercased().hasPrefix("OLLAMA_") { - env[key] = nil + env[key] = nil } } @@ -1012,7 +1082,7 @@ final class PythonBridgeService: ObservableObject { "MARCUT_METADATA_ARGS", "MARCUT_METADATA_PRESET", "MARCUT_METADATA_SETTINGS_JSON", - "MARCUT_SCRUB_REPORT_PATH" + "MARCUT_SCRUB_REPORT_PATH", ] for key in embeddedRuntimeKeys { if let rawValue = getenv(key) { @@ -1043,8 +1113,8 @@ final class PythonBridgeService: ObservableObject { env["HOME"] = localAppSupportURL.path // Ensure Ollama uses local storage only - env["OLLAMA_HOST"] = ollamaHost // server/CLI expects host:port (no scheme) - env["MARCUT_OLLAMA_HOST"] = ollamaHost // Python clients expect host:port + env["OLLAMA_HOST"] = ollamaHost // server/CLI expects host:port (no scheme) + env["MARCUT_OLLAMA_HOST"] = ollamaHost // Python clients expect host:port // Use app container temp directory for App Store safety guard let tmpDir = prepareAppContainerTempDir() else { let message = "Ollama could not start: cannot create temp directory in app container" @@ -1056,7 +1126,7 @@ final class PythonBridgeService: ObservableObject { env["TMPDIR"] = tmpDir.path env["OLLAMA_TMPDIR"] = tmpDir.path lastOllamaTmpDir = tmpDir - + if let runnersDir = Bundle.main.resourceURL?.appendingPathComponent("ollama_runners") { env["OLLAMA_RUNNERS_DIR"] = runnersDir.path bridgeLog("Set OLLAMA_RUNNERS_DIR=\(runnersDir.path)", component: "Ollama") @@ -1100,7 +1170,7 @@ final class PythonBridgeService: ObservableObject { if isDebugLoggingEnabled { try? FileManager.default.createDirectory(at: ollamaLogsDirectory, withIntermediateDirectories: true) } - + // Explicitly create models/blobs directory to prevent permission errors let modelsDir = modelsDirectory let blobsDir = modelsDir.appendingPathComponent("blobs") @@ -1109,7 +1179,7 @@ final class PythonBridgeService: ObservableObject { return env } - // Remove stale Ollama pid directories that cause subsequent starts to exit immediately + /// Remove stale Ollama pid directories that cause subsequent starts to exit immediately private func pruneStaleOllamaPidFiles() { let tmpDir = ollamaHomeURL.appendingPathComponent("tmp", isDirectory: true) guard let contents = try? FileManager.default.contentsOfDirectory( @@ -1120,7 +1190,8 @@ final class PythonBridgeService: ObservableObject { for entry in contents where entry.lastPathComponent.hasPrefix("ollama") { let pidFile = entry.appendingPathComponent("ollama.pid") guard let pidString = try? String(contentsOf: pidFile).trimmingCharacters(in: .whitespacesAndNewlines), - let pid = Int32(pidString) else { + let pid = Int32(pidString) + else { continue } @@ -1128,7 +1199,9 @@ final class PythonBridgeService: ObservableObject { errno = 0 let alive = (kill(pid, 0) == 0) let permissionDenied = errno == EPERM - if alive || permissionDenied { continue } + if alive || permissionDenied { + continue + } // Safe to remove the stale dir to unblock future launches try? FileManager.default.removeItem(at: entry) @@ -1186,7 +1259,9 @@ final class PythonBridgeService: ObservableObject { bridgeLog("ENV OLLAMA_MODELS=\(env["OLLAMA_MODELS"] ?? "")", component: "ENVIRONMENT_CHECK") bridgeLog("ENV OLLAMA_DATA=\(env["OLLAMA_DATA"] ?? "")", component: "ENVIRONMENT_CHECK") bridgeLog("ENV OLLAMA_HOST=\(env["OLLAMA_HOST"] ?? "")", component: "ENVIRONMENT_CHECK") - if let path = resolveOllamaPath() { bridgeLog("BIN ollama=\(path)", component: "ENVIRONMENT_CHECK") } + if let path = resolveOllamaPath() { + bridgeLog("BIN ollama=\(path)", component: "ENVIRONMENT_CHECK") + } } func refreshEnvironment() async { @@ -1203,7 +1278,7 @@ final class PythonBridgeService: ObservableObject { } self.prefetchedFromDisk = true } - if autoStartOllama && allowOllamaService { + if autoStartOllama, allowOllamaService { _ = await ensureOllamaRunning() } await promoteAllExistingModels() @@ -1285,7 +1360,7 @@ final class PythonBridgeService: ObservableObject { } } - private func probeExistingOllamaInstance() async -> Bool { + private func probeExistingOllamaInstance() async -> Bool { // Check if any processes are using the Ollama port let existingPids = await pidsUsingOllamaPort() let hasExistingProcesses = !existingPids.isEmpty @@ -1306,16 +1381,15 @@ final class PythonBridgeService: ObservableObject { // MARK: - Ollama Process Management private var ollamaBackgroundProcess: Process? - // Thread-safe logger (lazy initialized) - private lazy var logManager: OllamaLogger = { - OllamaLogger(logURL: self.ollamaLogFileURL) - }() + /// Thread-safe logger (lazy initialized) + private lazy var logManager: OllamaLogger = .init(logURL: self.ollamaLogFileURL) + private var lastOllamaCheckTime: TimeInterval = 0 - private let ollamaCheckInterval: TimeInterval = 5.0 // Cache for 5 seconds + private let ollamaCheckInterval: TimeInterval = 5.0 // Cache for 5 seconds private var prefetchedFromDisk = false private var lastOllamaTmpDir: URL? private var lastImmediateLaunchFailure = false - private var ollamaOutputPipe: Pipe? // Must be stored to prevent premature deallocation + private var ollamaOutputPipe: Pipe? // Must be stored to prevent premature deallocation private func startBundledOllama() async -> Bool { guard let ollamaPath = await prepareOllamaBinary() else { @@ -1324,7 +1398,10 @@ final class PythonBridgeService: ObservableObject { } if let existingProcess = ollamaBackgroundProcess, existingProcess.isRunning { - bridgeLog("Reusing existing Ollama background process (PID \(existingProcess.processIdentifier)).", component: "Ollama") + bridgeLog( + "Reusing existing Ollama background process (PID \(existingProcess.processIdentifier)).", + component: "Ollama" + ) writeOllamaLogEntry("Reusing existing process (PID \(existingProcess.processIdentifier))") ollamaLaunchError = nil return true @@ -1344,16 +1421,19 @@ final class PythonBridgeService: ObservableObject { } process.environment = env lastOllamaTmpDir = URL(fileURLWithPath: env["OLLAMA_TMPDIR"] ?? "") - bridgeLog("Launching Ollama: TMPDIR=\(env["TMPDIR"] ?? "nil") OLLAMA_TMPDIR=\(env["OLLAMA_TMPDIR"] ?? "nil") OLLAMA_HOST=\(env["OLLAMA_HOST"] ?? "nil")", component: "Ollama") + bridgeLog( + "Launching Ollama: TMPDIR=\(env["TMPDIR"] ?? "nil") OLLAMA_TMPDIR=\(env["OLLAMA_TMPDIR"] ?? "nil") OLLAMA_HOST=\(env["OLLAMA_HOST"] ?? "nil")", + component: "Ollama" + ) let logURL = ollamaLogURL - + // Ensure log file exists and is writable let fm = FileManager.default if !fm.fileExists(atPath: logURL.path) { fm.createFile(atPath: logURL.path, contents: nil) } - + // Ensure file has write permissions try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: logURL.path) @@ -1371,10 +1451,10 @@ final class PythonBridgeService: ObservableObject { // If stored as local variable, it can be released when this function returns, // which kills the readabilityHandler before any data is received. let pipe = Pipe() - self.ollamaOutputPipe = pipe // Retain for lifetime of process + self.ollamaOutputPipe = pipe // Retain for lifetime of process process.standardOutput = pipe process.standardError = pipe - + // Read data in background to prevent buffer filling let outputHandle = pipe.fileHandleForReading final class OutputAccumulator: @unchecked Sendable { @@ -1383,39 +1463,38 @@ final class PythonBridgeService: ObservableObject { let outputAccumulator = OutputAccumulator() // Use a dedicated serial queue for processing logic and file I/O let processingQueue = DispatchQueue(label: "com.marclaw.marcut.ollamaOutputProcessing") - + // Capture logger reference for safe background use let logger = self.logManager - + outputHandle.readabilityHandler = { handle in // Read data immediately, limiting the scope of work on this IO callback let data = handle.availableData guard !data.isEmpty else { return } - + // Dispatch completely asynchronously to avoid blocking this IO callback processingQueue.async { [logger, outputAccumulator] in // Write to log file safely on background thread via thread-safe logger logger.write(data) - + // Keep buffer size reasonable for internal accumulation if outputAccumulator.data.count > 1_000_000 { outputAccumulator.data = Data() } outputAccumulator.data.append(data) - + // Parse for server ready signal if let outputStr = String(data: data, encoding: .utf8) { // Check for listening address (standard Ollama startup msg) if outputStr.contains("Listening on") || outputStr.contains("127.0.0.1:11434") { - // We can't easily signal the outer async function from here without a continuation or checking a shared state externally, + // We can't easily signal the outer async function from here without a continuation or checking + // a shared state externally, // but we can log it. The outer loop checks the port via HTTP anyway. } } } } - - let launchStart = Date() do { try process.run() @@ -1425,11 +1504,18 @@ final class PythonBridgeService: ObservableObject { outputHandle.readabilityHandler = nil let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" - bridgeLog("❌ Ollama process exited immediately after launch. status=\(process.terminationStatus) stdout/stderr=\(output)", component: "Ollama") - bridgeLog("❌ Ollama env TMPDIR=\(env["TMPDIR"] ?? "nil") OLLAMA_TMPDIR=\(env["OLLAMA_TMPDIR"] ?? "nil") OLLAMA_HOME=\(env["OLLAMA_HOME"] ?? "nil")", component: "Ollama") + bridgeLog( + "❌ Ollama process exited immediately after launch. status=\(process.terminationStatus) stdout/stderr=\(output)", + component: "Ollama" + ) + bridgeLog( + "❌ Ollama env TMPDIR=\(env["TMPDIR"] ?? "nil") OLLAMA_TMPDIR=\(env["OLLAMA_TMPDIR"] ?? "nil") OLLAMA_HOME=\(env["OLLAMA_HOME"] ?? "nil")", + component: "Ollama" + ) // Detect port-in-use vs exec/noexec failures let lower = output.lowercased() - let portInUse = lower.contains("address already in use") || lower.contains("bind: address already in use") + let portInUse = lower.contains("address already in use") || lower + .contains("bind: address already in use") if !portInUse { let logPath = ollamaLogURL.path ollamaLaunchError = "Ollama failed to start. See \(logPath) for runner details." @@ -1442,7 +1528,11 @@ final class PythonBridgeService: ObservableObject { ollamaBackgroundProcess = process let elapsed = Date().timeIntervalSince(launchStart) bridgeLog( - String(format: "βœ… Ollama process started directly (PID: %d) in %.2fs", process.processIdentifier, elapsed), + String( + format: "βœ… Ollama process started directly (PID: %d) in %.2fs", + process.processIdentifier, + elapsed + ), component: "Ollama" ) @@ -1451,7 +1541,7 @@ final class PythonBridgeService: ObservableObject { // Stop reading outputHandle.readabilityHandler = nil - guard let self = self else { return } + guard let self else { return } Task { @MainActor in bridgeLog( @@ -1462,8 +1552,9 @@ final class PythonBridgeService: ObservableObject { self.launchedOllamaPID = nil self.launchedOllamaPort = nil self.ollamaBackgroundProcess = nil - self.ollamaOutputPipe = nil // Release pipe now that process is done - // self?.closeOllamaLogHandle() // Cleanup old handle if any - DISABLED to avoid race with readabilityHandler + self.ollamaOutputPipe = nil // Release pipe now that process is done + // self?.closeOllamaLogHandle() // Cleanup old handle if any - DISABLED to avoid race with + // readabilityHandler // Log captured output // Log captured output @@ -1480,7 +1571,10 @@ final class PythonBridgeService: ObservableObject { bridgeLog("πŸ” Last 20 lines of Ollama output:\n\(lastLines)", component: "Ollama") // Do not overwrite the log file here; readabilityHandler streams it. - bridgeLog("πŸ“ Ollama process finished. Log saved to \(self.ollamaLogURL.path)", component: "Ollama") + bridgeLog( + "πŸ“ Ollama process finished. Log saved to \(self.ollamaLogURL.path)", + component: "Ollama" + ) } else { bridgeLog("⚠️ No output captured from Ollama process", component: "Ollama") } @@ -1497,10 +1591,10 @@ final class PythonBridgeService: ObservableObject { } private func waitForOllamaHTTP() async -> Bool { - let maxAttempts = 15 // Increased from 8 for robust startup - let baseInterval: TimeInterval = 0.5 // More reasonable base interval + let maxAttempts = 15 // Increased from 8 for robust startup + let baseInterval: TimeInterval = 0.5 // More reasonable base interval - for attempt in 1...maxAttempts { + for attempt in 1 ... maxAttempts { bridgeLog("Ollama HTTP probe attempt \(attempt)/\(maxAttempts)", component: "Ollama") // Bail out early if the process died during startup instead of sleeping the full backoff window @@ -1527,7 +1621,7 @@ final class PythonBridgeService: ObservableObject { @discardableResult private func ensureOllamaRunning(forceProbe: Bool = false) async -> Bool { // XPC removed - use direct CLI subprocess only - return await ensureOllamaRunningDirect(forceProbe: forceProbe) + await ensureOllamaRunningDirect(forceProbe: forceProbe) } // XPC functionality removed - now using direct CLI subprocess approach only @@ -1535,15 +1629,17 @@ final class PythonBridgeService: ObservableObject { private func probeHTTPWithBackoff() async -> Bool { let attempts = 10 let base: TimeInterval = 0.2 - for i in 0.. Bool { guard allowOllamaService else { bridgeLog("Ollama service disabled; skipping ensureOllamaRunning()", component: "Ollama") @@ -1557,35 +1653,42 @@ final class PythonBridgeService: ObservableObject { let currentTime = Date().timeIntervalSince1970 // Performance optimization: Skip redundant checks if we recently verified Ollama is running - if !forceProbe && isOllamaRunning && (currentTime - lastOllamaCheckTime) < ollamaCheckInterval { + if !forceProbe, isOllamaRunning, (currentTime - lastOllamaCheckTime) < ollamaCheckInterval { bridgeLog("Ollama status cached - skipping check", component: "Ollama") return true } // If we already have a running bundled process, reuse it and avoid changing ports - if let process = ollamaBackgroundProcess, - process.isRunning, - let launchedPort = launchedOllamaPort { - // If the temp dir used by the running process differs from the desired exec-capable tmp, restart. - let currentTmp = prepareOllamaTmpDir() - if let currentTmp, lastOllamaTmpDir == nil || lastOllamaTmpDir?.path != currentTmp.path { - bridgeLog("Ollama temp dir missing/mismatched; restarting to apply exec-capable TMPDIR", component: "Ollama") - process.terminate() - ollamaBackgroundProcess = nil - launchedOllamaPID = nil - launchedOllamaPort = nil + if let process = ollamaBackgroundProcess, + process.isRunning, + let launchedPort = launchedOllamaPort + { + // If the temp dir used by the running process differs from the desired exec-capable tmp, restart. + let currentTmp = prepareOllamaTmpDir() + if let currentTmp, lastOllamaTmpDir == nil || lastOllamaTmpDir?.path != currentTmp.path { + bridgeLog( + "Ollama temp dir missing/mismatched; restarting to apply exec-capable TMPDIR", + component: "Ollama" + ) + process.terminate() + ollamaBackgroundProcess = nil + launchedOllamaPID = nil + launchedOllamaPort = nil + } else { + ollamaPort = launchedPort + updateOllamaPortEnvironment() + if await probeOllamaHTTP() { + bridgeLog( + "Reusing existing bundled Ollama (PID \(process.processIdentifier)) on port \(launchedPort)", + component: "Ollama" + ) + await MainActor.run { + self.isOllamaRunning = true + } + lastOllamaCheckTime = currentTime + ollamaLaunchError = nil + return true } else { - ollamaPort = launchedPort - updateOllamaPortEnvironment() - if await probeOllamaHTTP() { - bridgeLog("Reusing existing bundled Ollama (PID \(process.processIdentifier)) on port \(launchedPort)", component: "Ollama") - await MainActor.run { - self.isOllamaRunning = true - } - lastOllamaCheckTime = currentTime - ollamaLaunchError = nil - return true - } else { bridgeLog("Cached Ollama process unresponsive; clearing reference", component: "Ollama") ollamaBackgroundProcess = nil launchedOllamaPID = nil @@ -1617,7 +1720,7 @@ final class PythonBridgeService: ObservableObject { bridgeLog("Starting Ollama with host=\(ollamaHost) tmp=\(tmpPath)", component: "Ollama") // Try up to 20 ports starting at 11434; reuse our own instance if healthy - for offset in 0..<20 { + for offset in 0 ..< 20 { let candidatePort = 11434 + Int32(offset) let pidsOnPort = await pids(using: candidatePort) @@ -1627,24 +1730,34 @@ final class PythonBridgeService: ObservableObject { let launchedPID = launchedOllamaPID, launchedPort == candidatePort, pidsOnPort.contains(launchedPID), - await probeOllamaHTTP() { + await probeOllamaHTTP() + { ollamaPort = launchedPort updateOllamaPortEnvironment() - bridgeLog("Reusing bundled Ollama instance (PID \(launchedPID)) on port \(launchedPort)", component: "Ollama") + bridgeLog( + "Reusing bundled Ollama instance (PID \(launchedPID)) on port \(launchedPort)", + component: "Ollama" + ) isOllamaRunning = true lastOllamaCheckTime = currentTime return true } // Foreign process present; do not attempt to bind this port - bridgeLog("Skipping port \(candidatePort) - already in use by external process(es): \(pidsOnPort)", component: "Ollama") + bridgeLog( + "Skipping port \(candidatePort) - already in use by external process(es): \(pidsOnPort)", + component: "Ollama" + ) // Occupied by non-app process; move to next port continue } - + // SANDBOX FIX: lsof often fails to see other processes in sandbox. // Explicitly check if we can connect to this port. If we can, it's occupied. if await isPortOccupied(port: candidatePort) { - bridgeLog("Skipping port \(candidatePort) - TCP connection accepted (ghost/zombie process)", component: "Ollama") + bridgeLog( + "Skipping port \(candidatePort) - TCP connection accepted (ghost/zombie process)", + component: "Ollama" + ) continue } @@ -1654,7 +1767,10 @@ final class PythonBridgeService: ObservableObject { bridgeLog("πŸš€ Starting new Ollama instance on port \(candidatePort)", component: "Ollama") let started = await startBundledOllama() if !started { - bridgeLog("❌ OLLAMA_PROCESS_START_FAILED: Unable to start Ollama process on port \(candidatePort)", component: "Ollama") + bridgeLog( + "❌ OLLAMA_PROCESS_START_FAILED: Unable to start Ollama process on port \(candidatePort)", + component: "Ollama" + ) dumpOllamaLogs() // If launch failed immediately (likely exec/noexec), abort further port attempts // BUT: If it failed because of "address already in use", we MUST retry! @@ -1662,11 +1778,17 @@ final class PythonBridgeService: ObservableObject { // Check if it was a bind error let logContent = (try? String(contentsOf: ollamaLogFileURL)) ?? "" if logContent.contains("address already in use") { - bridgeLog("⚠️ Port \(candidatePort) was actually in use (bind failed). Retrying next port...", component: "Ollama") + bridgeLog( + "⚠️ Port \(candidatePort) was actually in use (bind failed). Retrying next port...", + component: "Ollama" + ) continue } - - bridgeLog("❌ OLLAMA_LAUNCH_ABORT: Immediate failure (exec/noexec). Not retrying other ports.", component: "Ollama") + + bridgeLog( + "❌ OLLAMA_LAUNCH_ABORT: Immediate failure (exec/noexec). Not retrying other ports.", + component: "Ollama" + ) break } continue @@ -1689,8 +1811,14 @@ final class PythonBridgeService: ObservableObject { listeningPIDs: listeningPIDs, ownPID: launchedOllamaPID ) { - let message = PythonBridgeService.ollamaPortConflictMessage(port: candidatePort, foreignPID: foreignPID) - bridgeLog("❌ OLLAMA_PORT_IDENTITY_MISMATCH on port \(candidatePort): \(message)", component: "Ollama") + let message = PythonBridgeService.ollamaPortConflictMessage( + port: candidatePort, + foreignPID: foreignPID + ) + bridgeLog( + "❌ OLLAMA_PORT_IDENTITY_MISMATCH on port \(candidatePort): \(message)", + component: "Ollama" + ) ollamaLaunchError = message dumpOllamaLogs() if let process = ollamaBackgroundProcess { @@ -1709,7 +1837,10 @@ final class PythonBridgeService: ObservableObject { return true } - bridgeLog("❌ OLLAMA_HTTP_TIMEOUT on port \(candidatePort): Process started but HTTP endpoint not responding", component: "Ollama") + bridgeLog( + "❌ OLLAMA_HTTP_TIMEOUT on port \(candidatePort): Process started but HTTP endpoint not responding", + component: "Ollama" + ) dumpOllamaLogs() // Clean up the failed process immediately @@ -1737,9 +1868,8 @@ final class PythonBridgeService: ObservableObject { return false } - let baseInterval: TimeInterval = 0.3 - for attempt in 1...maxAttempts { + for attempt in 1 ... maxAttempts { bridgeLog("Probing model readiness \(modelName) attempt \(attempt)/\(maxAttempts)", component: "Ollama") if await probeModelReady(modelName: modelName) { bridgeLog("Model \(modelName) is ready for generation", component: "Ollama") @@ -1758,7 +1888,12 @@ final class PythonBridgeService: ObservableObject { // This allows Ollama sufficient time to respond during startup if let (code, data) = try await ollamaHTTP(path: "/api/version", timeout: 3.0) { if code == 200 { - if let data = data { bridgeLog("βœ… HTTP /api/version status=200 body=\(stringSnippet(data))", component: "HTTP") } + if let data { + bridgeLog( + "βœ… HTTP /api/version status=200 body=\(stringSnippet(data))", + component: "HTTP" + ) + } return true } else if code == -1004 { // Connection refused - Ollama not running @@ -1769,7 +1904,12 @@ final class PythonBridgeService: ObservableObject { bridgeLog("⏰ HTTP request timeout - Ollama unresponsive", component: "HTTP") return false } else { - if let data = data { bridgeLog("⚠️ HTTP /api/version status=\(code) body=\(stringSnippet(data))", component: "HTTP") } + if let data { + bridgeLog( + "⚠️ HTTP /api/version status=\(code) body=\(stringSnippet(data))", + component: "HTTP" + ) + } return false } } @@ -1790,37 +1930,41 @@ final class PythonBridgeService: ObservableObject { // We use the existing ollamaHTTP probe logic but strictly for connection checking // If we get ANY response (even 404, 500, or just a connection), the port is taken. // We only consider it free if the connection is REFUSED. - + guard let url = URL(string: "http://127.0.0.1:\(port)/") else { return false } var req = URLRequest(url: url) req.httpMethod = "GET" // Use GET as HEAD might be rejected req.timeoutInterval = 1.0 // Increase timeout slightly - + let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 1.0 config.timeoutIntervalForResource = 1.0 config.requestCachePolicy = .reloadIgnoringLocalCacheData - + let session = URLSession(configuration: config) defer { session.invalidateAndCancel() } - + do { - let (_, _) = try await session.data(for: req) + let _ = try await session.data(for: req) // If we get here, we connected. Port is busy. bridgeLog("isPortOccupied: Port \(port) is BUSY (connection accepted)", component: "Ollama") return true } catch { let nsError = error as NSError // If connection refused (code -1004), port is likely free. - if nsError.domain == NSURLErrorDomain && nsError.code == -1004 { + if nsError.domain == NSURLErrorDomain, nsError.code == -1004 { // bridgeLog("isPortOccupied: Port \(port) is FREE (connection refused)", component: "Ollama") return false } // Any other error (timeout, etc) implies something might be there or network is weird. - bridgeLog("isPortOccupied: Port \(port) check failed with error: \(error). Assuming BUSY.", component: "Ollama") - return true + bridgeLog( + "isPortOccupied: Port \(port) check failed with error: \(error). Assuming BUSY.", + component: "Ollama" + ) + return true } } + private func ollamaHTTP(path: String, timeout: TimeInterval) async throws -> (Int, Data?)? { // Build URL from OLLAMA_HOST (host:port) guard let url = URL(string: "http://\(ollamaHost)\(path)") else { return nil } @@ -1836,7 +1980,9 @@ final class PythonBridgeService: ObservableObject { defer { session.invalidateAndCancel() } do { let (data, resp) = try await session.data(for: req) - if let http = resp as? HTTPURLResponse { return (http.statusCode, data) } + if let http = resp as? HTTPURLResponse { + return (http.statusCode, data) + } } catch { bridgeLog("HTTP request failed path=\(path) error=\(error)", component: "HTTP") throw error @@ -1844,7 +1990,9 @@ final class PythonBridgeService: ObservableObject { return nil } - private func ollamaHTTPPostJSON(path: String, payload: [String: Any], timeout: TimeInterval) async throws -> (Int, Data?)? { + private func ollamaHTTPPostJSON(path: String, payload: [String: Any], + timeout: TimeInterval) async throws -> (Int, Data?)? + { guard let url = URL(string: "http://\(ollamaHost)\(path)") else { return nil } var req = URLRequest(url: url) req.httpMethod = "POST" @@ -1862,7 +2010,9 @@ final class PythonBridgeService: ObservableObject { defer { session.invalidateAndCancel() } do { let (data, resp) = try await session.data(for: req) - if let http = resp as? HTTPURLResponse { return (http.statusCode, data) } + if let http = resp as? HTTPURLResponse { + return (http.statusCode, data) + } } catch { bridgeLog("HTTP POST failed path=\(path) error=\(error)", component: "HTTP") throw error @@ -1872,12 +2022,26 @@ final class PythonBridgeService: ObservableObject { private func probeModelReady(modelName: String) async -> Bool { do { - if let (code, data) = try await ollamaHTTPPostJSON(path: "/api/show", payload: ["name": modelName], timeout: 5.0) { + if let (code, data) = try await ollamaHTTPPostJSON( + path: "/api/show", + payload: ["name": modelName], + timeout: 5.0 + ) { if code == 200 { - if let data = data { bridgeLog("βœ… /api/show \(modelName) status=200 body=\(stringSnippet(data))", component: "HTTP") } + if let data { + bridgeLog( + "βœ… /api/show \(modelName) status=200 body=\(stringSnippet(data))", + component: "HTTP" + ) + } return true } else { - if let data = data { bridgeLog("⚠️ /api/show \(modelName) status=\(code) body=\(stringSnippet(data))", component: "HTTP") } + if let data { + bridgeLog( + "⚠️ /api/show \(modelName) status=\(code) body=\(stringSnippet(data))", + component: "HTTP" + ) + } return false } } @@ -1888,7 +2052,8 @@ final class PythonBridgeService: ObservableObject { return false } - private struct OllamaTags: Decodable { let models: [OllamaTags.Model]; struct Model: Decodable { let name: String } } + private struct OllamaTags: Decodable { let models: [OllamaTags.Model]; struct Model: Decodable { let name: String } + } private func stringSnippet(_ data: Data, limit: Int = 512) -> String { if let s = String(data: data.prefix(limit), encoding: .utf8) { @@ -1952,7 +2117,7 @@ final class PythonBridgeService: ObservableObject { } private func isPortInUse(_ port: Int32) async -> Bool { - !(await pids(using: port)).isEmpty + await !pids(using: port).isEmpty } /// Decides whether the PIDs holding a *listening* socket on our chosen port @@ -1973,12 +2138,12 @@ final class PythonBridgeService: ObservableObject { /// state rather than the generic "Ollama did not respond" timeout message (B3). static func ollamaPortConflictMessage(port: Int32, foreignPID: Int32) -> String { "Another Ollama (or other) instance is running on port \(port) (PID \(foreignPID))." + - " Marcut could not verify its own embedded service on this port and will try another." + " Marcut could not verify its own embedded service on this port and will try another." } private func selectBundledOllamaPort(base: Int32 = 11434, attempts: Int = 20) async -> Int32? { var candidate = base - for _ in 0.. Int32? { var port = start - for _ in 0.. Void)? = nil, + heartbeat _: ((Int, Int, String?) -> Void)? = nil, cliMode: Bool = false ) async -> Bool { applyRuleFilter(settings.enabledRules) @@ -2427,8 +2604,12 @@ final class PythonBridgeService: ObservableObject { let displayRoot = displayPath(allowedRoot) let displayInput = displayPath(inputPath) let errorMessage = """ -CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Support container. Place CLI inputs under \(displayRoot) (e.g. \(displayRoot)/Input or \(displayRoot)/Work) before running. -""" + CLI Error: Input file '\( + displayInput + )' is outside the sandboxed Application Support container. Place CLI inputs under \(displayRoot) (e.g. \( + displayRoot + )/Input or \(displayRoot)/Work) before running. + """ logToFile(logPath, "❌ \(errorMessage)") item.status = .failed item.errorMessage = errorMessage @@ -2556,7 +2737,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup if cliSuccess { // Continue with file moving logic - if !usingMockBackend && settings.mode.usesLLM { + if !usingMockBackend, settings.mode.usesLLM { item.concludeCurrentStage() item.beginStage(.merging) } @@ -2641,7 +2822,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup ) if success { - if !usingMockBackend && settings.mode.usesLLM { + if !usingMockBackend, settings.mode.usesLLM { item.concludeCurrentStage() item.beginStage(.merging) } @@ -2683,7 +2864,6 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup } else { logToFile(logPath, "[SUB] Subprocess execution failed") - // Original failure handling for non-fallback cases if item.status != .cancelled { item.status = .failed @@ -2692,23 +2872,21 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup logToFile(logPath, "=== MARCUT PROCESSING END (FAILED) ===") return false } - } - // MARK: - Command Execution private func runCommand( _ command: String, arguments: [String], environment: [String: String]? = nil, - background: Bool = false + background _: Bool = false ) async -> (success: Bool, output: String) { let env = environment ?? getOllamaEnvironment() return await legacyRunCommand(command, arguments: arguments, environment: env) } - // Fallback to direct process execution for non-Ollama commands + /// Fallback to direct process execution for non-Ollama commands private func legacyRunCommand( _ command: String, arguments: [String], @@ -2717,7 +2895,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup ) async -> (success: Bool, output: String) { bridgeLog("CLI: Executing command: \(command)", component: "CLI_SUBPROCESS") bridgeLog("CLI: Arguments: \(arguments)", component: "CLI_SUBPROCESS") - + return await Task.detached(priority: .userInitiated) { () async -> (success: Bool, output: String) in let process = Process() process.executableURL = URL(fileURLWithPath: command) @@ -2732,15 +2910,18 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup try process.run() var didTimeout = false let deadline = Date().addingTimeInterval(timeoutSeconds) - while process.isRunning && Date() < deadline { + while process.isRunning, Date() < deadline { try? await Task.sleep(nanoseconds: 100_000_000) } if process.isRunning { didTimeout = true - bridgeLog("CLI: Process timed out after \(timeoutSeconds)s, terminating...", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI: Process timed out after \(timeoutSeconds)s, terminating...", + component: "CLI_SUBPROCESS" + ) process.terminate() let terminateDeadline = Date().addingTimeInterval(5) - while process.isRunning && Date() < terminateDeadline { + while process.isRunning, Date() < terminateDeadline { try? await Task.sleep(nanoseconds: 100_000_000) } if process.isRunning { @@ -2749,7 +2930,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup _ = kill(pid, SIGKILL) } let killDeadline = Date().addingTimeInterval(2) - while process.isRunning && Date() < killDeadline { + while process.isRunning, Date() < killDeadline { try? await Task.sleep(nanoseconds: 100_000_000) } } @@ -2758,18 +2939,21 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup bridgeLog("CLI: Process still running after kill attempts", component: "CLI_SUBPROCESS") return (false, "Process timed out.") } - bridgeLog("CLI: Process finished with exit code: \(process.terminationStatus)", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI: Process finished with exit code: \(process.terminationStatus)", + component: "CLI_SUBPROCESS" + ) let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" let success = !didTimeout && process.terminationStatus == 0 - + if didTimeout { bridgeLog("CLI: Process timed out. Output:\n\(output)", component: "CLI_SUBPROCESS") } else if !success { bridgeLog("CLI: Process failed. Output:\n\(output)", component: "CLI_SUBPROCESS") } - + return (success, output) } catch { bridgeLog("CLI: Failed to run command: \(error)", component: "CLI_SUBPROCESS") @@ -2789,10 +2973,10 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup return result } - // Pure error-classification helpers for the model-download retry/fallback state - // machine -- static (same pattern as modelDownloadCLIIdleTimeout/modelDownloadSpaceShortfall - // above) so `swift test` can exercise the decision logic directly without a live - // Ollama download in flight (issue #50 / B8). + /// Pure error-classification helpers for the model-download retry/fallback state + /// machine -- static (same pattern as modelDownloadCLIIdleTimeout/modelDownloadSpaceShortfall + /// above) so `swift test` can exercise the decision logic directly without a live + /// Ollama download in flight (issue #50 / B8). static func normalizeModelDownloadErrorMessage(_ message: String) -> String { let cleaned = removeANSIEscapeCodes(message) let lines = cleaned @@ -2841,7 +3025,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup "disk full", "permission denied", "forbidden", - "unauthorized" + "unauthorized", ] if nonRetryable.contains(where: { lowered.contains($0) }) { return false @@ -2857,7 +3041,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup "network", "unreachable", "temporarily", - "stream ended" + "stream ended", ] return retryable.contains(where: { lowered.contains($0) }) } @@ -2870,7 +3054,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup "disk full", "permission denied", "forbidden", - "unauthorized" + "unauthorized", ] if disallow.contains(where: { lowered.contains($0) }) { return false @@ -2885,15 +3069,18 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup "timeout", "temporarily", "network", - "unreachable" + "unreachable", ] return fallbackTriggers.contains(where: { lowered.contains($0) }) } - static func modelDownloadCLIIdleTimeout(from environment: [String: String] = ProcessInfo.processInfo.environment) -> TimeInterval { + static func modelDownloadCLIIdleTimeout(from environment: [String: String] = ProcessInfo.processInfo + .environment) -> TimeInterval + { guard let raw = environment["MARCUT_MODEL_DOWNLOAD_CLI_IDLE_TIMEOUT"], let value = Double(raw), - value > 0 else { + value > 0 + else { return 120.0 } return max(0.1, value) @@ -2978,25 +3165,31 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup let maxAttempts = 3 var lastErrorMessage: String? - for attempt in 1...maxAttempts { + for attempt in 1 ... maxAttempts { do { updateProgress(5.0) // Confirmed server responsiveness - bridgeLog("Starting download for \(modelName) via HTTP (attempt \(attempt)/\(maxAttempts))", component: "MODEL_DOWNLOAD") + bridgeLog( + "Starting download for \(modelName) via HTTP (attempt \(attempt)/\(maxAttempts))", + component: "MODEL_DOWNLOAD" + ) if modelDownloadCancelled { lastErrorMessage = "Model download cancelled." break } let ok = try await pullModelViaHTTP(modelName: modelName, progress: updateProgress) - + if ok { updateProgress(99.0) bridgeLog("Download reported success, refreshing model list...", component: "MODEL_DOWNLOAD") await loadInstalledModels() - + // VERIFICATION: Check if the model is actually in the list // We check for partial match because "llama3.2" might become "llama3.2:latest" if isModelAvailable(modelName) { - bridgeLog("βœ… Model \(modelName) verified on disk; waiting for readiness.", component: "MODEL_DOWNLOAD") + bridgeLog( + "βœ… Model \(modelName) verified on disk; waiting for readiness.", + component: "MODEL_DOWNLOAD" + ) if await waitForModelReadiness(modelName: modelName, maxAttempts: 12) { updateProgress(100.0) modelDownloadCompletionNotifier(modelName) @@ -3026,7 +3219,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup break } bridgeLog("MODEL_DOWNLOAD attempt \(attempt) failed: \(message)", component: "MODEL_DOWNLOAD") - if attempt < maxAttempts && Self.shouldRetryModelDownload(message: message) { + if attempt < maxAttempts, Self.shouldRetryModelDownload(message: message) { bridgeLog("MODEL_DOWNLOAD: Retrying after error...", component: "MODEL_DOWNLOAD") // Do not force probe (which restarts Ollama) during retry; just ensure it's still running _ = await ensureOllamaRunning(forceProbe: false) @@ -3043,7 +3236,10 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup lastModelDownloadError = "Model download cancelled." return false } - bridgeLog("MODEL_DOWNLOAD: Falling back to CLI pull after HTTP error: \(message)", component: "MODEL_DOWNLOAD") + bridgeLog( + "MODEL_DOWNLOAD: Falling back to CLI pull after HTTP error: \(message)", + component: "MODEL_DOWNLOAD" + ) do { updateProgress(max(lastProgress, 8.0)) let ok = try await pullModelViaCLI(modelName: modelName, progress: updateProgress) @@ -3119,14 +3315,26 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup var completedByDigest: [String: Int64] = [:] if modelDownloadCancelled { - throw NSError(domain: "Ollama", code: NSURLErrorCancelled, userInfo: [NSLocalizedDescriptionKey: "Model download cancelled."]) + throw NSError( + domain: "Ollama", + code: NSURLErrorCancelled, + userInfo: [NSLocalizedDescriptionKey: "Model download cancelled."] + ) } let (stream, response) = try await session.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw NSError(domain: "Ollama", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unexpected response from Ollama pull"]) + throw NSError( + domain: "Ollama", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Unexpected response from Ollama pull"] + ) } guard httpResponse.statusCode == 200 else { - throw NSError(domain: "Ollama", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Ollama pull failed (HTTP \(httpResponse.statusCode))"]) + throw NSError( + domain: "Ollama", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Ollama pull failed (HTTP \(httpResponse.statusCode))"] + ) } // Show that the HTTP stream is open even before size-based progress is available @@ -3134,7 +3342,11 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup for try await line in stream.lines { if modelDownloadCancelled { - throw NSError(domain: "Ollama", code: NSURLErrorCancelled, userInfo: [NSLocalizedDescriptionKey: "Model download cancelled."]) + throw NSError( + domain: "Ollama", + code: NSURLErrorCancelled, + userInfo: [NSLocalizedDescriptionKey: "Model download cancelled."] + ) } let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } @@ -3149,15 +3361,19 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup throw NSError(domain: "Ollama", code: -2, userInfo: [NSLocalizedDescriptionKey: errorMessage]) } if event.status?.lowercased() == "error" { - throw NSError(domain: "Ollama", code: -2, userInfo: [NSLocalizedDescriptionKey: "Ollama reported an error while downloading the model."]) + throw NSError( + domain: "Ollama", + code: -2, + userInfo: [NSLocalizedDescriptionKey: "Ollama reported an error while downloading the model."] + ) } if let status = event.status { // Log status changes for debugging if !status.hasPrefix("pulling") && !status.hasPrefix("downloading") { - bridgeLog("MODEL_DOWNLOAD: Status update: \(status)", component: "MODEL_DOWNLOAD") + bridgeLog("MODEL_DOWNLOAD: Status update: \(status)", component: "MODEL_DOWNLOAD") } - + let lowered = status.lowercased() if lowered.contains("pulling manifest") || lowered.contains("resolving") { @@ -3195,16 +3411,27 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup bridgeLog("❌ MODEL_DOWNLOAD: Stream ended without 'success' status", component: "MODEL_DOWNLOAD") await loadInstalledModels() if isModelAvailable(modelName) { - bridgeLog("MODEL_DOWNLOAD: Model available after stream ended; treating as success", component: "MODEL_DOWNLOAD") + bridgeLog( + "MODEL_DOWNLOAD: Model available after stream ended; treating as success", + component: "MODEL_DOWNLOAD" + ) progress(100.0) return true } - throw NSError(domain: "Ollama", code: -3, userInfo: [NSLocalizedDescriptionKey: "Download stream ended unexpectedly. Please retry."]) + throw NSError( + domain: "Ollama", + code: -3, + userInfo: [NSLocalizedDescriptionKey: "Download stream ended unexpectedly. Please retry."] + ) } private func pullModelViaCLI(modelName: String, progress: @escaping (Double) -> Void) async throws -> Bool { guard let ollamaPath = await prepareOllamaBinary() else { - throw NSError(domain: "Ollama", code: -1, userInfo: [NSLocalizedDescriptionKey: "Ollama binary not found for CLI download."]) + throw NSError( + domain: "Ollama", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Ollama binary not found for CLI download."] + ) } let environment = getOllamaEnvironment() @@ -3276,11 +3503,15 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup outputState.buffer.append(chunk) let endsWithNewline = outputState.buffer.last?.isNewline == true - let parts = outputState.buffer.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) + let parts = outputState.buffer.split( + omittingEmptySubsequences: false, + whereSeparator: { $0.isNewline } + ) let completeLines = endsWithNewline ? parts : parts.dropLast() for line in completeLines { - let cleaned = removeANSIEscapeCodes(String(line)).trimmingCharacters(in: .whitespacesAndNewlines) + let cleaned = removeANSIEscapeCodes(String(line)) + .trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { continue } bridgeLog("MODEL_DOWNLOAD CLI: \(cleaned)", component: "MODEL_DOWNLOAD") if let value = parseOllamaPullProgressLine(cleaned) { @@ -3301,7 +3532,10 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup guard idleFor >= idleTimeout else { return } let message = "Ollama pull stalled with no output for \(Int(idleFor)) seconds. Please try again." outputState.setStallMessage(message) - bridgeLog("MODEL_DOWNLOAD CLI stalled; terminating process PID \(process.processIdentifier): \(message)", component: "MODEL_DOWNLOAD") + bridgeLog( + "MODEL_DOWNLOAD CLI stalled; terminating process PID \(process.processIdentifier): \(message)", + component: "MODEL_DOWNLOAD" + ) process.terminate() } idleTimer.resume() @@ -3315,10 +3549,11 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup } } - outputQueue.sync { } + outputQueue.sync {} if !outputState.buffer.isEmpty { - let finalLine = removeANSIEscapeCodes(outputState.buffer).trimmingCharacters(in: .whitespacesAndNewlines) + let finalLine = removeANSIEscapeCodes(outputState.buffer) + .trimmingCharacters(in: .whitespacesAndNewlines) if !finalLine.isEmpty { bridgeLog("MODEL_DOWNLOAD CLI: \(finalLine)", component: "MODEL_DOWNLOAD") if let value = parseOllamaPullProgressLine(finalLine) { @@ -3334,14 +3569,22 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup return } if let stallMessage = outputState.takeStallMessage() { - continuation.resume(throwing: NSError(domain: "Ollama", code: -4, userInfo: [NSLocalizedDescriptionKey: stallMessage])) + continuation.resume(throwing: NSError( + domain: "Ollama", + code: -4, + userInfo: [NSLocalizedDescriptionKey: stallMessage] + )) } else if process.terminationStatus == 0 { continuation.resume(returning: true) } else { let message = sanitizedOutput.isEmpty ? "Ollama pull failed with exit code \(process.terminationStatus)." : sanitizedOutput - continuation.resume(throwing: NSError(domain: "Ollama", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message])) + continuation.resume(throwing: NSError( + domain: "Ollama", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: message] + )) } } @@ -3364,8 +3607,9 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup private func loadInstalledModels() async { if let (code, data) = try? await ollamaHTTP(path: "/api/tags", timeout: 2.0), code == 200, - let data = data, - let names = parseModelList(from: data) { + let data, + let names = parseModelList(from: data) + { await MainActor.run { self.installedModels = names } @@ -3408,7 +3652,8 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup private func parseModelList(from data: Data) -> [String]? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let models = object["models"] as? [[String: Any]] else { + let models = object["models"] as? [[String: Any]] + else { return nil } return models.compactMap { $0["name"] as? String } @@ -3418,8 +3663,12 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup var models: [String] = [] for line in output.split(whereSeparator: { $0.isNewline }) { let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { continue } - if trimmed.lowercased().hasPrefix("name") { continue } + if trimmed.isEmpty { + continue + } + if trimmed.lowercased().hasPrefix("name") { + continue + } let components = trimmed.split(separator: " ", omittingEmptySubsequences: true) if let first = components.first { models.append(String(first)) @@ -3439,10 +3688,12 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup activeModelDownloadProcess = nil } - func cancelProcess(for documentId: UUID) { if let process = activeProcesses[documentId] { - bridgeLog("Cancelling active process for document ID: \(documentId) (PID: \(process.processIdentifier))", component: "Cancellation") + bridgeLog( + "Cancelling active process for document ID: \(documentId) (PID: \(process.processIdentifier))", + component: "Cancellation" + ) process.terminate() activeProcesses.removeValue(forKey: documentId) } else { @@ -3498,7 +3749,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup let discovered = await Task.detached { ModelPromotion.discoverModels(in: modelsDir) }.value - + if !discovered.isEmpty { installedModels = Array(discovered).sorted() prefetchedFromDisk = true @@ -3514,7 +3765,8 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup if let attributes = try? fm.attributesOfItem(atPath: url.path), let fileSize = attributes[.size] as? Int64, fileSize > 0, - let handle = try? FileHandle(forWritingTo: url) { + let handle = try? FileHandle(forWritingTo: url) + { do { try handle.seek(toOffset: 0) let chunkSize = 1_048_576 @@ -3542,7 +3794,12 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup let fm = FileManager.default let dir = workDirectory if fm.fileExists(atPath: dir.path) { - if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isDirectoryKey], options: [], errorHandler: nil) { + if let enumerator = fm.enumerator( + at: dir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [], + errorHandler: nil + ) { for case let fileURL as URL in enumerator { var isDirectory: ObjCBool = false if fm.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) { @@ -3568,7 +3825,7 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup let candidates = [ workDirectory.appendingPathComponent("\(baseName)_input.docx"), workDirectory.appendingPathComponent("\(baseName)_redacted.docx"), - workDirectory.appendingPathComponent("\(baseName)_report.json") + workDirectory.appendingPathComponent("\(baseName)_report.json"), ] for url in candidates { secureRemoveFile(at: url) @@ -3579,7 +3836,6 @@ CLI Error: Input file '\(displayInput)' is outside the sandboxed Application Sup // MARK: - CLI Subprocess Implementation (Hybrid Approach) extension PythonBridgeService { - /// Run redaction using CLI subprocess with MARCUT_PROGRESS protocol for non-blocking execution /// This provides true non-blocking behavior while using the same pipeline as CLI func runRedactionWithCLI( @@ -3607,7 +3863,7 @@ extension PythonBridgeService { let possiblePaths = [ "\(bundlePath)/Contents/Resources/marcut_cli_launcher.sh", "\(bundlePath)/../MarcutApp/Contents/Resources/marcut_cli_launcher.sh", - "\(bundlePath)/../../MarcutApp/Contents/Resources/marcut_cli_launcher.sh" + "\(bundlePath)/../../MarcutApp/Contents/Resources/marcut_cli_launcher.sh", ] scriptPath = possiblePaths.first { FileManager.default.fileExists(atPath: $0) } ?? possiblePaths[0] @@ -3657,12 +3913,18 @@ extension PythonBridgeService { } else { environment["TMPDIR"] = robustTmpDir.path environment["OLLAMA_TMPDIR"] = robustTmpDir.path - bridgeLog("CLI: ⚠️ CLI temp dir not exec-capable, using base: \(robustTmpDir.path)", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI: ⚠️ CLI temp dir not exec-capable, using base: \(robustTmpDir.path)", + component: "CLI_SUBPROCESS" + ) } } catch { environment["TMPDIR"] = robustTmpDir.path environment["OLLAMA_TMPDIR"] = robustTmpDir.path - bridgeLog("CLI: ⚠️ Failed to prepare CLI temp dir (\(runTmpDir.path)): \(error)", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI: ⚠️ Failed to prepare CLI temp dir (\(runTmpDir.path)): \(error)", + component: "CLI_SUBPROCESS" + ) } } else { bridgeLog("CLI: ⚠️ Failed to prepare robust TMPDIR, fallback to inherited", component: "CLI_SUBPROCESS") @@ -3684,7 +3946,7 @@ extension PythonBridgeService { "--backend", resolvedBackend, "--model", resolvedModel, "--llm-skip-confidence", String(llmSkipConfidence), - "--llm-concurrency", String(llmConcurrency) + "--llm-concurrency", String(llmConcurrency), ] if debug { @@ -3701,7 +3963,10 @@ extension PythonBridgeService { process.standardError = stderrPipe // Log subprocess details for debugging - bridgeLog("CLI subprocess command: \(process.executableURL!.path) \(process.arguments!.joined(separator: " "))", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI subprocess command: \(process.executableURL!.path) \(process.arguments!.joined(separator: " "))", + component: "CLI_SUBPROCESS" + ) bridgeLog("Working directory: \(process.currentDirectoryURL!.path)", component: "CLI_SUBPROCESS") let cleanupTempDir = cliTempDir @@ -3736,7 +4001,10 @@ extension PythonBridgeService { stdoutState.buffer.append(chunk) let endsWithNewline = stdoutState.buffer.last?.isNewline == true - let parts = stdoutState.buffer.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) + let parts = stdoutState.buffer.split( + omittingEmptySubsequences: false, + whereSeparator: { $0.isNewline } + ) let completeLines = endsWithNewline ? parts : parts.dropLast() for line in completeLines { @@ -3771,8 +4039,8 @@ extension PythonBridgeService { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil - outputQueue.sync { } - stderrQueue.sync { } + outputQueue.sync {} + stderrQueue.sync {} let remainingData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() if !remainingData.isEmpty, let remainingString = String(data: remainingData, encoding: .utf8) { @@ -3800,7 +4068,10 @@ extension PythonBridgeService { } let success = process.terminationStatus == 0 - bridgeLog("CLI subprocess completed with status: \(process.terminationStatus)", component: "CLI_SUBPROCESS") + bridgeLog( + "CLI subprocess completed with status: \(process.terminationStatus)", + component: "CLI_SUBPROCESS" + ) if let cleanupTempDir { Self.secureEraseDirectory(cleanupTempDir) @@ -3834,7 +4105,8 @@ extension PythonBridgeService { } if cleanLine.hasPrefix("MARCUT_STATUS:") { - let message = cleanLine.replacingOccurrences(of: "MARCUT_STATUS:", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + let message = cleanLine.replacingOccurrences(of: "MARCUT_STATUS:", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { return } var chunkValue: Int? @@ -3842,7 +4114,8 @@ extension PythonBridgeService { if let match = message.range(of: #"Processing chunk\s+(\d+)/(\d+)"#, options: .regularExpression) { let matched = String(message[match]) - let numbers = matched.replacingOccurrences(of: "Processing chunk", with: "").trimmingCharacters(in: .whitespaces) + let numbers = matched.replacingOccurrences(of: "Processing chunk", with: "") + .trimmingCharacters(in: .whitespaces) let parts = numbers.split(separator: "/").map { String($0).trimmingCharacters(in: .whitespaces) } if parts.count == 2, let c = Int(parts[0]), let t = Int(parts[1]), t > 0 { chunkValue = c @@ -3858,7 +4131,8 @@ extension PythonBridgeService { ) ) } else if cleanLine.hasPrefix("MARCUT_PROGRESS:") { - let progressLine = cleanLine.replacingOccurrences(of: "MARCUT_PROGRESS:", with: "").trimmingCharacters(in: .whitespacesAndNewlines) + let progressLine = cleanLine.replacingOccurrences(of: "MARCUT_PROGRESS:", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) let components = progressLine.components(separatedBy: "|") let phaseName = components.first?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3903,6 +4177,7 @@ extension PythonBridgeService { } // MARK: - Placeholder implementations for demo + // These would be replaced with actual processing logic extension PythonBridgeService { @@ -3915,7 +4190,6 @@ extension PythonBridgeService { } } } - } // MARK: - Helper Classes @@ -3926,37 +4200,37 @@ private class OllamaLogger: @unchecked Sendable { private let logURL: URL private var fileHandle: FileHandle? private let lock = NSLock() - + init(logURL: URL) { self.logURL = logURL } - + func write(_ data: Data) { lock.lock() defer { lock.unlock() } - + ensureHandleOpen() - + if let handle = fileHandle { try? handle.write(contentsOf: data) } } - + func write(_ string: String) { guard let data = string.data(using: .utf8) else { return } write(data) } - + func flush() { lock.lock() defer { lock.unlock() } try? fileHandle?.synchronize() } - + func close() { lock.lock() defer { lock.unlock() } - + if let handle = fileHandle { try? handle.synchronize() if #available(macOS 10.15.4, *) { @@ -3967,22 +4241,24 @@ private class OllamaLogger: @unchecked Sendable { fileHandle = nil } } - + deinit { // Ensure file handle is closed when logger is deallocated close() } - - // Note: Caller usually ensures directory exists, but we double check file existence + + /// Note: Caller usually ensures directory exists, but we double check file existence private func ensureHandleOpen() { - if fileHandle != nil { return } - + if fileHandle != nil { + return + } + let fm = FileManager.default if !fm.fileExists(atPath: logURL.path) { fm.createFile(atPath: logURL.path, contents: nil) } try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: logURL.path) - + do { let handle = try FileHandle(forWritingTo: logURL) handle.seekToEndOfFile() @@ -3991,8 +4267,8 @@ private class OllamaLogger: @unchecked Sendable { print("[OllamaLogger] Failed to open log handle: \(error)") } } - - // Explicitly open/reopen (useful for startup sequence) + + /// Explicitly open/reopen (useful for startup sequence) func forceOpen() -> Bool { lock.lock() defer { lock.unlock() } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/PythonKitBridge.swift b/src/swift/MarcutApp/Sources/MarcutApp/PythonKitBridge.swift index cf4f153..3d3c4e0 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/PythonKitBridge.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/PythonKitBridge.swift @@ -1,7 +1,7 @@ -import Foundation -import PythonKit import Darwin +import Foundation import Network +import PythonKit struct PythonRunnerProgressUpdate { let phaseIdentifier: String? @@ -94,7 +94,7 @@ private enum PythonSymbolState { "PyGILState_Release", "PyErr_SetInterrupt", "PyErr_CheckSignals", - "PyErr_Clear" + "PyErr_Clear", ] static func registerMissing(_ name: String) { @@ -144,49 +144,66 @@ private func loadPythonSymbol( } private func Py_IsInitialized() -> Int32 { - guard let fn: @convention(c) () -> Int32 = loadPythonSymbol("Py_IsInitialized", as: (@convention(c) () -> Int32).self) else { + guard let fn: @convention(c) () -> Int32 = loadPythonSymbol( + "Py_IsInitialized", + as: (@convention(c) () -> Int32).self + ) else { return 0 } return fn() } private func Py_Initialize() { - guard let fn: @convention(c) () -> Void = loadPythonSymbol("Py_Initialize", as: (@convention(c) () -> Void).self) else { + guard let fn: @convention(c) () -> Void = loadPythonSymbol("Py_Initialize", as: (@convention(c) () -> Void).self) + else { return } fn() } private func PyGILState_Ensure() -> PyGILState_STATE? { - guard let fn: @convention(c) () -> PyGILState_STATE = loadPythonSymbol("PyGILState_Ensure", as: (@convention(c) () -> PyGILState_STATE).self) else { + guard let fn: @convention(c) () -> PyGILState_STATE = loadPythonSymbol( + "PyGILState_Ensure", + as: (@convention(c) () -> PyGILState_STATE).self + ) else { return nil } return fn() } private func PyGILState_Release(_ state: PyGILState_STATE) { - guard let fn: @convention(c) (PyGILState_STATE) -> Void = loadPythonSymbol("PyGILState_Release", as: (@convention(c) (PyGILState_STATE) -> Void).self) else { + guard let fn: @convention(c) (PyGILState_STATE) -> Void = loadPythonSymbol( + "PyGILState_Release", + as: (@convention(c) (PyGILState_STATE) -> Void).self + ) else { return } fn(state) } private func PyErr_SetInterrupt() { - guard let fn: @convention(c) () -> Void = loadPythonSymbol("PyErr_SetInterrupt", as: (@convention(c) () -> Void).self) else { + guard let fn: @convention(c) () -> Void = loadPythonSymbol( + "PyErr_SetInterrupt", + as: (@convention(c) () -> Void).self + ) else { return } fn() } private func PyErr_CheckSignals() -> Int32 { - guard let fn: @convention(c) () -> Int32 = loadPythonSymbol("PyErr_CheckSignals", as: (@convention(c) () -> Int32).self) else { + guard let fn: @convention(c) () -> Int32 = loadPythonSymbol( + "PyErr_CheckSignals", + as: (@convention(c) () -> Int32).self + ) else { return 0 } return fn() } private func PyErr_Clear() { - guard let fn: @convention(c) () -> Void = loadPythonSymbol("PyErr_Clear", as: (@convention(c) () -> Void).self) else { + guard let fn: @convention(c) () -> Void = loadPythonSymbol("PyErr_Clear", as: (@convention(c) () -> Void).self) + else { return } fn() @@ -233,12 +250,14 @@ enum PythonBridgeError: Error, CustomStringConvertible, LocalizedError { var description: String { switch self { - case .workerStalled(let operation): - return "Embedded Python worker did not respond within the watchdog timeout during '\(operation)'; the runner has been abandoned and will reject further calls until Marcut is restarted." + case let .workerStalled(operation): + "Embedded Python worker did not respond within the watchdog timeout during '\(operation)'; the runner has been abandoned and will reject further calls until Marcut is restarted." } } - var errorDescription: String? { description } + var errorDescription: String? { + description + } } public enum PythonRunOutcome { @@ -269,8 +288,12 @@ private enum PythonTimeoutOverrides { static func disable(for operation: String) -> Bool { let envVars = env() - if envVars["MARCUT_DISABLE_PY_TIMEOUTS"] == "1" { return true } - if envVars["MARCUT_DISABLE_\(operation.uppercased())_TIMEOUT"] == "1" { return true } + if envVars["MARCUT_DISABLE_PY_TIMEOUTS"] == "1" { + return true + } + if envVars["MARCUT_DISABLE_\(operation.uppercased())_TIMEOUT"] == "1" { + return true + } return false } @@ -291,8 +314,8 @@ private enum PythonTimeoutOverrides { } } -// Internal (not private) so the watchdog behavior below is directly unit-testable via -// `@testable import MarcutApp` without needing a live embedded CPython interpreter. +/// Internal (not private) so the watchdog behavior below is directly unit-testable via +/// `@testable import MarcutApp` without needing a live embedded CPython interpreter. final class PythonWorkerThread: Thread { private let condition = NSCondition() private var tasks: [() -> Void] = [] @@ -383,7 +406,7 @@ final class PythonWorkerThread: Thread { var result: Result! enqueue { do { - result = .success(try work()) + result = try .success(work()) } catch { result = .failure(error) } @@ -407,7 +430,9 @@ final class PythonWorkerThread: Thread { // Fire-and-forget calls (e.g. broadcasting cancellation) don't block a caller, so a // stalled worker can't hang anything here -- but skip enqueueing anyway so the task // queue on an already-abandoned thread doesn't grow forever. - if isCurrentlyStalled() { return } + if isCurrentlyStalled() { + return + } enqueue(work) } } @@ -416,7 +441,7 @@ private func pythonSetupTracingEnabled() -> Bool { ProcessInfo.processInfo.environment["MARCUT_TRACE_PY_SETUP"] == "1" } -public final class PythonRuntime { +public enum PythonRuntime { static func resolvedOllamaHost() -> String { loopbackHost( from: ProcessInfo.processInfo.environment["MARCUT_OLLAMA_HOST"], @@ -439,10 +464,12 @@ public final class PythonRuntime { } let parts = hostPort.split(separator: ":") if let last = parts.last, parts.count >= 2, - let parsed = Int(last), (1...65535).contains(parsed) { + let parsed = Int(last), (1 ... 65535).contains(parsed) + { port = parsed } else if parts.count == 1, - let parsed = Int(parts[0]), (1...65535).contains(parsed) { + let parsed = Int(parts[0]), (1 ... 65535).contains(parsed) + { port = parsed } } @@ -453,11 +480,10 @@ public final class PythonRuntime { static func checkOllamaPort(_ port: UInt16 = 11434) -> Bool { let hostString = resolvedOllamaHost() let components = hostString.split(separator: ":") - let resolvedPort: UInt16 - if components.count == 2, let parsed = UInt16(components[1]) { - resolvedPort = parsed + let resolvedPort: UInt16 = if components.count == 2, let parsed = UInt16(components[1]) { + parsed } else { - resolvedPort = port + port } let group = DispatchGroup() @@ -523,7 +549,9 @@ public final class PythonRuntime { let isOllamaRunning = checkOllamaPort() let checkTime = Date().timeIntervalSince(start) - logger("PK_FAST_CHECK: Ollama \(isOllamaRunning ? "running" : "not running") host=\(hostString) (\(String(format: "%.0f", checkTime * 1000))ms total)") + logger( + "PK_FAST_CHECK: Ollama \(isOllamaRunning ? "running" : "not running") host=\(hostString) (\(String(format: "%.0f", checkTime * 1000))ms total)" + ) return isOllamaRunning } @@ -546,7 +574,9 @@ public final class PythonRuntime { } } - appendCandidate(label: "private frameworks") { Bundle.main.privateFrameworksURL?.appendingPathComponent("Python.framework") } + appendCandidate(label: "private frameworks") { + Bundle.main.privateFrameworksURL?.appendingPathComponent("Python.framework") + } appendCandidate(label: "app/Contents/Frameworks") { bundleURL .appendingPathComponent("Contents", isDirectory: true) @@ -591,7 +621,11 @@ public final class PythonRuntime { var pythonVersionDir = currentLink if let resolved = try? FileManager.default.destinationOfSymbolicLink(atPath: currentLink.path) { pythonVersionDir = URL(fileURLWithPath: resolved, relativeTo: versionsDir) - } else if let firstVersion = try? FileManager.default.contentsOfDirectory(at: versionsDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]).first(where: { $0.lastPathComponent.contains(".") }) { + } else if let firstVersion = try? FileManager.default.contentsOfDirectory( + at: versionsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).first(where: { $0.lastPathComponent.contains(".") }) { pythonVersionDir = firstVersion } else { pythonVersionDir = versionsDir.appendingPathComponent("3.10") @@ -614,7 +648,10 @@ public final class PythonRuntime { var siteCandidates: [URL] = [] if let resourcePath = Bundle.main.resourcePath { - siteCandidates.append(URL(fileURLWithPath: resourcePath).appendingPathComponent("python_site", isDirectory: true)) + siteCandidates.append(URL(fileURLWithPath: resourcePath).appendingPathComponent( + "python_site", + isDirectory: true + )) } siteCandidates.append( @@ -647,7 +684,6 @@ public final class PythonRuntime { ) } - private static func sanitizeProcessEnvironment(logger: (String) -> Void) { let keysToUnset = [ "PYTHONHOME", @@ -662,7 +698,7 @@ public final class PythonRuntime { "PYENV_ROOT", "CONDA_PREFIX", "CONDA_DEFAULT_ENV", - "VIRTUAL_ENV" + "VIRTUAL_ENV", ] for key in keysToUnset { @@ -692,12 +728,13 @@ public final class PythonRuntime { @discardableResult public static func initialize(logger: (String) -> Void) throws -> PythonRuntimeConfig { let startTime = Date() - let totalTimeout: TimeInterval = 30.0 // 30s total + let totalTimeout: TimeInterval = 30.0 // 30s total func checkTimeout(step: String) throws { let elapsed = Date().timeIntervalSince(startTime) if elapsed > totalTimeout { - throw PythonInitError.timeout("Total timeout exceeded (\(elapsed)s > \(totalTimeout)s) at step: \(step)") + throw PythonInitError + .timeout("Total timeout exceeded (\(elapsed)s > \(totalTimeout)s) at step: \(step)") } } @@ -757,7 +794,7 @@ public final class PythonRuntime { do { if FileManager.default.fileExists(atPath: tempDir.path) { Self.secureEraseDirectory(tempDir, logger: logger) - // Re-create it immediately so it's ready for next use, or just leave it gone? + // Re-create it immediately so it's ready for next use, or just leave it gone? // Leaving it gone is fine, but if we run another job, does it need it? // The environment variable TMPDIR is still set to this path for the process. // It is safer to re-create the empty directory so subsequent calls don't fail if they assume existence. @@ -773,10 +810,16 @@ public final class PythonRuntime { let fm = FileManager.default guard fm.fileExists(atPath: dir.path) else { return } - if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil) { + if let enumerator = fm.enumerator( + at: dir, + includingPropertiesForKeys: [.isRegularFileKey], + options: [], + errorHandler: nil + ) { for case let fileURL as URL in enumerator { if let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), - values.isRegularFile == true { + values.isRegularFile == true + { secureEraseFile(fileURL) } } @@ -794,7 +837,8 @@ public final class PythonRuntime { guard let attributes = try? fm.attributesOfItem(atPath: url.path), let size = attributes[.size] as? Int64, size > 0, - let handle = try? FileHandle(forWritingTo: url) else { + let handle = try? FileHandle(forWritingTo: url) + else { try? fm.removeItem(at: url) return } @@ -834,7 +878,7 @@ public final class PythonKitRunner { "MARCUT_RULE_FILTER", "MARCUT_EXCLUDED_WORDS_PATH", "MARCUT_SYSTEM_PROMPT_PATH", - "MARCUT_LOG_PATH" + "MARCUT_LOG_PATH", ] /// Resolves the outer bridge-level watchdog bound for `operation`, or `nil` for an @@ -852,7 +896,10 @@ public final class PythonKitRunner { worker.waitUntilReady() let initTimeout = Self.watchdogTimeout(for: "PYTHON_INIT", default: 60.0) - let config: PythonRuntimeConfig = try worker.performWithWatchdog(timeout: initTimeout, operation: "python_init") { + let config: PythonRuntimeConfig = try worker.performWithWatchdog( + timeout: initTimeout, + operation: "python_init" + ) { try PythonRuntime.initialize(logger: logger) } self.cfg = config @@ -1016,7 +1063,7 @@ public final class PythonKitRunner { "os", "lxml", "docx", - "marcut.pipeline" + "marcut.pipeline", ] for name in modules { @@ -1069,7 +1116,8 @@ public final class PythonKitRunner { let timersEnabled = !disableTimeouts if timersEnabled && elapsed > totalTimeout { - throw PythonInitError.timeout("Total timeout exceeded (\(elapsed)s > \(totalTimeout)s) during: \(operation)") + throw PythonInitError + .timeout("Total timeout exceeded (\(elapsed)s > \(totalTimeout)s) during: \(operation)") } if pythonSetupTracingEnabled() || operationKey == "ENV_SETUP" { @@ -1113,7 +1161,7 @@ public final class PythonKitRunner { } let operationElapsed = Date().timeIntervalSince(operationStart) - if timersEnabled && operationElapsed > stepTimeout { + if timersEnabled, operationElapsed > stepTimeout { logger("PK_\(operationKey)_TIMEOUT: \(String(format: "%.2f", operationElapsed))s > \(stepTimeout)s") throw PythonInitError.timeout("Step timeout: \(operation) took \(operationElapsed)s > \(stepTimeout)s") } @@ -1128,7 +1176,7 @@ public final class PythonKitRunner { outputPath: String, reportPath: String, debug: Bool, - cancellationChecker: @escaping () -> Bool + cancellationChecker _: @escaping () -> Bool ) -> PythonRunOutcome { let startTime = Date() _ = startNewRunToken() @@ -1238,9 +1286,10 @@ public final class PythonKitRunner { } catch { let totalElapsed = Date().timeIntervalSince(startTime) logger("PK_RULES_MOCK_ERROR: \(error) total=\(String(format: "%.2f", totalElapsed))s") - if case PythonError.exception(let exc, _) = error, + if case let PythonError.exception(exc, _) = error, let description = String(exc), - description.contains("KeyboardInterrupt") { + description.contains("KeyboardInterrupt") + { return .cancelled } return .failure @@ -1261,7 +1310,7 @@ public final class PythonKitRunner { temperature: Double = 0.1, seed: Int = 42, processingStepTimeout: TimeInterval? = nil, - cancellationChecker: @escaping () -> Bool, + cancellationChecker _: @escaping () -> Bool, heartbeat: ((PythonRunnerProgressUpdate) -> Void)? = nil ) -> PythonRunOutcome { let startTime = Date() @@ -1271,7 +1320,7 @@ public final class PythonKitRunner { logger("PK_ENHANCED_OLLAMA_START: model=\(model) mode=\(normalizedMode) needs_ollama=\(needsOllama)") // Skip environment check for rules-only mode - if needsOllama && !PythonRuntime.fastEnvironmentCheck(logger: logger) { + if needsOllama, !PythonRuntime.fastEnvironmentCheck(logger: logger) { let host = PythonRuntime.resolvedOllamaHost() logger("PK_ENHANCED_OLLAMA_ERROR: Fast environment check failed host=\(host)") return .failure @@ -1300,17 +1349,17 @@ public final class PythonKitRunner { let host = PythonRuntime.resolvedOllamaHost() py_os.environ["OLLAMA_HOST"] = PythonObject(host) py_os.environ["MARCUT_OLLAMA_HOST"] = PythonObject(host) - logger("PK_ENV_SETUP_RESOLVED_HOST: \(host)") - py_os.environ["HTTP_PROXY"] = "" - py_os.environ["HTTPS_PROXY"] = "" - py_os.environ["ALL_PROXY"] = "" - py_os.environ["NO_PROXY"] = "127.0.0.1,localhost" - py_os.environ["http_proxy"] = "" + logger("PK_ENV_SETUP_RESOLVED_HOST: \(host)") + py_os.environ["HTTP_PROXY"] = "" + py_os.environ["HTTPS_PROXY"] = "" + py_os.environ["ALL_PROXY"] = "" + py_os.environ["NO_PROXY"] = "127.0.0.1,localhost" + py_os.environ["http_proxy"] = "" py_os.environ["https_proxy"] = "" py_os.environ["all_proxy"] = "" py_os.environ["no_proxy"] = "127.0.0.1,localhost" py_os.environ["NO_COLOR"] = "1" - logger("PK_ENV_SETUP_READY") + logger("PK_ENV_SETUP_READY") // Phase 2: Heavy imports let importsStepTimeout = PythonTimeoutOverrides.step(for: "IMPORTS", default: 180.0) @@ -1375,8 +1424,8 @@ public final class PythonKitRunner { ) { try checkCancellation() let progressCallback: PythonObject = { - guard let heartbeat = heartbeat else { return Python.None } - let function = PythonFunction { args, kwargs in + guard let heartbeat else { return Python.None } + let function = PythonFunction { args, _ in guard let firstArg = args.first else { return Python.None } @@ -1444,7 +1493,7 @@ public final class PythonKitRunner { return .cancelled } catch { let totalElapsed = Date().timeIntervalSince(startTime) - if case PythonError.exception(let exc, _) = error { + if case let PythonError.exception(exc, _) = error { let typeName = (exc.__class__.__name__).toOptionalString() ?? "UnknownPythonException" let message = exc.toOptionalString() ?? "n/a" var tracebackSummary = "" @@ -1457,16 +1506,19 @@ public final class PythonKitRunner { logger("PK_ENHANCED_OLLAMA_PYERROR: type=\(typeName) message=\(message) traceback=\(tracebackSummary)") } logger("PK_ENHANCED_OLLAMA_ERROR: \(error) total=\(String(format: "%.2f", totalElapsed))s") - if case PythonError.exception(let exc, _) = error, + if case let PythonError.exception(exc, _) = error, let description = String(exc), - description.contains("KeyboardInterrupt") { + description.contains("KeyboardInterrupt") + { return .cancelled } return .failure } } - private func scheduleTimeout(for operationKey: String, seconds: TimeInterval, runToken: UUID) -> Task? { + private func scheduleTimeout(for operationKey: String, seconds: TimeInterval, + runToken: UUID) -> Task? + { guard seconds > 0 else { return nil } // Capture handler to avoid Sendable warnings let handler = self.handleTimeoutTrigger @@ -1532,7 +1584,10 @@ public final class PythonKitRunner { guard let self else { return PythonRunOutcome.failure } let outcome: PythonRunOutcome do { - outcome = try self.worker.performWithWatchdog(timeout: enhancedWatchdogTimeout, operation: "enhanced_ollama") { + outcome = try self.worker.performWithWatchdog( + timeout: enhancedWatchdogTimeout, + operation: "enhanced_ollama" + ) { self.externalCancellationChecker = cancellationChecker defer { self.externalCancellationChecker = nil @@ -1593,7 +1648,7 @@ public final class PythonKitRunner { guard let self else { return PythonRunOutcome.failure } // Simulated progress for deterministic mode let totalSteps = 5 - for step in 1...totalSteps { + for step in 1 ... totalSteps { if cancellationChecker() { streamContinuation.continuation.finish() return .cancelled @@ -1663,7 +1718,10 @@ public final class PythonKitRunner { // `PythonBridgeError`) -- this is a fast, deterministic, non-LLM operation, so a // generous-but-finite bound is enough to catch a genuine hang without false positives. let scrubWatchdogTimeout = Self.watchdogTimeout(for: "METADATA_SCRUB", default: 120.0) - let result: (Bool, String?, [String: Any]?) = try worker.performWithWatchdog(timeout: scrubWatchdogTimeout, operation: "metadata_scrub") { [logger, self] in + let result: (Bool, String?, [String: Any]?) = try worker.performWithWatchdog( + timeout: scrubWatchdogTimeout, + operation: "metadata_scrub" + ) { [logger, self] in guard let state = PyGILState_Ensure() else { let missing = PythonSymbolState.missingList().joined(separator: ", ") let detail = missing.isEmpty ? "Missing CPython symbols" : "Missing CPython symbols: \(missing)" @@ -1678,7 +1736,7 @@ public final class PythonKitRunner { } else { PyErr_Clear() } - + do { let py_os = try Python.attemptImport("os") self.syncEmbeddedEnvToPython(py_os) @@ -1690,12 +1748,12 @@ public final class PythonKitRunner { "debug": false, ] ) - + // Parse tuple result (success: bool, error: str, report: dict) if Bool(Python.isinstance(rawResult, Python.tuple)) == true { let success = Bool(rawResult[0]) == true let errorMsg = String(rawResult[1]) ?? "" - + // Extract report dictionary (full payload with groups) via JSON serialization var reportDict: [String: Any]? = nil let pyReport = rawResult[2] @@ -1704,14 +1762,15 @@ public final class PythonKitRunner { let json = try Python.attemptImport("json") let jsonString = String(json.dumps(pyReport)) ?? "" if let data = jsonString.data(using: .utf8), - let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] + { reportDict = obj } } catch { reportDict = nil } } - + return (success, errorMsg.isEmpty ? nil : errorMsg, reportDict) } return (false, "Unexpected result from Python", nil) @@ -1720,7 +1779,7 @@ public final class PythonKitRunner { return (false, "Python error: \(error)", nil) } } - + let totalElapsed = Date().timeIntervalSince(startTime) if result.0 { logger("PK_METADATA_SCRUB_OK total=\(String(format: "%.2f", totalElapsed))s") @@ -1733,7 +1792,7 @@ public final class PythonKitRunner { } else { logger("PK_METADATA_SCRUB_FAILED: \(result.1 ?? "Unknown") total=\(String(format: "%.2f", totalElapsed))s") } - + return result } @@ -1746,7 +1805,10 @@ public final class PythonKitRunner { // Same bridge-level watchdog rationale as `scrubMetadataOnlyAsync` above. let reportWatchdogTimeout = Self.watchdogTimeout(for: "METADATA_REPORT", default: 120.0) - let result: (Bool, String?, [String: Any]?, String?, String?) = try worker.performWithWatchdog(timeout: reportWatchdogTimeout, operation: "metadata_report") { [logger, self] in + let result: (Bool, String?, [String: Any]?, String?, String?) = try worker.performWithWatchdog( + timeout: reportWatchdogTimeout, + operation: "metadata_report" + ) { [logger, self] in guard let state = PyGILState_Ensure() else { let missing = PythonSymbolState.missingList().joined(separator: ", ") let detail = missing.isEmpty ? "Missing CPython symbols" : "Missing CPython symbols: \(missing)" @@ -1784,7 +1846,8 @@ public final class PythonKitRunner { let json = try Python.attemptImport("json") let jsonString = String(json.dumps(pyReport)) ?? "" if let data = jsonString.data(using: .utf8), - let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] + { reportDict = obj } } catch { @@ -1792,7 +1855,13 @@ public final class PythonKitRunner { } } let htmlPath = String(rawResult[4]) ?? "" - return (success, errorMsg.isEmpty ? nil : errorMsg, reportDict, String(rawResult[3]) ?? "", htmlPath) + return ( + success, + errorMsg.isEmpty ? nil : errorMsg, + reportDict, + String(rawResult[3]) ?? "", + htmlPath + ) } return (false, "Unexpected result from Python", nil, "", "") } catch { @@ -1815,7 +1884,10 @@ public final class PythonKitRunner { do { // Same bridge-level watchdog rationale as the metadata paths above. let htmlWatchdogTimeout = Self.watchdogTimeout(for: "GENERATE_SCRUB_HTML", default: 60.0) - let htmlPath: String = try worker.performWithWatchdog(timeout: htmlWatchdogTimeout, operation: "generate_scrub_html") { [logger] in + let htmlPath: String = try worker.performWithWatchdog( + timeout: htmlWatchdogTimeout, + operation: "generate_scrub_html" + ) { [logger] in guard let state = PyGILState_Ensure() else { let missing = PythonSymbolState.missingList().joined(separator: ", ") let detail = missing.isEmpty ? "Missing CPython symbols" : "Missing CPython symbols: \(missing)" diff --git a/src/swift/MarcutApp/Sources/MarcutApp/ReportViewer.swift b/src/swift/MarcutApp/Sources/MarcutApp/ReportViewer.swift index c9d3b73..9987994 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/ReportViewer.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/ReportViewer.swift @@ -1,8 +1,8 @@ -import SwiftUI -import WebKit import AppKit -import Security import Foundation +import Security +import SwiftUI +import WebKit private enum ReportDisclosureAction { case print @@ -43,7 +43,7 @@ struct ReportViewer: View { .buttonStyle(.bordered) .controlSize(.regular) .frame(width: 100, height: 32) - + Button { confirmDisclosure(.share) } label: { @@ -51,8 +51,8 @@ struct ReportViewer: View { } .buttonStyle(.bordered) .controlSize(.regular) - .frame(width: 100, height: 32) - + .frame(width: 100, height: 32) + Button { showBurnConfirm = true } label: { @@ -110,11 +110,17 @@ struct ReportViewer: View { pendingDisclosureAction = nil } } message: { - Text("Audit and metadata reports may include original detected text, filenames, document metadata, and forensic artifacts. Share or print only to approved destinations.") + Text( + "Audit and metadata reports may include original detected text, filenames, document metadata, and forensic artifacts. Share or print only to approved destinations." + ) } .alert("Burn Failed", isPresented: Binding( get: { burnErrorMessage != nil }, - set: { if !$0 { burnErrorMessage = nil } } + set: { + if !$0 { + burnErrorMessage = nil + } + } )) { Button("OK", role: .cancel) {} } message: { @@ -122,7 +128,11 @@ struct ReportViewer: View { } .alert("File Not Found", isPresented: Binding( get: { openBinaryErrorMessage != nil }, - set: { if !$0 { openBinaryErrorMessage = nil } } + set: { + if !$0 { + openBinaryErrorMessage = nil + } + } )) { Button("OK", role: .cancel) {} } message: { @@ -245,7 +255,8 @@ struct ReportViewer: View { guard FileManager.default.fileExists(atPath: jsonURL.path) else { return [] } guard let data = try? Data(contentsOf: jsonURL), let json = try? JSONSerialization.jsonObject(with: data, options: []), - let payload = json as? [String: Any] else { + let payload = json as? [String: Any] + else { return [] } let reportDir = jsonURL.deletingLastPathComponent() @@ -274,11 +285,13 @@ struct ReportViewer: View { let fm = FileManager.default var isDirectory: ObjCBool = false guard fm.fileExists(atPath: binariesDir.path, isDirectory: &isDirectory), - isDirectory.boolValue else { + isDirectory.boolValue + else { return } if let contents = try? fm.contentsOfDirectory(at: binariesDir, includingPropertiesForKeys: nil), - contents.isEmpty { + contents.isEmpty + { try? fm.removeItem(at: binariesDir) } } @@ -310,7 +323,12 @@ struct ReportViewer: View { guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { return } - if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [], errorHandler: nil) { + if let enumerator = fm.enumerator( + at: url, + includingPropertiesForKeys: [.isRegularFileKey], + options: [], + errorHandler: nil + ) { for case let fileURL as URL in enumerator { let isRegular = (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile ?? false if isRegular { @@ -326,8 +344,8 @@ struct ReportViewer: View { let status = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) if status != errSecSuccess { var generator = SystemRandomNumberGenerator() - for i in 0.. Void func makeCoordinator() -> Coordinator { - Coordinator(state: state, onPrintRequest: onPrintRequest, onShareRequest: onShareRequest, onBurnRequest: onBurnRequest, onOpenBinary: onOpenBinary) + Coordinator( + state: state, + onPrintRequest: onPrintRequest, + onShareRequest: onShareRequest, + onBurnRequest: onBurnRequest, + onOpenBinary: onOpenBinary + ) } func makeNSView(context: Context) -> WKWebView { @@ -476,7 +502,13 @@ struct ReportWebView: NSViewRepresentable { private let onOpenBinary: (String) -> Void var readAccessRootURL: URL? - init(state: ReportWebViewState, onPrintRequest: @escaping () -> Void, onShareRequest: @escaping () -> Void, onBurnRequest: @escaping () -> Void, onOpenBinary: @escaping (String) -> Void) { + init( + state: ReportWebViewState, + onPrintRequest: @escaping () -> Void, + onShareRequest: @escaping () -> Void, + onBurnRequest: @escaping () -> Void, + onOpenBinary: @escaping (String) -> Void + ) { self.state = state self.onPrintRequest = onPrintRequest self.onShareRequest = onShareRequest @@ -484,10 +516,11 @@ struct ReportWebView: NSViewRepresentable { self.onOpenBinary = onOpenBinary } - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == Coordinator.reportActionHandlerName else { return } if let body = message.body as? [String: Any], - let action = body["action"] as? String { + let action = body["action"] as? String + { switch action { case "print": onPrintRequest() @@ -514,9 +547,9 @@ struct ReportWebView: NSViewRepresentable { func webView( _ webView: WKWebView, - createWebViewWith configuration: WKWebViewConfiguration, + createWebViewWith _: WKWebViewConfiguration, for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures + windowFeatures _: WKWindowFeatures ) -> WKWebView? { if navigationAction.targetFrame == nil { if let targetURL = navigationAction.request.url, targetURL.isFileURL { @@ -529,19 +562,19 @@ struct ReportWebView: NSViewRepresentable { return nil } - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { state.canGoBack = webView.canGoBack } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { state.canGoBack = webView.canGoBack } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didFail _: WKNavigation!, withError _: Error) { state.canGoBack = webView.canGoBack } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_ webView: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { state.canGoBack = webView.canGoBack } } @@ -570,7 +603,7 @@ struct SharePickerButton: NSViewRepresentable { return button } - func updateNSView(_ nsView: NSButton, context: Context) { + func updateNSView(_: NSButton, context: Context) { context.coordinator.url = url } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/SettingsView.swift b/src/swift/MarcutApp/Sources/MarcutApp/SettingsView.swift index 7f6d51b..d7d364b 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/SettingsView.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/SettingsView.swift @@ -1,34 +1,34 @@ -import SwiftUI import AppKit +import SwiftUI import UniformTypeIdentifiers /// App appearance theme preference enum AppTheme: String, CaseIterable { - case system = "system" - case light = "light" - case dark = "dark" + case system + case light + case dark var displayName: String { switch self { - case .system: return "Follow System" - case .light: return "Light" - case .dark: return "Dark" + case .system: "Follow System" + case .light: "Light" + case .dark: "Dark" } } var appearance: NSAppearance? { switch self { - case .system: return nil - case .light: return NSAppearance(named: .aqua) - case .dark: return NSAppearance(named: .darkAqua) + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) } } var colorScheme: ColorScheme? { switch self { - case .system: return nil - case .light: return .light - case .dark: return .dark + case .system: nil + case .light: .light + case .dark: .dark } } } @@ -58,9 +58,12 @@ struct SettingsView: View { @State private var profileImportSucceeded = false @AppStorage(DefaultsKey.advancedModeEnabled.key) private var isAdvancedModeEnabled = true @AppStorage(DefaultsKey.advancedAIMode.key) private var advancedAIModeRaw = RedactionMode.rulesOverride.rawValue - @AppStorage(DefaultsKey.advancedLLMConfidence.key) private var advancedLlmConfidence = RedactionSettings.standardNormalModeConfidence - @AppStorage(DefaultsKey.outputSaveLocationPreference.key) private var outputSaveLocationRaw = OutputSaveLocation.alwaysAsk.rawValue - @AppStorage(DefaultsKey.unsavedReportQuitBehavior.key) private var unsavedReportQuitBehaviorRaw = UnsavedReportQuitBehavior.warn.rawValue + @AppStorage(DefaultsKey.advancedLLMConfidence.key) private var advancedLlmConfidence = RedactionSettings + .standardNormalModeConfidence + @AppStorage(DefaultsKey.outputSaveLocationPreference.key) private var outputSaveLocationRaw = OutputSaveLocation + .alwaysAsk.rawValue + @AppStorage(DefaultsKey.unsavedReportQuitBehavior.key) private var unsavedReportQuitBehaviorRaw = + UnsavedReportQuitBehavior.warn.rawValue @AppStorage(DefaultsKey.appTheme.key) private var appThemeRaw = AppTheme.system.rawValue @ObservedObject private var permissionManager = PermissionManager.shared private let overridesManager = UserOverridesManager.shared @@ -95,7 +98,7 @@ struct SettingsView: View { private static let sectionLabels: [SettingsSection: [String]] = [ .processingMode: [ "Processing Mode", "Rules Only", "Fast rule-based detection for structured PII.", - "Rules + AI" + "Rules + AI", ], .sharedSettings: [ "Shared Settings", "System Notifications", "Enable Completion Banners", @@ -108,30 +111,32 @@ struct SettingsView: View { "Edit Custom List…", "Reset to Defaults", "Settings Profile", "Share this configuration with your team as a JSON file, or load one someone else exported.", "Export Profile…", "Import Profile…", "Advanced Mode", - "Show advanced AI controls and override modes." + "Show advanced AI controls and override modes.", ], .rulesEngine: [ - "Rules Engine", "Select which deterministic rules run.", "Invert Selection" + "Rules Engine", "Select which deterministic rules run.", "Invert Selection", ], .aiModel: [ "AI Model", "Select AI Model", "Qwen 3.5 35B A3B", "Qwen 2.5 14B", "Qwen 2.5 7B", "Phi-4 Mini 3.8B", - "AI System Prompt", "Customize Prompt…", "Manage Models…", "Reveal Models…" + "AI System Prompt", "Customize Prompt…", "Manage Models…", "Reveal Models…", ], .advancedAI: [ "Advanced AI Settings", "Rules + AI Behavior", "Rules Override", "Constrained LLM Overrides", "LLM Overrides", "LLM Confidence", "Temperature", - "Chunk Size", "Chunk Overlap", "Processing Timeout", "Random Seed" + "Chunk Size", "Chunk Overlap", "Processing Timeout", "Random Seed", ], .debug: [ - "Debug", "Enable Debug Logging", "View Logs", "Open App Log", "Open Ollama Log", "Clear Logs" - ] + "Debug", "Enable Debug Logging", "View Logs", "Open App Log", "Open Ollama Log", "Clear Logs", + ], ] /// Pure, testable predicate: does `label` match `query`? Empty query always matches. /// Matching is a case-insensitive substring match. static func matchesSearch(_ label: String, query: String) -> Bool { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedQuery.isEmpty { return true } + if trimmedQuery.isEmpty { + return true + } return label.range(of: trimmedQuery, options: [.caseInsensitive, .diacriticInsensitive]) != nil } @@ -143,7 +148,9 @@ struct SettingsView: View { let ruleMatches = RedactionRule.allCases.contains { rule in Self.matchesSearch(rule.displayName, query: searchQuery) } - if ruleMatches { return true } + if ruleMatches { + return true + } } let labels = Self.sectionLabels[section] ?? [] return labels.contains { Self.matchesSearch($0, query: searchQuery) } @@ -154,7 +161,9 @@ struct SettingsView: View { guard !searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } // If the section header itself matches (e.g. "Rules Engine"), show all rows. let sectionLabels = Self.sectionLabels[.rulesEngine] ?? [] - if sectionLabels.contains(where: { Self.matchesSearch($0, query: searchQuery) }) { return true } + if sectionLabels.contains(where: { Self.matchesSearch($0, query: searchQuery) }) { + return true + } return Self.matchesSearch(rule.displayName, query: searchQuery) } @@ -163,7 +172,9 @@ struct SettingsView: View { var initialSettings = viewModel.settings if viewModel.availableModels.count == 1, let onlyModel = viewModel.availableModels.first { initialSettings.model = onlyModel - } else if !viewModel.availableModels.contains(initialSettings.model), let first = viewModel.availableModels.first { + } else if !viewModel.availableModels.contains(initialSettings.model), + let first = viewModel.availableModels.first + { initialSettings.model = first } let defaults = UserDefaults.standard @@ -179,17 +190,26 @@ struct SettingsView: View { } if defaults.object(forKey: DefaultsKey.advancedLLMConfidenceMigratedTo99.key) == nil { if let storedConfidence = defaults.object(forKey: DefaultsKey.advancedLLMConfidence.key) as? NSNumber, - storedConfidence.intValue == 95 { - defaults.set(RedactionSettings.standardNormalModeConfidence, forKey: DefaultsKey.advancedLLMConfidence.key) + storedConfidence.intValue == 95 + { + defaults.set( + RedactionSettings.standardNormalModeConfidence, + forKey: DefaultsKey.advancedLLMConfidence.key + ) } defaults.set(true, forKey: DefaultsKey.advancedLLMConfidenceMigratedTo99.key) } if defaults.object(forKey: DefaultsKey.outputSaveLocationPreference.key) == nil { - if let legacy = defaults.object(forKey: DefaultsKey.legacyMetadataReportAlwaysSaveToDownloads.key) as? Bool { + if let legacy = defaults + .object(forKey: DefaultsKey.legacyMetadataReportAlwaysSaveToDownloads.key) as? Bool + { let mapped = legacy ? OutputSaveLocation.downloads.rawValue : OutputSaveLocation.alwaysAsk.rawValue defaults.set(mapped, forKey: DefaultsKey.outputSaveLocationPreference.key) } else { - defaults.set(OutputSaveLocation.alwaysAsk.rawValue, forKey: DefaultsKey.outputSaveLocationPreference.key) + defaults.set( + OutputSaveLocation.alwaysAsk.rawValue, + forKey: DefaultsKey.outputSaveLocationPreference.key + ) } } if defaults.object(forKey: DefaultsKey.unsavedReportQuitBehavior.key) == nil { @@ -197,7 +217,8 @@ struct SettingsView: View { } let advancedEnabled = defaults.bool(forKey: DefaultsKey.advancedModeEnabled.key) - let storedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride.rawValue + let storedModeRaw = defaults.string(forKey: DefaultsKey.advancedAIMode.key) ?? RedactionMode.rulesOverride + .rawValue let storedMode = RedactionMode(rawValue: storedModeRaw) ?? .rulesOverride let normalizedMode = storedMode == .rules ? .rulesOverride : storedMode let storedConfidence = defaults.integer(forKey: DefaultsKey.advancedLLMConfidence.key) @@ -230,10 +251,10 @@ struct SettingsView: View { if isSectionVisible(.rulesEngine) { rulesEngineSection } - if localSettings.mode.usesLLM && isSectionVisible(.aiModel) { + if localSettings.mode.usesLLM, isSectionVisible(.aiModel) { aiModelSection } - if isAdvancedModeEnabled && isSectionVisible(.advancedAI) { + if isAdvancedModeEnabled, isSectionVisible(.advancedAI) { advancedAISection } if isSectionVisible(.debug) { @@ -292,26 +313,25 @@ struct SettingsView: View { LogViewerSheet() } .alert("Override Error", isPresented: overrideErrorBinding, presenting: overrideErrorMessage) { _ in - Button("OK", role: .cancel) { } + Button("OK", role: .cancel) {} .accessibilityIdentifier("settings.overrideError.ok") } message: { message in Text(message) } .alert("Import Failed", isPresented: profileErrorBinding, presenting: profileErrorMessage) { _ in - Button("OK", role: .cancel) { } + Button("OK", role: .cancel) {} .accessibilityIdentifier("settings.profile.importError.ok") } message: { message in Text(message) } .alert("Profile Imported", isPresented: $profileImportSucceeded) { - Button("OK", role: .cancel) { } + Button("OK", role: .cancel) {} .accessibilityIdentifier("settings.profile.importSuccess.ok") } message: { Text("Settings were replaced with the imported profile. Click Save Settings to apply.") } } - @ViewBuilder private var headerView: some View { VStack(spacing: 8) { Text("Redaction Settings") @@ -425,14 +445,16 @@ struct SettingsView: View { if PermissionManager.shared.notificationStatus == .denied { Button("Open System Settings (Permission Denied)") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { - NSWorkspace.shared.open(url) - } + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } } .font(.caption) .buttonStyle(.link) .accessibilityIdentifier("settings.notifications.openSystemSettings") - } else if PermissionManager.shared.notificationStatus == .notDetermined && permissionManager.userEnabledNotifications { + } else if PermissionManager.shared.notificationStatus == .notDetermined, + permissionManager.userEnabledNotifications + { Button("Authorize Notifications") { Task { try? await PermissionManager.shared.forceRequestNotificationPermission() } } @@ -626,9 +648,11 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 2) { Text("Advanced Mode") .font(.system(size: 14, weight: .medium)) - Text("Show advanced AI controls and override modes. Normal mode is equivalent to Advanced Mode set to Rules Override at 99% confidence.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + Text( + "Show advanced AI controls and override modes. Normal mode is equivalent to Advanced Mode set to Rules Override at 99% confidence." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } } .toggleStyle(.switch) @@ -659,7 +683,9 @@ struct SettingsView: View { Spacer() } - ForEach(RedactionRule.allCases.sorted { $0.displayName < $1.displayName }.filter(isRuleVisible)) { rule in + ForEach(RedactionRule.allCases.sorted { $0.displayName < $1.displayName } + .filter(isRuleVisible)) + { rule in Toggle(isOn: binding(for: rule)) { VStack(alignment: .leading, spacing: 2) { Text(rule.displayName) @@ -672,7 +698,6 @@ struct SettingsView: View { .toggleStyle(.checkbox) .accessibilityIdentifier("settings.rules.toggle.\(rule.rawValue)") } - } } } @@ -759,9 +784,11 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { Text("LLM Processing Concurrency") .font(.system(size: 14, weight: .medium)) - Text("Higher concurrency extracts entities faster but uses significantly more system Unified Memory.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + Text( + "Higher concurrency extracts entities faster but uses significantly more system Unified Memory." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) HStack { Slider( @@ -769,7 +796,7 @@ struct SettingsView: View { get: { Double(localSettings.llmConcurrency) }, set: { localSettings.llmConcurrency = Int($0) } ), - in: 1...5, + in: 1 ... 5, step: 1 ) .accessibilityIdentifier("settings.llmConcurrency.slider") @@ -824,8 +851,12 @@ struct SettingsView: View { private var timeoutSteps: [Int] { var steps: [Int] = [30, 60, 120] - for m in 3...45 { steps.append(m * 60) } - for i in 0..<30 { steps.append((50 + i * 5) * 60) } + for m in 3 ... 45 { + steps.append(m * 60) + } + for i in 0 ..< 30 { + steps.append((50 + i * 5) * 60) + } steps.append(Int.max) return steps } @@ -905,7 +936,7 @@ struct SettingsView: View { Stepper( "", value: advancedConfidenceBinding, - in: 0...100, + in: 0 ... 100, step: 1 ) .labelsHidden() @@ -917,9 +948,11 @@ struct SettingsView: View { } } - Text("Confidence is the minimum certainty required to override a rules match. Higher = more likely to remain redacted. Confidence has no impact on system prompt based redaction (a separate pipeline).") - .font(.system(size: 10)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + Text( + "Confidence is the minimum certainty required to override a rules match. Higher = more likely to remain redacted. Confidence has no impact on system prompt based redaction (a separate pipeline)." + ) + .font(.system(size: 10)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } Divider() @@ -928,7 +961,7 @@ struct SettingsView: View { Text("Temperature: \(localSettings.temperature, specifier: "%.1f")") .font(.system(size: 14, weight: .medium)) .foregroundColor(CustomColors.primaryText(for: colorScheme)) - Slider(value: $localSettings.temperature, in: 0.0...2.0, step: 0.1) + Slider(value: $localSettings.temperature, in: 0.0 ... 2.0, step: 0.1) .accessibilityIdentifier("settings.ai.temperature") Text("Lower = more focused and deterministic. Higher = more creative but may hallucinate entities.") .font(.system(size: 12)) @@ -942,11 +975,13 @@ struct SettingsView: View { Slider(value: Binding( get: { Double(localSettings.chunkTokens) }, set: { localSettings.chunkTokens = Int($0) } - ), in: 500...2000, step: 100) - .accessibilityIdentifier("settings.ai.chunkSize") - Text("Larger = fewer chunks, faster overall but may miss entities in long sections. Smaller = more thorough but slower.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + ), in: 500 ... 2000, step: 100) + .accessibilityIdentifier("settings.ai.chunkSize") + Text( + "Larger = fewer chunks, faster overall but may miss entities in long sections. Smaller = more thorough but slower." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } VStack(alignment: .leading, spacing: 8) { @@ -956,11 +991,13 @@ struct SettingsView: View { Slider(value: Binding( get: { Double(localSettings.overlap) }, set: { localSettings.overlap = Int($0) } - ), in: 50...400, step: 25) - .accessibilityIdentifier("settings.ai.chunkOverlap") - Text("Higher = catches entities at chunk boundaries but increases processing time. Lower = faster but may miss split names.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + ), in: 50 ... 400, step: 25) + .accessibilityIdentifier("settings.ai.chunkOverlap") + Text( + "Higher = catches entities at chunk boundaries but increases processing time. Lower = faster but may miss split names." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } VStack(alignment: .leading, spacing: 8) { @@ -968,7 +1005,7 @@ struct SettingsView: View { .font(.system(size: 14, weight: .medium)) .foregroundColor(CustomColors.primaryText(for: colorScheme)) - Slider(value: timeoutSliderIndex, in: 0...Double(timeoutSteps.count - 1), step: 1) + Slider(value: timeoutSliderIndex, in: 0 ... Double(timeoutSteps.count - 1), step: 1) .accessibilityIdentifier("settings.ai.timeout") Text("Maximum time per document. Increase for very large documents. Use ∞ to disable timeout entirely.") .font(.system(size: 12)) @@ -982,13 +1019,14 @@ struct SettingsView: View { Slider(value: Binding( get: { Double(localSettings.seed) }, set: { localSettings.seed = Int($0) } - ), in: 1...1000, step: 1) - .accessibilityIdentifier("settings.ai.seed") - Text("Fixed seed = reproducible results on same document. Change to get different AI outputs for testing.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + ), in: 1 ... 1000, step: 1) + .accessibilityIdentifier("settings.ai.seed") + Text( + "Fixed seed = reproducible results on same document. Change to get different AI outputs for testing." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } - } } @@ -998,10 +1036,12 @@ struct SettingsView: View { .toggleStyle(.checkbox) .accessibilityIdentifier("settings.debug.toggle") - Text("When enabled, both the app and helper write verbose diagnostics to logs for troubleshooting. This may impact performance and generate large files.") - .font(.system(size: 12)) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) - .padding(.top, 4) + Text( + "When enabled, both the app and helper write verbose diagnostics to logs for troubleshooting. This may impact performance and generate large files." + ) + .font(.system(size: 12)) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + .padding(.top, 4) HStack(spacing: 12) { Button("View Logs") { @@ -1065,14 +1105,22 @@ struct SettingsView: View { private var overrideErrorBinding: Binding { Binding( get: { overrideErrorMessage != nil }, - set: { if !$0 { overrideErrorMessage = nil } } + set: { + if !$0 { + overrideErrorMessage = nil + } + } ) } private var profileErrorBinding: Binding { Binding( get: { profileErrorMessage != nil }, - set: { if !$0 { profileErrorMessage = nil } } + set: { + if !$0 { + profileErrorMessage = nil + } + } ) } @@ -1172,7 +1220,7 @@ struct SettingsView: View { get: { advancedLlmConfidence }, set: { newValue in advancedLlmConfidence = newValue - if isAdvancedModeEnabled && localSettings.mode != .rules { + if isAdvancedModeEnabled, localSettings.mode != .rules { localSettings.llmConfidenceThreshold = newValue } } @@ -1224,7 +1272,7 @@ struct SettingsView: View { private func selectAdvancedAIMode(_ mode: RedactionMode) { let normalized = mode == .rules ? .rulesOverride : mode advancedAIModeRaw = normalized.rawValue - if isAdvancedModeEnabled && localSettings.mode != .rules { + if isAdvancedModeEnabled, localSettings.mode != .rules { localSettings.mode = normalized } } @@ -1232,13 +1280,13 @@ struct SettingsView: View { private func advancedModeTitle(_ mode: RedactionMode) -> String { switch mode { case .rulesOverride: - return "Rules Override" + "Rules Override" case .constrainedOverrides: - return "Constrained LLM Overrides" + "Constrained LLM Overrides" case .llmOverrides: - return "LLM Overrides" + "LLM Overrides" case .rules: - return "Rules Only" + "Rules Only" } } @@ -1484,13 +1532,16 @@ private struct ScrollableTextEditor: NSViewRepresentable { textView.isHorizontallyResizable = true textView.autoresizingMask = [.width] textView.textContainer?.widthTracksTextView = true - textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + textView.textContainer?.containerSize = NSSize( + width: scrollView.contentSize.width, + height: .greatestFiniteMagnitude + ) scrollView.documentView = textView return scrollView } - func updateNSView(_ nsView: NSScrollView, context: Context) { + func updateNSView(_ nsView: NSScrollView, context _: Context) { guard let textView = nsView.documentView as? NSTextView else { return } if textView.string != text { textView.string = text @@ -1551,7 +1602,7 @@ struct FirstRunSetupView: View { switch viewModel.firstRunEntryPoint { case .manageModels: initialStep = .modelSelection - case .downloadSpecificModel(let targetModelId): + case let .downloadSpecificModel(targetModelId): initialStep = .modelSelection resolvedModel = targetModelId shouldAutoDownload = true @@ -1604,7 +1655,9 @@ struct FirstRunSetupView: View { // Navigation buttons HStack(spacing: 16) { - if setupStep != .welcome && setupStep != .downloading && !(isManageFlow && setupStep == .modelSelection) { + if setupStep != .welcome, setupStep != .downloading, + !(isManageFlow && setupStep == .modelSelection) + { Button("Back") { withAnimation(.easeInOut(duration: 0.3)) { setupStep = previousStep @@ -1614,7 +1667,7 @@ struct FirstRunSetupView: View { .accessibilityIdentifier("setup.back") } - if setupStep != .downloading && !isManageFlow { + if setupStep != .downloading, !isManageFlow { Button("Use Rules Only") { completeRulesOnly() } @@ -1650,7 +1703,7 @@ struct FirstRunSetupView: View { if autoStartDownload { autoStartDownload = false downloadModel() - } else if !isManageFlow && hasInstalledSupportedModel { + } else if !isManageFlow, hasInstalledSupportedModel { // If a supported model already exists and we're not in the manage-models flow, // skip the download prompt and finish onboarding immediately. viewModel.markFirstRunComplete() @@ -1679,7 +1732,6 @@ struct FirstRunSetupView: View { .accessibilityIdentifier("setup.close") } - @ViewBuilder private var welcomeContent: some View { VStack(spacing: 24) { VStack(spacing: 16) { @@ -1687,10 +1739,12 @@ struct FirstRunSetupView: View { .font(.title2) .fontWeight(.semibold) - Text("MarcutApp uses local AI models to identify and redact sensitive information in your documents. All processing happens on your device - no data ever leaves your computer.") - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(CustomColors.secondaryText(for: colorScheme)) + Text( + "MarcutApp uses local AI models to identify and redact sensitive information in your documents. All processing happens on your device - no data ever leaves your computer." + ) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } VStack(alignment: .leading, spacing: 12) { @@ -1715,7 +1769,6 @@ struct FirstRunSetupView: View { } } - @ViewBuilder private var modelSelectionContent: some View { VStack(spacing: 24) { // Model selection cards @@ -1741,7 +1794,6 @@ struct FirstRunSetupView: View { } } - @ViewBuilder private var downloadingContent: some View { VStack(spacing: 32) { VStack(spacing: 16) { @@ -1778,7 +1830,6 @@ struct FirstRunSetupView: View { } } - @ViewBuilder private var completeContent: some View { VStack(spacing: 24) { Image(systemName: "checkmark.circle.fill") @@ -1800,19 +1851,19 @@ struct FirstRunSetupView: View { private var buttonTitle: String { switch setupStep { - case .welcome: return "Get Started" - case .modelSelection: return "Download Model" - case .downloading: return "Downloading..." - case .complete: return "Finish" + case .welcome: "Get Started" + case .modelSelection: "Download Model" + case .downloading: "Downloading..." + case .complete: "Finish" } } private var previousStep: SetupStep { switch setupStep { - case .welcome: return .welcome - case .modelSelection: return .welcome - case .downloading: return .modelSelection - case .complete: return .downloading + case .welcome: .welcome + case .modelSelection: .welcome + case .downloading: .modelSelection + case .complete: .downloading } } @@ -1944,8 +1995,19 @@ struct ModelSelectionRow: View { let onDownload: (() -> Void)? @Environment(\.colorScheme) private var colorScheme - // Convenience initializers for different use cases - init(modelId: String, displayName: String, description: String, processingTime: String, accentColor: Color, isSelected: Bool, isInstalled: Bool, accessibilityId: String? = nil, onDownload: (() -> Void)? = nil, onSelect: @escaping () -> Void) { + /// Convenience initializers for different use cases + init( + modelId: String, + displayName: String, + description: String, + processingTime: String, + accentColor: Color, + isSelected: Bool, + isInstalled: Bool, + accessibilityId: String? = nil, + onDownload: (() -> Void)? = nil, + onSelect: @escaping () -> Void + ) { self.modelId = modelId self.displayName = displayName self.description = description @@ -1960,7 +2022,18 @@ struct ModelSelectionRow: View { self.onDownload = onDownload } - init(modelId: String, displayName: String, description: String, size: String, badge: String, isSelected: Bool, isInstalled: Bool, accessibilityId: String? = nil, onDownload: (() -> Void)? = nil, onSelect: @escaping () -> Void) { + init( + modelId: String, + displayName: String, + description: String, + size: String, + badge: String, + isSelected: Bool, + isInstalled: Bool, + accessibilityId: String? = nil, + onDownload: (() -> Void)? = nil, + onSelect: @escaping () -> Void + ) { self.modelId = modelId self.displayName = displayName self.description = description @@ -1980,7 +2053,8 @@ struct ModelSelectionRow: View { HStack(spacing: 16) { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.system(size: 20)) - .foregroundColor(isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)) : CustomColors.secondaryText(for: colorScheme)) + .foregroundColor(isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)) : + CustomColors.secondaryText(for: colorScheme)) VStack(alignment: .leading, spacing: 6) { HStack { @@ -1988,7 +2062,7 @@ struct ModelSelectionRow: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(CustomColors.primaryText(for: colorScheme)) - if let badge = badge { + if let badge { Text(badge) .font(.system(size: 11, weight: .medium)) .padding(.horizontal, 8) @@ -2009,13 +2083,13 @@ struct ModelSelectionRow: View { .multilineTextAlignment(.leading) HStack { - if let size = size { + if let size { Text(size) .font(.system(size: 12, weight: .medium)) .foregroundColor(CustomColors.secondaryText(for: colorScheme)) } - if let processingTime = processingTime { + if let processingTime { Text("β€’ \(processingTime)") .font(.system(size: 12)) .foregroundColor(CustomColors.secondaryText(for: colorScheme)) @@ -2045,11 +2119,15 @@ struct ModelSelectionRow: View { .padding(16) .background( RoundedRectangle(cornerRadius: 12) - .fill(isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)).opacity(0.12) : CustomColors.contentBackground(for: colorScheme)) + .fill(isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)) + .opacity(0.12) : CustomColors.contentBackground(for: colorScheme)) ) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)) : Color.clear, lineWidth: 2) + .stroke( + isSelected ? (accentColor ?? CustomColors.accentColor(for: colorScheme)) : Color.clear, + lineWidth: 2 + ) ) } .buttonStyle(.plain) diff --git a/src/swift/MarcutApp/Sources/MarcutApp/UnsavedReportQuitBehavior.swift b/src/swift/MarcutApp/Sources/MarcutApp/UnsavedReportQuitBehavior.swift index c0a5d1e..3bdb98a 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/UnsavedReportQuitBehavior.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/UnsavedReportQuitBehavior.swift @@ -5,16 +5,18 @@ enum UnsavedReportQuitBehavior: Int, CaseIterable, Identifiable { case alwaysQuit = 1 case alwaysCancel = 2 - var id: Int { rawValue } + var id: Int { + rawValue + } var label: String { switch self { case .warn: - return "Warn" + "Warn" case .alwaysQuit: - return "Always Quit" + "Always Quit" case .alwaysCancel: - return "Always Cancel" + "Always Cancel" } } } diff --git a/src/swift/MarcutApp/Sources/MarcutApp/UserOverridesManager.swift b/src/swift/MarcutApp/Sources/MarcutApp/UserOverridesManager.swift index 52b58aa..c06d8b7 100644 --- a/src/swift/MarcutApp/Sources/MarcutApp/UserOverridesManager.swift +++ b/src/swift/MarcutApp/Sources/MarcutApp/UserOverridesManager.swift @@ -5,13 +5,13 @@ final class UserOverridesManager { private let fileManager = FileManager.default private let overridesDirectory: URL - + // MARK: - Custom State Detection - + var hasCustomExcludedWords: Bool { fileManager.fileExists(atPath: overridesExcludedWordsURL.path) } - + var hasCustomSystemPrompt: Bool { fileManager.fileExists(atPath: overridesSystemPromptURL.path) } @@ -27,21 +27,21 @@ final class UserOverridesManager { private var overridesExcludedWordsURL: URL { overridesDirectory.appendingPathComponent("excluded-words.txt") } - + private var overridesSystemPromptURL: URL { overridesDirectory.appendingPathComponent("system-prompt.txt") } - + // MARK: - Active Paths (either custom or default) - + var activeExcludedWordsURL: URL { if hasCustomExcludedWords { return overridesExcludedWordsURL } - return resolveDefaultResourceURL(named: "excluded-words", ext: "txt") + return resolveDefaultResourceURL(named: "excluded-words", ext: "txt") ?? overridesExcludedWordsURL } - + var activeSystemPromptURL: URL { if hasCustomSystemPrompt { return overridesSystemPromptURL @@ -53,7 +53,7 @@ final class UserOverridesManager { // MARK: - Excluded Words API func loadExcludedWords() throws -> String { - return try String(contentsOf: activeExcludedWordsURL, encoding: .utf8) + try String(contentsOf: activeExcludedWordsURL, encoding: .utf8) } func saveExcludedWords(_ text: String) throws { @@ -62,23 +62,27 @@ final class UserOverridesManager { .write(to: overridesExcludedWordsURL, atomically: true, encoding: .utf8) syncEnvironment() } - + func restoreDefaultExcludedWords() { try? fileManager.removeItem(at: overridesExcludedWordsURL) syncEnvironment() } - + func defaultExcludedWords() throws -> String { if let url = resolveDefaultResourceURL(named: "excluded-words", ext: "txt") { return try String(contentsOf: url, encoding: .utf8) } - throw NSError(domain: "UserOverrides", code: 404, userInfo: [NSLocalizedDescriptionKey: "Default excluded-words.txt not found in bundle"]) + throw NSError( + domain: "UserOverrides", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Default excluded-words.txt not found in bundle"] + ) } // MARK: - System Prompt API func loadSystemPrompt() throws -> String { - return try String(contentsOf: activeSystemPromptURL, encoding: .utf8) + try String(contentsOf: activeSystemPromptURL, encoding: .utf8) } func saveSystemPrompt(_ text: String) throws { @@ -87,19 +91,23 @@ final class UserOverridesManager { .write(to: overridesSystemPromptURL, atomically: true, encoding: .utf8) syncEnvironment() } - + func restoreDefaultSystemPrompt() { try? fileManager.removeItem(at: overridesSystemPromptURL) syncEnvironment() } - + func defaultSystemPrompt() throws -> String { if let url = resolveDefaultResourceURL(named: "system-prompt", ext: "txt") { return try String(contentsOf: url, encoding: .utf8) } - throw NSError(domain: "UserOverrides", code: 404, userInfo: [NSLocalizedDescriptionKey: "Default system-prompt.txt not found in bundle"]) + throw NSError( + domain: "UserOverrides", + code: 404, + userInfo: [NSLocalizedDescriptionKey: "Default system-prompt.txt not found in bundle"] + ) } - + /// Legacy compatibility - returns default prompt text as fallback func defaultSystemPromptText() -> String { do { @@ -107,21 +115,21 @@ final class UserOverridesManager { } catch { // Fallback hardcoded prompt if bundle is missing return """ -You are an extractor for legal-document redaction. + You are an extractor for legal-document redaction. -Goal: list only real entities with semantic identity β€” people (NAME), organizations (ORG), and geographical locations (LOC). Do not include generic roles, headings, placeholders, or boilerplate even if capitalized or defined in the document. + Goal: list only real entities with semantic identity β€” people (NAME), organizations (ORG), and geographical locations (LOC). Do not include generic roles, headings, placeholders, or boilerplate even if capitalized or defined in the document. -Entity types: -- NAME: Personal names. Prefer full names. Titles like Mr./Ms./Dr. may appear but should be attached to a real name. Single generic words (Company, Board, Stockholders) are NOT names. -- ORG: Registered organizations and companies. Prefer strings that include a company designator (Inc., LLC, Ltd., Corp., LLP, GmbH, AG, SA, BV, NV, PLC, Co., Holdings, Partners, Capital, Group, Management, Ventures, Bank, Trust, University). Do NOT output generic roles like "Company", "Board of Directors", "Stockholders", "Purchaser", "Seller", "Party/Parties". -- LOC: Geographic locations such as countries, states, cities, or specific street addresses. + Entity types: + - NAME: Personal names. Prefer full names. Titles like Mr./Ms./Dr. may appear but should be attached to a real name. Single generic words (Company, Board, Stockholders) are NOT names. + - ORG: Registered organizations and companies. Prefer strings that include a company designator (Inc., LLC, Ltd., Corp., LLP, GmbH, AG, SA, BV, NV, PLC, Co., Holdings, Partners, Capital, Group, Management, Ventures, Bank, Trust, University). Do NOT output generic roles like "Company", "Board of Directors", "Stockholders", "Purchaser", "Seller", "Party/Parties". + - LOC: Geographic locations such as countries, states, cities, or specific street addresses. -Rules: -- Return the exact surface text as it appears in the passage. -- Exclude boilerplate and document structural terms. -- Output strictly JSON of the form: {"entities": [{"text": "...", "type": "NAME|ORG|LOC"}, ...]}. No extra text. -Your entire response must be a single, valid JSON object inside a ```json code block. -""" + Rules: + - Return the exact surface text as it appears in the passage. + - Exclude boilerplate and document structural terms. + - Output strictly JSON of the form: {"entities": [{"text": "...", "type": "NAME|ORG|LOC"}, ...]}. No extra text. + Your entire response must be a single, valid JSON object inside a ```json code block. + """ } } @@ -131,7 +139,7 @@ Your entire response must be a single, valid JSON object inside a ```json code b // Excluded words - always point to active path setenv("MARCUT_EXCLUDED_WORDS_PATH", activeExcludedWordsURL.path, 1) - // System prompt - always point to active path + // System prompt - always point to active path setenv("MARCUT_SYSTEM_PROMPT_PATH", activeSystemPromptURL.path, 1) } @@ -144,7 +152,9 @@ Your entire response must be a single, valid JSON object inside a ```json code b private static func resolveOverridesDirectory(fileManager: FileManager) -> URL { var candidates: [URL] = [] - if let groupURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "QG85EMCQ75.group.com.marclaw.marcutapp") { + if let groupURL = fileManager + .containerURL(forSecurityApplicationGroupIdentifier: "QG85EMCQ75.group.com.marclaw.marcutapp") + { candidates.append(groupURL.appendingPathComponent("MarcutOverrides", isDirectory: true)) } diff --git a/src/swift/MarcutApp/Tests/MarcutAppTests/MarcutAppTests.swift b/src/swift/MarcutApp/Tests/MarcutAppTests/MarcutAppTests.swift index 6a92ce6..01eb7cf 100644 --- a/src/swift/MarcutApp/Tests/MarcutAppTests/MarcutAppTests.swift +++ b/src/swift/MarcutApp/Tests/MarcutAppTests/MarcutAppTests.swift @@ -1,10 +1,9 @@ -import XCTest -import SwiftUI @testable import MarcutApp +import SwiftUI +import XCTest @MainActor final class MarcutAppTests: XCTestCase { - // MARK: - Test Infrastructure /// Test helper for creating DocumentItems @@ -17,7 +16,7 @@ final class MarcutAppTests: XCTestCase { /// Test helper for creating a test ViewModel private func createTestViewModel() -> DocumentRedactionViewModel { - return DocumentRedactionViewModel() + DocumentRedactionViewModel() } /// Test helper for resolving sample file URLs from the repo root @@ -53,7 +52,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Launch Diagnostics Tests - func testLaunchArgumentRedactionHidesPathValues() throws { + func testLaunchArgumentRedactionHidesPathValues() { let args = [ "MarcutApp", "--redact", @@ -63,7 +62,7 @@ final class MarcutAppTests: XCTestCase { "--report", "/Users/example/Client A/report.json", "--mode", - "rules" + "rules", ] let redacted = redactedLaunchArguments(args) @@ -78,9 +77,9 @@ final class MarcutAppTests: XCTestCase { // MARK: - Tooltip Tests (Task 1.2) - func testTooltipButtonConfiguration() throws { + func testTooltipButtonConfiguration() { // Test that TooltipButton is properly configured - let action = { } + let action = {} let button = TooltipButton( action: action, icon: "doc.text.fill", @@ -94,7 +93,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(button.description, "Opens the redacted .docx file with sensitive information removed") } - func testTooltipButtonsInDocumentRow() throws { + func testTooltipButtonsInDocumentRow() { // Test that completed document rows have all 3 tooltip buttons let testItem = createTestDocumentItem(status: .completed) @@ -105,7 +104,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Button Position Tests (Task 1.3) - func testButtonOrderInActionButtons() throws { + func testButtonOrderInActionButtons() { // Test that Clear All appears before Redact Documents in the button layout // Create ContentView and verify the view can be constructed. let contentView = ContentView() @@ -115,7 +114,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Dynamic Button State Tests (Task 1.4) - func testFinishedProcessingStateLogic() throws { + func testFinishedProcessingStateLogic() { // Test the logic behind finished processing state // Since we can't mock the final class, we test the logic directly @@ -129,7 +128,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(hasFinishedProcessing, "Should be finished when completed but no processing or valid docs") } - func testProcessingStateLogic() throws { + func testProcessingStateLogic() { // Test different processing state combinations // Active processing @@ -152,7 +151,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Model Selection Tests (Task 1.5) - func testModelSelectionOptions() throws { + func testModelSelectionOptions() { // Test that the correct 3 models are available let expectedModels = ["qwen2.5:14b", "qwen2.5:7b", "phi4-mini:3.8b"] @@ -161,13 +160,13 @@ final class MarcutAppTests: XCTestCase { // Verify default model is one of the expected models XCTAssertTrue(expectedModels.contains(settings.model), - "Default model should be one of the supported models") + "Default model should be one of the supported models") // Test model descriptions exist (in a real test, we'd verify the UI) let modelDescriptions = [ "qwen2.5:14b": "Gold standard. Best accuracy for legal & complex documents.", "qwen2.5:7b": "Balanced. Excellent extraction with lower memory usage.", - "phi4-mini:3.8b": "Fast & lightweight. Good for simple documents." + "phi4-mini:3.8b": "Fast & lightweight. Good for simple documents.", ] for model in expectedModels { @@ -175,7 +174,7 @@ final class MarcutAppTests: XCTestCase { } } - func testModelSelectionPersistence() throws { + func testModelSelectionPersistence() { // Test that model selection persists in settings var settings = RedactionSettings() let originalModel = settings.model @@ -192,7 +191,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Model Catalog Tests (ticket #22) - func testModelCatalogLoadsExpectedModelsAndParameters() throws { + func testModelCatalogLoadsExpectedModelsAndParameters() { // Exercises the same bundled `models.json` resource shipped with the // app (kept in sync with `src/python/marcut/models.json` and // `assets/models.json`), via the production loader `ModelCatalog`. @@ -206,7 +205,10 @@ final class MarcutAppTests: XCTestCase { } XCTAssertEqual(qwen25_14b.displayName, "Qwen 2.5 14B") XCTAssertEqual(qwen25_14b.description, "Gold standard. Best accuracy for legal & complex documents.") - XCTAssertEqual(qwen25_14b.setupDescription, "Gold standard. Best accuracy for legal & complex documents. Recommended.") + XCTAssertEqual( + qwen25_14b.setupDescription, + "Gold standard. Best accuracy for legal & complex documents. Recommended." + ) XCTAssertEqual(qwen25_14b.processingTime, "~50s") XCTAssertEqual(qwen25_14b.sizeLabel, "9.0 GB") XCTAssertEqual(qwen25_14b.badge, "Best") @@ -234,7 +236,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertNil(catalog.entry(for: "not-a-real-model:1b")) } - func testModelCatalogDefaultModelIsListedAndIsSettingsDefault() throws { + func testModelCatalogDefaultModelIsListedAndIsSettingsDefault() { let catalog = ModelCatalog.shared XCTAssertTrue(catalog.modelIds.contains(catalog.defaultModelId)) XCTAssertEqual(RedactionSettings().model, catalog.defaultModelId) @@ -260,13 +262,17 @@ final class MarcutAppTests: XCTestCase { let pythonContents = try String(contentsOf: pythonCopy, encoding: .utf8) let assetsContents = try String(contentsOf: assetsCopy, encoding: .utf8) - XCTAssertEqual(swiftContents, pythonContents, "Swift Resources/models.json has drifted from src/python/marcut/models.json") + XCTAssertEqual( + swiftContents, + pythonContents, + "Swift Resources/models.json has drifted from src/python/marcut/models.json" + ) XCTAssertEqual(swiftContents, assetsContents, "Swift Resources/models.json has drifted from assets/models.json") } // MARK: - Progress Indicator Tests (Task 1.6) - func testPreparingStateLogic() throws { + func testPreparingStateLogic() { // Test the preparing state logic that should prevent beach balls // Simulate the preparing state @@ -286,7 +292,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Accessibility Identifier Tests - func testTooltipButtonAccessibilityIdentifier() throws { + func testTooltipButtonAccessibilityIdentifier() { let button = TooltipButton( action: {}, icon: "doc.text.fill", @@ -299,7 +305,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(button.accessibilityId, "document.openRedacted.test") } - func testFinalRedactedCopyURLUsesSeparateDocxCopy() throws { + func testFinalRedactedCopyURLUsesSeparateDocxCopy() { let source = URL(fileURLWithPath: "/tmp/client-review.docx") let finalURL = DocumentRedactionViewModel.finalRedactedCopyURL(for: source) { _ in false } @@ -308,11 +314,11 @@ final class MarcutAppTests: XCTestCase { XCTAssertNotEqual(finalURL, source) } - func testFinalRedactedCopyURLAvoidsOverwrite() throws { + func testFinalRedactedCopyURLAvoidsOverwrite() { let source = URL(fileURLWithPath: "/tmp/client-review.docx") let occupied = Set([ "/tmp/client-review Final Redacted.docx", - "/tmp/client-review Final Redacted 2.docx" + "/tmp/client-review Final Redacted 2.docx", ]) let finalURL = DocumentRedactionViewModel.finalRedactedCopyURL(for: source) { occupied.contains($0) } @@ -347,6 +353,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Ollama Port Conflict Detection Tests (ticket #45 / B3) + // // `performOllamaStartup()` needs a real bundled `ollama` binary to exercise // end-to-end (not present under `swift test`, see `resolveOllamaPath()`), so these @@ -388,6 +395,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Model Name Normalization Parity Tests (ticket #21) + // // `PythonBridgeService.normalizedModelIdentifier` is the Swift half of the // model-name-parsing rules; `marcut.model_naming.parse_model_identifier` / @@ -429,6 +437,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Model Download Notification Tests + // // NOTE: These tests inject fake closures for `modelDownloadAuthorizationRequester` and // `modelDownloadCompletionNotifier` instead of exercising the real `PermissionManager.shared` @@ -439,7 +448,7 @@ final class MarcutAppTests: XCTestCase { // success, with the correct model name, and never called on failure) without touching that // code path. - func testModelDownloadNotifierFiresOnSuccessWithModelName() async { + func testModelDownloadNotifierFiresOnSuccessWithModelName() { let bridge = PythonBridgeService() var notifiedModelNames: [String] = [] var authorizationRequestCount = 0 @@ -521,6 +530,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Model Download Failure Classification Tests (issue #50 / B8) + // // `shouldRetryModelDownload` / `shouldFallbackToCLIDownload` / `formatModelDownloadError` / // `normalizeModelDownloadErrorMessage` drive the retry-then-fallback-to-CLI state machine @@ -575,7 +585,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(PythonBridgeService.formatModelDownloadError(error), "Download cancelled.") } - func testModelSelectionRowAccessibilityIdentifier() throws { + func testModelSelectionRowAccessibilityIdentifier() { let row = ModelSelectionRow( modelId: "qwen2.5:14b", displayName: "Qwen 2.5 14B", @@ -592,18 +602,18 @@ final class MarcutAppTests: XCTestCase { // MARK: - Settings Search Tests - func testSettingsSearchMatchesCaseInsensitiveSubstring() throws { + func testSettingsSearchMatchesCaseInsensitiveSubstring() { XCTAssertTrue(SettingsView.matchesSearch("Excluded Terms", query: "exclud")) XCTAssertTrue(SettingsView.matchesSearch("Excluded Terms", query: "TERMS")) XCTAssertTrue(SettingsView.matchesSearch("Chunk Overlap", query: "chunk overlap")) } - func testSettingsSearchEmptyQueryMatchesEverything() throws { + func testSettingsSearchEmptyQueryMatchesEverything() { XCTAssertTrue(SettingsView.matchesSearch("Excluded Terms", query: "")) XCTAssertTrue(SettingsView.matchesSearch("Excluded Terms", query: " ")) } - func testSettingsSearchRejectsNonMatchingQuery() throws { + func testSettingsSearchRejectsNonMatchingQuery() { XCTAssertFalse(SettingsView.matchesSearch("Excluded Terms", query: "zzz-not-present")) XCTAssertFalse(SettingsView.matchesSearch("Chunk Overlap", query: "temperature")) } @@ -690,7 +700,7 @@ final class MarcutAppTests: XCTestCase { let existingSettings = nonDefaultRedactionSettings() XCTAssertThrowsError(try RedactionProfile.decoded(from: data)) { error in - guard case RedactionProfile.ProfileError.unsupportedSchemaVersion(let found, let supported) = error else { + guard case let RedactionProfile.ProfileError.unsupportedSchemaVersion(found, supported) = error else { XCTFail("Expected unsupportedSchemaVersion, got \(error)") return } @@ -704,7 +714,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Mass Progress Tests - func testMassTotalDeferredUntilEnhancedStage() throws { + func testMassTotalDeferredUntilEnhancedStage() { let item = createTestDocumentItem(status: .processing) XCTAssertTrue(item.ingestProgressPayload("{\"type\":\"mass_total\",\"value\":120}")) @@ -715,7 +725,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(item.isMassTrackingActive, "Mass tracking should activate during enhanced stage") } - func testChunkEndClampsProcessedMassToTotal() throws { + func testChunkEndClampsProcessedMassToTotal() { let item = createTestDocumentItem(status: .processing) item.beginStage(.enhancedDetection) @@ -730,7 +740,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Batch ETA Tests - func testBatchETAReturnsNilWithFewerThanTwoSamples() throws { + func testBatchETAReturnsNilWithFewerThanTwoSamples() { XCTAssertNil( BatchETACalculator.estimate(samples: [], remainingSizes: [1000]), "No samples should yield no estimate" @@ -744,7 +754,7 @@ final class MarcutAppTests: XCTestCase { ) } - func testBatchETAComputesRemainingTimeFromObservedRate() throws { + func testBatchETAComputesRemainingTimeFromObservedRate() { // Two documents completed: 1000 bytes in 10s, then 2000 bytes in 20s -> 100 bytes/sec overall. let samples = [ BatchETASample(duration: 10, size: 1000), @@ -757,7 +767,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(eta ?? -1, 5.0, accuracy: 0.001) } - func testBatchETASumsMultipleRemainingDocuments() throws { + func testBatchETASumsMultipleRemainingDocuments() { let samples = [ BatchETASample(duration: 10, size: 1000), BatchETASample(duration: 10, size: 1000), @@ -769,7 +779,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(eta ?? -1, 5.0, accuracy: 0.001) } - func testBatchETAReturnsZeroWhenNoRemainingWork() throws { + func testBatchETAReturnsZeroWhenNoRemainingWork() { let samples = [ BatchETASample(duration: 10, size: 1000), BatchETASample(duration: 10, size: 1000), @@ -779,7 +789,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(eta, 0.0, "No remaining documents should mean zero time remaining, not nil") } - func testBatchETAReturnsNilWhenObservedRateIsDegenerate() throws { + func testBatchETAReturnsNilWhenObservedRateIsDegenerate() { // Zero total size across samples (e.g. size signal unavailable) -> no reliable rate. let samples = [ BatchETASample(duration: 10, size: 0), @@ -795,10 +805,10 @@ final class MarcutAppTests: XCTestCase { // complete first (fast, low size); eight large documents (50x the // size) are still queued/in-flight. let smallSamples = [ - BatchETASample(duration: 2, size: 1_000), - BatchETASample(duration: 2, size: 1_000), + BatchETASample(duration: 2, size: 1000), + BatchETASample(duration: 2, size: 1000), ] - let remainingLargeSizes: [Int64] = Array(repeating: 50_000, count: 8) + let remainingLargeSizes: [Int64] = Array(repeating: 50000, count: 8) let eta = try XCTUnwrap( BatchETACalculator.estimate(samples: smallSamples, remainingSizes: remainingLargeSizes) @@ -819,7 +829,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Integration Tests - func testDocumentItemStatusTransitions() throws { + func testDocumentItemStatusTransitions() { // Test document item status transitions let item = createTestDocumentItem(status: .validDocument) @@ -838,7 +848,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Error Handling Tests - func testInvalidDocumentHandling() throws { + func testInvalidDocumentHandling() { let item = createTestDocumentItem(status: .invalidDocument) item.errorMessage = "Only DOCX files are supported" @@ -923,7 +933,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(isValid, "Expected absolute customXML relationship target to pass validation") } - func testFailedDocumentHandling() throws { + func testFailedDocumentHandling() { let item = createTestDocumentItem(status: .failed) item.errorMessage = "Processing failed - check logs for details" @@ -933,7 +943,7 @@ final class MarcutAppTests: XCTestCase { // MARK: - Retry Failed Tests - func testRetryFailedDocumentsOnlyReQueuesFailedItems() throws { + func testRetryFailedDocumentsOnlyReQueuesFailedItems() { let viewModel = createTestViewModel() let completedItem = createTestDocumentItem(status: .completed) @@ -966,11 +976,11 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(validItem.status, .validDocument) } - func testRetryFailedDocumentsNoOpWhenNoFailedItems() throws { + func testRetryFailedDocumentsNoOpWhenNoFailedItems() { let viewModel = createTestViewModel() viewModel.items = [ createTestDocumentItem(status: .completed), - createTestDocumentItem(status: .validDocument) + createTestDocumentItem(status: .validDocument), ] var retried: [DocumentItem] = [] @@ -983,7 +993,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(retried.isEmpty, "Retry should be a no-op when there are no failed documents") } - func testHasFailedDocumentsReflectsItemStatuses() throws { + func testHasFailedDocumentsReflectsItemStatuses() { let viewModel = createTestViewModel() XCTAssertFalse(viewModel.hasFailedDocuments, "No documents means no failed documents") @@ -997,6 +1007,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Batch State Transition Tests (issue #50 / B8) + // // `processAllDocuments()` walks a batch sequentially and does not stop the batch when one // document fails (DocumentRedactionViewModel.swift): the `while` loop only cares whether an @@ -1006,7 +1017,7 @@ final class MarcutAppTests: XCTestCase { // inert trigger to re-run `updateState()` -- the same indirect-call pattern used by // `testHasFailedDocumentsReflectsItemStatuses` above (`updateState()` itself is private). - func testBatchStateFlagsReflectPerDocumentFailureAmidInFlightBatch() throws { + func testBatchStateFlagsReflectPerDocumentFailureAmidInFlightBatch() { let viewModel = createTestViewModel() let queuedItem = createTestDocumentItem(status: .validDocument) let processingItem = createTestDocumentItem(status: .processing) @@ -1019,8 +1030,14 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(viewModel.hasValidDocuments, "A still-queued document should be reflected") XCTAssertTrue(viewModel.hasProcessingDocuments, "An in-flight document should be reflected") XCTAssertTrue(viewModel.hasCompletedDocuments, "A completed document should be reflected") - XCTAssertTrue(viewModel.hasFailedDocuments, "One document failing mid-batch must not be masked by its siblings' states") - XCTAssertFalse(viewModel.hasFinishedProcessing, "Batch isn't finished while a document is still queued/processing") + XCTAssertTrue( + viewModel.hasFailedDocuments, + "One document failing mid-batch must not be masked by its siblings' states" + ) + XCTAssertFalse( + viewModel.hasFinishedProcessing, + "Batch isn't finished while a document is still queued/processing" + ) // The in-flight document finishes and the queued one starts. The earlier failure must // keep reporting failed regardless of later, unrelated transitions in the same batch -- @@ -1029,7 +1046,10 @@ final class MarcutAppTests: XCTestCase { queuedItem.status = .processing viewModel.add(urls: []) - XCTAssertTrue(viewModel.hasFailedDocuments, "Earlier per-document failure persists across later transitions in the batch") + XCTAssertTrue( + viewModel.hasFailedDocuments, + "Earlier per-document failure persists across later transitions in the batch" + ) XCTAssertTrue(viewModel.hasProcessingDocuments) XCTAssertFalse(viewModel.hasFinishedProcessing) @@ -1052,7 +1072,10 @@ final class MarcutAppTests: XCTestCase { viewModel.add(urls: []) XCTAssertFalse(viewModel.hasFailedDocuments) - XCTAssertTrue(viewModel.hasFinishedProcessing, "Once the failure is resolved, a fully-completed batch reports finished") + XCTAssertTrue( + viewModel.hasFinishedProcessing, + "Once the failure is resolved, a fully-completed batch reports finished" + ) } // MARK: - Watchdog Tests (issue #43 / B1: embedded Python worker hang/crash) @@ -1080,7 +1103,7 @@ final class MarcutAppTests: XCTestCase { return 1 } ) { error in - guard case PythonBridgeError.workerStalled(let operation) = error else { + guard case let PythonBridgeError.workerStalled(operation) = error else { XCTFail("Expected PythonBridgeError.workerStalled, got \(error)") return } @@ -1142,11 +1165,15 @@ final class MarcutAppTests: XCTestCase { viewModel.ensureHeartbeatMonitorRunning(for: item) let deadline = Date().addingTimeInterval(5.0) - while item.status == .processing && Date() < deadline { + while item.status == .processing, Date() < deadline { try await Task.sleep(nanoseconds: 50_000_000) } - XCTAssertEqual(item.status, .failed, "A document with no progress signal for longer than the stall threshold must be marked failed, not left hanging forever") + XCTAssertEqual( + item.status, + .failed, + "A document with no progress signal for longer than the stall threshold must be marked failed, not left hanging forever" + ) XCTAssertEqual(item.errorMessage, DocumentRedactionViewModel.processingStalledMessage) } @@ -1176,10 +1203,16 @@ final class MarcutAppTests: XCTestCase { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { // Restore write permission before cleanup, otherwise removal itself can fail. - try? FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: tempDir.path) + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: tempDir.path + ) try? FileManager.default.removeItem(at: tempDir) } - try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: Int16(0o555))], ofItemAtPath: tempDir.path) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o555))], + ofItemAtPath: tempDir.path + ) let viewModel = createTestViewModel() let error = viewModel.validateDestination(tempDir) @@ -1193,7 +1226,10 @@ final class MarcutAppTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let viewModel = createTestViewModel() - XCTAssertNil(viewModel.validateDestination(tempDir), "A writable destination with no space estimate requested must pass") + XCTAssertNil( + viewModel.validateDestination(tempDir), + "A writable destination with no space estimate requested must pass" + ) } /// Free-space logic is exercised through the injectable `freeSpaceProvider` parameter @@ -1211,7 +1247,10 @@ final class MarcutAppTests: XCTestCase { freeSpaceProvider: { _ in 1024 } // simulate an almost-full disk ) - XCTAssertNotNil(error, "Insufficient free space at the destination must fail preflight, not wait for a mid-run write failure") + XCTAssertNotNil( + error, + "Insufficient free space at the destination must fail preflight, not wait for a mid-run write failure" + ) XCTAssertTrue(error?.contains("free disk space") ?? false) XCTAssertTrue(error?.contains("1.00 GB") ?? false, "Error should state the estimated need") } @@ -1250,7 +1289,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertNil(error, "An undeterminable free-space figure must not block the run") } - func testDiskSpaceCheckParseByteSizeHandlesModelCatalogLabels() throws { + func testDiskSpaceCheckParseByteSizeHandlesModelCatalogLabels() { XCTAssertEqual(DiskSpaceCheck.parseByteSize("9.0 GB"), 9_663_676_416) XCTAssertEqual(DiskSpaceCheck.parseByteSize("22 GB"), 23_622_320_128) XCTAssertEqual(DiskSpaceCheck.parseByteSize("512 MB"), 536_870_912) @@ -1261,7 +1300,7 @@ final class MarcutAppTests: XCTestCase { /// Model downloads must be checked against the catalog-declared size (`models.json` /// `sizeLabel`) before `ollama pull` even starts, rather than relying on pattern-matching /// "no space" out of Ollama's own output after the fact. - func testModelDownloadSpaceShortfallFailsWhenCatalogSizeExceedsAvailable() throws { + func testModelDownloadSpaceShortfallFailsWhenCatalogSizeExceedsAvailable() { let directory = FileManager.default.temporaryDirectory let shortfall = PythonBridgeService.modelDownloadSpaceShortfall( modelName: "qwen2.5:14b", @@ -1275,7 +1314,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(shortfall?.contains("free disk space") ?? false) } - func testModelDownloadSpaceShortfallPassesWhenCatalogSizeFitsAvailable() throws { + func testModelDownloadSpaceShortfallPassesWhenCatalogSizeFitsAvailable() { let directory = FileManager.default.temporaryDirectory let shortfall = PythonBridgeService.modelDownloadSpaceShortfall( modelName: "phi4-mini:3.8b", @@ -1287,7 +1326,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertNil(shortfall) } - func testModelDownloadSpaceShortfallSkipsCheckWhenSizeLabelMissingOrUnparseable() throws { + func testModelDownloadSpaceShortfallSkipsCheckWhenSizeLabelMissingOrUnparseable() { let directory = FileManager.default.temporaryDirectory XCTAssertNil(PythonBridgeService.modelDownloadSpaceShortfall( modelName: "custom:model", @@ -1328,7 +1367,11 @@ final class MarcutAppTests: XCTestCase { // is an irrelevant implementation detail for this test. let discoveredNames = DebugLogger.discoverLogFiles(in: tempDir).map(\.lastPathComponent) - XCTAssertEqual(discoveredNames, ["marcut-2.log", "marcut.log"], "Log files should be sorted most-recently-modified first, and non-.log files excluded") + XCTAssertEqual( + discoveredNames, + ["marcut-2.log", "marcut.log"], + "Log files should be sorted most-recently-modified first, and non-.log files excluded" + ) } func testDiscoverLogFilesReturnsEmptyArrayWhenNoLogsExist() throws { @@ -1339,19 +1382,23 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(DebugLogger.discoverLogFiles(in: tempDir).isEmpty, "Empty directory should yield no log files") } - func testDiscoverLogFilesReturnsEmptyArrayWhenDirectoryMissing() throws { - let missingDir = FileManager.default.temporaryDirectory.appendingPathComponent("does-not-exist-\(UUID().uuidString)") + func testDiscoverLogFilesReturnsEmptyArrayWhenDirectoryMissing() { + let missingDir = FileManager.default.temporaryDirectory + .appendingPathComponent("does-not-exist-\(UUID().uuidString)") - XCTAssertTrue(DebugLogger.discoverLogFiles(in: missingDir).isEmpty, "Nonexistent directory should yield no log files, not throw") + XCTAssertTrue( + DebugLogger.discoverLogFiles(in: missingDir).isEmpty, + "Nonexistent directory should yield no log files, not throw" + ) } // MARK: - Performance Tests - func testRedactionStatusPerformance() throws { + func testRedactionStatusPerformance() { // Test that RedactionStatus operations are performant measure { // Simulate rapid status checks - for i in 0..<10000 { + for i in 0 ..< 10000 { let status: RedactionStatus = (i % 2 == 0) ? .processing : .completed _ = status.isProcessing _ = status.isComplete @@ -1360,6 +1407,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Excluded-Word Match Preview Tests + // // Ground truth for these expectations was captured by running the production // matcher directly (`marcut.rules._is_excluded`) against the same phrases, e.g.: @@ -1372,20 +1420,20 @@ final class MarcutAppTests: XCTestCase { ExcludedWordMatcher.baseEntries + ExcludedWordMatcher.compileEntries(fromLines: lines) } - func testExcludedWordMatcherMatchesExactLiteral() throws { + func testExcludedWordMatcherMatchesExactLiteral() { let entries = excludedWordEntries(fromLines: ["Non-Disclosure Agreement", "Acme Widgets"]) let result = ExcludedWordMatcher.match("Non-Disclosure Agreement", entries: entries) XCTAssertTrue(result.matched) XCTAssertEqual(result.matchedEntry, "Non-Disclosure Agreement") } - func testExcludedWordMatcherIsCaseInsensitive() throws { + func testExcludedWordMatcherIsCaseInsensitive() { let entries = excludedWordEntries(fromLines: ["Confidential Information"]) XCTAssertTrue(ExcludedWordMatcher.match("confidential information", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("CONFIDENTIAL INFORMATION", entries: entries).matched) } - func testExcludedWordMatcherStripsLeadingDeterminer() throws { + func testExcludedWordMatcherStripsLeadingDeterminer() { let entries = excludedWordEntries(fromLines: ["Board of Directors"]) XCTAssertTrue(ExcludedWordMatcher.match("the Board of Directors", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("Board of Directors", entries: entries).matched) @@ -1398,7 +1446,7 @@ final class MarcutAppTests: XCTestCase { /// already determiner-stripped text and matches a second time. Ground-truth /// verified against `marcut.rules._is_excluded` for each phrase below β€” all /// return `True` in production, so the Swift preview must match them too. - func testExcludedWordMatcherStripsTwoStackedLeadingDeterminers() throws { + func testExcludedWordMatcherStripsTwoStackedLeadingDeterminers() { let entries = excludedWordEntries(fromLines: []) XCTAssertTrue(ExcludedWordMatcher.match("all such Notices", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("both the Parties", entries: entries).matched) @@ -1408,14 +1456,14 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(ExcludedWordMatcher.match("either such Party", entries: entries).matched) } - func testExcludedWordMatcherIgnoresTrailingPunctuationAndWhitespace() throws { + func testExcludedWordMatcherIgnoresTrailingPunctuationAndWhitespace() { let entries = excludedWordEntries(fromLines: ["Non-Disclosure Agreement"]) XCTAssertTrue(ExcludedWordMatcher.match("Non-Disclosure Agreement.", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("Non-Disclosure Agreement,", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match(" Non-Disclosure Agreement ", entries: entries).matched) } - func testExcludedWordMatcherTreatsSimplePluralsAsEquivalent() throws { + func testExcludedWordMatcherTreatsSimplePluralsAsEquivalent() { let entries = excludedWordEntries(fromLines: ["Agreement"]) XCTAssertTrue(ExcludedWordMatcher.match("Agreement", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("Agreements", entries: entries).matched) @@ -1425,26 +1473,26 @@ final class MarcutAppTests: XCTestCase { XCTAssertTrue(ExcludedWordMatcher.match("Companies", entries: iesEntries).matched) } - func testExcludedWordMatcherSupportsRegexEntries() throws { + func testExcludedWordMatcherSupportsRegexEntries() { let entries = excludedWordEntries(fromLines: ["Article [A-Z0-9]+"]) XCTAssertTrue(ExcludedWordMatcher.match("Article IV", entries: entries).matched) XCTAssertTrue(ExcludedWordMatcher.match("Article 5", entries: entries).matched) XCTAssertFalse(ExcludedWordMatcher.match("Preamble", entries: entries).matched) } - func testExcludedWordMatcherNoMatchForUnrelatedPhrase() throws { + func testExcludedWordMatcherNoMatchForUnrelatedPhrase() { let entries = excludedWordEntries(fromLines: ["Confidential Information", "Board of Directors"]) XCTAssertFalse(ExcludedWordMatcher.match("John Smith", entries: entries).matched) XCTAssertFalse(ExcludedWordMatcher.match("Acme Corp", entries: entries).matched) } - func testExcludedWordMatcherEmptyPhraseDoesNotMatch() throws { + func testExcludedWordMatcherEmptyPhraseDoesNotMatch() { let entries = excludedWordEntries(fromLines: ["Company"]) XCTAssertFalse(ExcludedWordMatcher.match("", entries: entries).matched) XCTAssertFalse(ExcludedWordMatcher.match(" ", entries: entries).matched) } - func testExcludedWordMatcherHonorsAlwaysOnBaseTerms() throws { + func testExcludedWordMatcherHonorsAlwaysOnBaseTerms() { // "Company", "Board of Directors", "Purchaser", etc. are excluded // unconditionally (marcut.model._get_base_excluded_literals) even though // they never appear in the user-editable excluded-words text. @@ -1487,7 +1535,7 @@ final class MarcutAppTests: XCTestCase { return defaults } - func testPendingBatchJobRecordRoundTripPreservesValues() throws { + func testPendingBatchJobRecordRoundTripPreservesValues() { let defaults = makePendingBatchJobTestDefaults() var settings = RedactionSettings() settings.model = "mistral:7b" @@ -1505,7 +1553,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertEqual(loaded?.schemaVersion, PendingBatchJobRecord.currentSchemaVersion) } - func testPendingBatchJobStoreSaveNilClearsRecord() throws { + func testPendingBatchJobStoreSaveNilClearsRecord() { let defaults = makePendingBatchJobTestDefaults() let record = PendingBatchJobRecord(documentPaths: ["/Users/test/a.docx"], settings: RedactionSettings()) PendingBatchJobStore.save(record, defaults: defaults) @@ -1515,7 +1563,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertNil(PendingBatchJobStore.load(defaults: defaults)) } - func testPendingBatchJobStoreDiscardsMalformedRecordWithoutCrashing() throws { + func testPendingBatchJobStoreDiscardsMalformedRecordWithoutCrashing() { let defaults = makePendingBatchJobTestDefaults() defaults.set(Data("{ this is not valid json".utf8), forKey: PendingBatchJobStore.defaultsKey) @@ -1535,7 +1583,7 @@ final class MarcutAppTests: XCTestCase { XCTAssertNil(defaults.data(forKey: PendingBatchJobStore.defaultsKey)) } - func testPendingBatchJobStoreDiscardsEmptyPathsRecord() throws { + func testPendingBatchJobStoreDiscardsEmptyPathsRecord() { let defaults = makePendingBatchJobTestDefaults() let record = PendingBatchJobRecord(documentPaths: [], settings: RedactionSettings()) PendingBatchJobStore.save(record, defaults: defaults) @@ -1544,6 +1592,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Resume/Discard Flow Tests (issue #19 regression) + // // `DocumentRedactionViewModel` persists pending-batch state via `PendingBatchJobStore`'s // `.standard`-defaulted parameter with no injection seam, so these tests exercise the real @@ -1617,6 +1666,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Kill-Mid-Document Resume Safety Tests (issue #48 / B6) + // // "Verify resume-after-quit never presents partial outputs as complete." Validated by // reading the two halves of the path together: @@ -1682,7 +1732,10 @@ final class MarcutAppTests: XCTestCase { viewModel.add(urls: []) let persisted = PendingBatchJobStore.load() - XCTAssertNotNil(persisted, "A batch with documents still pending/mid-processing must persist a resume record") + XCTAssertNotNil( + persisted, + "A batch with documents still pending/mid-processing must persist a resume record" + ) XCTAssertEqual( Set(persisted?.documentPaths ?? []), Set([midProcessingItem.url.path, queuedItem.url.path]), @@ -1743,7 +1796,10 @@ final class MarcutAppTests: XCTestCase { } } - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -1769,7 +1825,7 @@ final class MarcutAppTests: XCTestCase { viewModel.resumePendingJob() let deadline = Date().addingTimeInterval(10.0) - while viewModel.items.first?.status == .checking && Date() < deadline { + while viewModel.items.first?.status == .checking, Date() < deadline { try await Task.sleep(nanoseconds: 50_000_000) } @@ -1789,6 +1845,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Failure Message Mapping Tests (issue #46 / B4) + // // `FailureMessagePresenter.message(forCode:)` is the single place that turns a pipeline // `error_code` (or the absence of one, for bridge-level failures) into the user-facing @@ -1821,15 +1878,26 @@ final class MarcutAppTests: XCTestCase { FailureMessagePresenter.message(forCode: nil), "Known code '\(code)' must not fall back to the generic message" ) - XCTAssertTrue(message.contains(FailureMessagePresenter.logHint), "Message for '\(code)' should point to the App Log") + XCTAssertTrue( + message.contains(FailureMessagePresenter.logHint), + "Message for '\(code)' should point to the App Log" + ) seenMessages.insert(message) } - XCTAssertEqual(seenMessages.count, knownCodes.count, "Every known error code should map to a distinct friendly message") + XCTAssertEqual( + seenMessages.count, + knownCodes.count, + "Every known error code should map to a distinct friendly message" + ) } func testFailureMessageFallsBackToGenericForUnknownCode() { let message = FailureMessagePresenter.message(forCode: "SOME_CODE_THAT_DOES_NOT_EXIST") - XCTAssertEqual(message, FailureMessagePresenter.message(forCode: nil), "Unrecognized codes should get the generic message") + XCTAssertEqual( + message, + FailureMessagePresenter.message(forCode: nil), + "Unrecognized codes should get the generic message" + ) XCTAssertTrue(message.contains(FailureMessagePresenter.genericMessage)) } @@ -1862,6 +1930,7 @@ final class MarcutAppTests: XCTestCase { } // MARK: - Power Assertion / Sleep-Wake Tests (B5, issue #47) + // // `PowerAssertionGuard` itself is tested in isolation with injected acquire/release fakes // (no real IOKit calls, deterministic). The ViewModel-integration tests below inject a fresh @@ -1949,7 +2018,11 @@ final class MarcutAppTests: XCTestCase { shouldSucceed = true powerGuard.begin() - XCTAssertEqual(acquireCount, 2, "A later begin() must retry the acquire rather than remembering the earlier failure forever") + XCTAssertEqual( + acquireCount, + 2, + "A later begin() must retry the acquire rather than remembering the earlier failure forever" + ) XCTAssertTrue(powerGuard.isHoldingAssertion) powerGuard.end() } @@ -1981,7 +2054,7 @@ final class MarcutAppTests: XCTestCase { viewModel.ensureHeartbeatMonitorRunning(for: item) let deadline = Date().addingTimeInterval(5.0) - while item.status == .processing && Date() < deadline { + while item.status == .processing, Date() < deadline { try await Task.sleep(nanoseconds: 50_000_000) } @@ -2011,13 +2084,20 @@ final class MarcutAppTests: XCTestCase { XCTAssertFalse(ok, "Download should fail when the Ollama service is disallowed") XCTAssertEqual(acquireCount, 1, "downloadModel must hold the assertion for the duration of the attempt") - XCTAssertEqual(releaseCount, 1, "downloadModel's early failure path must still release the assertion, not leak it") + XCTAssertEqual( + releaseCount, + 1, + "downloadModel's early failure path must still release the assertion, not leak it" + ) } /// Wake with nothing processing must not probe Ollama at all -- the common case (waking up /// with no batch running) should be a pure no-op. - func testHandleSystemWakeNoOpsWhenNothingIsProcessing() async throws { - let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard(acquire: { _ in 1 }, release: { _ in })) + func testHandleSystemWakeNoOpsWhenNothingIsProcessing() async { + let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard( + acquire: { _ in 1 }, + release: { _ in } + )) var healthCheckCallCount = 0 viewModel.wakeOllamaHealthCheck = { healthCheckCallCount += 1; return true } @@ -2029,8 +2109,11 @@ final class MarcutAppTests: XCTestCase { /// If the wake-time health check finds Ollama responsive, in-flight documents must resume /// rather than fail, and `lastHeartbeat` must be refreshed so the heartbeat watchdog doesn't /// see the sleep duration itself as a stall. - func testHandleSystemWakeResumesProcessingWhenHealthCheckPasses() async throws { - let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard(acquire: { _ in 1 }, release: { _ in })) + func testHandleSystemWakeResumesProcessingWhenHealthCheckPasses() async { + let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard( + acquire: { _ in 1 }, + release: { _ in } + )) let item = createTestDocumentItem(status: .processing) let staleHeartbeat = Date().addingTimeInterval(-500) // would already read as stalled by wall-clock alone item.lastHeartbeat = staleHeartbeat @@ -2052,8 +2135,11 @@ final class MarcutAppTests: XCTestCase { /// If the wake-time health check finds Ollama unresponsive, in-flight documents must fail /// immediately with the wake-specific message rather than being left to the generic /// heartbeat-stall message (or hanging until it eventually fires). - func testHandleSystemWakeFailsInFlightDocumentsWhenHealthCheckFails() async throws { - let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard(acquire: { _ in 1 }, release: { _ in })) + func testHandleSystemWakeFailsInFlightDocumentsWhenHealthCheckFails() async { + let viewModel = DocumentRedactionViewModel(powerAssertion: PowerAssertionGuard( + acquire: { _ in 1 }, + release: { _ in } + )) let item = createTestDocumentItem(status: .processing) item.lastHeartbeat = Date() viewModel.items = [item] @@ -2073,18 +2159,18 @@ extension RedactionStatus { var isProcessing: Bool { switch self { case .processing, .analyzing, .redacting: - return true + true default: - return false + false } } var isComplete: Bool { switch self { case .completed: - return true + true default: - return false + false } } } diff --git a/tests/benchmark/model_benchmark.py b/tests/benchmark/model_benchmark.py index d8fe45b..30d1644 100644 --- a/tests/benchmark/model_benchmark.py +++ b/tests/benchmark/model_benchmark.py @@ -86,7 +86,7 @@ def run_gguf_extraction( elif 'MARCUT_SYSTEM_PROMPT_PATH' in os.environ: del os.environ['MARCUT_SYSTEM_PROMPT_PATH'] - from marcut.model import llama_cpp_extract, get_system_prompt, _map_label, _find_entity_spans + from marcut.model import llama_cpp_extract timing = { "prompt_build": 0.0, "http_request": 0.0, "ollama_model_load": 0.0, @@ -156,7 +156,7 @@ def check_ollama() -> bool: try: resp = requests.get('http://127.0.0.1:11434/api/tags', timeout=5) return resp.status_code == 200 - except: + except Exception: return False @@ -167,7 +167,7 @@ def get_available_ollama_models() -> List[str]: resp = requests.get('http://127.0.0.1:11434/api/tags', timeout=5) data = resp.json() return [m['name'] for m in data.get('models', [])] - except: + except Exception: return [] @@ -279,7 +279,7 @@ def main(): timing_detail = None error = None - for run in range(args.runs): + for _ in range(args.runs): try: spans, timing = run_extraction(path_or_id, text, args.prompt, is_gguf=is_gguf) times.append(timing['http_request']) diff --git a/tests/scripts/check_dependency_updates.py b/tests/scripts/check_dependency_updates.py index 5386d21..ded5bca 100644 --- a/tests/scripts/check_dependency_updates.py +++ b/tests/scripts/check_dependency_updates.py @@ -138,7 +138,7 @@ def main(): if updates["error"]: print(f"{RED}❌ Failed to check:{NC}") - for pkg, curr, _ in updates["error"]: + for pkg, _, _ in updates["error"]: print(f" {pkg}") print() diff --git a/tests/scripts/create_review_checklist.py b/tests/scripts/create_review_checklist.py index acb2814..fa5914b 100755 --- a/tests/scripts/create_review_checklist.py +++ b/tests/scripts/create_review_checklist.py @@ -153,7 +153,7 @@ def generate_review_checklist(results: dict) -> str: |----------|------------------|-------------------|-------------------|------------| """ - for i, result in enumerate(results['results'], 1): + for result in results['results']: doc_name = Path(result['source_file']).name template_content += f"| {doc_name} | [Score/10] | [Score/10] | [Preferred] | [Issues] |\n" diff --git a/tests/scripts/gui_e2e_watchdog.py b/tests/scripts/gui_e2e_watchdog.py index c97f6dd..99b01e8 100644 --- a/tests/scripts/gui_e2e_watchdog.py +++ b/tests/scripts/gui_e2e_watchdog.py @@ -22,7 +22,6 @@ import argparse import json -import os import shutil import subprocess import sys @@ -184,7 +183,7 @@ def make_input_docx(default_path: Path) -> Path: d.save(tmp) return tmp except Exception: - raise SystemExit('No input docx available and python-docx not importable') + raise SystemExit('No input docx available and python-docx not importable') from None def run_one(app_bin: Path, input_path: Path, outdir: Path, mode: str, model: str, timeout_sec: int, backend: str = None) -> bool: diff --git a/tests/scripts/run_full_test_suite.py b/tests/scripts/run_full_test_suite.py index 68ae43b..3b86dff 100755 --- a/tests/scripts/run_full_test_suite.py +++ b/tests/scripts/run_full_test_suite.py @@ -12,7 +12,6 @@ import sys import os from pathlib import Path -from datetime import datetime REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/scripts/run_llm_matrix.py b/tests/scripts/run_llm_matrix.py index aa21624..98a220b 100644 --- a/tests/scripts/run_llm_matrix.py +++ b/tests/scripts/run_llm_matrix.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Dict, Iterable, List, Optional, Set, Tuple +from typing import Dict, Iterable, List, Set, Tuple REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/scripts/run_metadata_matrix.py b/tests/scripts/run_metadata_matrix.py index 1a8e313..f809512 100644 --- a/tests/scripts/run_metadata_matrix.py +++ b/tests/scripts/run_metadata_matrix.py @@ -3,7 +3,6 @@ import csv import json import os -import re import zipfile from dataclasses import fields from pathlib import Path @@ -16,8 +15,8 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from marcut.docx_io import MetadataCleaningSettings -from marcut import pipeline +from marcut.docx_io import MetadataCleaningSettings # noqa: E402 -- must follow the sys.path fixup above +from marcut import pipeline # noqa: E402 -- must follow the sys.path fixup above def read_baseline_args(log_path: Path) -> List[str]: diff --git a/tests/scripts/test_end_to_end.py b/tests/scripts/test_end_to_end.py index fb498d7..d499102 100755 --- a/tests/scripts/test_end_to_end.py +++ b/tests/scripts/test_end_to_end.py @@ -33,8 +33,8 @@ PSUTIL_AVAILABLE = False try: - from marcut.cli import main as marcut_main - from marcut.pipeline import RedactionError + from marcut.cli import main as marcut_main # noqa: F401 -- import itself is the availability probe + from marcut.pipeline import RedactionError # noqa: F401 -- import itself is the availability probe except ImportError as e: print(f"❌ Failed to import marcut: {e}") print("Please install marcut with: pip install -e .") @@ -440,9 +440,9 @@ def run_single_file_test(self, source_file: Path) -> Dict: # Print summary print(f" πŸ“Š Comparison: {comparison}") if validation.get("expected_corrupt"): - print(f" ⚠️ Expected corrupt sample; validation failures ignored") + print(" ⚠️ Expected corrupt sample; validation failures ignored") elif validation["automated_checks"] == "pass": - print(f" βœ… Automated validation passed") + print(" βœ… Automated validation passed") else: print(f" ❌ Automated validation failed: {', '.join(validation['issues'])}") @@ -543,7 +543,7 @@ def test_app_bundle(self) -> Dict: if not self.args.test_app_bundle: return {"app_bundle_tested": False, "reason": "Not requested"} - print(f"\n🍎 Testing macOS App Bundle...") + print("\n🍎 Testing macOS App Bundle...") # Import the app bundle tester try: @@ -586,7 +586,7 @@ def test_app_bundle(self) -> Dict: def run(self): """Run the complete test suite""" - print(f"πŸš€ Starting Marcut End-to-End Test Suite") + print("πŸš€ Starting Marcut End-to-End Test Suite") print(f"πŸ“… Test Run ID: {self.test_run_id}") print(f"πŸ“ Output Directory: {self.output_dir}") print(f"πŸ€– AI Available: {self.ollama_available}") @@ -613,14 +613,14 @@ def run(self): if app_bundle_result.get("app_bundle_tested"): report["app_bundle_test"] = app_bundle_result - report_path = self.save_test_report(report) + self.save_test_report(report) # Print final summary self.print_final_summary(report) # Check app bundle test success if app_bundle_result.get("app_bundle_tested") and not app_bundle_result.get("overall_success", True): - print(f"\n❌ App bundle tests failed") + print("\n❌ App bundle tests failed") return 1 # Return appropriate exit code @@ -631,11 +631,11 @@ def run(self): print(f"\n⚠️ {failed_validations} files had validation failures") return 1 - print(f"\nβœ… All tests completed successfully!") + print("\nβœ… All tests completed successfully!") return 0 except KeyboardInterrupt: - print(f"\n⏹️ Test suite interrupted by user") + print("\n⏹️ Test suite interrupted by user") return 130 except Exception as e: print(f"\nπŸ’₯ Test suite failed: {e}") @@ -648,9 +648,9 @@ def print_final_summary(self, report: Dict): """Print final test summary""" summary = report["summary"] - print(f"\n" + "=" * 60) - print(f"πŸ“Š FINAL TEST SUMMARY") - print(f"=" * 60) + print("\n" + "=" * 60) + print("πŸ“Š FINAL TEST SUMMARY") + print("=" * 60) print(f"πŸ“„ Total Files Tested: {summary['total_files_tested']}") if summary.get("expected_corrupt_files"): print(f"πŸ§ͺ Expected Corrupt Samples: {summary['expected_corrupt_files']}") @@ -661,7 +661,7 @@ def print_final_summary(self, report: Dict): print(f"⏱️ Total Time: {summary['total_processing_time']:.1f}s") # Print pathway comparisons - print(f"\nπŸ“ˆ Pathway Comparisons:") + print("\nπŸ“ˆ Pathway Comparisons:") comparisons = {} for result in self.results: comparison = result.get("comparison", "unknown") diff --git a/tests/scripts/test_gui_functionality.py b/tests/scripts/test_gui_functionality.py index bec6803..5871934 100755 --- a/tests/scripts/test_gui_functionality.py +++ b/tests/scripts/test_gui_functionality.py @@ -1823,7 +1823,6 @@ def test_accessibility_identifiers(self) -> bool: """Test that key accessibility identifiers exist and respond to basic actions.""" print("\n🧭 Testing Accessibility Identifiers...") - app_name = self.app_path.stem all_ok = True # Main window controls diff --git a/tests/scripts/test_macos_app.py b/tests/scripts/test_macos_app.py index 46b54cd..4e291a9 100755 --- a/tests/scripts/test_macos_app.py +++ b/tests/scripts/test_macos_app.py @@ -18,10 +18,9 @@ import plistlib import subprocess import sys -import time from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, Optional REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/scripts/validate_app_environment.py b/tests/scripts/validate_app_environment.py index 820a8e5..0f10f28 100755 --- a/tests/scripts/validate_app_environment.py +++ b/tests/scripts/validate_app_environment.py @@ -21,7 +21,7 @@ import time from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, Optional REPO_ROOT = Path(__file__).resolve().parents[2] @@ -422,7 +422,7 @@ def test_model_discovery(self) -> bool: # Check if we can access model info (doesn't download, just checks availability) test_model = "qwen2.5:14b" request = urllib.request.Request( - f"http://localhost:11434/api/show", + "http://localhost:11434/api/show", data=json.dumps({"name": test_model}).encode(), headers={ "Content-Type": "application/json", @@ -623,19 +623,19 @@ def run_all_tests(self) -> Dict: # Provide recommendations if not overall_success: - print(f"\nπŸ”§ RECOMMENDATIONS:") + print("\nπŸ”§ RECOMMENDATIONS:") if not results.get("PythonKit Environment", False): - print(f" β€’ Fix Python framework bundling in build process") - print(f" β€’ Ensure Python.framework is properly signed") + print(" β€’ Fix Python framework bundling in build process") + print(" β€’ Ensure Python.framework is properly signed") if not results.get("Ollama Service Detection", False): - print(f" β€’ Bundle Ollama binary in app resources") - print(f" β€’ Or ensure system Ollama is available") + print(" β€’ Bundle Ollama binary in app resources") + print(" β€’ Or ensure system Ollama is available") if not results.get("App Container Access", False): - print(f" β€’ Check app bundle permissions") - print(f" β€’ Verify app is properly signed") + print(" β€’ Check app bundle permissions") + print(" β€’ Verify app is properly signed") # Return comprehensive results return { diff --git a/tests/scripts/validate_test_outputs.py b/tests/scripts/validate_test_outputs.py index 87a61cd..17ffecd 100755 --- a/tests/scripts/validate_test_outputs.py +++ b/tests/scripts/validate_test_outputs.py @@ -18,7 +18,6 @@ from pathlib import Path from typing import Dict, List, Tuple, Optional import zipfile -import xml.etree.ElementTree as ET REPO_ROOT = Path(__file__).resolve().parents[2] @@ -243,7 +242,6 @@ def validate_app_bundle(self, app_path: Optional[Path] = None) -> Dict: return validation validation["app_found"] = True - app_path_str = str(app_path) # Check executable executable = app_path / "Contents/MacOS/MarcutApp" @@ -523,7 +521,7 @@ def validate_test_results(self, include_app_bundle: bool = True) -> Dict: app_startup_validation = None if include_app_bundle: - print(f"\n🍎 Validating macOS App Bundle...") + print("\n🍎 Validating macOS App Bundle...") app_bundle_validation = self.validate_app_bundle() if app_bundle_validation["app_found"]: @@ -542,7 +540,7 @@ def validate_test_results(self, include_app_bundle: bool = True) -> Dict: # Test app startup if bundle is valid if app_bundle_validation["valid"]: - print(f"\nπŸš€ Testing App Startup...") + print("\nπŸš€ Testing App Startup...") app_startup_validation = self.validate_app_startup() startup_status = "βœ…" if app_startup_validation["startup_success"] else "❌" @@ -553,7 +551,7 @@ def validate_test_results(self, include_app_bundle: bool = True) -> Dict: for error in app_startup_validation["errors_found"]: print(f" β€’ {error}") else: - print(f" ⚠️ App bundle not found - skipping app validation") + print(" ⚠️ App bundle not found - skipping app validation") # Generate summary total_pairs = len(results) @@ -581,9 +579,9 @@ def validate_test_results(self, include_app_bundle: bool = True) -> Dict: summary["overall_success"] = file_success and app_success - print(f"\n" + "=" * 60) - print(f"πŸ“Š COMPREHENSIVE VALIDATION SUMMARY") - print(f"=" * 60) + print("\n" + "=" * 60) + print("πŸ“Š COMPREHENSIVE VALIDATION SUMMARY") + print("=" * 60) print(f"πŸ“ Test file pairs: {valid_pairs}/{total_pairs} valid") if include_app_bundle: if app_bundle_validation: @@ -592,13 +590,13 @@ def validate_test_results(self, include_app_bundle: bool = True) -> Dict: print(f"🍎 App bundle: {bundle_status} valid") print(f"πŸš€ App startup: {startup_status} successful") else: - print(f"🍎 App bundle: ⚠️ not found") + print("🍎 App bundle: ⚠️ not found") overall_status = "βœ… PASSED" if summary["overall_success"] else "❌ FAILED" print(f"🎯 Overall: {overall_status}") if self.issues: - print(f"\n⚠️ Additional issues:") + print("\n⚠️ Additional issues:") for issue in self.issues: print(f" β€’ {issue}") @@ -688,12 +686,12 @@ def main(): if app_issues: error_messages.append("App bundle validation failed") - print(f"\n⚠️ Validation issues found:") + print("\n⚠️ Validation issues found:") for msg in error_messages: print(f" β€’ {msg}") return 1 - print(f"\nβœ… All validations passed!") + print("\nβœ… All validations passed!") return 0 diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 93392c8..95e8b6d 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -2,7 +2,6 @@ Tests for the chunker module, including Phase 1 small document optimization. """ -import pytest from marcut.chunker import make_chunks, SMALL_DOC_THRESHOLD diff --git a/tests/test_docx_io.py b/tests/test_docx_io.py index 8dc6bf9..e1868bf 100644 --- a/tests/test_docx_io.py +++ b/tests/test_docx_io.py @@ -159,7 +159,7 @@ def test_xml_with_namespace(self): def test_malformed_xml_raises(self): """Test that malformed XML raises an error.""" xml = b"" - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 -- exact parser error type is intentionally unspecified _safe_fromstring(xml) def test_xxe_prevention(self): @@ -193,13 +193,18 @@ def test_all_fields_have_cli_args(self): """Test that all MetadataCleaningSettings bool fields have CLI args.""" from dataclasses import fields settings = MetadataCleaningSettings() - setting_fields = {f.name for f in fields(settings) if f.type == bool} mapped_fields = set(CLI_ARG_MAP.values()) - + # All mapped fields should be in settings for field in mapped_fields: assert hasattr(settings, field), f"CLI mapped field {field} not in settings" + # All bool fields on the dataclass should in turn be mapped to a CLI arg, + # so a newly-added field can't silently ship without one. + setting_fields = {f.name for f in fields(settings) if f.type is bool} + unmapped = setting_fields - mapped_fields + assert not unmapped, f"Settings bool fields missing a CLI_ARG_MAP entry: {sorted(unmapped)}" + def test_cli_arg_format(self): """Test that all CLI args follow --no-clean-* format.""" for cli_arg in CLI_ARG_MAP.keys(): diff --git a/tests/test_failure_scenarios.py b/tests/test_failure_scenarios.py index 2a4bee7..2ac92c8 100644 --- a/tests/test_failure_scenarios.py +++ b/tests/test_failure_scenarios.py @@ -14,7 +14,6 @@ import pytest import os -import io import tempfile import shutil import zipfile @@ -28,7 +27,7 @@ # Check if we're in an environment where marcut is available try: from marcut.model import ollama_extract, get_ollama_base_url, parse_llm_response - from marcut.pipeline import run_redaction, RedactionError + from marcut.pipeline import run_redaction from marcut.docx_io import DocxMap MARCUT_AVAILABLE = True except ImportError: @@ -449,7 +448,7 @@ def test_docx_with_malformed_revisions(self, temp_dir): try: dm = DocxMap.load(str(corrupt_path)) assert "Test content" in dm.text - except Exception as e: + except Exception: # If it fails, that's also acceptable - we're testing error handling pass diff --git a/tests/test_fixes.py b/tests/test_fixes.py index b3c9c85..93b74e7 100644 --- a/tests/test_fixes.py +++ b/tests/test_fixes.py @@ -6,7 +6,7 @@ # Ensure we can import marcut sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from marcut.pipeline import _merge_overlaps, _snap_to_boundaries +from marcut.pipeline import _merge_overlaps from marcut.unified_redactor import validate_parameters # We need to test model_enhanced but it requires significant mocking. # We'll focus on the logic we can isolate or verify basic import safety. @@ -28,12 +28,14 @@ def test_merge_overlaps_updates_text(self): self.assertEqual(m["text"], "23456") def test_cli_validation_balanced_rejected(self): - with open("dummy_balanced.docx", "w") as f: f.write("dummy") + with open("dummy_balanced.docx", "w") as f: + f.write("dummy") try: with self.assertRaises(ValueError): validate_parameters("dummy_balanced.docx", "out.docx", "rep.json", mode="balanced") finally: - if os.path.exists("dummy_balanced.docx"): os.remove("dummy_balanced.docx") + if os.path.exists("dummy_balanced.docx"): + os.remove("dummy_balanced.docx") def test_pipeline_variable_safety(self): # We can't easily execute the full pipeline without mocking models, diff --git a/tests/test_large_docx_performance.py b/tests/test_large_docx_performance.py index 9cd2e34..75c6663 100644 --- a/tests/test_large_docx_performance.py +++ b/tests/test_large_docx_performance.py @@ -3,7 +3,6 @@ import io import json import os -import resource import struct import subprocess import sys @@ -28,7 +27,6 @@ IMPORTS_SUCCESS = False try: - from marcut.docx_io import DocxMap, MetadataCleaningSettings DOCX_IO_IMPORTS_SUCCESS = True except Exception: DOCX_IO_IMPORTS_SUCCESS = False @@ -122,7 +120,7 @@ def _build_synthetic_docx(path: Path) -> None: table = doc.add_table(rows=36, cols=4) for row_idx, row in enumerate(table.rows): row.cells[0].text = f"Row {row_idx}" - row.cells[1].text = f"Acme Confidential Holdings LLC" + row.cells[1].text = "Acme Confidential Holdings LLC" row.cells[2].text = f"Client {row_idx} SSN 123-45-{row_idx % 10000:04d}" row.cells[3].text = f"https://example.com/client/{row_idx}" diff --git a/tests/test_malformed_docx_corpus.py b/tests/test_malformed_docx_corpus.py index 0bbb900..2964329 100644 --- a/tests/test_malformed_docx_corpus.py +++ b/tests/test_malformed_docx_corpus.py @@ -56,13 +56,13 @@ class TestMalformedDocxCorpusLoad: def test_variant_fails_to_load(self, corrupt_variant): name, path = corrupt_variant - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 -- corpus variants fail with different exception types by design DocxMap.load_accepting_revisions(str(path)) def test_variant_never_hangs_on_load(self, corrupt_variant): name, path = corrupt_variant start = time.perf_counter() - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 -- corpus variants fail with different exception types by design DocxMap.load_accepting_revisions(str(path)) elapsed = time.perf_counter() - start assert elapsed < MAX_FAIL_SECONDS, f"{name}: DocxMap.load took {elapsed:.2f}s -- looks hung" diff --git a/tests/test_metadata_scrubbing.py b/tests/test_metadata_scrubbing.py index 81438d5..ecef6e5 100644 --- a/tests/test_metadata_scrubbing.py +++ b/tests/test_metadata_scrubbing.py @@ -14,7 +14,6 @@ import unittest import zipfile from dataclasses import fields -from pathlib import Path from xml.etree import ElementTree as ET try: diff --git a/tests/test_model.py b/tests/test_model.py index c775565..36ea155 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -204,29 +204,29 @@ class TestValidCandidate: def test_valid_name(self): """Test valid person name.""" - assert _valid_candidate("John Smith", "NAME") == True - assert _valid_candidate("Mary Jane Watson", "NAME") == True + assert _valid_candidate("John Smith", "NAME") + assert _valid_candidate("Mary Jane Watson", "NAME") def test_single_word_name_rejected(self): """Test that single-word names are rejected.""" - assert _valid_candidate("John", "NAME") == False - assert _valid_candidate("Smith", "NAME") == False + assert not _valid_candidate("John", "NAME") + assert not _valid_candidate("Smith", "NAME") def test_valid_org(self): """Test valid organization names.""" - assert _valid_candidate("Sample 123 Corporation Inc.", "ORG") == True - assert _valid_candidate("Sample 123 & Associates LLC", "ORG") == True + assert _valid_candidate("Sample 123 Corporation Inc.", "ORG") + assert _valid_candidate("Sample 123 & Associates LLC", "ORG") def test_boilerplate_rejected(self): """Test that boilerplate terms are rejected.""" - assert _valid_candidate("the Agreement", "ORG") == False - assert _valid_candidate("Section 1", "NAME") == False - assert _valid_candidate("Board of Directors", "ORG") == False + assert not _valid_candidate("the Agreement", "ORG") + assert not _valid_candidate("Section 1", "NAME") + assert not _valid_candidate("Board of Directors", "ORG") def test_empty_string_rejected(self): """Test that empty strings are rejected.""" - assert _valid_candidate("", "NAME") == False - assert _valid_candidate(" ", "ORG") == False + assert not _valid_candidate("", "NAME") + assert not _valid_candidate(" ", "ORG") class TestExclusionNormalization: @@ -246,14 +246,14 @@ def test_normalize_strips_possessive(self): def test_matches_exclusion_literal_singularizes(self): literals = {"agreement", "company"} - assert _matches_exclusion_literal("agreements", literals) == True - assert _matches_exclusion_literal("company(s)", literals) == True - assert _matches_exclusion_literal("cats", literals) == False - assert _matches_exclusion_literal("parties", {"party"}) == True + assert _matches_exclusion_literal("agreements", literals) + assert _matches_exclusion_literal("company(s)", literals) + assert not _matches_exclusion_literal("cats", literals) + assert _matches_exclusion_literal("parties", {"party"}) def test_generic_term_singularization(self): - assert _is_generic_term("The Agreements") == True - assert _is_generic_term("A Company(s)") == True + assert _is_generic_term("The Agreements") + assert _is_generic_term("A Company(s)") class TestFindEntitySpans: @@ -709,23 +709,23 @@ class TestIsGenericTerm: def test_agreement_generic(self): """Test that 'agreement' is detected as generic.""" - assert _is_generic_term("agreement") == True - assert _is_generic_term("Agreement") == True + assert _is_generic_term("agreement") + assert _is_generic_term("Agreement") def test_company_generic(self): """Test that 'company' is detected as generic.""" - assert _is_generic_term("company") == True + assert _is_generic_term("company") # Note: "the Company" with article is handled differently by _valid_candidate def test_board_generic(self): """Test that 'board' terms are generic.""" - assert _is_generic_term("board") == True - assert _is_generic_term("Board of Directors") == True + assert _is_generic_term("board") + assert _is_generic_term("Board of Directors") def test_real_name_not_generic(self): """Test that real names are not generic.""" - assert _is_generic_term("John Smith") == False - assert _is_generic_term("Sample 123 Inc.") == False + assert not _is_generic_term("John Smith") + assert not _is_generic_term("Sample 123 Inc.") class TestGetExclusionPatterns: @@ -745,7 +745,7 @@ def test_base_patterns_included(self): # Test that 'agreement' matches at least one pattern matched = any(p.match("agreement") for p in patterns) - assert matched == True + assert matched class TestGetSystemPrompt: diff --git a/tests/test_model_naming.py b/tests/test_model_naming.py index cc6aa5e..5a2d288 100644 --- a/tests/test_model_naming.py +++ b/tests/test_model_naming.py @@ -8,7 +8,6 @@ change a case here, update it there too (and vice versa). """ -import pytest from marcut.model_naming import ( ParsedModelName, diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3bbaa67..0dd27e1 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -34,7 +34,6 @@ RedactionError, _write_failure_report, safe_print, - UNICODE_TO_ASCII, ) diff --git a/tests/test_pipeline_fixes.py b/tests/test_pipeline_fixes.py index b287c96..9e659b3 100644 --- a/tests/test_pipeline_fixes.py +++ b/tests/test_pipeline_fixes.py @@ -5,7 +5,6 @@ import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'python')) -import pytest from marcut import pipeline class TestPipelineFixes: @@ -69,7 +68,6 @@ def test_trim_trailing_delimited_dots_slashes(self): # 26. Add . and / to delimiters # "Company Name/Division" # If "Division" is excluded, trim it. - text = "Company Name/Division" # Excluded combo needs to be mocked or we rely on real excluded list. # "Division" might not be in real excluded list. # Let's try known excluded term if any? diff --git a/tests/test_progress.py b/tests/test_progress.py index bb960b9..eb8e4d1 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -2,10 +2,9 @@ Tests for the progress.py module - progress tracking and time estimation. """ -import pytest import time from marcut.progress import ( - ProcessingPhase, PhaseInfo, PHASE_INFO, + ProcessingPhase, PHASE_INFO, TimeEstimator, ProgressUpdate, ProgressTracker, create_progress_callback ) @@ -42,7 +41,7 @@ def test_all_phases_have_info(self): def test_phase_info_structure(self): """Test that PhaseInfo has correct structure.""" - for phase, info in PHASE_INFO.items(): + for _phase, info in PHASE_INFO.items(): assert isinstance(info.name, str) assert isinstance(info.display_name, str) assert isinstance(info.base_duration, (int, float)) @@ -195,7 +194,7 @@ def simple_cb(chunk, total, message): pass tracker = ProgressTracker(simple_cb, "test", 10) - assert tracker.is_simple_callback == True + assert tracker.is_simple_callback def test_rich_callback_detection(self): """Test that rich callbacks are detected correctly.""" @@ -204,7 +203,7 @@ def rich_cb(update): pass tracker = ProgressTracker(rich_cb, "test", 10) - assert tracker.is_simple_callback == False + assert not tracker.is_simple_callback def test_update_phase(self): """Test phase updates.""" diff --git a/tests/test_report_common.py b/tests/test_report_common.py index 4c28808..a5c7915 100644 --- a/tests/test_report_common.py +++ b/tests/test_report_common.py @@ -14,6 +14,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'python')) import pytest +from unittest.mock import patch from marcut.report_common import ( escape_html, get_mime_type, @@ -204,8 +205,6 @@ def test_uses_legacy_function_syntax(self): # assert '=>' not in js -from unittest.mock import patch, MagicMock - class TestReadMdlsMetadata: """Tests for _read_mdls_metadata function.""" diff --git a/tests/test_rule_filter.py b/tests/test_rule_filter.py index 49df722..1b17868 100644 --- a/tests/test_rule_filter.py +++ b/tests/test_rule_filter.py @@ -1,4 +1,3 @@ -import os import pytest diff --git a/tests/test_rules.py b/tests/test_rules.py index ddf8290..1ee941a 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -5,12 +5,9 @@ and signature block name extraction. """ -import pytest import os from marcut.rules import ( - run_rules, EMAIL, PHONE, SSN, CURRENCY, DATE, URL, IPV4, ADDRESS, - SIGNATURE_NAME, INDIVIDUAL_NAME, luhn_ok, COMPANY_SUFFIX, NUMBER_BRACKET, - _is_excluded, _is_generic_org_span, _is_excluded_combo, _is_specific_org_span, + run_rules, INDIVIDUAL_NAME, luhn_ok, _is_excluded, _is_generic_org_span, _is_excluded_combo, _is_specific_org_span, _trim_org_jurisdiction_suffix ) @@ -376,15 +373,15 @@ class TestLuhnValidation: def test_valid_card(self): """Test valid credit card number.""" # Known valid test card number - assert luhn_ok("4532015112830366") == True + assert luhn_ok("4532015112830366") def test_invalid_card(self): """Test invalid credit card number.""" - assert luhn_ok("1234567890123456") == False + assert not luhn_ok("1234567890123456") def test_too_short(self): """Test number that's too short.""" - assert luhn_ok("123456789012") == False # 12 digits + assert not luhn_ok("123456789012") # 12 digits class TestNumberBracketPattern: @@ -714,38 +711,38 @@ class TestExclusionHelpers: """Test exclusion helper behavior for determiners and plurals.""" def test_is_excluded_strips_determiners(self): - assert _is_excluded("The Agreement") == True - assert _is_excluded("An Agreement") == True + assert _is_excluded("The Agreement") + assert _is_excluded("An Agreement") def test_is_excluded_handles_plural_variants(self): - assert _is_excluded("Agreements") == True - assert _is_excluded("Agreement(s)") == True + assert _is_excluded("Agreements") + assert _is_excluded("Agreement(s)") def test_is_excluded_handles_possessive(self): """Issue #41: an excluded term's possessive form must also be excluded -- e.g. if "Company" is excluded, "Company's" must not slip through and get redacted just because the apostrophe-s wasn't stripped before lookup.""" - assert _is_excluded("Company's") == True - assert _is_excluded("Company’s") == True # curly apostrophe - assert _is_excluded("the Company's") == True - assert _is_excluded("Companies'") == True # plural possessive + assert _is_excluded("Company's") + assert _is_excluded("Company’s") # curly apostrophe + assert _is_excluded("the Company's") + assert _is_excluded("Companies'") # plural possessive def test_is_excluded_possessive_does_not_overmatch(self): """Negative test: possessive stripping must not cause non-excluded terms to be treated as excluded.""" - assert _is_excluded("Acme's") == False - assert _is_excluded("Foobar's") == False + assert not _is_excluded("Acme's") + assert not _is_excluded("Foobar's") def test_is_excluded_combo_all_tokens(self): - assert _is_excluded_combo("Company Parties") == True - assert _is_excluded_combo("Sample 123 Parties") == False + assert _is_excluded_combo("Company Parties") + assert not _is_excluded_combo("Sample 123 Parties") def test_is_generic_org_with_determiners(self): - assert _is_generic_org_span("The Company") == True - assert _is_generic_org_span("Certain Company") == True - assert _is_generic_org_span("Sample 123 Company") == False - assert _is_generic_org_span("TIME USA, LLC") == False - assert _is_generic_org_span("Limited Liability Company") == True + assert _is_generic_org_span("The Company") + assert _is_generic_org_span("Certain Company") + assert not _is_generic_org_span("Sample 123 Company") + assert not _is_generic_org_span("TIME USA, LLC") + assert _is_generic_org_span("Limited Liability Company") class TestDocIdPattern: @@ -776,11 +773,11 @@ def test_sentence_boundary_improvements(self): from marcut.rules import _contains_sentence_boundary # Should NOT be boundaries - assert _contains_sentence_boundary("U.S. Navy") == False - assert _contains_sentence_boundary("Mr. Smith") == False - assert _contains_sentence_boundary("St. John") == False - assert _contains_sentence_boundary("Inc. A") == False + assert not _contains_sentence_boundary("U.S. Navy") + assert not _contains_sentence_boundary("Mr. Smith") + assert not _contains_sentence_boundary("St. John") + assert not _contains_sentence_boundary("Inc. A") # Should BE boundaries - assert _contains_sentence_boundary("End. Start") == True - assert _contains_sentence_boundary("Company. Then") == True + assert _contains_sentence_boundary("End. Start") + assert _contains_sentence_boundary("Company. Then") diff --git a/tests/test_rules_fixes.py b/tests/test_rules_fixes.py index 5e2d820..8a5cec0 100644 --- a/tests/test_rules_fixes.py +++ b/tests/test_rules_fixes.py @@ -6,7 +6,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src', 'python')) import pytest -import regex as re from marcut import rules class TestRulesFixes: @@ -32,7 +31,8 @@ def test_account_context_boundary(self): # It looks BEFORE the start index. # Let's say "of account number: " is in the window. idx = text.find("12345") - if idx == -1: pytest.fail("setup error") + if idx == -1: + pytest.fail("setup error") assert rules._looks_like_account_context(text, idx, idx+5) def test_url_punctuation(self): @@ -47,7 +47,7 @@ def test_url_punctuation(self): text2 = "Contact sample123@example.com." spans2 = rules.run_rules(text2) - email_spans = [s for s in spans2 if s['label'] == 'EMAIL'] + [s for s in spans2 if s['label'] == 'EMAIL'] # Email has its own regex, but URL logic is similar. URL regex matches emails too sometimes? # Typically EMAIL label comes from EMAIL regex. URL regex handles "sample123@example.com" too if strict? # The fix is for URL regex specifically. diff --git a/tests/test_security.py b/tests/test_security.py index 477bf51..863de82 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,6 +1,5 @@ import pytest -import os from marcut.unified_redactor import validate_model_name, validate_parameters from marcut.docx_io import _safe_fromstring diff --git a/tests/test_unified_redactor.py b/tests/test_unified_redactor.py index d4334ac..d8ec2bf 100644 --- a/tests/test_unified_redactor.py +++ b/tests/test_unified_redactor.py @@ -73,7 +73,6 @@ def temp_docx(self): """Create a temporary DOCX file for testing.""" # Create a minimal valid DOCX (just the minimal zip structure) import zipfile - from io import BytesIO fd, path = tempfile.mkstemp(suffix=".docx") os.close(fd) diff --git a/tests/test_url_redaction.py b/tests/test_url_redaction.py index 817fd8c..bdb2aad 100644 --- a/tests/test_url_redaction.py +++ b/tests/test_url_redaction.py @@ -6,10 +6,8 @@ import unittest import sys -import os import tempfile from pathlib import Path -from typing import List, Dict, Any # Add project root to path for imports project_root = Path(__file__).parent.parent @@ -184,10 +182,9 @@ def test_url_sequential_ids(self): ] # Create a mock DocxMap - test_text = "Visit https://example.com and http://test.org, then https://example.com again" # Test the finalization process with URLs - ct = ClusterTable() + ClusterTable() url_counter = {} # Process spans like in _finalize_and_write