kotsu-cli 0.1.0__py3-none-any.whl
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.
- kotsu_cli-0.1.0.dist-info/METADATA +252 -0
- kotsu_cli-0.1.0.dist-info/RECORD +33 -0
- kotsu_cli-0.1.0.dist-info/WHEEL +4 -0
- kotsu_cli-0.1.0.dist-info/entry_points.txt +3 -0
- kotsu_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- money_cli/__init__.py +3 -0
- money_cli/__main__.py +197 -0
- money_cli/armazenamento/__init__.py +4 -0
- money_cli/armazenamento/backup.py +70 -0
- money_cli/armazenamento/banco.py +525 -0
- money_cli/armazenamento/caminhos.py +72 -0
- money_cli/armazenamento/migracoes/0001_inicial.sql +35 -0
- money_cli/armazenamento/migracoes/0002_recorrencia.sql +21 -0
- money_cli/armazenamento/migracoes/0003_mesclagem.sql +54 -0
- money_cli/armazenamento/migracoes/__init__.py +32 -0
- money_cli/armazenamento/preferencias.py +31 -0
- money_cli/armazenamento/trava.py +123 -0
- money_cli/armazenamento/troca.py +285 -0
- money_cli/dominio/__init__.py +5 -0
- money_cli/dominio/dinheiro.py +10 -0
- money_cli/dominio/filtro.py +63 -0
- money_cli/dominio/linha.py +179 -0
- money_cli/dominio/mes.py +57 -0
- money_cli/dominio/mescla.py +440 -0
- money_cli/dominio/parcelas.py +52 -0
- money_cli/dominio/recorrencia.py +20 -0
- money_cli/dominio/tipos.py +95 -0
- money_cli/dominio/totais.py +17 -0
- money_cli/tela/__init__.py +1 -0
- money_cli/tela/app.py +756 -0
- money_cli/tela/cores.py +31 -0
- money_cli/tela/perguntas.py +144 -0
- money_cli/tela/temas.py +74 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
-- 0003: identidade para mesclar bancos de aparelhos diferentes (D39).
|
|
2
|
+
-- `uid` identifica o registro em qualquer aparelho; `id` continua local. `alterado_em` é o
|
|
3
|
+
-- carimbo da última alteração (relógio híbrido: milissegundos-contador-réplica), comparável
|
|
4
|
+
-- como texto. Exclusão de lançamento deixa uma lápide, para ele não voltar na mesclagem.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE meta (
|
|
7
|
+
chave TEXT PRIMARY KEY,
|
|
8
|
+
valor TEXT NOT NULL
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
INSERT INTO meta (chave, valor) VALUES ('replica', lower(hex(randomblob(4))));
|
|
12
|
+
INSERT INTO meta (chave, valor) VALUES ('relogio', '0000000000000-0000-00000000');
|
|
13
|
+
|
|
14
|
+
ALTER TABLE conta ADD COLUMN uid TEXT;
|
|
15
|
+
ALTER TABLE conta ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
|
|
16
|
+
UPDATE conta SET uid = lower(hex(randomblob(16)));
|
|
17
|
+
CREATE UNIQUE INDEX conta_uid ON conta (uid);
|
|
18
|
+
|
|
19
|
+
ALTER TABLE categoria ADD COLUMN uid TEXT;
|
|
20
|
+
ALTER TABLE categoria ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
|
|
21
|
+
UPDATE categoria SET uid = lower(hex(randomblob(16)));
|
|
22
|
+
CREATE UNIQUE INDEX categoria_uid ON categoria (uid);
|
|
23
|
+
|
|
24
|
+
-- A série já tem identidade universal (`grupo`); falta só o carimbo do modelo.
|
|
25
|
+
ALTER TABLE recorrencia ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
|
|
26
|
+
|
|
27
|
+
-- `gerado` = mês criado pela série e nunca mexido: fica fora do código de conferência,
|
|
28
|
+
-- porque cada aparelho gera meses futuros até onde a pessoa navegou.
|
|
29
|
+
ALTER TABLE lancamento ADD COLUMN uid TEXT;
|
|
30
|
+
ALTER TABLE lancamento ADD COLUMN alterado_em TEXT NOT NULL DEFAULT '0000000000000-0000-00000000';
|
|
31
|
+
ALTER TABLE lancamento ADD COLUMN gerado INTEGER NOT NULL DEFAULT 0 CHECK (gerado IN (0, 1));
|
|
32
|
+
UPDATE lancamento SET uid = lower(hex(randomblob(16)));
|
|
33
|
+
CREATE UNIQUE INDEX lancamento_uid ON lancamento (uid);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE lapide (
|
|
36
|
+
uid TEXT PRIMARY KEY,
|
|
37
|
+
excluido_em TEXT NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- Quem insere sem `uid` ganha um: nenhum registro fica sem identidade.
|
|
41
|
+
CREATE TRIGGER conta_uid_novo AFTER INSERT ON conta WHEN NEW.uid IS NULL
|
|
42
|
+
BEGIN
|
|
43
|
+
UPDATE conta SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
|
|
44
|
+
END;
|
|
45
|
+
|
|
46
|
+
CREATE TRIGGER categoria_uid_novo AFTER INSERT ON categoria WHEN NEW.uid IS NULL
|
|
47
|
+
BEGIN
|
|
48
|
+
UPDATE categoria SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
|
|
49
|
+
END;
|
|
50
|
+
|
|
51
|
+
CREATE TRIGGER lancamento_uid_novo AFTER INSERT ON lancamento WHEN NEW.uid IS NULL
|
|
52
|
+
BEGIN
|
|
53
|
+
UPDATE lancamento SET uid = lower(hex(randomblob(16))) WHERE id = NEW.id;
|
|
54
|
+
END;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Migrações numeradas: arquivos NNNN_nome.sql nesta pasta, aplicados em ordem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import resources
|
|
8
|
+
|
|
9
|
+
_PADRAO = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Migracao:
|
|
14
|
+
versao: int
|
|
15
|
+
nome: str
|
|
16
|
+
sql: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def carregar() -> list[Migracao]:
|
|
20
|
+
"""Lê as migrações do pacote e confere que a numeração é 1, 2, 3... sem buracos."""
|
|
21
|
+
encontradas = []
|
|
22
|
+
for item in resources.files(__package__).iterdir():
|
|
23
|
+
casamento = _PADRAO.match(item.name)
|
|
24
|
+
if casamento:
|
|
25
|
+
encontradas.append(
|
|
26
|
+
Migracao(int(casamento.group(1)), item.name, item.read_text(encoding="utf-8"))
|
|
27
|
+
)
|
|
28
|
+
encontradas.sort(key=lambda m: m.versao)
|
|
29
|
+
for esperado, migracao in enumerate(encontradas, start=1):
|
|
30
|
+
if migracao.versao != esperado:
|
|
31
|
+
raise RuntimeError(f"migração {esperado:04d} faltando antes de {migracao.nome}")
|
|
32
|
+
return encontradas
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Preferências do usuário em `tema.toml`, na pasta de dados. Erro nunca impede o app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
NOME_ARQUIVO = "tema.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ler_tema(pasta: Path) -> str | None:
|
|
12
|
+
"""Nome do tema salvo, ou None se o arquivo não existe, está ilegível ou sem `tema`."""
|
|
13
|
+
try:
|
|
14
|
+
with (pasta / NOME_ARQUIVO).open("rb") as arquivo:
|
|
15
|
+
nome = tomllib.load(arquivo).get("tema")
|
|
16
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
17
|
+
return None
|
|
18
|
+
return nome if isinstance(nome, str) else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def gravar_tema(pasta: Path, nome: str) -> bool:
|
|
22
|
+
"""Grava o tema por arquivo temporário e troca atômica. False se não foi possível."""
|
|
23
|
+
destino = pasta / NOME_ARQUIVO
|
|
24
|
+
temporario = destino.with_suffix(".toml.tmp")
|
|
25
|
+
try:
|
|
26
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
temporario.write_text(f'tema = "{nome}"\n', encoding="utf-8")
|
|
28
|
+
temporario.replace(destino)
|
|
29
|
+
except OSError:
|
|
30
|
+
return False
|
|
31
|
+
return True
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Trava de uso (D38): evita abrir o mesmo banco em dois computadores ao mesmo tempo.
|
|
2
|
+
|
|
3
|
+
Com a pasta de dados num serviço de sincronização (Google Drive), dois computadores abertos
|
|
4
|
+
juntos geram "cópias em conflito" e perdem lançamentos. Enquanto o Kotsu está aberto existe
|
|
5
|
+
`kotsu.trava` ao lado do banco, dizendo quem está usando. A trava é um aviso entre
|
|
6
|
+
computadores, não uma garantia: o Drive leva alguns segundos para propagá-la.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
NOME_TRAVA = "kotsu.trava"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BancoEmUso(Exception):
|
|
23
|
+
"""Outro Kotsu (ou outro computador) está com o banco aberto."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, maquina: str, desde: str, mesma_maquina: bool) -> None:
|
|
26
|
+
self.maquina, self.desde, self.mesma_maquina = maquina, desde, mesma_maquina
|
|
27
|
+
onde = "neste computador" if mesma_maquina else f"no computador {maquina}"
|
|
28
|
+
super().__init__(f"O Kotsu já está aberto {onde} (desde {desde}).")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Trava:
|
|
33
|
+
caminho: Path
|
|
34
|
+
|
|
35
|
+
def liberar(self) -> None:
|
|
36
|
+
"""Remove a trava, só se ainda for deste processo."""
|
|
37
|
+
if (
|
|
38
|
+
_ler(self.caminho).get("pid") == os.getpid()
|
|
39
|
+
and _ler(self.caminho).get("maquina") == _maquina()
|
|
40
|
+
):
|
|
41
|
+
self.caminho.unlink(missing_ok=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tomar(pasta: Path, desde: str, forcar: bool = False) -> Trava:
|
|
45
|
+
"""Cria a trava ou levanta `BancoEmUso`. `forcar` assume uma trava de outro dono."""
|
|
46
|
+
caminho = pasta / NOME_TRAVA
|
|
47
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
dono = _ler(caminho)
|
|
49
|
+
if dono and not forcar:
|
|
50
|
+
mesma = dono.get("maquina") == _maquina()
|
|
51
|
+
# Na mesma máquina, trava de processo que já morreu (queda de energia) é descartada.
|
|
52
|
+
if not (mesma and not _vivo(int(dono.get("pid", 0)))):
|
|
53
|
+
raise BancoEmUso(str(dono.get("maquina", "?")), str(dono.get("desde", "?")), mesma)
|
|
54
|
+
conteudo = json.dumps({"maquina": _maquina(), "pid": os.getpid(), "desde": desde})
|
|
55
|
+
caminho.unlink(missing_ok=True)
|
|
56
|
+
try:
|
|
57
|
+
descritor = os.open(caminho, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
58
|
+
except FileExistsError: # dois Kotsu na mesma hora: perde quem chegou depois
|
|
59
|
+
dono = _ler(caminho)
|
|
60
|
+
raise BancoEmUso(
|
|
61
|
+
str(dono.get("maquina", "?")),
|
|
62
|
+
str(dono.get("desde", "?")),
|
|
63
|
+
dono.get("maquina") == _maquina(),
|
|
64
|
+
) from None
|
|
65
|
+
with os.fdopen(descritor, "w", encoding="utf-8") as arquivo:
|
|
66
|
+
arquivo.write(conteudo)
|
|
67
|
+
return Trava(caminho)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def conflitos(pasta: Path, nome_banco: str) -> list[Path]:
|
|
71
|
+
"""Cópias do banco que o Drive cria ao ver duas edições (ex.: `money (1).sqlite`).
|
|
72
|
+
|
|
73
|
+
Ignora o banco, backups e as cópias de pré-migração (`money.antes-v2.sqlite`).
|
|
74
|
+
"""
|
|
75
|
+
stem = Path(nome_banco).stem
|
|
76
|
+
padrao = re.compile(re.escape(stem) + r".+\.sqlite", re.IGNORECASE)
|
|
77
|
+
antes = re.compile(re.escape(stem) + r"\.antes-v\d+\.sqlite", re.IGNORECASE)
|
|
78
|
+
try:
|
|
79
|
+
itens = sorted(pasta.iterdir())
|
|
80
|
+
except OSError:
|
|
81
|
+
return []
|
|
82
|
+
return [
|
|
83
|
+
p for p in itens if p.is_file() and padrao.fullmatch(p.name) and not antes.fullmatch(p.name)
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _maquina() -> str:
|
|
88
|
+
return socket.gethostname()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ler(caminho: Path) -> dict:
|
|
92
|
+
try:
|
|
93
|
+
dados = json.loads(caminho.read_text(encoding="utf-8"))
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
# Trava existe mas está ilegível (ainda sincronizando): trata como de dono desconhecido.
|
|
96
|
+
return {"maquina": "?", "pid": 0, "desde": "?"} if caminho.exists() else {}
|
|
97
|
+
return dados if isinstance(dados, dict) else {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _vivo(pid: int) -> bool:
|
|
101
|
+
if pid <= 0:
|
|
102
|
+
return False
|
|
103
|
+
if sys.platform == "win32": # os.kill(pid, 0) MATA o processo no Windows
|
|
104
|
+
import ctypes
|
|
105
|
+
|
|
106
|
+
kernel = ctypes.windll.kernel32
|
|
107
|
+
processo = kernel.OpenProcess(0x1000, False, pid) # QUERY_LIMITED_INFORMATION
|
|
108
|
+
if not processo:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
codigo = ctypes.c_ulong()
|
|
112
|
+
return bool(kernel.GetExitCodeProcess(processo, ctypes.byref(codigo))) and (
|
|
113
|
+
codigo.value == 259 # STILL_ACTIVE
|
|
114
|
+
)
|
|
115
|
+
finally:
|
|
116
|
+
kernel.CloseHandle(processo)
|
|
117
|
+
try:
|
|
118
|
+
os.kill(pid, 0)
|
|
119
|
+
except ProcessLookupError:
|
|
120
|
+
return False
|
|
121
|
+
except PermissionError:
|
|
122
|
+
return True
|
|
123
|
+
return True
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Arquivo de troca entre aparelhos (D39): exportar, importar (mesclar) e conferir.
|
|
2
|
+
|
|
3
|
+
Cada aparelho tem o próprio banco. Para juntar os dados, um exporta um arquivo JSON com
|
|
4
|
+
tudo o que tem e o outro importa: a mesclagem (`dominio.mescla`) decide registro a registro.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from money_cli.armazenamento.banco import Banco, copiar_banco
|
|
17
|
+
from money_cli.dominio import mescla
|
|
18
|
+
from money_cli.dominio.mescla import Comparacao, ErroDeTroca, Resumo
|
|
19
|
+
|
|
20
|
+
NOME_ANTES = "antes-da-troca"
|
|
21
|
+
|
|
22
|
+
_CAMPOS_SERIE = (
|
|
23
|
+
"grupo",
|
|
24
|
+
"dia",
|
|
25
|
+
"descricao",
|
|
26
|
+
"valor",
|
|
27
|
+
"tipo",
|
|
28
|
+
"conta",
|
|
29
|
+
"categoria",
|
|
30
|
+
"gerada_ate",
|
|
31
|
+
"encerrada",
|
|
32
|
+
"alterado_em",
|
|
33
|
+
)
|
|
34
|
+
_CAMPOS_LANCAMENTO = (
|
|
35
|
+
"uid",
|
|
36
|
+
"data",
|
|
37
|
+
"descricao",
|
|
38
|
+
"valor",
|
|
39
|
+
"tipo",
|
|
40
|
+
"conta",
|
|
41
|
+
"categoria",
|
|
42
|
+
"recorrencia",
|
|
43
|
+
"gerado",
|
|
44
|
+
"alterado_em",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ler_estado(banco: Banco) -> dict:
|
|
49
|
+
"""O conteúdo inteiro do banco, com `uid` no lugar dos ids locais."""
|
|
50
|
+
c = banco.conexao
|
|
51
|
+
contas = [
|
|
52
|
+
{"uid": uid, "nome": nome, "cor": cor, "alterado_em": em}
|
|
53
|
+
for uid, nome, cor, em in c.execute(
|
|
54
|
+
"SELECT uid, nome, cor, alterado_em FROM conta ORDER BY uid"
|
|
55
|
+
)
|
|
56
|
+
]
|
|
57
|
+
categorias = [
|
|
58
|
+
{"uid": uid, "nome": nome, "recorrente": bool(rec), "alterado_em": em}
|
|
59
|
+
for uid, nome, rec, em in c.execute(
|
|
60
|
+
"SELECT uid, nome, recorrente, alterado_em FROM categoria ORDER BY uid"
|
|
61
|
+
)
|
|
62
|
+
]
|
|
63
|
+
recorrencias = []
|
|
64
|
+
for linha in c.execute(
|
|
65
|
+
"SELECT r.grupo, r.dia, r.descricao, r.valor, r.tipo, conta.uid, categoria.uid,"
|
|
66
|
+
" r.gerada_ate, r.encerrada, r.alterado_em FROM recorrencia r"
|
|
67
|
+
" JOIN conta ON conta.id = r.conta_id"
|
|
68
|
+
" LEFT JOIN categoria ON categoria.id = r.categoria_id ORDER BY r.grupo"
|
|
69
|
+
):
|
|
70
|
+
serie = dict(zip(_CAMPOS_SERIE, linha, strict=True))
|
|
71
|
+
serie["encerrada"] = bool(serie["encerrada"])
|
|
72
|
+
recorrencias.append(serie)
|
|
73
|
+
lancamentos = []
|
|
74
|
+
for *comuns, grupo, numero, total in c.execute(
|
|
75
|
+
"SELECT l.uid, l.data, l.descricao, l.valor, l.tipo, conta.uid, categoria.uid,"
|
|
76
|
+
" l.grupo_recorrencia, l.gerado, l.alterado_em,"
|
|
77
|
+
" l.grupo_parcela, l.parcela_numero, l.parcela_total FROM lancamento l"
|
|
78
|
+
" JOIN conta ON conta.id = l.conta_id"
|
|
79
|
+
" LEFT JOIN categoria ON categoria.id = l.categoria_id ORDER BY l.uid"
|
|
80
|
+
):
|
|
81
|
+
lanc = dict(zip(_CAMPOS_LANCAMENTO, comuns, strict=True))
|
|
82
|
+
lanc["gerado"] = bool(lanc["gerado"])
|
|
83
|
+
lanc["parcela"] = (
|
|
84
|
+
None if grupo is None else {"grupo": grupo, "numero": numero, "total": total}
|
|
85
|
+
)
|
|
86
|
+
lancamentos.append(lanc)
|
|
87
|
+
lapides = [
|
|
88
|
+
{"uid": uid, "excluido_em": em}
|
|
89
|
+
for uid, em in c.execute("SELECT uid, excluido_em FROM lapide ORDER BY uid")
|
|
90
|
+
]
|
|
91
|
+
return {
|
|
92
|
+
"contas": contas,
|
|
93
|
+
"categorias": categorias,
|
|
94
|
+
"recorrencias": recorrencias,
|
|
95
|
+
"lancamentos": lancamentos,
|
|
96
|
+
"lapides": lapides,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def codigo(banco: Banco) -> str:
|
|
101
|
+
"""Código de conferência dos dados deste banco."""
|
|
102
|
+
return mescla.codigo(ler_estado(banco))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def exportar(banco: Banco, destino: Path, agora: dt.datetime | None = None) -> str:
|
|
106
|
+
"""Escreve o arquivo de troca em `destino` (troca atômica). Devolve o código."""
|
|
107
|
+
estado = ler_estado(banco)
|
|
108
|
+
conferencia = mescla.codigo(estado)
|
|
109
|
+
arquivo = {
|
|
110
|
+
"app": "kotsu",
|
|
111
|
+
"formato": mescla.FORMATO,
|
|
112
|
+
"replica": banco.replica,
|
|
113
|
+
"exportado_em": (agora or dt.datetime.now()).isoformat(timespec="seconds"),
|
|
114
|
+
"conferencia": conferencia,
|
|
115
|
+
**estado,
|
|
116
|
+
}
|
|
117
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
descritor, nome = tempfile.mkstemp(prefix=".troca-", suffix=".tmp", dir=destino.parent)
|
|
119
|
+
temporario = Path(nome)
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(descritor, "w", encoding="utf-8") as saida:
|
|
122
|
+
json.dump(arquivo, saida, ensure_ascii=False, separators=(",", ":"))
|
|
123
|
+
saida.flush()
|
|
124
|
+
os.fsync(saida.fileno())
|
|
125
|
+
temporario.replace(destino)
|
|
126
|
+
finally:
|
|
127
|
+
temporario.unlink(missing_ok=True)
|
|
128
|
+
return conferencia
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ler_arquivo(origem: Path) -> dict:
|
|
132
|
+
"""Lê e valida um arquivo de troca. Levanta `ErroDeTroca` com mensagem para a pessoa."""
|
|
133
|
+
try:
|
|
134
|
+
texto = origem.read_text(encoding="utf-8")
|
|
135
|
+
except (OSError, UnicodeDecodeError) as erro:
|
|
136
|
+
raise ErroDeTroca(f"não foi possível ler {origem}: {erro}") from None
|
|
137
|
+
try:
|
|
138
|
+
dados = json.loads(texto)
|
|
139
|
+
except ValueError:
|
|
140
|
+
raise ErroDeTroca(f"{origem} não é uma exportação do Kotsu") from None
|
|
141
|
+
return mescla.validar(dados)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def comparar(banco: Banco, origem: Path) -> Comparacao:
|
|
145
|
+
"""Diferenças entre este banco e o arquivo de outro aparelho. Não muda nada."""
|
|
146
|
+
return mescla.comparar(ler_estado(banco), ler_arquivo(origem))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def importar(banco: Banco, origem: Path) -> Resumo:
|
|
150
|
+
"""Mescla o arquivo de outro aparelho neste banco, numa transação só.
|
|
151
|
+
|
|
152
|
+
Antes de mudar qualquer coisa, guarda uma cópia do banco em `backups/` (uma só,
|
|
153
|
+
substituída a cada importação que muda algo).
|
|
154
|
+
"""
|
|
155
|
+
remoto = ler_arquivo(origem)
|
|
156
|
+
local = ler_estado(banco)
|
|
157
|
+
novo = mescla.mesclar(local, remoto)
|
|
158
|
+
visto = mescla.maior_carimbo(remoto)
|
|
159
|
+
if novo == local:
|
|
160
|
+
with banco.transacao() as c:
|
|
161
|
+
_avancar_relogio(c, visto)
|
|
162
|
+
return Resumo()
|
|
163
|
+
if banco.caminho is not None:
|
|
164
|
+
pasta = banco.pasta_backups or banco.caminho.parent / "backups"
|
|
165
|
+
try:
|
|
166
|
+
pasta.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
copiar_banco(banco.conexao, pasta / f"{banco.caminho.stem}.{NOME_ANTES}.sqlite")
|
|
168
|
+
except (OSError, sqlite3.Error) as erro:
|
|
169
|
+
raise ErroDeTroca(
|
|
170
|
+
f"não foi possível guardar a cópia de segurança em {pasta}: {erro}"
|
|
171
|
+
) from None
|
|
172
|
+
with banco.transacao() as c:
|
|
173
|
+
_aplicar(c, novo)
|
|
174
|
+
_avancar_relogio(c, visto)
|
|
175
|
+
return mescla.resumir(local, novo)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _avancar_relogio(c: sqlite3.Connection, visto: str) -> None:
|
|
179
|
+
"""O próximo carimbo daqui passa de tudo o que o arquivo trouxe."""
|
|
180
|
+
c.execute(
|
|
181
|
+
"UPDATE meta SET valor = max(valor, ?) WHERE chave = 'relogio'",
|
|
182
|
+
(visto,),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _aplicar(c: sqlite3.Connection, novo: dict) -> None:
|
|
187
|
+
"""Deixa o banco igual ao estado `novo`, preservando os ids locais."""
|
|
188
|
+
contas = _aplicar_nomeados(c, "conta", novo["contas"], ("cor",))
|
|
189
|
+
categorias = _aplicar_nomeados(c, "categoria", novo["categorias"], ("recorrente",))
|
|
190
|
+
|
|
191
|
+
for r in novo["recorrencias"]:
|
|
192
|
+
c.execute(
|
|
193
|
+
"INSERT INTO recorrencia (grupo, dia, descricao, valor, tipo, conta_id, categoria_id,"
|
|
194
|
+
" gerada_ate, encerrada, alterado_em) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
195
|
+
" ON CONFLICT (grupo) DO UPDATE SET dia = excluded.dia,"
|
|
196
|
+
" descricao = excluded.descricao, valor = excluded.valor, tipo = excluded.tipo,"
|
|
197
|
+
" conta_id = excluded.conta_id, categoria_id = excluded.categoria_id,"
|
|
198
|
+
" gerada_ate = excluded.gerada_ate, encerrada = excluded.encerrada,"
|
|
199
|
+
" alterado_em = excluded.alterado_em",
|
|
200
|
+
(
|
|
201
|
+
r["grupo"],
|
|
202
|
+
r["dia"],
|
|
203
|
+
r["descricao"],
|
|
204
|
+
r["valor"],
|
|
205
|
+
r["tipo"],
|
|
206
|
+
contas[r["conta"]],
|
|
207
|
+
categorias.get(r["categoria"]),
|
|
208
|
+
r["gerada_ate"],
|
|
209
|
+
int(r["encerrada"]),
|
|
210
|
+
r["alterado_em"],
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
locais = {uid for (uid,) in c.execute("SELECT uid FROM lancamento")}
|
|
215
|
+
vivos = {x["uid"] for x in novo["lancamentos"]}
|
|
216
|
+
c.executemany("DELETE FROM lancamento WHERE uid = ?", [(uid,) for uid in locais - vivos])
|
|
217
|
+
for x in novo["lancamentos"]:
|
|
218
|
+
p = x["parcela"] or {}
|
|
219
|
+
valores = (
|
|
220
|
+
x["data"],
|
|
221
|
+
x["descricao"],
|
|
222
|
+
x["valor"],
|
|
223
|
+
x["tipo"],
|
|
224
|
+
contas[x["conta"]],
|
|
225
|
+
categorias.get(x["categoria"]),
|
|
226
|
+
p.get("grupo"),
|
|
227
|
+
p.get("numero"),
|
|
228
|
+
p.get("total"),
|
|
229
|
+
x["recorrencia"],
|
|
230
|
+
int(x["gerado"]),
|
|
231
|
+
x["alterado_em"],
|
|
232
|
+
x["uid"],
|
|
233
|
+
)
|
|
234
|
+
if x["uid"] in locais:
|
|
235
|
+
c.execute(
|
|
236
|
+
"UPDATE lancamento SET data = ?, descricao = ?, valor = ?, tipo = ?, conta_id = ?,"
|
|
237
|
+
" categoria_id = ?, grupo_parcela = ?, parcela_numero = ?, parcela_total = ?,"
|
|
238
|
+
" grupo_recorrencia = ?, gerado = ?, alterado_em = ? WHERE uid = ?",
|
|
239
|
+
valores,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
c.execute(
|
|
243
|
+
"INSERT INTO lancamento (data, descricao, valor, tipo, conta_id, categoria_id,"
|
|
244
|
+
" grupo_parcela, parcela_numero, parcela_total, grupo_recorrencia, gerado,"
|
|
245
|
+
" alterado_em, uid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
246
|
+
valores,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
c.executemany(
|
|
250
|
+
"INSERT OR REPLACE INTO lapide (uid, excluido_em) VALUES (?, ?)",
|
|
251
|
+
[(x["uid"], x["excluido_em"]) for x in novo["lapides"]],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _aplicar_nomeados(
|
|
256
|
+
c: sqlite3.Connection, tabela: str, itens: list[dict], campos: tuple[str, ...]
|
|
257
|
+
) -> dict[str, int]:
|
|
258
|
+
"""Grava contas ou categorias mescladas. Devolve `uid -> id local`.
|
|
259
|
+
|
|
260
|
+
A que existe aqui com o mesmo nome e outro `uid` é a mesma: passa a usar o `uid` que a
|
|
261
|
+
mesclagem escolheu, e os lançamentos daqui continuam apontando para ela.
|
|
262
|
+
"""
|
|
263
|
+
por_uid, por_nome = {}, {}
|
|
264
|
+
for id_, uid, nome in c.execute(f"SELECT id, uid, nome FROM {tabela}").fetchall():
|
|
265
|
+
por_uid[uid] = id_
|
|
266
|
+
por_nome[mescla.chave_do_nome(nome)] = id_
|
|
267
|
+
ids = {}
|
|
268
|
+
atribuicoes = ", ".join(f"{campo} = ?" for campo in campos)
|
|
269
|
+
for item in itens:
|
|
270
|
+
valores = [int(v) if isinstance(v, bool) else v for v in (item[k] for k in campos)]
|
|
271
|
+
id_ = por_uid.get(item["uid"]) or por_nome.get(mescla.chave_do_nome(item["nome"]))
|
|
272
|
+
if id_ is None:
|
|
273
|
+
id_ = c.execute(
|
|
274
|
+
f"INSERT INTO {tabela} (uid, nome, alterado_em, {', '.join(campos)})"
|
|
275
|
+
f" VALUES (?, ?, ?{', ?' * len(campos)})",
|
|
276
|
+
(item["uid"], item["nome"], item["alterado_em"], *valores),
|
|
277
|
+
).lastrowid
|
|
278
|
+
else:
|
|
279
|
+
c.execute(
|
|
280
|
+
f"UPDATE {tabela} SET uid = ?, nome = ?, alterado_em = ?, {atribuicoes}"
|
|
281
|
+
" WHERE id = ?",
|
|
282
|
+
(item["uid"], item["nome"], item["alterado_em"], *valores, id_),
|
|
283
|
+
)
|
|
284
|
+
ids[item["uid"]] = id_
|
|
285
|
+
return ids
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Formatação de valores em centavos inteiros."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def formatar(centavos: int) -> str:
|
|
5
|
+
"""Formata centavos como BRL sem sinal: 123456 -> '1.234,56'.
|
|
6
|
+
|
|
7
|
+
O sinal nunca aparece no texto: negativo é mostrado só em vermelho pela tela.
|
|
8
|
+
"""
|
|
9
|
+
reais, cents = divmod(abs(centavos), 100)
|
|
10
|
+
return f"{reais:,}".replace(",", ".") + f",{cents:02d}"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Filtro da tela principal (D12, D26). Puro: lê o texto; a tela resolve os nomes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from money_cli.dominio.linha import ErroDeLinha
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class FiltroDigitado:
|
|
12
|
+
"""O que foi digitado depois de `/`, antes de virar ids."""
|
|
13
|
+
|
|
14
|
+
conta: str | None = None # @nome
|
|
15
|
+
categoria: str | None = None # #nome
|
|
16
|
+
qualquer: str | None = None # nome sem prefixo: conta ou categoria
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def vazio(self) -> bool:
|
|
20
|
+
return self.conta is None and self.categoria is None and self.qualquer is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Filtro:
|
|
25
|
+
"""Filtro já resolvido em ids. Vale o "e" entre conta e categoria."""
|
|
26
|
+
|
|
27
|
+
conta_id: int | None = None
|
|
28
|
+
categoria_id: int | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def ativo(self) -> bool:
|
|
32
|
+
return self.conta_id is not None or self.categoria_id is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SEM_FILTRO = Filtro()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def interpretar_filtro(texto: str) -> FiltroDigitado:
|
|
39
|
+
"""`@nubank`, `#casa`, `@nubank #casa` ou `nubank`. Levanta `ErroDeLinha`."""
|
|
40
|
+
conta = categoria = qualquer = None
|
|
41
|
+
soltas: list[str] = []
|
|
42
|
+
for parte in texto.split():
|
|
43
|
+
if parte.startswith("@"):
|
|
44
|
+
if conta is not None:
|
|
45
|
+
raise ErroDeLinha("só uma @conta no filtro")
|
|
46
|
+
conta = _nome(parte, "conta")
|
|
47
|
+
elif parte.startswith("#"):
|
|
48
|
+
if categoria is not None:
|
|
49
|
+
raise ErroDeLinha("só uma #categoria no filtro")
|
|
50
|
+
categoria = _nome(parte, "categoria")
|
|
51
|
+
else:
|
|
52
|
+
soltas.append(parte)
|
|
53
|
+
if soltas:
|
|
54
|
+
if conta is not None or categoria is not None:
|
|
55
|
+
raise ErroDeLinha("use @conta e #categoria juntos, ou só um nome")
|
|
56
|
+
qualquer = " ".join(soltas)
|
|
57
|
+
return FiltroDigitado(conta, categoria, qualquer)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _nome(parte: str, oque: str) -> str:
|
|
61
|
+
if len(parte) == 1:
|
|
62
|
+
raise ErroDeLinha(f"falta o nome da {oque} depois de {parte}")
|
|
63
|
+
return parte[1:]
|