presentation/folder/
ai_edit_runner.py 325 một lượt AI sửa file, từ gửi tới xem trước
document_preview_manager.py 317 PDF/Word/Excel/PowerPoint/ảnh/HTML/mã
ai_file_editor_dialog.py 317 dựng panel AI + chọn model
code_editor.py 183 ô soạn mã, đánh số dòng, tô cú pháp
ai_output_writer.py 140 phần DUY NHẤT chạm vào file người dùng
image_model_picker.py 115 dò model sinh ảnh trên mọi provider
file_helpers.py 112 nhận dạng loại file + ngưỡng
workspace_file_tree.py 38 cây thư mục
ui/folder_tab.py 305 lắp ráp + retranslate
Plan ghi 3 file; khối lượng thật cần 8. Hai file tôi thêm ngoài dự kiến vì
đọc kỹ thì chúng là ranh giới thật:
* ai_output_writer.py — tách ra vì đây là phần duy nhất THẬT SỰ ghi đè file
của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. Ranh giới đó đáng
nhìn thấy trong cấu trúc thư mục.
* image_model_picker.py — chỗ duy nhất trong màn Thư mục biết tới nhiều
provider cùng lúc (nó gợi ý được model sinh ảnh của provider KHÁC cái đang
chọn).
Gom mọi hằng nhận dạng loại file (_IMAGE_SUFFIXES, _HAS_PDF, _MAX_EDIT_BYTES…)
về file_helpers.py: cả tám file trong gói đều hỏi tới, để rải ra thì thêm một
đuôi file phải sửa vài chỗ.
LẠI IMPORT LAZY THỤT LỀ: regex đổi mức tương đối của tôi chỉ khớp đầu dòng
nên bỏ sót import nằm trong thân hàm — 3 checker đỏ. Lần này tôi sửa một lượt
cho CẢ cây presentation/ thay vì riêng thư mục vừa tách; nó tìm ra thêm 3 file
ở scheduling cũng đang sai mà chưa nổ.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
115 lines
4.2 KiB
Python
115 lines
4.2 KiB
Python
"""Hàm phụ trợ đọc và nhận dạng file — R08-T12.
|
|
|
|
Thuần hàm, không widget. ``_is_probably_text`` là chỗ quyết định file
|
|
mở bằng ô soạn thảo hay báo là nhị phân — đoán sai thì người dùng thấy
|
|
một màn hình ký tự rác.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from ...ui.libreoffice_view import DOC_SUFFIXES # noqa: F401 — dùng lại ở cả gói
|
|
|
|
# Nhận dạng loại file và các ngưỡng — gom về đây vì cả sáu file trong gói
|
|
# đều hỏi tới, để rải ra thì mỗi lần thêm một đuôi file phải sửa vài chỗ.
|
|
try:
|
|
from ...graph.graph_web import _HAS_WEB
|
|
except Exception: # pragma: no cover
|
|
_HAS_WEB = False
|
|
|
|
try:
|
|
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
|
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
|
_HAS_PDF = True
|
|
except Exception: # pragma: no cover - QtPdf not bundled
|
|
_HAS_PDF = False
|
|
|
|
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
|
_HTML_SUFFIXES = {".html", ".htm"}
|
|
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
|
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
|
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
|
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
|
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QColor, QFont, QTextCharFormat
|
|
from ...i18n import tr
|
|
|
|
|
|
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
|
f = QTextCharFormat()
|
|
f.setForeground(QColor(color))
|
|
if italic:
|
|
f.setFontItalic(True)
|
|
if bold:
|
|
f.setFontWeight(QFont.Bold)
|
|
return f
|
|
|
|
|
|
def _pptx_available() -> bool:
|
|
"""True when python-pptx is importable. If it's MISSING, auto-download &
|
|
install it (via deps.ensure_module) so pptx editing 'just works' — cached so
|
|
the (one-time) install is attempted only once."""
|
|
global _PPTX_READY
|
|
if _PPTX_READY is None:
|
|
try:
|
|
from ...core.deps import ensure_module
|
|
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
|
except Exception: # noqa: BLE001
|
|
_PPTX_READY = False
|
|
return _PPTX_READY
|
|
|
|
|
|
def _split_code_block(text: str):
|
|
"""Split an AI reply into ``(file_content, summary)``. ``file_content`` is
|
|
the first fenced code block (the edited file); ``summary`` is any prose
|
|
before it. Returns ``(None, text)`` when there's no code block."""
|
|
import re
|
|
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
|
|
if not m:
|
|
return None, (text or "")
|
|
return m.group(1), (text[:m.start()].strip())
|
|
|
|
|
|
def _parse_ai_output(text: str):
|
|
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
|
|
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> => <path>``
|
|
lines request generated illustration images (relative paths)."""
|
|
import re
|
|
content, summary = _split_code_block(text)
|
|
target = None
|
|
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
|
|
if m:
|
|
target = m.group(1).strip().strip("`\"'")
|
|
image_gens = []
|
|
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
|
|
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
|
|
# Strip the directive lines out of the shown summary.
|
|
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
|
|
return target, content, summary, image_gens
|
|
|
|
|
|
def _read_text(path: str) -> str:
|
|
try:
|
|
return Path(path).read_text(encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
return f"[could not read file: {exc}]"
|
|
|
|
|
|
def _is_probably_text(path: str) -> bool:
|
|
try:
|
|
with open(path, "rb") as f:
|
|
chunk = f.read(4096)
|
|
except OSError:
|
|
return False
|
|
if b"\x00" in chunk:
|
|
return False
|
|
try:
|
|
chunk.decode("utf-8")
|
|
return True
|
|
except UnicodeDecodeError:
|
|
# Latin-ish text still edits fine via errors="replace"; only reject on
|
|
# a hard binary signal (NUL above), so most source files pass.
|
|
return True
|