pyflutter-app 0.3.10__tar.gz → 0.3.12__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 (92) hide show
  1. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/PKG-INFO +1 -1
  2. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/cartas/__init__.py +2 -2
  3. pyflutter_app-0.3.10/converter/home_page/view.py → pyflutter_app-0.3.12/converter/home_page/page_sheets.py +9 -48
  4. pyflutter_app-0.3.12/converter/home_page/view.py +71 -0
  5. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/controller_forms.py +55 -3
  6. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/model_forms.py +15 -0
  7. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/view_forms.py +11 -14
  8. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/common.py +65 -6
  9. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/controller_image.py +130 -4
  10. pyflutter_app-0.3.12/converter/page_image/model_image.py +222 -0
  11. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/view_image.py +46 -0
  12. pyflutter_app-0.3.12/converter/page_ocr/__init__.py +4 -0
  13. pyflutter_app-0.3.12/converter/page_ocr/common.py +55 -0
  14. pyflutter_app-0.3.12/converter/page_ocr/controller_ocr.py +338 -0
  15. pyflutter_app-0.3.12/converter/page_ocr/model_ocr.py +191 -0
  16. pyflutter_app-0.3.12/converter/page_ocr/view_ocr.py +183 -0
  17. pyflutter_app-0.3.12/converter/page_whatsapp/PATCH_whatsapp_files.md +245 -0
  18. pyflutter_app-0.3.12/converter/page_whatsapp/__init__.py +4 -0
  19. pyflutter_app-0.3.12/converter/page_whatsapp/common.py +41 -0
  20. pyflutter_app-0.3.12/converter/page_whatsapp/controller_whatsapp.py +391 -0
  21. pyflutter_app-0.3.12/converter/page_whatsapp/model_whatsapp.py +395 -0
  22. pyflutter_app-0.3.12/converter/page_whatsapp/view_whatsapp.py +271 -0
  23. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/run_app_convert.py +4 -0
  24. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/table_widget.py +34 -16
  25. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyproject.toml +1 -1
  26. pyflutter_app-0.3.12/teste.py +9 -0
  27. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/uv.lock +5 -5
  28. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/whatsapp_files/__init__.py +150 -49
  29. pyflutter_app-0.3.10/converter/page_image/model_image.py +0 -95
  30. pyflutter_app-0.3.10/converter/whatsapp/__init__.py +0 -0
  31. pyflutter_app-0.3.10/converter/whatsapp/model.py +0 -29
  32. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.gitignore +0 -0
  33. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/.gitignore +0 -0
  34. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/inspectionProfiles/Project_Default.xml +0 -0
  35. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
  36. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/misc.xml +0 -0
  37. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/modules.xml +0 -0
  38. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/pyflutter_app.iml +0 -0
  39. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/.idea/vcs.xml +0 -0
  40. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/README.md +0 -0
  41. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/cartas/origin.py +0 -0
  42. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/convert_stream/__init__.py +0 -0
  43. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/__init__.py +0 -0
  44. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/__main__.py +0 -0
  45. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/assets/forms_config.json +0 -0
  46. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/config_app.py +0 -0
  47. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/home_page/__init__.py +0 -0
  48. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/home_page/_page_edit.py +0 -0
  49. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/home_page/controller.py +0 -0
  50. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/home_page/model.py +0 -0
  51. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/lib_toi.py +0 -0
  52. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/__init__.py +0 -0
  53. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/common.py +0 -0
  54. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/controller_circuit.py +0 -0
  55. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/model_circuit.py +0 -0
  56. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/service.py +0 -0
  57. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_circuit/view_circuit.py +0 -0
  58. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/Diagnorstico.txt +0 -0
  59. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/__init__.py +0 -0
  60. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/common.py +0 -0
  61. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/config.py +0 -0
  62. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/diagnose_forms.py +0 -0
  63. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/master_forms.py +0 -0
  64. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_forms/service.py +0 -0
  65. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/__init__.py +0 -0
  66. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/renderer.py +0 -0
  67. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/page_image/widgets.py +0 -0
  68. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/converter/util.py +0 -0
  69. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/local_build/linux.sh +0 -0
  70. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/local_build/send.sh +0 -0
  71. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/main.py +0 -0
  72. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/mvc/controller.py +0 -0
  73. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/mvc/model.py +0 -0
  74. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/mvc/view.py +0 -0
  75. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/new-export_app.sh +0 -0
  76. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/ocr_stream/__init__.py +0 -0
  77. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/__init__.py +0 -0
  78. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/app.py +0 -0
  79. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/file_picker.py +0 -0
  80. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/navigator.py +0 -0
  81. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/scheduler.py +0 -0
  82. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/theme.py +0 -0
  83. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/__init__.py +0 -0
  84. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/buttons.py +0 -0
  85. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/container.py +0 -0
  86. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/core.py +0 -0
  87. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/list_builder.py +0 -0
  88. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/progress.py +0 -0
  89. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/tables.py +0 -0
  90. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/pyflutter_ui/ui/widgets.py +0 -0
  91. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/table_stream/__init__.py +0 -0
  92. {pyflutter_app-0.3.10 → pyflutter_app-0.3.12}/update.sh +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyflutter_app
3
- Version: 0.3.10
3
+ Version: 0.3.12
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
@@ -51,7 +51,7 @@ __version__ = "0.2"
51
51
  class ConfigTOI:
52
52
 
53
53
  file_relatorio_toi: File
54
- file_relatorio_forms: File
54
+ file_cartas_lancadas: File
55
55
  file_table_toi: File
56
56
  file_master_forms: File # NOVO
57
57
  dir_base_toi: Directory
@@ -126,7 +126,7 @@ class BuilderConfigTOI(object):
126
126
  dir_base_toi=self.dir_retorno_de_campo,
127
127
  file_relatorio_toi=self.file_relatorio_toi,
128
128
  file_table_toi=self.file_table_toi,
129
- file_relatorio_forms=self.file_relatorio_forms,
129
+ file_cartas_lancadas=self.file_relatorio_forms,
130
130
  dir_log_excel=self.dir_log_excel,
131
131
  dir_relatorios=self.dir_relatorios,
132
132
  file_master_forms=self.file_master_forms,
@@ -1,8 +1,6 @@
1
1
  from pyflutter_ui import (
2
- ChangeNotifier,
3
2
  BuildContext,
4
3
  FlutterWidget,
5
- LinearProgress,
6
4
  StatelessWidget,
7
5
  Consumer,
8
6
  Column,
@@ -23,10 +21,7 @@ from pyflutter_ui import (
23
21
  )
24
22
 
25
23
  from tkinter import messagebox
26
- from typing import Any
27
24
  import pandas as pd
28
- from pandas import DataFrame
29
-
30
25
 
31
26
  from table_stream import (
32
27
  ArrayList,
@@ -40,25 +35,20 @@ from table_stream import (
40
35
  from converter.home_page.controller import ControllerHomePage
41
36
  from converter.table_widget import TableOrProgress
42
37
  from converter.home_page._page_edit import PageEditExcel
43
-
44
- # Páginas para navegação.
45
- from converter.page_forms import PageRespondsForms
46
- from converter.page_forms import ControllerForms
47
- from converter.config_app import MapConfig, AppConfigNotifier
48
- from converter.page_image import PageImage
49
- from converter.page_circuit import PageCircuit
38
+ from converter.config_app import AppConfigNotifier
50
39
 
51
40
 
52
- class HomePage(StatelessWidget):
41
+ class PageSheets(StatelessWidget):
42
+ """Importar/carregar planilhas. De lá se acessa a edição de dados."""
53
43
 
54
44
  def __init__(self, key: str | None = None) -> None:
55
45
  super().__init__(key)
56
46
 
57
47
  def build(self, context: BuildContext) -> FlutterWidget:
58
- return _HomePageState()
48
+ return _PageSheetsState()
59
49
 
60
50
 
61
- class _HomePageState(StatelessWidget):
51
+ class _PageSheetsState(StatelessWidget):
62
52
 
63
53
  def __init__(self, key: str | None = None) -> None:
64
54
  super().__init__(key)
@@ -95,8 +85,7 @@ class _HomePageState(StatelessWidget):
95
85
 
96
86
  @property
97
87
  def app_config(self) -> AppConfigNotifier:
98
- state = Provider.read(AppConfigNotifier)
99
- return state
88
+ return Provider.read(AppConfigNotifier)
100
89
 
101
90
  @property
102
91
  def controller(self) -> ControllerHomePage:
@@ -232,29 +221,11 @@ class _HomePageState(StatelessWidget):
232
221
  df=data.head(),
233
222
  )
234
223
 
235
- def action_open_forms(self, context: BuildContext, controller: ControllerHomePage):
236
- """
237
- Seta o DataFrame() da página atual como propriedade do ControllerForms()
238
- antes de carregar a página.
239
- """
240
- # df = controller.data_notifier.value
241
- # forms_controller: ControllerForms = context.read(ControllerForms)
242
- # forms_controller.set_data(df)
243
- # if not forms_controller.forms_state.data_is_valid():
244
- # messagebox.showerror("Erro", "Tabela TOI inválida")
245
- # return
246
-
247
- context.navigator.push(PageRespondsForms())
248
-
249
224
  def _builder_body(
250
225
  self, context: BuildContext, controller: ControllerHomePage
251
226
  ) -> FlutterWidget:
252
227
  self.context = context
253
228
 
254
- filename = controller.get_file_sheet()
255
- if filename is not None:
256
- filename = filename.basename()
257
-
258
229
  return Column(
259
230
  children=[
260
231
  # -----------------------------------#
@@ -315,24 +286,14 @@ class _HomePageState(StatelessWidget):
315
286
  Expanded(Consumer(ControllerHomePage, self._build_table)),
316
287
  Row(
317
288
  [
289
+ ElevatedButton(
290
+ "<- Voltar", lambda: context.navigator.pop()
291
+ ),
318
292
  Text("Ir para: "),
319
293
  ElevatedButton(
320
294
  "Edição de dados",
321
295
  lambda: context.navigator.push(PageEditExcel()),
322
296
  ),
323
- # ElevatedButton("Exportar dados", lambda: print()),
324
- ElevatedButton(
325
- "Responds Forms",
326
- lambda: self.action_open_forms(context, controller),
327
- ),
328
- ElevatedButton(
329
- "Renomear imagens",
330
- lambda: context.navigator.push(PageImage()),
331
- ),
332
- ElevatedButton(
333
- "Dividir base (QGIS)",
334
- lambda: context.navigator.push(PageCircuit()),
335
- ),
336
297
  ],
337
298
  main_axis_alignment=MainAxisAlignment.START,
338
299
  ),
@@ -0,0 +1,71 @@
1
+ from typing import Callable
2
+
3
+ from pyflutter_ui import (
4
+ BuildContext,
5
+ FlutterWidget,
6
+ StatelessWidget,
7
+ Column,
8
+ Row,
9
+ ElevatedButton,
10
+ Divider,
11
+ MainAxisAlignment,
12
+ Scaffold,
13
+ Text,
14
+ )
15
+
16
+ # Páginas para navegação.
17
+ from converter.home_page.page_sheets import PageSheets
18
+ from converter.page_forms import PageRespondsForms
19
+ from converter.page_image import PageImage
20
+ from converter.page_circuit import PageCircuit
21
+ from converter.page_whatsapp import PageWhatsApp
22
+ from converter.page_ocr import PageOcr
23
+ from pyflutter_ui.ui.core import GlobalKey
24
+
25
+
26
+ class HomePage(StatelessWidget):
27
+ """Home: somente botões para navegar entre as páginas."""
28
+
29
+ def __init__(self, key: GlobalKey | None = None) -> None:
30
+ super().__init__(key)
31
+
32
+ def build(self, context: BuildContext) -> FlutterWidget:
33
+ return _HomePageState()
34
+
35
+
36
+ class _HomePageState(StatelessWidget):
37
+
38
+ # (rótulo do botão, descrição, página)
39
+ PAGES: list[tuple[str, str, Callable[[], FlutterWidget]]] = [
40
+ ("Planilhas", "Importar/carregar planilhas e editar dados", PageSheets),
41
+ ("WhatsApp", "Importar chats (.zip) para o cache do WhatsApp", PageWhatsApp),
42
+ ("Responds Forms", "Lançar cartas TOI no Microsoft Forms", PageRespondsForms),
43
+ ("Renomear imagens", "Identificar cartas (UC / TOI / data)", PageImage),
44
+ ("Dividir base (QGIS)", "Dividir a base por coluna (CSV/Excel)", PageCircuit),
45
+ ("OCR", "Aplicar OCR em PDFs e imagens de uma pasta", PageOcr),
46
+ ]
47
+
48
+ def __init__(self, key: GlobalKey | None = None) -> None:
49
+ super().__init__(key)
50
+
51
+ def _nav_item(
52
+ self,
53
+ context: BuildContext,
54
+ label: str,
55
+ description: str,
56
+ page: Callable[[], FlutterWidget],
57
+ ) -> FlutterWidget:
58
+ return Row(
59
+ [
60
+ ElevatedButton(label, lambda: context.navigator.push(page())),
61
+ Text(description),
62
+ ],
63
+ main_axis_alignment=MainAxisAlignment.START,
64
+ )
65
+
66
+ def build(self, context: BuildContext) -> FlutterWidget:
67
+ items = [
68
+ self._nav_item(context, label, desc, page)
69
+ for label, desc, page in self.PAGES
70
+ ]
71
+ return Scaffold(Column([Text("App Convert"), Divider(), *items]))
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
  import os
3
3
  from datetime import datetime
4
4
  from typing import Callable
5
+ import threading
5
6
 
6
7
  import pandas as pd
7
8
 
@@ -47,6 +48,8 @@ class ControllerForms(ChangeNotifier):
47
48
  self.master = ControllerMasterForms()
48
49
  self.generating: bool = False
49
50
 
51
+ self.scanning: bool = False # NOVO: lendo a pasta
52
+
50
53
  # Log em Excel gravado durante o lançamento (1 arquivo por execução).
51
54
  self.save_every: int = 2 # grava a cada N linhas concluídas (sucesso OU falha)
52
55
  self.result_file: File | None = None
@@ -123,9 +126,10 @@ class ControllerForms(ChangeNotifier):
123
126
 
124
127
  # ------------------------------------------------------------------ gerar tabela (cache)
125
128
  def generate_table(self, files: ArrayList[File]) -> None:
126
- if self.running or self.generating:
129
+ if self.running or self.generating or self.scanning:
127
130
  self.show_info("Existe outra operação em andamento.")
128
131
  return
132
+
129
133
  if files.empty():
130
134
  return
131
135
 
@@ -146,6 +150,54 @@ class ControllerForms(ChangeNotifier):
146
150
  self.generating = False
147
151
  self.show_error(str(e))
148
152
 
153
+ def generate_table_from_folder(self, folder: Directory) -> None:
154
+ """Varre a pasta (recursivo, PDF/imagem) e gera a tabela só com o cache."""
155
+ if self.running or self.generating or self.scanning:
156
+ self.show_info("Existe outra operação em andamento.")
157
+ return
158
+ if not folder.exists():
159
+ self.show_error(f"A pasta não existe:\n{folder.absolute()}")
160
+ return
161
+
162
+ self.scanning = True
163
+ self.status_text = f"Lendo {folder.absolute()} (incluindo subpastas) ..."
164
+ self.notify_listeners()
165
+
166
+ def work() -> None:
167
+ files: ArrayList[File] | None = None
168
+ err: Exception | None = None
169
+ try:
170
+ files = self.model.list_documents(folder)
171
+ except Exception as e:
172
+ err = e
173
+ _ui(lambda: self._on_folder_scanned(folder, files, err))
174
+
175
+ threading.Thread(target=work, daemon=True, name="forms-scan").start()
176
+
177
+ def _on_folder_scanned(
178
+ self,
179
+ folder: Directory,
180
+ files: ArrayList[File] | None,
181
+ err: Exception | None,
182
+ ) -> None:
183
+ self.scanning = False
184
+
185
+ if err is not None or files is None:
186
+ self.status_text = f"Erro ao ler a pasta: {err}"
187
+ self.notify_listeners()
188
+ self.show_error(f"Não foi possível ler a pasta:\n{err}")
189
+ return
190
+
191
+ if files.empty():
192
+ self.status_text = "Nenhum PDF/imagem encontrado na pasta."
193
+ self.notify_listeners()
194
+ self.show_info(f"Nenhum PDF ou imagem encontrado em:\n{folder.absolute()}")
195
+ return
196
+
197
+ self.status_text = f"{files.size()} arquivo(s) encontrados. Gerando tabela ..."
198
+ self.notify_listeners()
199
+ self.generate_table(files) # fluxo existente (hash + cache + Excel)
200
+
149
201
  def _on_gen_progress(self, done: int, total: int) -> None:
150
202
  self.progress = (done, total)
151
203
  self.status_text = f"Calculando hashes {done}/{total}"
@@ -235,10 +287,10 @@ class ControllerForms(ChangeNotifier):
235
287
 
236
288
  # ------------------------------------------------------------------ execução
237
289
  def start_forms(self) -> None:
238
-
239
- if self.running or self.generating:
290
+ if self.running or self.generating or self.scanning:
240
291
  self.show_info("Já existe uma operação em andamento.")
241
292
  return
293
+
242
294
  if self.map_rows.is_empty():
243
295
  self.show_error("Importe uma tabela TOI antes de iniciar.")
244
296
  return
@@ -15,6 +15,8 @@ from table_stream import (
15
15
  HashMapDict,
16
16
  RowH,
17
17
  TableData,
18
+ ContentFiles,
19
+ EnumDocuments,
18
20
  load_file_hash,
19
21
  )
20
22
  from cartas import (
@@ -250,6 +252,19 @@ class ModelForms(object):
250
252
  t = getattr(self, "_gen_thread", None)
251
253
  return t is not None and t.is_alive()
252
254
 
255
+ @staticmethod
256
+ def list_documents(folder: Directory, max_files: int = 200_000) -> ArrayList[File]:
257
+ """PDFs e imagens da pasta, lendo as subpastas (extensão sem diferenciar maiúsculas)."""
258
+ content = ContentFiles(folder, max_files=max_files)
259
+ everything = content.get_files(EnumDocuments.ALL, sort=True, recursive=True)
260
+
261
+ valid = ArrayList[File]()
262
+ for f in everything:
263
+ ext = (f.extension() or "").lower()
264
+ if ext == ".pdf" or ext in (".png", ".jpg", ".jpeg"):
265
+ valid.add(f)
266
+ return valid
267
+
253
268
  def generate_table(
254
269
  self,
255
270
  files: ArrayList[File],
@@ -43,11 +43,8 @@ class _PageRespondsFormsState(StatelessWidget):
43
43
  def __init__(self, key=None):
44
44
  super().__init__(key)
45
45
 
46
- self.file_picker_cartas = FilePickerController(
47
- type=FilePickerType.custom,
48
- allow_multiple=True,
49
- allowed_extensions=["pdf", "png", "jpg", "jpeg"],
50
- dialog_title="Escolha as cartas (PDF/imagem)",
46
+ self.picker_cartas_dir = FilePickerController(
47
+ dialog_title="Escolha a pasta com as cartas (PDF/imagem)",
51
48
  )
52
49
 
53
50
  self.file_picker = FilePickerController(
@@ -70,21 +67,21 @@ class _PageRespondsFormsState(StatelessWidget):
70
67
 
71
68
  # ------------------------------------------------------------------ ações
72
69
  def action_generate_table(self, controller: ControllerForms) -> None:
73
- if controller.running or controller.generating:
70
+ if controller.running or controller.generating or controller.scanning:
74
71
  self.show_info("Existe outra operação em andamento.")
75
72
  return
76
73
 
77
74
  conf = controller.get_config()
78
- self.file_picker_cartas.initial_directory = conf.value.initial_dir.absolute()
75
+ if conf.value.initial_dir.exists():
76
+ self.picker_cartas_dir.initial_directory = conf.value.initial_dir.absolute()
79
77
 
80
- result: FilePickerResult | None = self.file_picker_cartas.pick_files()
81
- if result is None or not result.files:
78
+ path = self.picker_cartas_dir.pick_directory()
79
+ if not path:
82
80
  return
83
81
 
84
- if self.file_picker_cartas.initial_directory is not None:
85
- conf.set_initial_dir(Directory(self.file_picker_cartas.initial_directory))
86
-
87
- controller.generate_table(ArrayList([File(p) for p in result.paths]))
82
+ folder = Directory(path)
83
+ conf.set_initial_dir(folder)
84
+ controller.generate_table_from_folder(folder)
88
85
 
89
86
  def action_pick_table(self, controller: ControllerForms) -> None:
90
87
  if controller.running:
@@ -122,7 +119,7 @@ class _PageRespondsFormsState(StatelessWidget):
122
119
  Row(
123
120
  [
124
121
  ElevatedButton(
125
- "Gerar tabela",
122
+ "Gerar tabela (pasta)",
126
123
  lambda: self.action_generate_table(controller),
127
124
  ),
128
125
  ElevatedButton(
@@ -1,11 +1,19 @@
1
1
  from __future__ import annotations
2
2
  import re
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from enum import StrEnum
5
5
  from typing import Final
6
6
 
7
7
  from table_stream import File
8
- from cartas import normalizar_data, toi_valido, uc_valida
8
+ from cartas import (
9
+ normalizar_data,
10
+ toi_valido,
11
+ uc_valida,
12
+ normalizar_data,
13
+ identificar_por_nome,
14
+ ResultadoUcToi,
15
+ OrigemUcToi,
16
+ )
9
17
 
10
18
  # Extensões que o OpenCV consegue abrir (minúsculas). .svg fica de fora.
11
19
  IMAGE_EXTENSIONS: Final[frozenset[str]] = frozenset(
@@ -35,8 +43,7 @@ class FieldError(ValueError):
35
43
  def natural_key(f: File) -> list[str | int]:
36
44
  """Ordenação natural: img2.jpg vem antes de img10.jpg."""
37
45
  return [
38
- int(t) if t.isdigit() else t.lower()
39
- for t in _RE_NATURAL.split(f.absolute())
46
+ int(t) if t.isdigit() else t.lower() for t in _RE_NATURAL.split(f.absolute())
40
47
  ]
41
48
 
42
49
 
@@ -82,11 +89,15 @@ class RenameRequest:
82
89
  if not uc:
83
90
  raise FieldError(FieldName.UC, "Informe a UC (obrigatória).")
84
91
  if not _RE_UC.fullmatch(uc):
85
- raise FieldError(FieldName.UC, "A UC deve ter apenas números (4 a 10 dígitos).")
92
+ raise FieldError(
93
+ FieldName.UC, "A UC deve ter apenas números (4 a 10 dígitos)."
94
+ )
86
95
  if not toi:
87
96
  raise FieldError(FieldName.TOI, "Informe o TOI (obrigatório).")
88
97
  if not _RE_TOI.fullmatch(toi):
89
- raise FieldError(FieldName.TOI, "O TOI deve ter apenas números (4 a 12 dígitos).")
98
+ raise FieldError(
99
+ FieldName.TOI, "O TOI deve ter apenas números (4 a 12 dígitos)."
100
+ )
90
101
 
91
102
  date: tuple[str, str, str] | None = None
92
103
  if date_text:
@@ -128,3 +139,51 @@ class ResultRename:
128
139
  cached: bool
129
140
  warnings: tuple[str, ...] = ()
130
141
  cache_error: Exception | None = None
142
+
143
+
144
+ # Extensões aceitas no REGISTRO em cache (o visualizador só abre imagens).
145
+ REGISTER_EXTENSIONS: Final[frozenset[str]] = IMAGE_EXTENSIONS | {".pdf"}
146
+
147
+ _RE_NAME_UC_TOI: Final = re.compile(r"(\d+)[\s_]+(\d+)")
148
+ _RE_COLLISION: Final = re.compile(r"-\d{1,2}$")
149
+
150
+
151
+ def parse_named_file(f: File) -> ResultadoUcToi | None:
152
+ """
153
+ Reconhece cartas já nomeadas:
154
+ '1305934 193166923.pdf' -> UC TOI (padrão novo)
155
+ '2026 11 03 1305934 193166923' -> data + UC + TOI
156
+ 'UC 1305934 TOI 193166923' -> com rótulos
157
+ Sufixos de colisão '-1', '-2' são ignorados. None = fora do padrão.
158
+ """
159
+ stem = _RE_COLLISION.sub("", f.name().strip()).strip()
160
+
161
+ m = _RE_NAME_UC_TOI.fullmatch(stem)
162
+ if m:
163
+ uc, toi = m.groups()
164
+ # UC 6-8 dígitos e TOI 8-10 (evita confundir "2026 11" com UC/TOI).
165
+ if uc_valida(uc) and toi_valido(toi):
166
+ return ResultadoUcToi(uc=uc, toi=toi, origem=OrigemUcToi.NOME_ARQUIVO)
167
+ return None
168
+
169
+ return identificar_por_nome(f.basename())
170
+
171
+
172
+ @dataclass
173
+ class ResultRegisterNamed:
174
+ """Resultado do registro em cache de cartas já nomeadas."""
175
+
176
+ total: int = 0 # arquivos verificados (imagem/pdf)
177
+ recognized: int = 0 # hashes únicas reconhecidas pelo nome
178
+ changed: int = 0 # linhas gravadas/atualizadas no cache TOI
179
+ with_date: int = 0 # reconhecidas que têm data (chat ou nome)
180
+ ignored: list[str] = field(default_factory=list)
181
+ failures: list[tuple[str, Exception]] = field(default_factory=list)
182
+ warnings: list[str] = field(default_factory=list)
183
+
184
+ moved: int = 0 # NOVO: arquivos movidos p/ Retorno-de-campo
185
+ moves: dict[str, str] = field(default_factory=dict) # NOVO: caminho antigo -> novo
186
+
187
+ @property
188
+ def without_date(self) -> int:
189
+ return self.recognized - self.with_date
@@ -15,6 +15,24 @@ from converter.page_image.common import (
15
15
  )
16
16
  from converter.page_image.model_image import ModelImage
17
17
  from converter.page_image.widgets import TextEditingController
18
+ import threading
19
+ from pyflutter_ui.scheduler import MainThreadScheduler
20
+ from converter.page_image.common import (
21
+ FieldError,
22
+ FieldName,
23
+ RenameRequest,
24
+ ResultRename,
25
+ ResultRegisterNamed,
26
+ natural_key,
27
+ )
28
+
29
+
30
+ def _ui(fn: Callable[[], None]) -> None:
31
+ """Executa fn na thread da UI (o registro roda em worker thread)."""
32
+ try:
33
+ MainThreadScheduler.run(fn)
34
+ except RuntimeError:
35
+ fn()
18
36
 
19
37
 
20
38
  class ControllerImage(ChangeNotifier):
@@ -51,6 +69,9 @@ class ControllerImage(ChangeNotifier):
51
69
  self.fn_show_error: Callable[[str], None] | None = None
52
70
  self.fn_show_info: Callable[[str], None] | None = None
53
71
 
72
+ self.auto_register: bool = True # registrar no cache ao importar
73
+ self._reg_thread: threading.Thread | None = None
74
+
54
75
  # ------------------------------------------------------------------ infra
55
76
  def get_config(self) -> AppConfigNotifier:
56
77
  return Provider.read(AppConfigNotifier)
@@ -101,11 +122,17 @@ class ControllerImage(ChangeNotifier):
101
122
  self.show_error(f"A pasta não existe:\n{folder.absolute()}")
102
123
  return
103
124
  self.set_status(f"Lendo {folder.absolute()} ...")
104
- found = self.model.list_images(folder)
105
- self._add(found, f"Pasta {folder.basename()}")
125
+ images, registrable = self.model.scan_folder(folder)
126
+ origin = f"Pasta {folder.basename()}"
127
+ self._add(images, origin)
128
+ if self.auto_register:
129
+ self.register_named_files(registrable, origin) # inclui PDFs
106
130
 
107
131
  def import_files(self, files: Iterable[File]) -> None:
108
- self._add(self.model.filter_images(files), "Imagens selecionadas")
132
+ images = self.model.filter_images(files)
133
+ self._add(images, "Imagens selecionadas")
134
+ if self.auto_register:
135
+ self.register_named_files(images, "Imagens selecionadas")
109
136
 
110
137
  def _add(self, found: ArrayList[File], origin: str) -> None:
111
138
  known = {f.absolute() for f in self.files}
@@ -128,7 +155,9 @@ class ControllerImage(ChangeNotifier):
128
155
  break
129
156
 
130
157
  self._select_no_emit(idx)
131
- self.set_status(f"{origin}: {len(new)} imagens adicionadas (total {self.total}).")
158
+ self.set_status(
159
+ f"{origin}: {len(new)} imagens adicionadas (total {self.total})."
160
+ )
132
161
  self._emit()
133
162
  self.uc_input.request_focus()
134
163
 
@@ -156,6 +185,103 @@ class ControllerImage(ChangeNotifier):
156
185
  self._select_no_emit(min(idx, self.total - 1))
157
186
  self._emit()
158
187
 
188
+ # ------------------------------------------------------------------ registrar nomeadas
189
+
190
+ def set_auto_register(self, v: bool) -> None:
191
+ self.auto_register = v # sem notify: não precisa redesenhar
192
+
193
+ def register_named_files(
194
+ self,
195
+ files: Iterable[File],
196
+ origin: str = "Arquivos",
197
+ organize: bool = False,
198
+ ) -> None:
199
+ """Registra no cache TOI (UC/TOI do nome + data do chat) em worker thread."""
200
+ files = list(files)
201
+ if not files:
202
+ return
203
+ if self._reg_thread is not None and self._reg_thread.is_alive():
204
+ self.show_info("Já existe um registro de cartas em andamento.")
205
+ return
206
+
207
+ self.set_status(f"{origin}: verificando {len(files)} arquivos ...")
208
+
209
+ def progress(done: int, total: int) -> None:
210
+ if done == total or done % 20 == 0: # evita rebuild excessivo
211
+ _ui(
212
+ lambda: self.set_status(f"{origin}: registrando {done}/{total} ...")
213
+ )
214
+
215
+ def work() -> None:
216
+ res: ResultRegisterNamed | None = None
217
+ err: Exception | None = None
218
+ try:
219
+ res = self.model.register_named(files, progress, organize)
220
+ except Exception as e:
221
+ err = e
222
+ _ui(lambda: self._on_registered(origin, res, err))
223
+
224
+ self._reg_thread = threading.Thread(
225
+ target=work, daemon=True, name="image-register"
226
+ )
227
+ self._reg_thread.start()
228
+
229
+ def _on_registered(
230
+ self, origin: str, res: ResultRegisterNamed | None, err: Exception | None
231
+ ) -> None:
232
+ if err is not None or res is None:
233
+ self.set_status(f"{origin}: falha ao registrar no cache TOI.")
234
+ self.show_error(f"Falha ao registrar no cache TOI:\n{err}")
235
+ return
236
+
237
+ # Arquivos movidos: atualiza a lista do visualizador para os novos caminhos.
238
+ if res.moves:
239
+ for i, f in enumerate(self.files):
240
+ new_path = res.moves.get(f.absolute())
241
+ if new_path is None:
242
+ continue
243
+ if f.absolute() in self.renamed:
244
+ self.renamed.discard(f.absolute())
245
+ self.renamed.add(new_path)
246
+ self.files[i] = File(new_path)
247
+ self.files_version += 1
248
+ self._emit()
249
+
250
+ moved_txt = f" | movidas: {res.moved}" if res.moved else ""
251
+ self.set_status(
252
+ f"{origin}: {res.recognized} cartas reconhecidas | "
253
+ f"{res.changed} gravadas/atualizadas no cache | "
254
+ f"com data: {res.with_date} | sem data: {res.without_date}"
255
+ f"{moved_txt}"
256
+ )
257
+
258
+ if res.recognized == 0:
259
+ self.set_status(
260
+ f"{origin}: nenhum arquivo no padrão 'UC TOI' "
261
+ f"({res.total} verificados)."
262
+ )
263
+ return
264
+
265
+ self.set_status(
266
+ f"{origin}: {res.recognized} cartas reconhecidas | "
267
+ f"{res.changed} gravadas/atualizadas no cache | "
268
+ f"com data: {res.with_date} | sem data: {res.without_date}"
269
+ )
270
+
271
+ problems: list[str] = []
272
+ if res.without_date:
273
+ problems.append(
274
+ f"{res.without_date} carta(s) sem data (não encontradas na tabela WhatsApp). "
275
+ "Foram gravadas só com UC/TOI; importe de novo depois de atualizar o chat "
276
+ "para completar a data."
277
+ )
278
+ if res.failures:
279
+ sample = "\n".join(f"{p}: {e}" for p, e in res.failures[:5])
280
+ problems.append(f"{len(res.failures)} arquivo(s) com falha:\n{sample}")
281
+ problems.extend(res.warnings)
282
+ if problems:
283
+ self.show_info("\n\n".join(problems))
284
+
159
285
  # ------------------------------------------------------------------ navegação
160
286
  def select(self, idx: int) -> None:
161
287
  self._select_no_emit(idx)