From 66dd58b641d803f9afd5bf2d52c05cb199abda17 Mon Sep 17 00:00:00 2001 From: VHugoDevIA Date: Sat, 14 Mar 2026 18:05:30 +0000 Subject: [PATCH] chore: adicionar script para limpar artefactos de conflito --- .github/workflows/validate.yml | 22 ++++ .gitignore | 3 + 1} | 0 Optional[str] | 0 README.md | 11 ++ config.json | 26 +--- int | 0 scripts/fix_conflict_artifacts.py | 68 ++++++++++ scripts/validate_repo.py | 42 +++++++ src/app.py | 199 ++++++++++++++++++++++-------- str | 0 11 files changed, 296 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/validate.yml delete mode 100644 1} delete mode 100644 Optional[str] delete mode 100644 int create mode 100644 scripts/fix_conflict_artifacts.py create mode 100644 scripts/validate_repo.py delete mode 100644 str diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..1771819 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,22 @@ +name: Validate + +on: + pull_request: + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run repository validation + run: python scripts/validate_repo.py diff --git a/.gitignore b/.gitignore index fcb385d..bf9390f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ venv/ .venv/ logs/*.log *.DS_Store + +# Configuração local da app +config.json diff --git a/1} b/1} deleted file mode 100644 index e69de29..0000000 diff --git a/Optional[str] b/Optional[str] deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index bed58bd..1b6d253 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,14 @@ cd duplicate-file-cleaner pip install -r requirements.txt python main.py ``` + +## Resolução rápida de conflitos no `src/app.py` + +Se após um `git pull` aparecer `IndentationError` com texto como `codex/review-repository-code-...`, usa: + +```bash +python scripts/fix_conflict_artifacts.py src/app.py --write +python -m compileall main.py src +``` + +O script remove marcadores de conflito e artefactos de branch comuns e cria backup automático (`src/app.py.bak`). diff --git a/config.json b/config.json index 5f18a57..0967ef4 100644 --- a/config.json +++ b/config.json @@ -1,25 +1 @@ -{ - "scan_paths": [ - "C:/Users", - "C:/Users", - "C:/Users", - "C:/Users" - ], - "scan_mode": 1, - "selected_extensions": [ - ".png", - ".gif", - ".bmp", - ".tiff", - ".tif", - ".webp", - ".svg", - ".ico", - ".raw", - ".cr2", - ".nef", - ".heic", - ".heif" - ], - "custom_extensions": "" -} \ No newline at end of file +{} diff --git a/int b/int deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/fix_conflict_artifacts.py b/scripts/fix_conflict_artifacts.py new file mode 100644 index 0000000..b47addd --- /dev/null +++ b/scripts/fix_conflict_artifacts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path +import argparse +import shutil +import sys + +CONFLICT_PREFIXES = ("<<<<<<<", "=======", ">>>>>>>") + + +def should_remove_line(raw_line: str) -> bool: + stripped = raw_line.strip() + if not stripped: + return False + if stripped.startswith(CONFLICT_PREFIXES): + return True + if stripped.startswith("codex/review-repository-code-"): + return True + if stripped == "main": + return True + return False + + +def sanitize_file(path: Path, write: bool) -> tuple[int, int]: + lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() + cleaned: list[str] = [] + removed = 0 + + for line in lines: + if should_remove_line(line): + removed += 1 + continue + cleaned.append(line) + + if write and removed: + backup = path.with_suffix(path.suffix + ".bak") + shutil.copy2(path, backup) + path.write_text("\n".join(cleaned) + "\n", encoding="utf-8") + + return removed, len(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove artefactos de merge em ficheiros Python.") + parser.add_argument("path", nargs="?", default="src/app.py", help="Ficheiro alvo (default: src/app.py)") + parser.add_argument("--write", action="store_true", help="Aplicar alterações ao ficheiro") + args = parser.parse_args() + + target = Path(args.path) + if not target.exists(): + print(f"Ficheiro não encontrado: {target}") + return 1 + + removed, total = sanitize_file(target, write=args.write) + if args.write: + if removed: + print(f"Removidas {removed} linha(s) suspeita(s) de {total}. Backup criado em {target.with_suffix(target.suffix + '.bak')}") + else: + print("Nenhuma linha suspeita encontrada. Nada para alterar.") + else: + print(f"Pré-visualização: {removed} linha(s) suspeita(s) em {total}.") + print("Use --write para aplicar.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_repo.py b/scripts/validate_repo.py new file mode 100644 index 0000000..b263a19 --- /dev/null +++ b/scripts/validate_repo.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +import compileall +import sys + +ROOT = Path(__file__).resolve().parents[1] +CONFLICT_TOKENS = ("<<<<<<<", "=======", ">>>>>>>") + + +def check_conflict_markers() -> list[str]: + problems: list[str] = [] + targets = [ROOT / "main.py", *(ROOT / "src").rglob("*.py")] + for path in targets: + text = path.read_text(encoding="utf-8", errors="ignore") + for idx, line in enumerate(text.splitlines(), start=1): + if any(token in line for token in CONFLICT_TOKENS): + problems.append(f"{path.relative_to(ROOT)}:{idx}: marcador de conflito encontrado") + if line.strip().startswith("codex/review-repository-code-"): + problems.append(f"{path.relative_to(ROOT)}:{idx}: artefacto de branch encontrado") + return problems + + +def main() -> int: + marker_errors = check_conflict_markers() + if marker_errors: + for error in marker_errors: + print(error) + return 1 + + ok = compileall.compile_dir(str(ROOT / "src"), quiet=1) + ok_main = compileall.compile_file(str(ROOT / "main.py"), quiet=1) + if not ok or not ok_main: + print("Falha de compilação Python.") + return 1 + + print("Validação concluída com sucesso.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/app.py b/src/app.py index f38ca5e..d02825b 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,6 @@ import os import json +from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( @@ -21,24 +22,50 @@ from src.utils import format_size, get_file_size, calculate_savings, COMMON_EXTENSIONS, IMAGE_EXTENSIONS -# Ficheiro de configuracao -CONFIG_FILE = "config.json" +# Ficheiros de configuracao +APP_CONFIG_DIR = Path.home() / ".duplicate-file-cleaner" +CONFIG_FILE = APP_CONFIG_DIR / "config.json" +LEGACY_CONFIG_FILE = Path("config.json") -def load_config(): - """Carrega a configuracao do ficheiro.""" +def _migrate_legacy_config_if_needed(): + """Migra config.json antigo da raiz para o perfil do utilizador sem remover o original.""" + if CONFIG_FILE.exists() or not LEGACY_CONFIG_FILE.exists(): + return + + try: + legacy_data = json.loads(LEGACY_CONFIG_FILE.read_text(encoding="utf-8")) + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(legacy_data, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +def _read_json_file(path: Path): + """Le JSON de um ficheiro devolvendo dict vazio em caso de erro.""" try: - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, "r", encoding="utf-8") as f: + if path.exists(): + with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} +def load_config(): + """Carrega a configuracao do ficheiro.""" + _migrate_legacy_config_if_needed() + + config = _read_json_file(CONFIG_FILE) + if config: + return config + return _read_json_file(LEGACY_CONFIG_FILE) + + def save_config(config): """Guarda a configuracao no ficheiro.""" try: + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) except Exception: @@ -71,6 +98,19 @@ def run(self): self.error.emit(str(e)) +class SortableTreeWidgetItem(QTreeWidgetItem): + """Item de resultados com ordenacao numerica para a coluna de tamanho.""" + + def __lt__(self, other): + tree = self.treeWidget() + if tree and tree.sortColumn() == 1: + self_size = self.data(1, Qt.ItemDataRole.UserRole) + other_size = other.data(1, Qt.ItemDataRole.UserRole) + if isinstance(self_size, (int, float)) and isinstance(other_size, (int, float)): + return self_size < other_size + return super().__lt__(other) + + class DuplicateCleanerApp(QMainWindow): def __init__(self): super().__init__() @@ -81,6 +121,8 @@ def __init__(self): self.category_checkboxes = {} self.config = load_config() self.scan_paths = [] + self._selection_loaded = False + self.filters_panel_visible = True self.setWindowTitle("Duplicate File Cleaner") self.setMinimumSize(900, 620) @@ -88,7 +130,6 @@ def __init__(self): self._center_on_screen() self._apply_dark_theme() self._build_ui() - self._load_last_selection() self.logger.info("Aplicacao iniciada.") def _center_on_screen(self): @@ -277,6 +318,9 @@ def _build_scan_tab(self): layout = QVBoxLayout(widget) layout.setSpacing(6) + scan_splitter = QSplitter(Qt.Orientation.Vertical) + scan_splitter.setChildrenCollapsible(False) + # Grupo de caminhos paths_group = QGroupBox("Caminhos de Varredura") paths_layout = QVBoxLayout(paths_group) @@ -299,9 +343,9 @@ def _build_scan_tab(self): # Lista de caminhos list_layout = QHBoxLayout() self.paths_list = QListWidget() - self.paths_list.setMaximumHeight(120) + self.paths_list.setMinimumHeight(120) self.paths_list.setAlternatingRowColors(True) - + list_btns = QVBoxLayout() remove_btn = QPushButton("Remover") remove_btn.setObjectName("danger") @@ -312,7 +356,7 @@ def _build_scan_tab(self): list_btns.addWidget(remove_btn) list_btns.addWidget(clear_btn) list_btns.addStretch() - + list_layout.addWidget(self.paths_list, 1) list_layout.addLayout(list_btns) paths_layout.addLayout(list_layout) @@ -322,7 +366,7 @@ def _build_scan_tab(self): self.paths_count_label.setStyleSheet("color: #6c7086; font-size: 11px;") paths_layout.addWidget(self.paths_count_label) - layout.addWidget(paths_group) + scan_splitter.addWidget(paths_group) filter_group = QGroupBox("Filtros de Ficheiros") filter_layout = QVBoxLayout(filter_group) @@ -339,40 +383,16 @@ def _build_scan_tab(self): self.scan_mode.currentIndexChanged.connect(self._on_mode_change) mode_layout.addWidget(self.scan_mode) mode_layout.addStretch() - filter_layout.addLayout(mode_layout) - # Scroll area para categorias e extensoes - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setMinimumHeight(150) - scroll.setMaximumHeight(220) - - self.categories_container = QWidget() - self.categories_layout = QVBoxLayout(self.categories_container) - self.categories_layout.setSpacing(6) - self.categories_layout.setContentsMargins(4, 4, 4, 4) - self.categories_layout.addStretch() - - scroll.setWidget(self.categories_container) - filter_layout.addWidget(scroll) + self.toggle_filters_btn = QPushButton("Esconder Selecao") + self.toggle_filters_btn.clicked.connect(self._toggle_filters_panel) + mode_layout.addWidget(self.toggle_filters_btn) + filter_layout.addLayout(mode_layout) - quick_btn_layout = QHBoxLayout() - select_all_btn = QPushButton("Selecionar Tudo") - select_all_btn.clicked.connect(self._select_all_extensions) - deselect_all_btn = QPushButton("Desselecionar Tudo") - deselect_all_btn.clicked.connect(self._deselect_all_extensions) - quick_btn_layout.addWidget(select_all_btn) - quick_btn_layout.addWidget(deselect_all_btn) - quick_btn_layout.addStretch() - filter_layout.addLayout(quick_btn_layout) + self.filters_panel_widget = self._build_filters_panel() + filter_layout.addWidget(self.filters_panel_widget, 1) - custom_layout = QHBoxLayout() - custom_layout.addWidget(QLabel("Extensoes personalizadas:")) - self.custom_ext_input = QLineEdit() - self.custom_ext_input.setPlaceholderText("ex: .xyz .abc .custom") - custom_layout.addWidget(self.custom_ext_input) - filter_layout.addLayout(custom_layout) - layout.addWidget(filter_group) + scan_splitter.addWidget(filter_group) progress_group = QGroupBox("Progresso") progress_layout = QVBoxLayout(progress_group) @@ -382,7 +402,14 @@ def _build_scan_tab(self): self.progress_label.setStyleSheet("color: #6c7086;") progress_layout.addWidget(self.progress_bar) progress_layout.addWidget(self.progress_label) - layout.addWidget(progress_group) + scan_splitter.addWidget(progress_group) + + scan_splitter.setStretchFactor(0, 0) + scan_splitter.setStretchFactor(1, 1) + scan_splitter.setStretchFactor(2, 0) + scan_splitter.setSizes([220, 360, 120]) + + layout.addWidget(scan_splitter, 1) btn_layout = QHBoxLayout() self.scan_btn = QPushButton("Iniciar Varredura") @@ -398,9 +425,48 @@ def _build_scan_tab(self): btn_layout.addWidget(self.scan_btn) btn_layout.addWidget(self.stop_btn) layout.addLayout(btn_layout) - layout.addStretch() return widget + def _build_filters_panel(self): + """Cria o painel de filtros reutilizável para reduzir conflitos de merge.""" + panel = QWidget() + filters_panel_layout = QVBoxLayout(panel) + filters_panel_layout.setContentsMargins(0, 0, 0, 0) + filters_panel_layout.setSpacing(4) + + # Scroll area para categorias e extensoes + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMinimumHeight(230) + + self.categories_container = QWidget() + self.categories_layout = QVBoxLayout(self.categories_container) + self.categories_layout.setSpacing(6) + self.categories_layout.setContentsMargins(4, 4, 4, 4) + self.categories_layout.addStretch() + + scroll.setWidget(self.categories_container) + filters_panel_layout.addWidget(scroll, 1) + + quick_btn_layout = QHBoxLayout() + select_all_btn = QPushButton("Selecionar Tudo") + select_all_btn.clicked.connect(self._select_all_extensions) + deselect_all_btn = QPushButton("Desselecionar Tudo") + deselect_all_btn.clicked.connect(self._deselect_all_extensions) + quick_btn_layout.addWidget(select_all_btn) + quick_btn_layout.addWidget(deselect_all_btn) + quick_btn_layout.addStretch() + filters_panel_layout.addLayout(quick_btn_layout) + + custom_layout = QHBoxLayout() + custom_layout.addWidget(QLabel("Extensoes personalizadas:")) + self.custom_ext_input = QLineEdit() + self.custom_ext_input.setPlaceholderText("ex: .xyz .abc .custom") + custom_layout.addWidget(self.custom_ext_input) + filters_panel_layout.addLayout(custom_layout) + + return panel + def _populate_extension_categories(self): """Popula as categorias com extensoes lado a lado.""" self.ext_checkboxes = {} @@ -568,6 +634,8 @@ def _build_results_tab(self): layout.addWidget(self.summary_label) splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(8) left_widget = QWidget() left_layout = QVBoxLayout(left_widget) @@ -580,9 +648,11 @@ def _build_results_tab(self): self.results_tree.setAlternatingRowColors(True) self.results_tree.setSelectionMode(QTreeWidget.SelectionMode.MultiSelection) self.results_tree.setIndentation(12) - self.results_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - self.results_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - self.results_tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.results_tree.header().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + self.results_tree.header().setStretchLastSection(False) + self.results_tree.setColumnWidth(0, 320) + self.results_tree.setColumnWidth(1, 140) + self.results_tree.setColumnWidth(2, 520) self.results_tree.setSortingEnabled(True) self.results_tree.itemClicked.connect(self._on_item_clicked) self.results_tree.itemChanged.connect(self._on_item_changed) @@ -597,9 +667,16 @@ def _build_results_tab(self): sel_keep_btn = QPushButton("Manter 1/Grupo") sel_keep_btn.setObjectName("warning") sel_keep_btn.clicked.connect(self._select_keep_first) + expand_btn = QPushButton("Expandir Grupos") + expand_btn.clicked.connect(self._expand_all_groups) + collapse_btn = QPushButton("Encolher Grupos") + collapse_btn.clicked.connect(self._collapse_all_groups) + sel_layout.addWidget(sel_all_btn) sel_layout.addWidget(sel_none_btn) sel_layout.addWidget(sel_keep_btn) + sel_layout.addWidget(expand_btn) + sel_layout.addWidget(collapse_btn) left_layout.addLayout(sel_layout) action_layout = QHBoxLayout() @@ -650,6 +727,23 @@ def _build_results_tab(self): layout.addWidget(splitter, 1) return widget + def _toggle_filters_panel(self): + self.filters_panel_visible = not self.filters_panel_visible + self.filters_panel_widget.setVisible(self.filters_panel_visible) + self.toggle_filters_btn.setText( + "Esconder Selecao" if self.filters_panel_visible else "Mostrar Selecao" + ) + + def _expand_all_groups(self): + root = self.results_tree.invisibleRootItem() + for i in range(root.childCount()): + root.child(i).setExpanded(True) + + def _collapse_all_groups(self): + root = self.results_tree.invisibleRootItem() + for i in range(root.childCount()): + root.child(i).setExpanded(False) + def _build_log_tab(self): widget = QWidget() layout = QVBoxLayout(widget) @@ -781,7 +875,7 @@ def _populate_results(self, duplicates): group_size = sum(get_file_size(p) for p in paths) # Grupo com checkbox funcional - group_item = QTreeWidgetItem(self.results_tree) + group_item = SortableTreeWidgetItem(self.results_tree) group_item.setText(0, "Grupo {} ({} ficheiros)".format(idx, len(paths))) group_item.setText(1, format_size(group_size)) group_item.setText(2, "Hash: {}...".format(file_hash[:16])) @@ -793,7 +887,7 @@ def _populate_results(self, duplicates): # Ficheiros do grupo for i, path in enumerate(paths): - child = QTreeWidgetItem(group_item) + child = SortableTreeWidgetItem(group_item) filename = os.path.basename(path) size = get_file_size(path) label = "[ORIG]" if i == 0 else "[DUP]" @@ -1046,12 +1140,17 @@ def _load_last_selection(self): def showEvent(self, event): """Chamado quando a janela e mostrada.""" super().showEvent(event) + + if self._selection_loaded: + return + # Popular categorias apos a janela estar pronta self._populate_extension_categories() # Carregar selecao apos popular categorias self._load_last_selection() + self._selection_loaded = True def closeEvent(self, event): """Chamado quando a janela e fechada.""" self._save_selection() - event.accept() \ No newline at end of file + event.accept() diff --git a/str b/str deleted file mode 100644 index e69de29..0000000