pyflutter-app 0.3.7__tar.gz → 0.3.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/PKG-INFO +1 -1
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/cartas/__init__.py +1 -1
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/cartas/origin.py +1 -1
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/view.py +5 -0
- pyflutter_app-0.3.10/converter/page_circuit/__init__.py +20 -0
- pyflutter_app-0.3.10/converter/page_circuit/common.py +99 -0
- pyflutter_app-0.3.10/converter/page_circuit/controller_circuit.py +359 -0
- pyflutter_app-0.3.10/converter/page_circuit/model_circuit.py +110 -0
- pyflutter_app-0.3.10/converter/page_circuit/service.py +356 -0
- pyflutter_app-0.3.10/converter/page_circuit/view_circuit.py +208 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/renderer.py +2 -2
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/run_app_convert.py +2 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/new-export_app.sh +10 -3
- {pyflutter_app-0.3.7/ocrlib → pyflutter_app-0.3.10/ocr_stream}/__init__.py +3 -3
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyproject.toml +1 -1
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/uv.lock +1 -1
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/whatsapp_files/__init__.py +1 -1
- pyflutter_app-0.3.7/test_claude.py +0 -505
- pyflutter_app-0.3.7/teste_ocr.py +0 -28
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.gitignore +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/.gitignore +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/inspectionProfiles/Project_Default.xml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/misc.xml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/modules.xml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/pyflutter_app.iml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/vcs.xml +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/README.md +0 -0
- {pyflutter_app-0.3.7/pdflib → pyflutter_app-0.3.10/convert_stream}/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/__main__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/assets/forms_config.json +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/config_app.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/_page_edit.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/controller.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/model.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/lib_toi.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/Diagnorstico.txt +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/common.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/config.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/controller_forms.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/diagnose_forms.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/master_forms.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/model_forms.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/service.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/view_forms.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/common.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/controller_image.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/model_image.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/view_image.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/widgets.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/table_widget.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/util.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/whatsapp/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/whatsapp/model.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/local_build/linux.sh +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/local_build/send.sh +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/main.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/controller.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/model.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/view.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/app.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/file_picker.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/navigator.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/scheduler.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/theme.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/buttons.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/container.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/core.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/list_builder.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/progress.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/tables.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/widgets.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/table_stream/__init__.py +0 -0
- {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/update.sh +0 -0
|
@@ -46,6 +46,7 @@ from converter.page_forms import PageRespondsForms
|
|
|
46
46
|
from converter.page_forms import ControllerForms
|
|
47
47
|
from converter.config_app import MapConfig, AppConfigNotifier
|
|
48
48
|
from converter.page_image import PageImage
|
|
49
|
+
from converter.page_circuit import PageCircuit
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
class HomePage(StatelessWidget):
|
|
@@ -328,6 +329,10 @@ class _HomePageState(StatelessWidget):
|
|
|
328
329
|
"Renomear imagens",
|
|
329
330
|
lambda: context.navigator.push(PageImage()),
|
|
330
331
|
),
|
|
332
|
+
ElevatedButton(
|
|
333
|
+
"Dividir base (QGIS)",
|
|
334
|
+
lambda: context.navigator.push(PageCircuit()),
|
|
335
|
+
),
|
|
331
336
|
],
|
|
332
337
|
main_axis_alignment=MainAxisAlignment.START,
|
|
333
338
|
),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .common import (
|
|
2
|
+
ItemStatus,
|
|
3
|
+
SplitConfig,
|
|
4
|
+
SplitEvent,
|
|
5
|
+
SplitEventType,
|
|
6
|
+
SplitSummary,
|
|
7
|
+
)
|
|
8
|
+
from .service import (
|
|
9
|
+
ConsoleObserver,
|
|
10
|
+
CsvExporter,
|
|
11
|
+
DataFrameSplitService,
|
|
12
|
+
ExcelExporter,
|
|
13
|
+
ReportObserver,
|
|
14
|
+
SplitGroup,
|
|
15
|
+
SplitObserver,
|
|
16
|
+
SplitPlanner,
|
|
17
|
+
)
|
|
18
|
+
from .model_circuit import ModelCircuit
|
|
19
|
+
from .controller_circuit import ControllerCircuit
|
|
20
|
+
from .view_circuit import PageCircuit
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum, StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from table_stream import CsvOptions, Directory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# Status / eventos (Observer)
|
|
12
|
+
# ============================================================
|
|
13
|
+
class ItemStatus(StrEnum):
|
|
14
|
+
PENDING = "pendente"
|
|
15
|
+
DONE = "exportado"
|
|
16
|
+
SKIPPED = "pulado"
|
|
17
|
+
ERROR = "erro"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SplitEventType(Enum):
|
|
21
|
+
STARTED = "started"
|
|
22
|
+
ITEM = "item" # um grupo (ex.: numliv = 5) foi processado
|
|
23
|
+
FINISHED = "finished"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SplitSummary:
|
|
28
|
+
total_groups: int = 0
|
|
29
|
+
exported_files: int = 0
|
|
30
|
+
skipped_files: int = 0
|
|
31
|
+
errors: int = 0
|
|
32
|
+
stopped: bool = False
|
|
33
|
+
elapsed_s: float = 0.0
|
|
34
|
+
output_dir: str = ""
|
|
35
|
+
period: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SplitEvent:
|
|
40
|
+
"""Evento enviado pelo service a todos os observadores."""
|
|
41
|
+
|
|
42
|
+
type: SplitEventType
|
|
43
|
+
done: int = 0
|
|
44
|
+
total: int = 0
|
|
45
|
+
group: str = ""
|
|
46
|
+
key: str = ""
|
|
47
|
+
period: str = ""
|
|
48
|
+
rows: int = 0
|
|
49
|
+
status: ItemStatus | None = None
|
|
50
|
+
files: tuple[str, ...] = ()
|
|
51
|
+
message: str = ""
|
|
52
|
+
summary: SplitSummary | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ============================================================
|
|
56
|
+
# Configuração
|
|
57
|
+
# ============================================================
|
|
58
|
+
@dataclass
|
|
59
|
+
class SplitConfig:
|
|
60
|
+
column_split: str
|
|
61
|
+
output_dir: Directory
|
|
62
|
+
export_csv: bool = True
|
|
63
|
+
export_excel: bool = True
|
|
64
|
+
|
|
65
|
+
# Estrutura OUTPUT/<numano>/<nummes>/<CSV|EXCEL>/<grupo>.<ext>
|
|
66
|
+
use_period_dirs: bool = True
|
|
67
|
+
year_column: str = "numano"
|
|
68
|
+
month_column: str = "nummes"
|
|
69
|
+
|
|
70
|
+
replace: bool = False
|
|
71
|
+
csv_options: CsvOptions = field(default_factory=CsvOptions)
|
|
72
|
+
|
|
73
|
+
# Opcional: prefixo no nome do arquivo, vindo de outra coluna ("<prefixo>-<grupo>")
|
|
74
|
+
filename_prefix_column: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ============================================================
|
|
78
|
+
# Utilidades de texto
|
|
79
|
+
# ============================================================
|
|
80
|
+
_INVALID_NAME = re.compile(r'[\\/:*?"<>|\r\n\t]+')
|
|
81
|
+
_NATURAL = re.compile(r"(\d+)")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def clean_cell(value: Any) -> str:
|
|
85
|
+
"""'2025.0' -> '2025'; nan/None -> ''."""
|
|
86
|
+
s = str(value).strip()
|
|
87
|
+
if s in ("", "nan", "NaN", "None", "NaT"):
|
|
88
|
+
return ""
|
|
89
|
+
return s[:-2] if s.endswith(".0") and s[:-2].isdigit() else s
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def safe_filename(name: str) -> str:
|
|
93
|
+
cleaned = _INVALID_NAME.sub("_", name).strip(" .")
|
|
94
|
+
return cleaned or "vazio"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def natural_key(text: str) -> list[str | int]:
|
|
98
|
+
"""Ordenação natural: 2 vem antes de 10."""
|
|
99
|
+
return [int(t) if t.isdecimal() else t.lower() for t in _NATURAL.split(text)]
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from pyflutter_ui import ChangeNotifier, Provider
|
|
11
|
+
from pyflutter_ui.scheduler import MainThreadScheduler
|
|
12
|
+
from table_stream import ArrayList, CsvOptions, Directory, File
|
|
13
|
+
|
|
14
|
+
from converter.config_app import AppConfigNotifier
|
|
15
|
+
from converter.page_circuit.common import (
|
|
16
|
+
ItemStatus,
|
|
17
|
+
SplitConfig,
|
|
18
|
+
SplitEvent,
|
|
19
|
+
SplitEventType,
|
|
20
|
+
SplitSummary,
|
|
21
|
+
)
|
|
22
|
+
from converter.page_circuit.model_circuit import ModelCircuit
|
|
23
|
+
from converter.page_circuit.service import ReportObserver, SplitGroup, SplitObserver
|
|
24
|
+
|
|
25
|
+
DEFAULT_SPLIT_COLUMN = "numliv"
|
|
26
|
+
MAX_VIEW_ROWS = 1000
|
|
27
|
+
|
|
28
|
+
CSV_SEPARATORS: dict[str, str] = {
|
|
29
|
+
"tabulação": "\t",
|
|
30
|
+
"ponto_virgula": ";",
|
|
31
|
+
"virgula": ",",
|
|
32
|
+
"pipe": "|",
|
|
33
|
+
}
|
|
34
|
+
CSV_ENCODINGS: dict[str, str] = {
|
|
35
|
+
"utf8": "utf-8",
|
|
36
|
+
"cp1252": "cp1252",
|
|
37
|
+
"iso-8859-1": "iso-8859-1",
|
|
38
|
+
"latin": "latin1",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ui(fn: Callable[[], None]) -> None:
|
|
43
|
+
"""Executa fn na thread da UI (os eventos vêm da worker thread)."""
|
|
44
|
+
try:
|
|
45
|
+
MainThreadScheduler.run(fn)
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
fn()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _UiObserver(SplitObserver):
|
|
51
|
+
"""Observer que leva os eventos do service para o controller, na thread da UI."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, controller: ControllerCircuit) -> None:
|
|
54
|
+
super().__init__()
|
|
55
|
+
self._controller = controller
|
|
56
|
+
|
|
57
|
+
def update(self, value: SplitEvent) -> None:
|
|
58
|
+
_ui(lambda: self._controller.on_event(value))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ControllerCircuit(ChangeNotifier):
|
|
62
|
+
|
|
63
|
+
def __init__(self, model: ModelCircuit) -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.model: ModelCircuit = model
|
|
66
|
+
|
|
67
|
+
self.file: File | None = None
|
|
68
|
+
self.df: pd.DataFrame = pd.DataFrame()
|
|
69
|
+
self.columns: ArrayList[str] = ArrayList()
|
|
70
|
+
self.split_column: str | None = None
|
|
71
|
+
|
|
72
|
+
self._output_dir: Directory | None = None
|
|
73
|
+
self.export_csv: bool = True
|
|
74
|
+
self.export_excel: bool = True
|
|
75
|
+
self.use_period_dirs: bool = True
|
|
76
|
+
self.replace: bool = False
|
|
77
|
+
self.separator_name: str = "tabulação"
|
|
78
|
+
self.encoding_name: str = "utf8"
|
|
79
|
+
|
|
80
|
+
self.plan: list[SplitGroup] = []
|
|
81
|
+
self.group_state: dict[str, tuple[ItemStatus, str]] = {}
|
|
82
|
+
self.summary: SplitSummary | None = None
|
|
83
|
+
|
|
84
|
+
self.loading: bool = False
|
|
85
|
+
self.exporting: bool = False
|
|
86
|
+
self.progress: tuple[int, int] = (0, 0)
|
|
87
|
+
self.status_text: str = "Importe a base exportada do sistema para começar."
|
|
88
|
+
|
|
89
|
+
self._last_notify: float = 0.0
|
|
90
|
+
self._observer = _UiObserver(self)
|
|
91
|
+
self._report = ReportObserver()
|
|
92
|
+
|
|
93
|
+
self.fn_show_error: Callable[[str], None] | None = None
|
|
94
|
+
self.fn_show_info: Callable[[str], None] | None = None
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ infra
|
|
97
|
+
def get_config(self) -> AppConfigNotifier:
|
|
98
|
+
return Provider.read(AppConfigNotifier)
|
|
99
|
+
|
|
100
|
+
def show_error(self, msg: str) -> None:
|
|
101
|
+
if self.fn_show_error is not None:
|
|
102
|
+
self.fn_show_error(msg)
|
|
103
|
+
|
|
104
|
+
def show_info(self, msg: str) -> None:
|
|
105
|
+
if self.fn_show_info is not None:
|
|
106
|
+
self.fn_show_info(msg)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def busy(self) -> bool:
|
|
110
|
+
return self.loading or self.exporting
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def progress_fraction(self) -> float:
|
|
114
|
+
done, total = self.progress
|
|
115
|
+
return (done / total) if total > 0 else 0.0
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def output_dir(self) -> Directory:
|
|
119
|
+
if self._output_dir is None:
|
|
120
|
+
self._output_dir = self.get_config().value.work_dir.concat("Circuitos")
|
|
121
|
+
return self._output_dir
|
|
122
|
+
|
|
123
|
+
def set_output_dir(self, d: Directory) -> None:
|
|
124
|
+
self._output_dir = d
|
|
125
|
+
self.notify_listeners()
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def file_label(self) -> str:
|
|
129
|
+
return self.file.basename() if self.file else "Nenhuma base importada"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def info_text(self) -> str:
|
|
133
|
+
if self.df.empty:
|
|
134
|
+
return "-"
|
|
135
|
+
periods = sorted({g.period for g in self.plan if g.period})
|
|
136
|
+
period = f"Período: {', '.join(periods)} | " if periods else ""
|
|
137
|
+
return f"{period}{len(self.df)} linhas | {len(self.plan)} grupos"
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ opções
|
|
140
|
+
def build_config(self) -> SplitConfig:
|
|
141
|
+
return SplitConfig(
|
|
142
|
+
column_split=self.split_column or "",
|
|
143
|
+
output_dir=self.output_dir,
|
|
144
|
+
export_csv=self.export_csv,
|
|
145
|
+
export_excel=self.export_excel,
|
|
146
|
+
use_period_dirs=self.use_period_dirs,
|
|
147
|
+
replace=self.replace,
|
|
148
|
+
csv_options=CsvOptions(
|
|
149
|
+
encoding=CSV_ENCODINGS[self.encoding_name],
|
|
150
|
+
separator=CSV_SEPARATORS[self.separator_name],
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def set_export_csv(self, v: bool) -> None:
|
|
155
|
+
self.export_csv = v
|
|
156
|
+
|
|
157
|
+
def set_export_excel(self, v: bool) -> None:
|
|
158
|
+
self.export_excel = v
|
|
159
|
+
|
|
160
|
+
def set_replace(self, v: bool) -> None:
|
|
161
|
+
self.replace = v
|
|
162
|
+
|
|
163
|
+
def set_separator(self, name: str) -> None:
|
|
164
|
+
if name in CSV_SEPARATORS:
|
|
165
|
+
self.separator_name = name
|
|
166
|
+
|
|
167
|
+
def set_encoding(self, name: str) -> None:
|
|
168
|
+
if name in CSV_ENCODINGS:
|
|
169
|
+
self.encoding_name = name
|
|
170
|
+
|
|
171
|
+
def set_use_period(self, v: bool) -> None:
|
|
172
|
+
self.use_period_dirs = v
|
|
173
|
+
self.refresh_plan()
|
|
174
|
+
self.notify_listeners()
|
|
175
|
+
|
|
176
|
+
def set_split_column(self, col: str) -> None:
|
|
177
|
+
if self.busy or col == self.split_column:
|
|
178
|
+
return
|
|
179
|
+
self.split_column = col
|
|
180
|
+
self.refresh_plan()
|
|
181
|
+
self.notify_listeners()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ plano
|
|
184
|
+
def refresh_plan(self) -> None:
|
|
185
|
+
self.group_state.clear()
|
|
186
|
+
self.summary = None
|
|
187
|
+
self.progress = (0, 0)
|
|
188
|
+
|
|
189
|
+
if self.df.empty or not self.split_column:
|
|
190
|
+
self.plan = []
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
self.plan = self.model.plan(self.df, self.build_config())
|
|
194
|
+
except Exception as e:
|
|
195
|
+
self.plan = []
|
|
196
|
+
self.show_error(str(e))
|
|
197
|
+
return
|
|
198
|
+
self.status_text = (
|
|
199
|
+
f"Pronto: {len(self.plan)} arquivos por formato "
|
|
200
|
+
f"(coluna '{self.split_column}')."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def view_data(self) -> pd.DataFrame:
|
|
204
|
+
if not self.plan:
|
|
205
|
+
return pd.DataFrame({"-": ["Sem dados"]})
|
|
206
|
+
|
|
207
|
+
records = []
|
|
208
|
+
for n, g in enumerate(self.plan[:MAX_VIEW_ROWS], start=1):
|
|
209
|
+
status, files = self.group_state.get(g.key, (ItemStatus.PENDING, ""))
|
|
210
|
+
records.append(
|
|
211
|
+
{
|
|
212
|
+
"#": n,
|
|
213
|
+
"PERÍODO": g.period or "-",
|
|
214
|
+
"GRUPO": g.name or "(vazio)",
|
|
215
|
+
"LINHAS": g.rows,
|
|
216
|
+
"STATUS": status.value,
|
|
217
|
+
"ARQUIVOS": files,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
return pd.DataFrame(records)
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------ importação
|
|
223
|
+
def import_file(self, f: File) -> None:
|
|
224
|
+
if self.busy:
|
|
225
|
+
self.show_info("Existe outra operação em andamento.")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
self.loading = True
|
|
229
|
+
self.status_text = f"Lendo {f.basename()} ..."
|
|
230
|
+
self.notify_listeners()
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
self.model.start_load(
|
|
234
|
+
f,
|
|
235
|
+
CSV_ENCODINGS[self.encoding_name],
|
|
236
|
+
lambda df, err: _ui(lambda: self._on_loaded(f, df, err)),
|
|
237
|
+
)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
self.loading = False
|
|
240
|
+
self.show_error(str(e))
|
|
241
|
+
|
|
242
|
+
def _on_loaded(self, f: File, df: pd.DataFrame | None, err: Exception | None) -> None:
|
|
243
|
+
self.loading = False
|
|
244
|
+
|
|
245
|
+
if err is not None or df is None:
|
|
246
|
+
self.status_text = f"Erro ao ler a base: {err}"
|
|
247
|
+
self.notify_listeners()
|
|
248
|
+
self.show_error(f"Não foi possível ler a base:\n{err}")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
self.file = f
|
|
252
|
+
self.df = df
|
|
253
|
+
self.columns = ArrayList(df.columns.astype(str).tolist())
|
|
254
|
+
self.split_column = (
|
|
255
|
+
DEFAULT_SPLIT_COLUMN if self.columns.contains(DEFAULT_SPLIT_COLUMN)
|
|
256
|
+
else (self.columns.get_first() if not self.columns.empty() else None)
|
|
257
|
+
)
|
|
258
|
+
self.refresh_plan()
|
|
259
|
+
self.notify_listeners()
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------ exportação
|
|
262
|
+
def start_export(self) -> None:
|
|
263
|
+
if self.busy:
|
|
264
|
+
self.show_info("Existe outra operação em andamento.")
|
|
265
|
+
return
|
|
266
|
+
if self.df.empty or not self.split_column:
|
|
267
|
+
self.show_error("Importe uma base antes de exportar.")
|
|
268
|
+
return
|
|
269
|
+
if not (self.export_csv or self.export_excel):
|
|
270
|
+
self.show_error("Selecione ao menos um formato (CSV ou Excel).")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
self.refresh_plan()
|
|
274
|
+
if not self.plan:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
self.exporting = True
|
|
278
|
+
self.progress = (0, len(self.plan))
|
|
279
|
+
self.status_text = "Iniciando exportação..."
|
|
280
|
+
self.notify_listeners()
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
self.model.start_export(
|
|
284
|
+
self.df,
|
|
285
|
+
self.build_config(),
|
|
286
|
+
[self._observer, self._report],
|
|
287
|
+
lambda e: _ui(lambda: self._on_export_error(e)),
|
|
288
|
+
)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
self.exporting = False
|
|
291
|
+
self.show_error(str(e))
|
|
292
|
+
|
|
293
|
+
def stop_export(self) -> None:
|
|
294
|
+
if not self.exporting:
|
|
295
|
+
return
|
|
296
|
+
self.model.request_stop()
|
|
297
|
+
self.status_text = "Parando após o grupo atual..."
|
|
298
|
+
self.notify_listeners()
|
|
299
|
+
|
|
300
|
+
def _on_export_error(self, e: Exception) -> None:
|
|
301
|
+
self.exporting = False
|
|
302
|
+
self.status_text = f"Erro: {e}"
|
|
303
|
+
self.notify_listeners()
|
|
304
|
+
self.show_error(str(e))
|
|
305
|
+
|
|
306
|
+
# ------------------------------------------------------------------ eventos (thread da UI)
|
|
307
|
+
def on_event(self, ev: SplitEvent) -> None:
|
|
308
|
+
if ev.type == SplitEventType.STARTED:
|
|
309
|
+
self.progress = (0, ev.total)
|
|
310
|
+
self.notify_listeners()
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
if ev.type == SplitEventType.ITEM:
|
|
314
|
+
files = ", ".join(os.path.basename(p) for p in ev.files) or ev.message
|
|
315
|
+
self.group_state[ev.key] = (ev.status or ItemStatus.PENDING, files)
|
|
316
|
+
self.progress = (ev.done, ev.total)
|
|
317
|
+
self.status_text = f"Exportando {ev.done}/{ev.total} | {ev.group or '(vazio)'}"
|
|
318
|
+
|
|
319
|
+
now = time.monotonic() # evita piscar a tela: no máx. ~5 atualizações/s
|
|
320
|
+
if ev.done == ev.total or now - self._last_notify > 0.2:
|
|
321
|
+
self._last_notify = now
|
|
322
|
+
self.notify_listeners()
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
if ev.type == SplitEventType.FINISHED and ev.summary is not None:
|
|
326
|
+
s = ev.summary
|
|
327
|
+
self.exporting = False
|
|
328
|
+
self.summary = s
|
|
329
|
+
self.progress = (s.total_groups, s.total_groups) if not s.stopped else self.progress
|
|
330
|
+
head = "Interrompido" if s.stopped else "Concluído"
|
|
331
|
+
self.status_text = (
|
|
332
|
+
f"{head} | arquivos: {s.exported_files} | pulados: {s.skipped_files} "
|
|
333
|
+
f"| erros: {s.errors} | {s.elapsed_s:.1f}s"
|
|
334
|
+
)
|
|
335
|
+
self.notify_listeners()
|
|
336
|
+
|
|
337
|
+
if s.errors:
|
|
338
|
+
self.show_error(
|
|
339
|
+
f"{s.errors} grupo(s) com erro. Veja a coluna STATUS e o relatório."
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
report = self._report.last_report
|
|
343
|
+
extra = f"\n\nRelatório: {report.absolute()}" if report else ""
|
|
344
|
+
self.show_info(f"{head}.\n\nSaída: {s.output_dir}{extra}")
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------------ utilidades
|
|
347
|
+
def open_output_dir(self) -> None:
|
|
348
|
+
d = self.output_dir
|
|
349
|
+
d.mkdir()
|
|
350
|
+
path = d.absolute()
|
|
351
|
+
try:
|
|
352
|
+
if sys.platform == "win32":
|
|
353
|
+
os.startfile(path) # type: ignore[attr-defined]
|
|
354
|
+
elif sys.platform == "darwin":
|
|
355
|
+
subprocess.Popen(["open", path])
|
|
356
|
+
else:
|
|
357
|
+
subprocess.Popen(["xdg-open", path])
|
|
358
|
+
except Exception as e:
|
|
359
|
+
self.show_error(f"Não foi possível abrir a pasta:\n{e}")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from table_stream import File
|
|
8
|
+
|
|
9
|
+
from converter.page_circuit.common import SplitConfig
|
|
10
|
+
from converter.page_circuit.service import (
|
|
11
|
+
DataFrameSplitService,
|
|
12
|
+
SplitGroup,
|
|
13
|
+
SplitObserver,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModelCircuit:
|
|
18
|
+
"""Model: sem UI. Lê a base, calcula o plano e roda a exportação em worker thread."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.service = DataFrameSplitService()
|
|
22
|
+
self._export_thread: threading.Thread | None = None
|
|
23
|
+
self._load_thread: threading.Thread | None = None
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------ estado
|
|
26
|
+
def is_exporting(self) -> bool:
|
|
27
|
+
return self._export_thread is not None and self._export_thread.is_alive()
|
|
28
|
+
|
|
29
|
+
def is_loading(self) -> bool:
|
|
30
|
+
return self._load_thread is not None and self._load_thread.is_alive()
|
|
31
|
+
|
|
32
|
+
def request_stop(self) -> None:
|
|
33
|
+
self.service.stop()
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ leitura
|
|
36
|
+
@staticmethod
|
|
37
|
+
def read_dataframe(file: File, encoding: str) -> pd.DataFrame:
|
|
38
|
+
"""Tudo como texto (dtype=str): preserva zeros à esquerda e evita 5 -> 5.0."""
|
|
39
|
+
if file.is_excel():
|
|
40
|
+
return pd.read_excel(file.absolute(), dtype=str, keep_default_na=False)
|
|
41
|
+
if file.is_ods():
|
|
42
|
+
return pd.read_excel(
|
|
43
|
+
file.absolute(), dtype=str, keep_default_na=False, engine="odf"
|
|
44
|
+
)
|
|
45
|
+
if file.is_csv():
|
|
46
|
+
last: Exception | None = None
|
|
47
|
+
for enc in dict.fromkeys([encoding, "utf-8", "latin1"]):
|
|
48
|
+
try:
|
|
49
|
+
return pd.read_csv(
|
|
50
|
+
file.absolute(),
|
|
51
|
+
dtype=str,
|
|
52
|
+
keep_default_na=False,
|
|
53
|
+
sep=None, # detecta o separador sozinho
|
|
54
|
+
engine="python",
|
|
55
|
+
encoding=enc,
|
|
56
|
+
)
|
|
57
|
+
except UnicodeDecodeError as e:
|
|
58
|
+
last = e
|
|
59
|
+
raise ValueError(f"Não foi possível decodificar o CSV: {last}")
|
|
60
|
+
raise ValueError(f"Tipo de planilha inválido: {file.basename()}")
|
|
61
|
+
|
|
62
|
+
def start_load(
|
|
63
|
+
self,
|
|
64
|
+
file: File,
|
|
65
|
+
encoding: str,
|
|
66
|
+
on_done: Callable[[pd.DataFrame | None, Exception | None], None],
|
|
67
|
+
) -> None:
|
|
68
|
+
if self.is_loading():
|
|
69
|
+
raise RuntimeError("Já existe uma leitura em andamento")
|
|
70
|
+
|
|
71
|
+
def work() -> None:
|
|
72
|
+
df: pd.DataFrame | None = None
|
|
73
|
+
err: Exception | None = None
|
|
74
|
+
try:
|
|
75
|
+
df = self.read_dataframe(file, encoding)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
err = e
|
|
78
|
+
finally:
|
|
79
|
+
on_done(df, err)
|
|
80
|
+
|
|
81
|
+
self._load_thread = threading.Thread(target=work, daemon=True, name="circuit-load")
|
|
82
|
+
self._load_thread.start()
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------ plano / exportação
|
|
85
|
+
def plan(self, df: pd.DataFrame, config: SplitConfig) -> list[SplitGroup]:
|
|
86
|
+
return self.service.plan(df, config)
|
|
87
|
+
|
|
88
|
+
def start_export(
|
|
89
|
+
self,
|
|
90
|
+
df: pd.DataFrame,
|
|
91
|
+
config: SplitConfig,
|
|
92
|
+
observers: list[SplitObserver],
|
|
93
|
+
on_error: Callable[[Exception], None],
|
|
94
|
+
) -> None:
|
|
95
|
+
if self.is_exporting():
|
|
96
|
+
raise RuntimeError("Já existe uma exportação em andamento")
|
|
97
|
+
|
|
98
|
+
def work() -> None:
|
|
99
|
+
for o in observers:
|
|
100
|
+
self.service.attach(o)
|
|
101
|
+
try:
|
|
102
|
+
self.service.run(df, config)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
on_error(e)
|
|
105
|
+
finally:
|
|
106
|
+
for o in observers:
|
|
107
|
+
self.service.detach(o)
|
|
108
|
+
|
|
109
|
+
self._export_thread = threading.Thread(target=work, daemon=True, name="circuit-export")
|
|
110
|
+
self._export_thread.start()
|