diff --git a/README.md b/README.md index 5acd19c..effaa71 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ O botão **Opções**, ao lado de Sobre, guarda o que vale entre sessões, em `% - **Menu do Explorer:** **Ligar** põe "Analisar com MapDisk" no botão direito das pastas, das unidades e do fundo de uma pasta, só para o seu usuário e sem administrador. Esse item abre o MapDisk já varrendo. No Windows 11, ele fica em "Mostrar mais opções". Se o `mapdisk.exe` mudar de lugar, as Opções avisam e **Ligar** aponta para o novo lugar. **Desligar** tira o item por completo. - **Análises:** quantos maiores arquivos mostrar e o tamanho mínimo dos duplicados. +- **Pastas excluídas da varredura:** pastas que a varredura não lê, guardadas em `%LOCALAPPDATA%\MapDisk\excluidas.txt`. O nome, como `node_modules`, vale em qualquer nível; o caminho completo, como `D:\Backup\Veeam`, vale só para aquela pasta. Sem `*` nem `?`. A pasta excluída aparece como **excluída**, fora dos totais, e é contada na barra de baixo, nas análises e nos relatórios. Para olhar dentro dela, use **Atualizar esta pasta**. A lista vale na próxima varredura. - **Relatório para o cliente:** os critérios do **Sugerir**. A janela do relatório também guarda os critérios usados. - **Últimos alvos:** **Esquecer** um alvo ou **Limpar a lista**. - **Registro de ações:** onde ele fica, com o botão para abrir. @@ -72,7 +73,7 @@ Linha de comando (só lê, nunca apaga nem move arquivo). O alvo é um caminho c mapdisk --integrar mapdisk --remover-integracao -`--relatorio` grava o relatório HTML, com `--top` itens em cada lista (padrão: 10). `--integrar` e `--remover-integracao` ligam e desligam o item do menu do Explorer. +`--relatorio` grava o relatório HTML, com `--top` itens em cada lista (padrão: 10). `--excluir ` deixa uma pasta fora da varredura e pode repetir; a linha de comando não usa a lista das Opções. `--integrar` e `--remover-integracao` ligam e desligam o item do menu do Explorer. No Prompt de Comando, use `start /wait mapdisk varrer C:` para o prompt esperar o fim. No PowerShell, termine a linha com `| Out-Host`. @@ -88,6 +89,6 @@ No Prompt de Comando, use `start /wait mapdisk varrer C:` para o prompt esperar | 6 | Relatório para o cliente avaliar: página, planilha e leitura da resposta | `relatorio-cliente` | Spec em [#11](https://github.com/manfredjr/mapdisk/pull/11), plano em [#12](https://github.com/manfredjr/mapdisk/pull/12), código em [#13](https://github.com/manfredjr/mapdisk/pull/13) | Concluída em 30/09/2026 | | 7 | Duplicados: busca em três etapas, aba no painel e cópias no relatório para o cliente | `duplicados` | Plano em [#14](https://github.com/manfredjr/mapdisk/pull/14), código em [#15](https://github.com/manfredjr/mapdisk/pull/15) | Concluída em 01/10/2026 | | 8 | Relatório do técnico em HTML com o gráfico, exportar pela janela, linha de comando completa, item "Analisar com MapDisk" no Explorer, tela de Opções e página do programa | `relatorio-e-integracao` | Plano em [#16](https://github.com/manfredjr/mapdisk/pull/16), código em [#17](https://github.com/manfredjr/mapdisk/pull/17) | Concluída em 01/10/2026 | -| 9 | Pastas excluídas da varredura: regras por nome ou caminho nas Opções, estado "excluída" fora dos totais, relatórios e `--excluir` | `pastas-excluidas` | Plano em [#25](https://github.com/manfredjr/mapdisk/pull/25) | Plano em revisão | +| 9 | Pastas excluídas da varredura: regras por nome ou caminho nas Opções, estado "excluída" fora dos totais, relatórios e `--excluir` | `pastas-excluidas` | Plano em [#25](https://github.com/manfredjr/mapdisk/pull/25), código em [#26](https://github.com/manfredjr/mapdisk/pull/26) | Aguardando o teste do Manfred | | - | Versão 1.0.0: troca a versão de 0.1.0 para 1.0.0, para a primeira Release | `versao-1-0-0` | [#19](https://github.com/manfredjr/mapdisk/pull/19) | Publicada em 01/10/2026: [Release v1.0.0](https://github.com/manfredjr/mapdisk/releases/tag/v1.0.0) | | - | Página do programa em mapdisk.manfred.com.br, pelo Git do cPanel | `publicar-pagina` | [#20](https://github.com/manfredjr/mapdisk/pull/20) | Publicada em 01/10/2026: [mapdisk.manfred.com.br](https://mapdisk.manfred.com.br) | diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md index 45d69cf..181e214 100644 --- a/docs/superpowers/pendencias.md +++ b/docs/superpowers/pendencias.md @@ -90,7 +90,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| -| Pastas excluídas da varredura (spec, seção 6) | Fica para depois da versão 1, por decisão do Manfred em 01/10/2026. Mexe no motor e em todas as contas | Uma fatia própria depois da versão 1.0.0 | +| Pastas excluídas da varredura (spec, seção 6) | Fica para depois da versão 1, por decisão do Manfred em 01/10/2026. Mexe no motor e em todas as contas | Fechado: entrou na fatia 9, PR #26 | | Item no menu compacto do Windows 11 | Exige empacotamento MSIX e assinatura (spec, seção 13) | Fora da versão 1 | | Publicação da página | Publicar pede autorização. O roteiro do cPanel fica fora da pasta do projeto | Fechado: publicada em 01/10/2026 pelo Git do cPanel, depois do teste de lançamento e da Release v1.0.0 | | Teste do item do Explorer | Ligar grava no registro do Windows do Manfred. O agente testou só com as chaves em memória | Fechado: teste do Manfred no PR #17, em 01/10/2026 | @@ -101,3 +101,11 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| | Memória da janela com 1 milhão de arquivos | A janela parada já usa cerca de 150 MB (WPF e fontes), e a árvore do disco ocupa cerca de 145 MB. Varrendo um disco com 1,1 milhão de arquivos, o pico ficou entre 390 e 430 MB, contra cerca de 480 MB com o `.exe` comprimido. O que sobra é lixo temporário da varredura e das análises, que o coletor junta antes de limpar. Um ajuste do coletor (`GCgen0MaxBudget`) baixa uns 40 MB, mas só vale por variável de ambiente: o programa não aceita esse ajuste na própria configuração | Aceito para a versão 1. Se fizer falta: criar menos objetos de vida curta nas análises (a extensão de cada arquivo vira um texto novo) e guardar a data do arquivo em 4 bytes | + +## Fatia 9: pastas excluídas da varredura + +| Item | Motivo | O que fecha | +|---|---|---| +| Curinga nas regras (`*`, `?`) | Fora por decisão do plano: curinga mal escrito exclui demais sem o técnico perceber | Reavaliar se o uso pedir | +| Pasta excluída em nível fundo no relatório do técnico | O relatório lista as excluídas logo abaixo da pasta do relatório e conta as de níveis mais fundos no aviso do resumo | Aceito. O CSV traz todas, uma por linha | +| Teste num disco real com pastas excluídas por nome e por caminho | Os testes usam pastas pequenas | Teste do Manfred no PR #26 | diff --git a/src/mapdisk.nucleo/acoes/preparador-acoes.cs b/src/mapdisk.nucleo/acoes/preparador-acoes.cs index 1b0e19d..ad16ca8 100644 --- a/src/mapdisk.nucleo/acoes/preparador-acoes.cs +++ b/src/mapdisk.nucleo/acoes/preparador-acoes.cs @@ -103,6 +103,11 @@ public AvaliacaoSelecao AvaliarItens(IReadOnlyList itens) return Bloqueado($"{item.Nome}: é link. Trate pelo Explorer.", unicos); } + if (item.EhPasta && item.Pasta.Estado == EstadoPasta.Excluida) + { + return Bloqueado($"{item.Nome}: pasta excluída da varredura. Use Atualizar esta pasta para ler antes de agir.", unicos); + } + if (item.EhPasta && item.Pasta.Estado != EstadoPasta.Lida) { return Bloqueado($"{item.Nome}: pasta sem leitura. Atualize ou varra como administrador antes.", unicos); diff --git a/src/mapdisk.nucleo/arvore/no-pasta.cs b/src/mapdisk.nucleo/arvore/no-pasta.cs index 184aa17..efe00e2 100644 --- a/src/mapdisk.nucleo/arvore/no-pasta.cs +++ b/src/mapdisk.nucleo/arvore/no-pasta.cs @@ -10,6 +10,9 @@ public enum EstadoPasta /// Junção ou link simbólico. Não é seguido e não soma. Link, + + /// Pasta que bate com uma regra das Opções. Não é lida e não soma; aparece como "excluída". + Excluida, } [Flags] @@ -50,6 +53,7 @@ public sealed class NoPasta private long _pastasTotal; private long _semAcesso; private long _comErro; + private long _excluidas; private long _modificacao; public NoPasta(string nome, NoPasta? pai, DateTime modificacao = default) @@ -95,6 +99,9 @@ public NoPasta(string nome, NoPasta? pai, DateTime modificacao = default) public long PastasComErro => Interlocked.Read(ref _comErro); + /// Pastas excluídas da varredura nesta subárvore, contando esta. + public long PastasExcluidas => Interlocked.Read(ref _excluidas); + public DateTime UltimaModificacao => new(Interlocked.Read(ref _modificacao)); public int Nivel @@ -142,6 +149,7 @@ internal void DescontarAcima() var pastas = PastasTotal; var semAcesso = PastasSemAcesso; var comErro = PastasComErro; + var excluidas = PastasExcluidas; for (var no = Pai; no != null; no = no.Pai) { Interlocked.Add(ref no._tamanho, -tamanho); @@ -150,6 +158,7 @@ internal void DescontarAcima() Interlocked.Add(ref no._pastasTotal, -pastas); Interlocked.Add(ref no._semAcesso, -semAcesso); Interlocked.Add(ref no._comErro, -comErro); + Interlocked.Add(ref no._excluidas, -excluidas); } } @@ -157,7 +166,7 @@ internal void DescontarAcima() internal void RemoverSubpasta(NoPasta sub) { sub.DescontarAcima(); - Somar(0, 0, 0, -1, 0, 0, 0); + Somar(0, 0, 0, -1, 0, 0, 0, 0); Volatile.Write(ref _subpastas, Volatile.Read(ref _subpastas).Where(s => s != sub).ToArray()); } @@ -174,7 +183,7 @@ internal void RemoverArquivo(ArquivoInfo arquivo) var alocado = arquivo.Soma ? arquivo.Alocado : 0; TamanhoProprio -= tamanho; AlocadoProprio -= alocado; - Somar(-tamanho, -alocado, -1, 0, 0, 0, 0); + Somar(-tamanho, -alocado, -1, 0, 0, 0, 0, 0); Volatile.Write(ref _arquivos, lista.ToArray()); } @@ -259,31 +268,38 @@ public void Preencher(ArquivoInfo[] arquivos, NoPasta[] subpastas) Volatile.Write(ref _arquivos, arquivos); Volatile.Write(ref _subpastas, subpastas); _estado = EstadoPasta.Lida; - Somar(tamanho, alocado, arquivos.Length, subpastas.Length, 0, 0, recente); + Somar(tamanho, alocado, arquivos.Length, subpastas.Length, 0, 0, 0, recente); } public void MarcarSemAcesso(string motivo) { Motivo = motivo; _estado = EstadoPasta.SemAcesso; - Somar(0, 0, 0, 0, 1, 0, ModificacaoPropria.Ticks); + Somar(0, 0, 0, 0, 1, 0, 0, ModificacaoPropria.Ticks); } public void MarcarErro(string motivo) { Motivo = motivo; _estado = EstadoPasta.ErroLeitura; - Somar(0, 0, 0, 0, 0, 1, ModificacaoPropria.Ticks); + Somar(0, 0, 0, 0, 0, 1, 0, ModificacaoPropria.Ticks); } public void MarcarLink(string? destino) { DestinoLink = destino; _estado = EstadoPasta.Link; - Somar(0, 0, 0, 0, 0, 0, ModificacaoPropria.Ticks); + Somar(0, 0, 0, 0, 0, 0, 0, ModificacaoPropria.Ticks); + } + + public void MarcarExcluida(string regra) + { + Motivo = regra; + _estado = EstadoPasta.Excluida; + Somar(0, 0, 0, 0, 0, 0, 1, ModificacaoPropria.Ticks); } - private void Somar(long tamanho, long alocado, long arquivos, long pastas, long semAcesso, long comErro, long modificacao) + private void Somar(long tamanho, long alocado, long arquivos, long pastas, long semAcesso, long comErro, long excluidas, long modificacao) { for (var no = this; no != null; no = no.Pai) { @@ -293,6 +309,7 @@ private void Somar(long tamanho, long alocado, long arquivos, long pastas, long Interlocked.Add(ref no._pastasTotal, pastas); Interlocked.Add(ref no._semAcesso, semAcesso); Interlocked.Add(ref no._comErro, comErro); + Interlocked.Add(ref no._excluidas, excluidas); long atual; while (modificacao > (atual = Interlocked.Read(ref no._modificacao)) diff --git a/src/mapdisk.nucleo/grafico/itens-grafico.cs b/src/mapdisk.nucleo/grafico/itens-grafico.cs index 9b80e2d..8f3723e 100644 --- a/src/mapdisk.nucleo/grafico/itens-grafico.cs +++ b/src/mapdisk.nucleo/grafico/itens-grafico.cs @@ -11,7 +11,7 @@ public enum TipoItemGrafico public sealed record ItemGrafico(string Nome, long Valor, TipoItemGrafico Tipo, NoPasta? Pasta); /// Itens do maior para o menor, o total deles e as subpastas sem leitura, que ficam fora do desenho. -public sealed record ConteudoGrafico(IReadOnlyList Itens, long Total, IReadOnlyList NaoLidas); +public sealed record ConteudoGrafico(IReadOnlyList Itens, long Total, IReadOnlyList NaoLidas, IReadOnlyList Excluidas); /// /// O que o gráfico desenha de uma pasta: um nível abaixo dela, no mesmo critério da árvore. @@ -25,6 +25,7 @@ public static ConteudoGrafico DaPasta(NoPasta pasta, ModoExibicao modo, int maxi { var itens = new List(); var naoLidas = new List(); + var excluidas = new List(); foreach (var sub in pasta.Subpastas) { if (sub.Estado == EstadoPasta.Link) @@ -32,6 +33,12 @@ public static ConteudoGrafico DaPasta(NoPasta pasta, ModoExibicao modo, int maxi continue; } + if (sub.Estado == EstadoPasta.Excluida) + { + excluidas.Add(sub.Nome); + continue; + } + if (sub.Estado != EstadoPasta.Lida) { naoLidas.Add(sub.Nome); @@ -68,7 +75,7 @@ public static ConteudoGrafico DaPasta(NoPasta pasta, ModoExibicao modo, int maxi null)); } - return new ConteudoGrafico(itens, itens.Sum(i => i.Valor), naoLidas); + return new ConteudoGrafico(itens, itens.Sum(i => i.Valor), naoLidas, excluidas); } private static long Valor(NoPasta pasta, ModoExibicao modo) => modo switch diff --git a/src/mapdisk.nucleo/linha-de-comando/argumentos.cs b/src/mapdisk.nucleo/linha-de-comando/argumentos.cs index 762ab02..e139aaa 100644 --- a/src/mapdisk.nucleo/linha-de-comando/argumentos.cs +++ b/src/mapdisk.nucleo/linha-de-comando/argumentos.cs @@ -35,6 +35,9 @@ mapdisk varrer [opções] varre e mostra os maiores itens --relatorio grava o relatório com a marca da MT e o gráfico --top quantos itens mostrar e pôr em cada lista do relatório, de 1 a 1000 (padrão: 10) + --excluir não lê esta pasta, que fica fora do total + (pode repetir: --excluir node_modules + --excluir D:\Backup\Veeam) Exemplos: mapdisk varrer C: @@ -53,6 +56,9 @@ A linha de comando só lê. Ela nunca apaga nem move arquivos. public string? Relatorio { get; private set; } + /// Pastas que esta varredura não lê, já conferidas. A lista das Opções não entra aqui. + public List Excluir { get; } = []; + public int Top { get; private set; } = 10; public List Erros { get; } = []; @@ -90,6 +96,20 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) case "--remover-integracao": a.Comando = ComandoCli.RemoverIntegracao; comandos++; + break; + case "--excluir": + if (Valor(args, ref i, arg, a.Erros) is { } texto) + { + if (RegrasExclusao.Validar(texto, out var motivo) is { } regra) + { + a.Excluir.Add(regra); + } + else + { + a.Erros.Add($"--excluir {texto}: {motivo}"); + } + } + break; case "--relatorio": a.Relatorio = Valor(args, ref i, arg, a.Erros); @@ -160,9 +180,9 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) a.Erros.Add($"Comando desconhecido: {alvos[0]}. Use varrer ou --ajuda."); } - if (a.Csv != null || a.Relatorio != null || topDado) + if (a.Csv != null || a.Relatorio != null || topDado || a.Excluir.Count > 0) { - a.Erros.Add("As opções --csv, --relatorio e --top pedem o comando varrer."); + a.Erros.Add("As opções --csv, --relatorio, --top e --excluir pedem o comando varrer."); } } diff --git a/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs b/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs index 967d4ac..b4fe083 100644 --- a/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs +++ b/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs @@ -45,6 +45,7 @@ public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, Text } saida.WriteLine($"Varrendo {argumentos.Caminho}..."); + motor.Exclusoes = RegrasExclusao.De(argumentos.Excluir); var r = motor.Iniciar(argumentos.Caminho!, cancelar).Conclusao.GetAwaiter().GetResult(); var raiz = r.Raiz; if (raiz.Estado is EstadoPasta.SemAcesso or EstadoPasta.ErroLeitura) @@ -72,6 +73,11 @@ public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, Text saida.WriteLine($"Atenção: {Formatador.Plural(raiz.PastasComErro, "pasta com erro de leitura", "pastas com erro de leitura")}. O total não inclui o que está nelas."); } + if (raiz.PastasExcluidas > 0) + { + saida.WriteLine($"Atenção: {Formatador.Plural(raiz.PastasExcluidas, "pasta excluída da varredura", "pastas excluídas da varredura")}, fora do total."); + } + if (!r.Cancelada && r.PastasNaoLidas > 0) { saida.WriteLine($"Atenção: {Formatador.Plural(r.PastasNaoLidas, "pasta não foi lida", "pastas não foram lidas")}. Rode de novo para conferir."); @@ -128,6 +134,7 @@ private static IEnumerable MaioresItens(NoPasta raiz, int quantos) EstadoPasta.SemAcesso => "sem acesso", EstadoPasta.ErroLeitura => "erro", EstadoPasta.Link => "link", + EstadoPasta.Excluida => "excluída", _ => Formatador.Tamanho(p.Tamanho), }, Lida: p.Estado == EstadoPasta.Lida)) .ToList(); diff --git a/src/mapdisk.nucleo/painel/demonstracao.cs b/src/mapdisk.nucleo/painel/demonstracao.cs index eb42a20..2a970c2 100644 --- a/src/mapdisk.nucleo/painel/demonstracao.cs +++ b/src/mapdisk.nucleo/painel/demonstracao.cs @@ -20,11 +20,22 @@ public static class Demonstracao Operacoes = new OperacoesDemonstracao(), Registro = new RegistroEmMemoria(), Leitor = new LeitorDemonstracao(), + Exclusoes = ExclusoesDaDemonstracao(), Demonstracao = true, }); + private static ExclusoesEmMemoria ExclusoesDaDemonstracao() + { + var exclusoes = new ExclusoesEmMemoria(); + exclusoes.Gravar(RegrasExclusao.De([Lixeira])); + return exclusoes; + } + public static IMotorVarredura Motor() => new MotorDemonstracao(); + // A pasta da Lixeira entra excluída, para a tela de exemplo mostrar o estado "excluída". + private const string Lixeira = "$Recycle.Bin"; + internal static NoPasta Montar(string alvo) { var d = new DateTime(2026, 9, 25, 10, 0, 0); @@ -34,13 +45,15 @@ internal static NoPasta Montar(string alvo) var programas = new NoPasta("Program Files", raiz, d); var dados = new NoPasta("Dados", raiz, d); var sistema = new NoPasta("System Volume Information", raiz, d); + var lixeira = new NoPasta(Lixeira, raiz, d); raiz.Preencher( [ new ArquivoInfo("pagefile.sys", 16 * Gb, 16 * Gb, d, MarcaArquivo.Sistema), new ArquivoInfo("hiberfil.sys", 12 * Gb, 12 * Gb, d, MarcaArquivo.Sistema), ], - [users, windows, programas, dados, sistema]); + [users, windows, programas, dados, sistema, lixeira]); sistema.MarcarSemAcesso("acesso negado"); + lixeira.MarcarExcluida(Lixeira); var ana = new NoPasta("ana.souza", users, d); var bruno = new NoPasta("bruno.lima", users, d); diff --git a/src/mapdisk.nucleo/painel/dependencias-painel.cs b/src/mapdisk.nucleo/painel/dependencias-painel.cs index a6ee175..3716932 100644 --- a/src/mapdisk.nucleo/painel/dependencias-painel.cs +++ b/src/mapdisk.nucleo/painel/dependencias-painel.cs @@ -24,6 +24,9 @@ public sealed class DependenciasPainel /// Opções guardadas. O padrão fica só na memória; o Padrao() do painel liga o arquivo. public IArmazemPreferencias Preferencias { get; init; } = new PreferenciasEmMemoria(); + /// Pastas excluídas da varredura. O padrão fica só na memória; o Padrao() do painel liga o arquivo. + public IArmazemExclusoes Exclusoes { get; init; } = new ExclusoesEmMemoria(); + public IRegistroAcoes Registro { get; init; } = new RegistroEmMemoria(); public LocaisProtegidos Locais { get; init; } = LocaisProtegidos.DoSistema(); diff --git a/src/mapdisk.nucleo/painel/linha-arvore.cs b/src/mapdisk.nucleo/painel/linha-arvore.cs index 885754e..3837177 100644 --- a/src/mapdisk.nucleo/painel/linha-arvore.cs +++ b/src/mapdisk.nucleo/painel/linha-arvore.cs @@ -86,7 +86,7 @@ internal LinhaArvore(TipoLinha tipo, NoPasta pasta, ArquivoInfo arquivo = defaul /// A pasta ainda não lida aparece durante a varredura e depois de uma varredura interrompida. /// public bool SemValor => Tipo == TipoLinha.Pasta - && Pasta.Estado is EstadoPasta.Pendente or EstadoPasta.SemAcesso or EstadoPasta.ErroLeitura or EstadoPasta.Link; + && Pasta.Estado is EstadoPasta.Pendente or EstadoPasta.SemAcesso or EstadoPasta.ErroLeitura or EstadoPasta.Link or EstadoPasta.Excluida; public string Rotulo => Tipo switch { @@ -95,6 +95,7 @@ internal LinhaArvore(TipoLinha tipo, NoPasta pasta, ArquivoInfo arquivo = defaul { EstadoPasta.ErroLeitura => $"erro de leitura: {Pasta.Motivo}", EstadoPasta.Link => Pasta.DestinoLink is { } destino ? $"link para {destino}" : "link", + EstadoPasta.Excluida => $"excluída da varredura (regra: {Pasta.Motivo})", EstadoPasta.Lida when Pasta.PastasSemAcesso + Pasta.PastasComErro is var n and > 0 => $"{Formatador.Plural(n, "pasta", "pastas")} sem leitura dentro", _ => string.Empty, @@ -169,6 +170,7 @@ internal void Atualizar(ModoExibicao modo, UnidadeExibicao unidade, long valorDo EstadoPasta.Pendente => "não lida", EstadoPasta.SemAcesso => "sem acesso", EstadoPasta.Link => "link", + EstadoPasta.Excluida => "excluída", _ => "erro", }; Fracao = 0; diff --git a/src/mapdisk.nucleo/painel/painel-analises.cs b/src/mapdisk.nucleo/painel/painel-analises.cs index bdf8e29..ffbfb21 100644 --- a/src/mapdisk.nucleo/painel/painel-analises.cs +++ b/src/mapdisk.nucleo/painel/painel-analises.cs @@ -98,9 +98,18 @@ public async Task CalcularAsync(NoPasta pasta, DateTime hoje) Grafico.Carregar(pasta); Titulo = $"Análise de {pasta.CaminhoCompleto()}"; var semLeitura = pasta.PastasSemAcesso + pasta.PastasComErro; - Aviso = semLeitura > 0 - ? $"{Formatador.Plural(semLeitura, "pasta sem leitura não entra", "pastas sem leitura não entram")} nesta conta" - : string.Empty; + var avisos = new List(); + if (semLeitura > 0) + { + avisos.Add($"{Formatador.Plural(semLeitura, "pasta sem leitura não entra", "pastas sem leitura não entram")} nesta conta"); + } + + if (pasta.PastasExcluidas > 0) + { + avisos.Add($"{Formatador.Plural(pasta.PastasExcluidas, "pasta excluída da varredura não entra", "pastas excluídas da varredura não entram")} nesta conta"); + } + + Aviso = string.Join(". ", avisos); Maiores = r.Maiores.Select(Linha).ToList(); AplicarAntigos(r.Antigos, idade); var total = pasta.Tamanho; diff --git a/src/mapdisk.nucleo/painel/painel-grafico.cs b/src/mapdisk.nucleo/painel/painel-grafico.cs index 7201776..31ceac4 100644 --- a/src/mapdisk.nucleo/painel/painel-grafico.cs +++ b/src/mapdisk.nucleo/painel/painel-grafico.cs @@ -44,6 +44,8 @@ public sealed class PainelGrafico : INotifyPropertyChanged public string TextoNaoLidas { get; private set; } = string.Empty; + public string TextoExcluidas { get; private set; } = string.Empty; + public bool MostrarBlocos => Tipo == TipoGrafico.Blocos; public bool MostrarPizza => Tipo == TipoGrafico.Pizza; @@ -84,11 +86,12 @@ private void Montar() { Blocos = []; Fatias = []; - TextoNaoLidas = string.Empty; + TextoNaoLidas = TextoExcluidas = string.Empty; if (_pasta is { } pasta) { var c = ItensGrafico.DaPasta(pasta, Modo, Tipo == TipoGrafico.Blocos ? MaximoBlocos : MaximoFatias); TextoNaoLidas = c.NaoLidas.Count == 0 ? string.Empty : $"Sem leitura, fora do gráfico: {string.Join(", ", c.NaoLidas)}"; + TextoExcluidas = c.Excluidas.Count == 0 ? string.Empty : $"Excluídas da varredura, fora do gráfico: {string.Join(", ", c.Excluidas)}"; var cores = Paleta.CoresDe(c.Itens); if (Tipo == TipoGrafico.Blocos) { diff --git a/src/mapdisk.nucleo/painel/painel-principal.cs b/src/mapdisk.nucleo/painel/painel-principal.cs index a1014f0..c6f17e0 100644 --- a/src/mapdisk.nucleo/painel/painel-principal.cs +++ b/src/mapdisk.nucleo/painel/painel-principal.cs @@ -20,6 +20,7 @@ public sealed class PainelPrincipal : INotifyPropertyChanged private readonly IHistoricoAlvos _historico; private readonly Func _elevar; private readonly IArmazemPreferencias _preferencias; + private readonly IArmazemExclusoes _exclusoes; private CancellationTokenSource? _cancelar; private Varredura? _varredura; private InfoVolume? _volume; @@ -39,6 +40,9 @@ public PainelPrincipal(DependenciasPainel dependencias) Analises = new PainelAnalises(dependencias.Leitor); _preferencias = dependencias.Preferencias; Aplicar(_preferencias.Ler()); + _exclusoes = dependencias.Exclusoes; + Exclusoes = _exclusoes.Ler(); + _motor.Exclusoes = Exclusoes; Unidades = _listarUnidades(); TextoAlvo = Unidades.FirstOrDefault()?.Raiz ?? string.Empty; MontarOpcoes(); @@ -61,8 +65,12 @@ public PainelPrincipal(IMotorVarredura motor, Func> li Operacoes = new OperacoesArquivo(), Registro = RegistroAcoes.Padrao(), Preferencias = ArquivoPreferencias.Padrao(), + Exclusoes = ArquivoExclusoes.Padrao(), }); + /// Pastas que a próxima varredura não lê. + public RegrasExclusao Exclusoes { get; private set; } = RegrasExclusao.Nenhuma; + public Preferencias Preferencias { get; private set; } = new(); /// Volume da última varredura, para o relatório do técnico. @@ -436,6 +444,11 @@ private void AtualizarTextos() partes.Add(Formatador.Plural(raiz.PastasComErro, "pasta com erro de leitura", "pastas com erro de leitura")); } + if (raiz.PastasExcluidas > 0) + { + partes.Add(Formatador.Plural(raiz.PastasExcluidas, "pasta excluída", "pastas excluídas")); + } + TextoSemLeitura = string.Join(" | ", partes); } @@ -451,6 +464,15 @@ public void GravarPreferencias(Preferencias preferencias) Aplicar(preferencias); } + /// Grava a lista e entrega ao motor. Vale na próxima varredura. + public void GravarExclusoes(RegrasExclusao regras) + { + _exclusoes.Gravar(regras); + Exclusoes = regras; + _motor.Exclusoes = regras; + Avisar(); + } + public void EsquecerAlvo(string alvo) { _historico.Gravar(UltimosAlvos.Esquecer(_historico.Ler(), alvo)); diff --git a/src/mapdisk.nucleo/relatorios/exportador-csv.cs b/src/mapdisk.nucleo/relatorios/exportador-csv.cs index bd47992..7510c74 100644 --- a/src/mapdisk.nucleo/relatorios/exportador-csv.cs +++ b/src/mapdisk.nucleo/relatorios/exportador-csv.cs @@ -48,6 +48,7 @@ private static string Linha(NoPasta no, string caminho) EstadoPasta.SemAcesso => "sem acesso", EstadoPasta.ErroLeitura => "erro de leitura", EstadoPasta.Link => "link", + EstadoPasta.Excluida => "excluída", _ => "não lida", }; diff --git a/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs b/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs index 2302d91..256a446 100644 --- a/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs +++ b/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs @@ -94,6 +94,11 @@ private static void Resumo(StringBuilder s, Avaliacao a) s.Append($" {H(Formatador.Plural(semLeitura, "pasta não pôde ser lida e não entra", "pastas não puderam ser lidas e não entram"))} nesta conta."); } + if (p.PastasExcluidas > 0) + { + s.Append($" {H(Formatador.Plural(p.PastasExcluidas, "pasta foi excluída da varredura pelo técnico e não entra", "pastas foram excluídas da varredura pelo técnico e não entram"))} nesta conta."); + } + s.Append("

Maiores pastas

    "); foreach (var sub in p.Subpastas.Where(x => x.Estado == EstadoPasta.Lida).OrderByDescending(x => x.Tamanho).Take(5)) { diff --git a/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs b/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs index ee9b08e..5334999 100644 --- a/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs +++ b/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs @@ -90,6 +90,11 @@ private static void Resumo(StringBuilder s, DadosRelatorio d) s.Append($"

    {H(Formatador.Plural(semLeitura, "pasta não pôde ser lida e não entra nesta conta", "pastas não puderam ser lidas e não entram nesta conta"))}. "); s.Append("O botão \"Varrer como administrador\" lê as pastas locais que ficaram sem acesso.

    "); } + + if (p.PastasExcluidas > 0) + { + s.Append($"

    {H(Formatador.Plural(p.PastasExcluidas, "pasta excluída da varredura não entra nesta conta", "pastas excluídas da varredura não entram nesta conta"))}.

    "); + } } private static void Grafico(StringBuilder s, NoPasta p) @@ -132,7 +137,7 @@ private static void MaioresPastas(StringBuilder s, NoPasta p, int quantos) { s.Append("

    Maiores pastas

    "); var lidas = p.Subpastas.Where(x => x.Estado == EstadoPasta.Lida).OrderByDescending(x => x.Tamanho).Take(quantos); - var semLeitura = p.Subpastas.Where(x => x.Estado is EstadoPasta.SemAcesso or EstadoPasta.ErroLeitura or EstadoPasta.Pendente); + var semLeitura = p.Subpastas.Where(x => x.Estado is EstadoPasta.SemAcesso or EstadoPasta.ErroLeitura or EstadoPasta.Pendente or EstadoPasta.Excluida); foreach (var x in lidas) { s.Append($""); @@ -146,9 +151,10 @@ private static void MaioresPastas(StringBuilder s, NoPasta p, int quantos) { EstadoPasta.SemAcesso => "sem acesso", EstadoPasta.ErroLeitura => "erro de leitura", + EstadoPasta.Excluida => $"excluída da varredura (regra: {x.Motivo})", _ => "não lida", }; - s.Append($""); + s.Append($""); } s.Append("
    PastaTamanho% da pastaArquivosÚltima alteração
    {H(x.Nome)}{H(Formatador.Tamanho(x.Tamanho))}
    {H(x.Nome)}{estado}
    {H(x.Nome)}{H(estado)}
    "); diff --git a/src/mapdisk.nucleo/varredura/motor-varredura.cs b/src/mapdisk.nucleo/varredura/motor-varredura.cs index daf1c40..1abface 100644 --- a/src/mapdisk.nucleo/varredura/motor-varredura.cs +++ b/src/mapdisk.nucleo/varredura/motor-varredura.cs @@ -51,6 +51,13 @@ public interface IMotorVarredura /// Começa a varrer o alvo, já normalizado, e devolve na hora, com a raiz para a tela. Varredura Iniciar(string alvo, CancellationToken cancelar); + /// Pastas que a próxima varredura não lê. O padrão não exclui nada. + RegrasExclusao Exclusoes + { + get => RegrasExclusao.Nenhuma; + set { } + } + /// Lê de novo só esta pasta. O padrão não relê nada e devolve a mesma pasta. Varredura Reler(NoPasta pasta, CancellationToken cancelar) => new Varredura(pasta).Comecar(_ => Task.FromResult(new ResultadoVarredura @@ -68,6 +75,9 @@ Varredura Reler(NoPasta pasta, CancellationToken cancelar) => /// public sealed class MotorVarredura(int? tarefas = null) : IMotorVarredura { + /// Lidas no começo de cada varredura: mudar no meio vale só para a próxima. + public RegrasExclusao Exclusoes { get; set; } = RegrasExclusao.Nenhuma; + public Varredura Iniciar(string alvo, CancellationToken cancelar) => Comecar(new NoPasta(alvo, null), alvo, cancelar); /// @@ -96,10 +106,11 @@ public Varredura Reler(NoPasta pasta, CancellationToken cancelar) private Varredura Comecar(NoPasta raiz, string caminho, CancellationToken cancelar) { var quantas = tarefas ?? (Alvo.EhRede(caminho) ? 4 : Math.Clamp(Environment.ProcessorCount, 4, 16)); - return new Varredura(raiz).Comecar(v => Task.Run(() => Executar(v, caminho, quantas, cancelar), CancellationToken.None)); + var regras = Exclusoes; + return new Varredura(raiz).Comecar(v => Task.Run(() => Executar(v, caminho, quantas, regras, cancelar), CancellationToken.None)); } - private static ResultadoVarredura Executar(Varredura varredura, string caminhoDaRaiz, int tarefas, CancellationToken cancelar) + private static ResultadoVarredura Executar(Varredura varredura, string caminhoDaRaiz, int tarefas, RegrasExclusao regras, CancellationToken cancelar) { var relogio = Stopwatch.StartNew(); var raiz = varredura.Raiz; @@ -129,7 +140,7 @@ private static ResultadoVarredura Executar(Varredura varredura, string caminhoDa { foreach (var (no, caminho) in fila.GetConsumingEnumerable(cancelar)) { - LerPasta(varredura, no, caminho, alvoERaizDoVolume && no == raiz, primeiraVez, nomes, arquivos, entradas); + LerPasta(varredura, no, caminho, alvoERaizDoVolume && no == raiz, primeiraVez, nomes, regras, arquivos, entradas); foreach (var sub in no.Subpastas) { if (sub.Estado == EstadoPasta.Pendente) @@ -171,6 +182,7 @@ private static void LerPasta( bool raizDoVolume, Func primeiraVez, ConjuntoNomes nomes, + RegrasExclusao regras, List arquivos, List entradas) { @@ -204,6 +216,10 @@ private static void LerPasta( { sub.MarcarLink(DestinoDoLink(Alvo.Juntar(caminho, entrada.Nome))); } + else if (regras.Motivo(Alvo.Juntar(caminho, entrada.Nome), entrada.Nome) is { } regra) + { + sub.MarcarExcluida(regra); + } subpastas[i] = sub; } diff --git a/src/mapdisk.nucleo/varredura/regras-exclusao.cs b/src/mapdisk.nucleo/varredura/regras-exclusao.cs new file mode 100644 index 0000000..0be964d --- /dev/null +++ b/src/mapdisk.nucleo/varredura/regras-exclusao.cs @@ -0,0 +1,146 @@ +namespace MapDisk.Nucleo; + +/// +/// Pastas que a varredura não lê. Nome (sem barra nem dois-pontos) vale em qualquer nível; +/// caminho completo vale só para aquela pasta. Sem curinga: o técnico precisa saber +/// exatamente o que ficou fora da conta (regra 3). +/// +public sealed class RegrasExclusao +{ + public const int Maximo = 100; + + public static readonly RegrasExclusao Nenhuma = new([]); + + private readonly HashSet _nomes; + private readonly HashSet _caminhos; + + private RegrasExclusao(IReadOnlyList regras) + { + Regras = regras; + _nomes = new HashSet(regras.Where(r => !EhCaminho(r)), StringComparer.OrdinalIgnoreCase); + _caminhos = new HashSet(regras.Where(EhCaminho), StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList Regras { get; } + + public static RegrasExclusao De(IEnumerable textos) + { + var regras = new List(); + foreach (var texto in textos) + { + if (Validar(texto, out _) is { } regra + && !regras.Contains(regra, StringComparer.OrdinalIgnoreCase) + && regras.Count < Maximo) + { + regras.Add(regra); + } + } + + return new RegrasExclusao(regras); + } + + /// A regra que exclui esta pasta, ou null. + public string? Motivo(string caminhoDaPasta, string nome) + { + if (_nomes.Count > 0 && _nomes.TryGetValue(nome, out var porNome)) + { + return porNome; + } + + return _caminhos.Count > 0 && _caminhos.TryGetValue(caminhoDaPasta.TrimEnd('\\'), out var porCaminho) ? porCaminho : null; + } + + /// A regra limpa, pronta para guardar, ou null com o motivo. + public static string? Validar(string texto, out string? erro) + { + erro = null; + var t = texto.Trim().Trim('"').Trim(); + if (t.Length == 0) + { + erro = "Escreva o nome da pasta ou o caminho completo."; + return null; + } + + if (t.IndexOfAny(['*', '?']) >= 0) + { + erro = "Use o nome da pasta ou o caminho completo, sem * nem ?"; + return null; + } + + if (!t.Contains('\\') && !t.Contains('/') && !t.Contains(':')) + { + if (t.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + erro = "O nome tem um caractere que o Windows não aceita em pasta."; + return null; + } + + return t; + } + + var caminho = Alvo.Normalizar(t, out _); + if (caminho is null || !(caminho.Length >= 3 && caminho[1] == ':' || caminho.StartsWith(@"\\", StringComparison.Ordinal))) + { + erro = "Use só o nome da pasta, como node_modules, ou o caminho completo, como D:\\Backup."; + return null; + } + + // Alvo.Normalizar devolve a raiz da unidade e a do compartilhamento terminando em barra. + if (caminho.EndsWith('\\')) + { + erro = "A raiz da unidade não pode ser excluída. Escolha outra unidade para varrer."; + return null; + } + + return caminho.TrimEnd('\\'); + } + + private static bool EhCaminho(string regra) => regra.Contains('\\'); +} + +public interface IArmazemExclusoes +{ + RegrasExclusao Ler(); + + void Gravar(RegrasExclusao regras); +} + +/// Uma regra por linha, só nesta máquina. Falha de leitura ou de gravação deixa a lista vazia. +public sealed class ArquivoExclusoes(string arquivo) : IArmazemExclusoes +{ + public static ArquivoExclusoes Padrao() => new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MapDisk", "excluidas.txt")); + + public RegrasExclusao Ler() + { + try + { + return File.Exists(arquivo) ? RegrasExclusao.De(File.ReadAllLines(arquivo)) : RegrasExclusao.Nenhuma; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return RegrasExclusao.Nenhuma; + } + } + + public void Gravar(RegrasExclusao regras) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(arquivo)!); + File.WriteAllLines(arquivo, regras.Regras); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } + } +} + +public sealed class ExclusoesEmMemoria : IArmazemExclusoes +{ + private RegrasExclusao _atual = RegrasExclusao.Nenhuma; + + public RegrasExclusao Ler() => _atual; + + public void Gravar(RegrasExclusao regras) => _atual = regras; +} diff --git a/src/mapdisk/janela-opcoes.xaml b/src/mapdisk/janela-opcoes.xaml index 86a4f38..b30ecff 100644 --- a/src/mapdisk/janela-opcoes.xaml +++ b/src/mapdisk/janela-opcoes.xaml @@ -53,6 +53,29 @@ + + + + + + + + + + + +