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.
Files changed (80) hide show
  1. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/PKG-INFO +1 -1
  2. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/cartas/__init__.py +1 -1
  3. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/cartas/origin.py +1 -1
  4. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/view.py +5 -0
  5. pyflutter_app-0.3.10/converter/page_circuit/__init__.py +20 -0
  6. pyflutter_app-0.3.10/converter/page_circuit/common.py +99 -0
  7. pyflutter_app-0.3.10/converter/page_circuit/controller_circuit.py +359 -0
  8. pyflutter_app-0.3.10/converter/page_circuit/model_circuit.py +110 -0
  9. pyflutter_app-0.3.10/converter/page_circuit/service.py +356 -0
  10. pyflutter_app-0.3.10/converter/page_circuit/view_circuit.py +208 -0
  11. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/renderer.py +2 -2
  12. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/run_app_convert.py +2 -0
  13. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/new-export_app.sh +10 -3
  14. {pyflutter_app-0.3.7/ocrlib → pyflutter_app-0.3.10/ocr_stream}/__init__.py +3 -3
  15. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyproject.toml +1 -1
  16. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/uv.lock +1 -1
  17. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/whatsapp_files/__init__.py +1 -1
  18. pyflutter_app-0.3.7/test_claude.py +0 -505
  19. pyflutter_app-0.3.7/teste_ocr.py +0 -28
  20. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.gitignore +0 -0
  21. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/.gitignore +0 -0
  22. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  23. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  24. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/misc.xml +0 -0
  25. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/modules.xml +0 -0
  26. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/pyflutter_app.iml +0 -0
  27. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/.idea/vcs.xml +0 -0
  28. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/README.md +0 -0
  29. {pyflutter_app-0.3.7/pdflib → pyflutter_app-0.3.10/convert_stream}/__init__.py +0 -0
  30. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/__init__.py +0 -0
  31. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/__main__.py +0 -0
  32. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/assets/forms_config.json +0 -0
  33. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/config_app.py +0 -0
  34. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/__init__.py +0 -0
  35. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/_page_edit.py +0 -0
  36. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/controller.py +0 -0
  37. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/home_page/model.py +0 -0
  38. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/lib_toi.py +0 -0
  39. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/Diagnorstico.txt +0 -0
  40. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/__init__.py +0 -0
  41. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/common.py +0 -0
  42. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/config.py +0 -0
  43. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/controller_forms.py +0 -0
  44. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/diagnose_forms.py +0 -0
  45. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/master_forms.py +0 -0
  46. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/model_forms.py +0 -0
  47. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/service.py +0 -0
  48. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_forms/view_forms.py +0 -0
  49. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/__init__.py +0 -0
  50. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/common.py +0 -0
  51. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/controller_image.py +0 -0
  52. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/model_image.py +0 -0
  53. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/view_image.py +0 -0
  54. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/page_image/widgets.py +0 -0
  55. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/table_widget.py +0 -0
  56. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/util.py +0 -0
  57. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/whatsapp/__init__.py +0 -0
  58. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/converter/whatsapp/model.py +0 -0
  59. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/local_build/linux.sh +0 -0
  60. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/local_build/send.sh +0 -0
  61. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/main.py +0 -0
  62. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/controller.py +0 -0
  63. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/model.py +0 -0
  64. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/mvc/view.py +0 -0
  65. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/__init__.py +0 -0
  66. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/app.py +0 -0
  67. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/file_picker.py +0 -0
  68. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/navigator.py +0 -0
  69. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/scheduler.py +0 -0
  70. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/theme.py +0 -0
  71. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/__init__.py +0 -0
  72. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/buttons.py +0 -0
  73. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/container.py +0 -0
  74. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/core.py +0 -0
  75. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/list_builder.py +0 -0
  76. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/progress.py +0 -0
  77. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/tables.py +0 -0
  78. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/pyflutter_ui/ui/widgets.py +0 -0
  79. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/table_stream/__init__.py +0 -0
  80. {pyflutter_app-0.3.7 → pyflutter_app-0.3.10}/update.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyflutter_app
3
- Version: 0.3.7
3
+ Version: 0.3.10
4
4
  Summary: Bibliotecas Python para conversão de documentos, interface e processamento de tabelas.
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: opencv-python>=5.0.0.93
@@ -33,7 +33,7 @@ from table_stream import (
33
33
  load_file_hash,
34
34
  )
35
35
 
36
- from ocrlib import HeaderOcr, BuilderConfigOcr, CreateRelatorioOcr
36
+ from ocr_stream import HeaderOcr, BuilderConfigOcr, CreateRelatorioOcr
37
37
 
38
38
  from whatsapp_files import (
39
39
  RowWhatsApp,
@@ -31,7 +31,7 @@ from table_stream import (
31
31
  load_file_hash,
32
32
  )
33
33
 
34
- from ocrlib import HeaderOcr, BuilderConfigOcr, CreateRelatorioOcr
34
+ from ocr_stream import HeaderOcr, BuilderConfigOcr, CreateRelatorioOcr
35
35
 
36
36
  from whatsapp_files import (
37
37
  RowWhatsApp,
@@ -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()