diff --git a/README.md b/README.md index def7074..7d3e731 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su Baixe o `mapdisk.exe` e abra. Não precisa instalar nem ser administrador. Escolha a unidade, digite uma pasta ou um caminho de rede (`\\servidor\pasta`) e clique em **Varrer**. -> Uso autorizado. O MapDisk - MT mostra o espaço ocupado em discos e pastas e permite enviar arquivos para a Lixeira, movê-los ou, em pastas de rede, excluí-los definitivamente. Use o programa só em computadores e pastas que você tem autorização para administrar. Antes de apagar ou mover, confira a lista de itens e o destino. Em pastas de rede não existe Lixeira: a exclusão não pode ser desfeita pelo programa. O programa não envia nenhuma informação para fora do computador. +> Uso autorizado. O MapDisk - MT mostra o espaço ocupado em discos e pastas e permite enviar arquivos para a Lixeira, movê-los ou excluí-los definitivamente. Use o programa só em computadores e pastas que você tem autorização para administrar. Antes de apagar ou mover, confira a lista de itens e o destino. Em pastas de rede e em unidades removíveis ou mapeadas, o MapDisk não usa a Lixeira: a exclusão não pode ser desfeita pelo programa e os itens só voltam por uma cópia de segurança. O programa não envia nenhuma informação para fora do computador. + +> Licença e garantias. O MapDisk - MT é distribuído gratuitamente sob a GPL-3.0. Os tamanhos mostrados dependem do que o sistema de arquivos informa e das permissões da conta que roda o programa, e podem ficar incompletos quando alguma pasta não pode ser lida. A licença não inclui promessa de funcionamento em todo computador nem serviço de suporte técnico. Quem apaga ou move arquivos pelo programa decide o que tratar e deve ter cópia de segurança do que for importante. As disposições da GPL-3.0 sobre garantias e responsabilidade aplicam-se nos limites permitidos pela legislação brasileira e não restringem direitos assegurados ao consumidor por lei. Pastas que o Windows não deixa ler aparecem como **sem acesso**, e a barra de baixo diz quantas são. O total não inclui o que está nelas. @@ -26,7 +28,7 @@ Na árvore: À direita da árvore, o painel **Análises** responde "o que eu trato?" sobre a pasta selecionada: - **Gráfico:** o que pesa dentro da pasta, em blocos (área proporcional ao tamanho) ou em pizza (as 10 maiores, com legenda). Segue o "Mostrar" da árvore. Clique duplo num bloco ou numa fatia abre a pasta, e Voltar retorna. Pasta sem leitura fica fora do desenho e aparece pelo nome embaixo dele. -- **Maiores arquivos:** os 100 maiores. +- **Maiores arquivos:** os 100 maiores, ou a quantidade escolhida em **Opções**. - **Arquivos antigos:** sem alteração há mais de 6 meses, 1, 2 ou 5 anos, com a quantidade e o total. - **Por tipo:** vídeo, imagem, áudio, e-mail (.pst, .ost), imagem de disco, compactado e backup, instalador, documento e outros, mais o ranking por extensão. - **Por usuário:** o ranking das pastas de perfil em `Users`. @@ -42,17 +44,35 @@ Para tratar o que ocupa espaço, selecione pastas ou arquivos na árvore ou nas - **Excluir definitivamente**, em pasta de rede, unidade mapeada, pendrive e disco removível, onde o programa não usa a Lixeira. A confirmação pede que você digite EXCLUIR. - **Mover**, para a pasta que você escolher. Entre unidades, a origem só é apagada depois de a cópia ser conferida. -A confirmação mostra a lista, o total e o destino. O programa não deixa agir na raiz da unidade, nas pastas do Windows, em Program Files, em ProgramData, em arquivos do sistema e em pastas sem leitura, e diz o motivo. Cada item fica no registro de ações, em `%LOCALAPPDATA%\MapDisk\acoes.log`, que o botão **Registro de ações** mostra no Explorer. A barra de baixo soma o que a sessão enviou para a Lixeira, moveu e excluiu. No modo `--demonstracao`, as ações seguem o fluxo na tela, mas nada é apagado nem movido. +A confirmação mostra a lista, o total e o destino. O programa não deixa agir na raiz da unidade, nas pastas do Windows, em Program Files, em ProgramData, em arquivos do sistema e em pastas sem leitura, e diz o motivo. Cada item fica no registro de ações, em `%LOCALAPPDATA%\MapDisk\acoes.log`, que **Opções** > **Registro de ações** mostra no Explorer. A barra de baixo soma o que a sessão enviou para a Lixeira, moveu e excluiu. No modo `--demonstracao`, as ações seguem o fluxo na tela, mas nada é apagado nem movido. Quando os arquivos são do cliente, ele decide o que sai. O botão **Relatório do cliente**, no cartão Pastas, tem duas opções: - **Gerar relatório:** monte a lista com **Sugerir** (maiores pastas, maiores arquivos, antigos e tipos que costumam sobrar, com os critérios que você ajusta), com **Acrescentar a seleção** e com **Tirar da lista**. Escreva o nome do cliente e clique em **Gerar**. Saem dois arquivos: uma página HTML, que o cliente abre no navegador, marca e salva a resposta (ou imprime em PDF), e uma planilha Excel com a coluna Decisão. Para cada item, ele escolhe Apagar, Mover, Manter ou Conversar. Itens bloqueados pelas ações, como pastas do sistema, não entram. Se você já procurou duplicados, o **Sugerir** também traz as cópias repetidas, deixando a mais antiga. - **Ler resposta:** depois de varrer a pasta de novo, abra a planilha devolvida ou o arquivo salvo pela página. O MapDisk mostra o que o cliente decidiu e a situação de cada item. Item que mudou depois do relatório ou não existe mais fica de fora. Se a resposta veio por e-mail, use **Marcar pelos números** (por exemplo, `1, 3, 7-9`). Apagar e mover seguem a confirmação de sempre. Guarde a resposta com a ordem de serviço: ela é a autorização do cliente. -Linha de comando (só lê, nunca apaga nem move). O alvo é um caminho completo: +O botão **Exportar**, no cartão Pastas, grava a pasta das análises (a selecionada ou a raiz mostrada) em dois formatos: + +- **Relatório HTML:** um arquivo só, com o logo da MT, o resumo, o gráfico em blocos, as maiores pastas e arquivos, os tipos, os arquivos antigos, os perfis de usuário e, se você já procurou, os duplicados. Pasta sem leitura aparece como "sem acesso", nunca como zero. Para ter em PDF, use o "Imprimir" do navegador. +- **Planilha CSV:** todas as pastas, para abrir no Excel. + +O botão **Opções**, ao lado de Sobre, guarda o que vale entre sessões, em `%LOCALAPPDATA%\MapDisk\opcoes.txt`: + +- **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. +- **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. + +Linha de comando (só lê, nunca apaga nem move arquivo). O alvo é um caminho completo: mapdisk varrer C: + mapdisk varrer D:\ --relatorio d.html mapdisk varrer \\servidor\dados --csv dados.csv --top 30 + 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. No Prompt de Comando, use `start /wait mapdisk varrer C:` para o prompt esperar o fim. No PowerShell, termine a linha com `| Out-Host`. @@ -67,4 +87,4 @@ No Prompt de Comando, use `start /wait mapdisk varrer C:` para o prompt esperar | 5 | Ações seguras: Lixeira, mover, exclusão onde não há Lixeira, proteção, registro de ações, "Nesta sessão" | `acoes` | Plano em [#9](https://github.com/manfredjr/mapdisk/pull/9), código em [#10](https://github.com/manfredjr/mapdisk/pull/10) | Concluída em 30/09/2026 | | 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) | Plano em revisão | +| 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 | diff --git a/docs/legal/verificacao-distribuicao-e-lgpd-2026-09-28.md b/docs/legal/verificacao-distribuicao-e-lgpd-2026-09-28.md index 4774035..2d414da 100644 --- a/docs/legal/verificacao-distribuicao-e-lgpd-2026-09-28.md +++ b/docs/legal/verificacao-distribuicao-e-lgpd-2026-09-28.md @@ -110,12 +110,12 @@ Os textos seguem o modelo do parecer do MapNet, adaptados ao risco do MapDisk. P ## 6. Requisitos para implementação -- [ ] Textos "Uso autorizado" e "Licença e garantias" na página e no README, perto do download (fatia 7). +- [x] Textos "Uso autorizado" e "Licença e garantias" na página e no README, perto do download (fatia 8, com o ajuste da seção 11). - [x] Confirmação de cada ação com a lista, o total e o destino, e o aviso de risco da seção 5 (fatia 5). - [x] Exclusão em rede com a palavra EXCLUIR digitada e o aviso de que não há Lixeira (fatia 5). Vale também para unidade removível e mapeada, com o texto da seção 9. - [x] Registro de ações só com data e hora, usuário do Windows, ação, origem, destino, tamanho e resultado. Sem conteúdo de arquivo (fatia 5). -- [x] Opções mostra onde fica o registro e abre a pasta dele (fatia 5). Por enquanto, pelo botão "Registro de ações" no cartão das pastas; vai para a tela de Opções na fatia 7. -- [ ] Nada sai da máquina: nenhum envio de rede no programa, conferido por teste (todas as fatias). +- [x] Opções mostra onde fica o registro e abre a pasta dele (fatia 5, e na tela de Opções desde a fatia 8). +- [x] Nada sai da máquina: nenhum envio de rede no programa, conferido pelo teste `SemRedeTestes` (fatia 8). - [ ] Fora do programa, no atendimento da MT: autorização do cliente para as exclusões registrada na ordem de serviço, com cópia do `acoes.log` anexada. Fica em `docs/superpowers/pendencias.md` como decisão de processo do Manfred. ## 7. Pendências de validação @@ -162,3 +162,52 @@ Autorização, na página e na planilha: **RECOMENDAÇÃO de processo, fora do programa.** O relatório tem nomes de pastas e de arquivos do cliente. No atendimento, enviar só ao contato do cliente que pediu o serviço e guardar a resposta junto da ordem de serviço. Não há pendência de validação. + +## 11. Página do programa (acréscimo de 01/10/2026) + +**Contexto.** A fatia 8 cria a página `public/index.html`, para `mapdisk.manfred.com.br`, com o download, a lista do que o programa faz, os textos "Uso autorizado" e "Licença e garantias" da seção 5 e uma nota de privacidade. A página fica num serviço de hospedagem, atrás do Cloudflare. A publicação é um passo separado, com autorização do Manfred. + +**Premissas.** Valem as da seção 3. Vale também a premissa de que a distribuição gratuita pode ser relação de consumo, adotada por cautela. Na página, a MT é quem decide sobre os registros de acesso que a hospedagem e o Cloudflare guardam, como o endereço IP. Nessa condição, ela é controladora desses dados, que são dados pessoais (LGPD, art. 5º, I e VI, verificados na seção 3). + +**FATO LEGAL.** CDC, art. 30, verificado no Planalto em 01/10/2026 (`https://www.planalto.gov.br/ccivil_03/leis/l8078compilado.htm`): + +> "Toda informação ou publicidade, suficientemente precisa, veiculada por qualquer forma ou meio de comunicação com relação a produtos e serviços oferecidos ou apresentados, obriga o fornecedor que a fizer veicular ou dela se utilizar e integra o contrato que vier a ser celebrado." + +**Incidência:** a página apresenta o programa ao público, perto do download, e cada frase precisa sobre o que ele faz passa a valer como oferta. O art. 37, § 1º, verificado na mesma fonte e na mesma data, diz que "É enganosa qualquer modalidade de informação ou comunicação de caráter publicitário, inteira ou parcialmente falsa, ou, por qualquer outro modo, mesmo por omissão, capaz de induzir em erro o consumidor [...]". + +**FATO LEGAL.** LGPD, art. 6º, VI, na biblioteca local (`fontes-md/leis/03-lgpd-lei-13709-2018-compilado.md`), em dia no relatório de 23/09/2026: "transparência: garantia, aos titulares, de informações claras, precisas e facilmente acessíveis sobre a realização do tratamento e os respectivos agentes de tratamento [...]". + +**INTERPRETAÇÃO, ponto 1: "Uso autorizado".** O texto aprovado fala de exclusão definitiva só em pastas de rede. Desde a seção 9, a exclusão definitiva vale também em unidade removível e em unidade mapeada. Uma página que cita só a rede omite um caso de risco, e o art. 37, § 1º, alcança a omissão capaz de induzir em erro. O texto passa a citar as duas situações, com a mesma frase da seção 9 ("o MapDisk não usa a Lixeira"). + +**INTERPRETAÇÃO, ponto 2: frases sobre segurança.** As frases sobre segurança e funcionamento vinculam a MT (art. 30). Por isso, cada uma tem que corresponder ao que o programa faz e ao que os testes conferem: + +| Frase | Situação | +|---|---| +| "Feito para não dar susto" | Promessa vaga de resultado. Sai. O título passa a ser "Segurança" | +| "O programa não envia nenhuma informação pela internet" | Já aprovada na seção 5. Fica. A seção 6 pedia um teste que confira isso. A fatia 8 cria esse teste | +| "Pasta do sistema e raiz de unidade ficam bloqueadas" | Fica, com o alcance exato: bloqueadas para apagar e mover | +| "Nunca como zero" | Fica: regra 3 do produto, conferida por teste | +| Requisitos (Windows 10 e 11, Windows Server 2016 ou mais novo, 64 bits) | Fica. A página só vai ao ar depois do teste de lançamento do Manfred, que inclui um Windows Server | + +**INTERPRETAÇÃO, ponto 3: privacidade da página.** "Não usa cookies" seria uma afirmação absoluta sobre um serviço de terceiros que a MT não controla por completo: o Cloudflare pode gravar cookie técnico de segurança. O texto passa a dizer o que a página não tem e o que a hospedagem registra, para atender o art. 6º, VI, sem prometer o que depende de terceiros. + +**INTERPRETAÇÃO, ponto 4: GitHub.** O nome do GitHub aparece só para indicar onde está o download e o código, sem o logotipo e sem sugerir parceria. Não precisa de aviso de marca. A marca TreeSize não aparece, e um teste confere isso (seção 4.7). + +**RECOMENDAÇÃO aplicada.** Textos finais da página: + +> Segurança. O programa não envia nenhuma informação pela internet. Abre como usuário comum e só pede administrador pelo botão "Varrer como administrador". Não instala nada: roda de um pendrive ou de uma pasta de rede. Antes de apagar ou mover, mostra a lista, o total e o destino, e espera a sua confirmação. Cada ação fica registrada num arquivo do computador. As pastas do sistema do Windows e a raiz das unidades ficam bloqueadas para apagar e mover. Pasta que não pôde ser lida aparece como "sem acesso", nunca como zero. + +> Uso autorizado. O MapDisk - MT mostra o espaço ocupado em discos e pastas e permite enviar arquivos para a Lixeira, movê-los ou excluí-los definitivamente. Use o programa só em computadores e pastas que você tem autorização para administrar. Antes de apagar ou mover, confira a lista de itens e o destino. Em pastas de rede e em unidades removíveis ou mapeadas, o MapDisk não usa a Lixeira: a exclusão não pode ser desfeita pelo programa e os itens só voltam por uma cópia de segurança. O programa não envia nenhuma informação para fora do computador. + +> Licença e garantias. Texto da seção 5, sem mudança. + +> Privacidade. O programa não coleta nem envia dados. Esta página não tem formulário, propaganda nem ferramenta de estatística. A hospedagem e o Cloudflare, que entrega a página, registram dados técnicos de acesso, como o endereço IP, e podem gravar um cookie técnico de segurança. + +**Requisitos para implementação.** + +- [ ] Página com os quatro textos acima, "Uso autorizado" e "Licença e garantias" perto do botão de download (fatia 8). +- [ ] README com o novo "Uso autorizado" e com "Licença e garantias" perto do download (fatia 8). Fecha o primeiro item da seção 6. +- [ ] Teste que confere que o programa não usa classe de rede (fatia 8). Fecha o item "Nada sai da máquina" da seção 6. +- [ ] Página publicada só depois do teste de lançamento, que inclui um Windows Server. + +Não há pendência de validação. diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md index 7c8fc15..ece68a1 100644 --- a/docs/superpowers/pendencias.md +++ b/docs/superpowers/pendencias.md @@ -38,7 +38,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| -| Esquecer um alvo da lista de últimos alvos | Fora do escopo da fatia 2. A lista guarda até 10 caminhos só nesta máquina, em `%LOCALAPPDATA%\MapDisk\alvos.txt` | Opções da fatia 7, junto com "limpar a lista" | +| Esquecer um alvo da lista de últimos alvos | Fora do escopo da fatia 2. A lista guarda até 10 caminhos só nesta máquina, em `%LOCALAPPDATA%\MapDisk\alvos.txt` | Fechado: entrou na fatia 8, PR #17 (Opções > Últimos alvos) | | Hard link depois de "Atualizar esta pasta" | A releitura confere hard link só dentro da pasta relida. Um arquivo com outro nome fora dela passa a somar duas vezes | Aceito. Reavaliar se aparecer caso real | | Leitura como administrador em caminho de rede | O privilégio de backup vale só para disco local. Em `\\servidor\pasta`, quem manda são as permissões do servidor | Aceito, é como o Windows funciona | | Conferência da janela elevada | O aviso de elevação do Windows é confirmado pelo Manfred, não pelo agente | Teste do Manfred no PR da fatia 2 | @@ -47,7 +47,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| -| Quantidade de maiores arquivos configurável (R11) | A fatia 3 usa 100, o padrão da spec | Tela de Opções da fatia 8 (era a 7 antes da mudança de ordem de 30/09/2026) | +| Quantidade de maiores arquivos configurável (R11) | A fatia 3 usa 100, o padrão da spec | Fechado: entrou na fatia 8, PR #17 | | Pasta de perfis fora do padrão | O resumo por usuário procura a pasta `Users`. Perfis redirecionados para outra pasta ou compartilhamento de rede não entram | Aceito. Reavaliar se aparecer caso real | | Idade pela data de último acesso | "Antigo" usa a última modificação. O último acesso muitas vezes vem desligado no Windows e não é confiável | Aceito | @@ -55,7 +55,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| -| Imagem do gráfico no relatório | O R10 pede o gráfico na tela. Levar a imagem para o relatório é decisão do relatório | Relatório HTML da fatia 8 (era a 7 antes da mudança de ordem de 30/09/2026) | +| Imagem do gráfico no relatório | O R10 pede o gráfico na tela. Levar a imagem para o relatório é decisão do relatório | Fechado: entrou na fatia 8, PR #17 (gráfico em blocos no relatório do técnico) | | Gráfico com mais de um nível | O gráfico mostra um nível abaixo da pasta, que se lê de relance. Para descer, clique duplo | Aceito. Reavaliar se o uso pedir | | Clique duplo no gráfico conferido pelo agente | O Windows não deixou o roteiro de teste trazer a janela para a frente e clicar | Fechado: o Manfred testou no PR #8 em 30/09/2026 | @@ -65,7 +65,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. |---|---|---| | Registro de ações sem limite de tamanho | Uma linha por item, antes e depois. Em uso normal cresce devagar | Aceito. Reavaliar se aparecer registro grande | | Hard link apagado que deixa a outra cópia sem somar | Se o item apagado era a cópia que somava, a outra continua marcada como repetida até a próxima varredura | Aceito. Atualizar a pasta resolve | -| Botão "Registro de ações" no cartão das pastas | A tela de Opções ainda não existe | Tela de Opções da fatia 8 (era a 7 antes da mudança de ordem de 30/09/2026) | +| Botão "Registro de ações" no cartão das pastas | A tela de Opções ainda não existe | Fechado: entrou na fatia 8, PR #17 (Opções > Registro de ações) | | Permissões (ACL) no mover entre unidades | A cópia recebe as permissões da pasta de destino, como no Explorer | Aceito | | Teste da Lixeira real, da exclusão em rede e do mover pela janela | A Lixeira leva o arquivo para fora da pasta do projeto e o programa real grava o registro em `%LOCALAPPDATA%`. O agente testou pela janela só em demonstração | Fechado: o Manfred testou no PR #10 em 30/09/2026 | @@ -74,7 +74,7 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| | Duplicados como critério do "Sugerir" | Os duplicados ainda não existem | Fechado: entrou na fatia 7, PR #15 | -| Critérios do "Sugerir" guardados entre sessões | A tela de Opções ainda não existe. Cada relatório começa com os padrões | Opções da fatia 8 | +| Critérios do "Sugerir" guardados entre sessões | A tela de Opções ainda não existe. Cada relatório começa com os padrões | Fechado: entrou na fatia 8, PR #17 | | Planilha aberta e salva pelo Excel de verdade | Os testes simulam o jeito do Excel e do LibreOffice salvar. O agente não opera o Excel do computador do Manfred | Fechado: o Manfred testou no PR #13 em 30/09/2026 | | Varredura com a pasta do técnico em `C:\Users` | A pasta do registro de ações é protegida, então no computador do próprio técnico a pasta `Users` não entra no relatório | Aceito: no servidor do cliente, `Users` não é a do técnico | @@ -82,7 +82,16 @@ Rascunho feito a partir da seção 4.2 da verificação jurídica de 28/09/2026. | Item | Motivo | O que fecha | |---|---|---| -| Tamanho mínimo dos duplicados guardado entre sessões | A tela de Opções ainda não existe. Cada sessão começa com 1 MB | Opções da fatia 8 | +| Tamanho mínimo dos duplicados guardado entre sessões | A tela de Opções ainda não existe. Cada sessão começa com 1 MB | Fechado: entrou na fatia 8, PR #17 | | Resultado dos duplicados some ao atualizar uma pasta | "Atualizar esta pasta" e a releitura do destino do mover limpam o resultado, porque os caminhos podem ter mudado | Aceito. Procurar de novo | | Busca de duplicados num disco grande de verdade | Os testes usam arquivos pequenos. O tempo e o peso num servidor com muitos arquivos grandes só aparecem no uso | Fechado: teste do Manfred no PR #15, em 01/10/2026 | +## Fatia 8: relatório do técnico, linha de comando, Explorer, Opções e página + +| 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 | +| 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 | Passo da versão 1.0.0, depois do teste de lançamento, com autorização | +| 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 | +| Exportar pela janela | O diálogo de salvar não foi operado pelo agente. O relatório e o CSV foram conferidos pela linha de comando, que usa o mesmo código | Fechado: teste do Manfred no PR #17, em 01/10/2026 | diff --git a/public/imagens/janela.png b/public/imagens/janela.png new file mode 100644 index 0000000..7187f5b Binary files /dev/null and b/public/imagens/janela.png differ diff --git a/public/imagens/mapdisk.png b/public/imagens/mapdisk.png new file mode 100644 index 0000000..9d23838 Binary files /dev/null and b/public/imagens/mapdisk.png differ diff --git a/public/imagens/mt-logo.png b/public/imagens/mt-logo.png new file mode 100644 index 0000000..c5f290f Binary files /dev/null and b/public/imagens/mt-logo.png differ diff --git a/public/imagens/relatorio.png b/public/imagens/relatorio.png new file mode 100644 index 0000000..741b71b Binary files /dev/null and b/public/imagens/relatorio.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..610ccff --- /dev/null +++ b/public/index.html @@ -0,0 +1,129 @@ + + + + + +MapDisk - MT: onde está o espaço ocupado no disco + + + + + +
+
+Símbolo do MapDisk - MT +
+

MapDisk - MT

+

Descubra o que ocupa o disco e libere espaço com segurança.

+

Analisador de espaço em disco para Windows, gratuito e de código aberto. Um arquivo só, sem instalar e sem pedir administrador.

+Baixar para Windows +

Windows 10 e 11 e Windows Server 2016 ou mais novo, 64 bits.

+
+
+
+
+ +
+
+

O que ele faz

+
+
+
    +
  • Varre uma unidade, uma pasta ou um caminho de rede e mostra as pastas em árvore, da maior para a menor.
  • +
  • Mostra num gráfico de blocos ou de pizza o que ocupa cada pasta.
  • +
  • Lista os maiores arquivos, os arquivos antigos e o espaço por tipo de arquivo e por usuário.
  • +
  • Acha arquivos repetidos, comparando o conteúdo, quando você pede.
  • +
  • Envia para a Lixeira ou move o que você escolher, sempre com confirmação.
  • +
  • Gera um relatório para o cliente decidir o que apagar e lê a resposta dele.
  • +
  • Grava o relatório do técnico em HTML, com o gráfico, e a lista das pastas em CSV.
  • +
  • Roda pela linha de comando, para varrer e gerar relatório sem abrir a janela.
  • +
+
+
+Janela do MapDisk com a árvore de pastas e o gráfico em blocos, em modo de demonstração +
A janela em modo de demonstração, com dados fictícios.
+
+
+
+ +
+

Relatório do técnico

+
+
+Relatório de espaço em disco com o resumo e o gráfico em blocos, gerado em modo de demonstração +
O relatório em HTML, também com dados fictícios.
+
+
+

Um arquivo só, com o resumo da pasta, o gráfico, as maiores pastas e arquivos, o espaço por tipo e os arquivos antigos. Abre em qualquer navegador e vira PDF pelo "Imprimir".

+

Pela linha de comando:

+

mapdisk varrer D:\ --relatorio d.html

+
+
+
+ +
+

Segurança

+
+

O programa não envia nenhuma informação pela internet. Abre como usuário comum e só pede administrador pelo botão "Varrer como administrador". Não instala nada: roda de um pendrive ou de uma pasta de rede.

+

Antes de apagar ou mover, mostra a lista, o total e o destino, e espera a sua confirmação. Cada ação fica registrada num arquivo do computador. As pastas do sistema do Windows e a raiz das unidades ficam bloqueadas para apagar e mover. Pasta que não pôde ser lida aparece como "sem acesso", nunca como zero.

+
+
+ +
+

Uso autorizado

+

O MapDisk - MT mostra o espaço ocupado em discos e pastas e permite enviar arquivos para a Lixeira, movê-los ou excluí-los definitivamente. Use o programa só em computadores e pastas que você tem autorização para administrar. Antes de apagar ou mover, confira a lista de itens e o destino. Em pastas de rede e em unidades removíveis ou mapeadas, o MapDisk não usa a Lixeira: a exclusão não pode ser desfeita pelo programa e os itens só voltam por uma cópia de segurança. O programa não envia nenhuma informação para fora do computador.

+ +

Licença e garantias

+

O MapDisk - MT é distribuído gratuitamente sob a GPL-3.0. Os tamanhos mostrados dependem do que o sistema de arquivos informa e das permissões da conta que roda o programa, e podem ficar incompletos quando alguma pasta não pode ser lida. A licença não inclui promessa de funcionamento em todo computador nem serviço de suporte técnico. Quem apaga ou move arquivos pelo programa decide o que tratar e deve ter cópia de segurança do que for importante. As disposições da GPL-3.0 sobre garantias e responsabilidade aplicam-se nos limites permitidos pela legislação brasileira e não restringem direitos assegurados ao consumidor por lei.

+

Código-fonte e versões: github.com/manfredjr/mapdisk.

+ +

Privacidade

+

O programa não coleta nem envia dados. Esta página não tem formulário, propaganda nem ferramenta de estatística. A hospedagem e o Cloudflare, que entrega a página, registram dados técnicos de acesso, como o endereço IP, e podem gravar um cookie técnico de segurança.

+
+
+ + + + diff --git a/src/mapdisk.nucleo/grafico/paleta.cs b/src/mapdisk.nucleo/grafico/paleta.cs index f4c8879..46d4bb3 100644 --- a/src/mapdisk.nucleo/grafico/paleta.cs +++ b/src/mapdisk.nucleo/grafico/paleta.cs @@ -26,4 +26,17 @@ public static class Paleta /// Grafite sobre as cores claras, branco sobre as escuras. public static string CorTexto(string cor) => Claras.Contains(cor) ? "#202020" : "#FFFFFF"; + + /// As subpastas contam a posição entre elas, para a primeira ser sempre o verde da MT. + public static string[] CoresDe(IReadOnlyList itens) + { + var cores = new string[itens.Count]; + var pasta = 0; + for (var i = 0; i < itens.Count; i++) + { + cores[i] = Cor(itens[i].Tipo == TipoItemGrafico.Pasta ? pasta++ : 0, itens[i].Tipo); + } + + return cores; + } } diff --git a/src/mapdisk.nucleo/integracao/integracao-explorer.cs b/src/mapdisk.nucleo/integracao/integracao-explorer.cs new file mode 100644 index 0000000..b06083c --- /dev/null +++ b/src/mapdisk.nucleo/integracao/integracao-explorer.cs @@ -0,0 +1,115 @@ +using Microsoft.Win32; + +namespace MapDisk.Nucleo; + +/// Chaves do registro do usuário atual. Os testes usam a versão em memória. +public interface IChavesUsuario +{ + void Gravar(string chave, string? nome, string valor); + + string? Ler(string chave, string? nome); + + void ApagarArvore(string chave); +} + +/// HKCU de verdade. Não precisa de administrador (regra 4). +public sealed class ChavesUsuarioWindows : IChavesUsuario +{ + public void Gravar(string chave, string? nome, string valor) + { + using var k = Registry.CurrentUser.CreateSubKey(chave, writable: true); + k.SetValue(nome ?? string.Empty, valor, RegistryValueKind.String); + } + + public string? Ler(string chave, string? nome) + { + using var k = Registry.CurrentUser.OpenSubKey(chave); + return k?.GetValue(nome ?? string.Empty) as string; + } + + public void ApagarArvore(string chave) => Registry.CurrentUser.DeleteSubKeyTree(chave, throwOnMissingSubKey: false); +} + +public sealed class ChavesEmMemoria : IChavesUsuario +{ + private readonly Dictionary<(string Chave, string Nome), string> _valores = []; + + public IReadOnlyCollection Todas => _valores.Keys.Select(k => k.Chave).Distinct().ToList(); + + public void Gravar(string chave, string? nome, string valor) => _valores[(chave.ToLowerInvariant(), nome ?? string.Empty)] = valor; + + public string? Ler(string chave, string? nome) => + _valores.TryGetValue((chave.ToLowerInvariant(), nome ?? string.Empty), out var v) ? v : null; + + public void ApagarArvore(string chave) + { + var prefixo = chave.ToLowerInvariant(); + foreach (var k in _valores.Keys.Where(k => k.Chave == prefixo || k.Chave.StartsWith(prefixo + "\\", StringComparison.Ordinal)).ToList()) + { + _valores.Remove(k); + } + } +} + +public enum EstadoIntegracao +{ + Desligada, + Ligada, + + /// Ligada, mas apontando para outro .exe (o programa mudou de lugar). + OutroLocal, +} + +/// Item "Analisar com MapDisk" no menu clássico do Explorer (R19). Sai por completo ao desligar (regra 5). +public static class IntegracaoExplorer +{ + public const string Texto = "Analisar com MapDisk"; + + public static readonly string[] Chaves = + [ + @"Software\Classes\Directory\shell\MapDisk", + @"Software\Classes\Drive\shell\MapDisk", + @"Software\Classes\Directory\Background\shell\MapDisk", + ]; + + public static string ComandoDe(string exe, string marcador) => $"\"{exe}\" {Abertura.Argumento} \"{marcador}\""; + + public static void Ligar(IChavesUsuario chaves, string exe) + { + foreach (var chave in Chaves) + { + var marcador = chave.Contains(@"\Background\", StringComparison.Ordinal) ? "%V" : "%1"; + chaves.Gravar(chave, null, Texto); + chaves.Gravar(chave, "Icon", exe + ",0"); + chaves.Gravar(chave + @"\command", null, ComandoDe(exe, marcador)); + } + } + + public static void Desligar(IChavesUsuario chaves) + { + foreach (var chave in Chaves) + { + chaves.ApagarArvore(chave); + } + } + + /// O .exe que o item chama hoje, ou null quando desligado. + public static string? ExeRegistrado(IChavesUsuario chaves) + { + var comando = chaves.Ler(Chaves[0] + @"\command", null); + if (comando is null || !comando.StartsWith('"')) + { + return null; + } + + var fim = comando.IndexOf('"', 1); + return fim > 1 ? comando[1..fim] : null; + } + + public static EstadoIntegracao Estado(IChavesUsuario chaves, string exe) => ExeRegistrado(chaves) switch + { + null => EstadoIntegracao.Desligada, + var e when string.Equals(e, exe, StringComparison.OrdinalIgnoreCase) => EstadoIntegracao.Ligada, + _ => EstadoIntegracao.OutroLocal, + }; +} diff --git a/src/mapdisk.nucleo/linha-de-comando/argumentos.cs b/src/mapdisk.nucleo/linha-de-comando/argumentos.cs index 1d3094e..762ab02 100644 --- a/src/mapdisk.nucleo/linha-de-comando/argumentos.cs +++ b/src/mapdisk.nucleo/linha-de-comando/argumentos.cs @@ -8,6 +8,8 @@ public enum ComandoCli Ajuda, Versao, Varrer, + Integrar, + RemoverIntegracao, } /// Argumentos da linha de comando já interpretados. A linha de comando só lê. @@ -20,6 +22,8 @@ public sealed class ArgumentosCli Uso: mapdisk abre a janela mapdisk varrer [opções] varre e mostra os maiores itens + mapdisk --integrar põe "Analisar com MapDisk" no menu do Explorer + mapdisk --remover-integracao tira o item do menu do Explorer mapdisk --ajuda mostra esta ajuda mapdisk --versao mostra a versão @@ -27,11 +31,14 @@ mapdisk varrer [opções] varre e mostra os maiores itens (\\servidor\pasta). Opções de varrer: - --csv grava todas as pastas num arquivo CSV - --top quantos itens mostrar, de 1 a 1000 (padrão: 10) + --csv grava todas as pastas num arquivo CSV + --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) Exemplos: mapdisk varrer C: + mapdisk varrer D:\ --relatorio d.html mapdisk varrer \\servidor\dados --csv dados.csv --top 30 A linha de comando só lê. Ela nunca apaga nem move arquivos. @@ -44,6 +51,8 @@ A linha de comando só lê. Ela nunca apaga nem move arquivos. public string? Csv { get; private set; } + public string? Relatorio { get; private set; } + public int Top { get; private set; } = 10; public List Erros { get; } = []; @@ -74,6 +83,17 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) a.Comando = ComandoCli.Varrer; comandos++; break; + case "--integrar": + a.Comando = ComandoCli.Integrar; + comandos++; + break; + case "--remover-integracao": + a.Comando = ComandoCli.RemoverIntegracao; + comandos++; + break; + case "--relatorio": + a.Relatorio = Valor(args, ref i, arg, a.Erros); + break; case "--csv": a.Csv = Valor(args, ref i, arg, a.Erros); break; @@ -101,7 +121,7 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) if (comandos > 1) { - a.Erros.Add("Use só um comando por vez: varrer, --ajuda ou --versao."); + a.Erros.Add("Use só um comando por vez: varrer, --integrar, --remover-integracao, --ajuda ou --versao."); } if (a.Comando == ComandoCli.Varrer) @@ -127,6 +147,11 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) { a.Erros.Add("O arquivo de --csv tem que terminar em .csv."); } + + if (a.Relatorio is { } rel && !rel.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + { + a.Erros.Add("O arquivo de --relatorio tem que terminar em .html."); + } } else { @@ -135,9 +160,9 @@ public static ArgumentosCli Interpretar(IReadOnlyList args) a.Erros.Add($"Comando desconhecido: {alvos[0]}. Use varrer ou --ajuda."); } - if (a.Csv != null || topDado) + if (a.Csv != null || a.Relatorio != null || topDado) { - a.Erros.Add("As opções --csv e --top pedem o comando varrer."); + a.Erros.Add("As opções --csv, --relatorio e --top 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 91d8908..967d4ac 100644 --- a/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs +++ b/src/mapdisk.nucleo/linha-de-comando/executor-cli.cs @@ -11,7 +11,8 @@ public static class ExecutorCli public static string Versao => typeof(ExecutorCli).Assembly.GetName().Version!.ToString(3); - public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, TextWriter saida, TextWriter erro, CancellationToken cancelar) + /// As chaves e o .exe só mudam nos testes; o padrão é o registro do usuário e este programa. + public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, TextWriter saida, TextWriter erro, CancellationToken cancelar, IChavesUsuario? chaves = null, string? exe = null) { if (!argumentos.Valido) { @@ -32,6 +33,15 @@ public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, Text case ComandoCli.Ajuda or ComandoCli.Janela: saida.WriteLine(ArgumentosCli.TextoAjuda); return CodigoSucesso; + case ComandoCli.Integrar: + IntegracaoExplorer.Ligar(chaves ?? new ChavesUsuarioWindows(), exe ?? Environment.ProcessPath!); + saida.WriteLine("Item \"Analisar com MapDisk\" ligado no menu das pastas e unidades do Explorer."); + saida.WriteLine("No Windows 11, ele fica em \"Mostrar mais opções\". Se o mapdisk.exe mudar de lugar, rode --integrar de novo."); + return CodigoSucesso; + case ComandoCli.RemoverIntegracao: + IntegracaoExplorer.Desligar(chaves ?? new ChavesUsuarioWindows()); + saida.WriteLine("Item \"Analisar com MapDisk\" tirado do menu do Explorer."); + return CodigoSucesso; } saida.WriteLine($"Varrendo {argumentos.Caminho}..."); @@ -90,6 +100,22 @@ public static int Executar(ArgumentosCli argumentos, IMotorVarredura motor, Text saida.WriteLine($"CSV gravado em {Path.GetFullPath(csv)}"); } + if (argumentos.Relatorio is { } relatorio) + { + try + { + RelatorioTecnico.Gravar(new DadosRelatorio(raiz, DateTime.Now, Environment.MachineName, r.Volume, r.Cancelada, argumentos.Top, null), relatorio); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + erro.WriteLine($"Não foi possível gravar o relatório: {e.Message}"); + return CodigoFalha; + } + + saida.WriteLine(); + saida.WriteLine($"Relatório gravado em {Path.GetFullPath(relatorio)}"); + } + return r.Cancelada ? CodigoCancelado : CodigoSucesso; } diff --git a/src/mapdisk.nucleo/painel/dependencias-painel.cs b/src/mapdisk.nucleo/painel/dependencias-painel.cs index 6a0d1ee..a6ee175 100644 --- a/src/mapdisk.nucleo/painel/dependencias-painel.cs +++ b/src/mapdisk.nucleo/painel/dependencias-painel.cs @@ -21,6 +21,9 @@ public sealed class DependenciasPainel /// Leitura de conteúdo para os duplicados. A demonstração troca por uma que não abre arquivo. public ILeitorConteudo Leitor { get; init; } = new LeitorConteudo(); + /// 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(); + public IRegistroAcoes Registro { get; init; } = new RegistroEmMemoria(); public LocaisProtegidos Locais { get; init; } = LocaisProtegidos.DoSistema(); diff --git a/src/mapdisk.nucleo/painel/painel-analises.cs b/src/mapdisk.nucleo/painel/painel-analises.cs index 420aa64..bdf8e29 100644 --- a/src/mapdisk.nucleo/painel/painel-analises.cs +++ b/src/mapdisk.nucleo/painel/painel-analises.cs @@ -30,6 +30,9 @@ public sealed class PainelAnalises : INotifyPropertyChanged /// A aba Duplicados. O resultado diz de qual pasta é e só some numa nova varredura. public PainelDuplicados Duplicados { get; } + /// Quantos arquivos a aba "Maiores arquivos" mostra. Vem das Opções. + public int QuantosMaiores { get; set; } = 100; + public event PropertyChangedEventHandler? PropertyChanged; public AbaAnalise Aba { get; private set; } @@ -80,8 +83,9 @@ public async Task CalcularAsync(NoPasta pasta, DateTime hoje) { _pasta = pasta; var idade = Idade; + var quantos = QuantosMaiores; var r = await Task.Run(() => ( - Maiores: Analises.MaioresArquivos(pasta), + Maiores: Analises.MaioresArquivos(pasta, quantos), Antigos: Analises.ArquivosAntigos(pasta, idade, hoje), Categorias: Analises.PorCategoria(pasta), Extensoes: Analises.PorExtensao(pasta), diff --git a/src/mapdisk.nucleo/painel/painel-grafico.cs b/src/mapdisk.nucleo/painel/painel-grafico.cs index b2f0f1b..7201776 100644 --- a/src/mapdisk.nucleo/painel/painel-grafico.cs +++ b/src/mapdisk.nucleo/painel/painel-grafico.cs @@ -89,7 +89,7 @@ private void Montar() { 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)}"; - var cores = Cores(c.Itens); + var cores = Paleta.CoresDe(c.Itens); if (Tipo == TipoGrafico.Blocos) { var r = Treemap.Dispor(c.Itens.Select(i => i.Valor).ToList(), _largura, _altura); @@ -116,19 +116,6 @@ private void Montar() PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); } - // As subpastas contam a posição entre elas, para a primeira ser sempre o verde da MT. - private static string[] Cores(IReadOnlyList itens) - { - var cores = new string[itens.Count]; - var pasta = 0; - for (var i = 0; i < itens.Count; i++) - { - cores[i] = Paleta.Cor(itens[i].Tipo == TipoItemGrafico.Pasta ? pasta++ : 0, itens[i].Tipo); - } - - return cores; - } - private string TextoValor(long valor) => Modo == ModoExibicao.Contagem ? Formatador.Plural(valor, "arquivo", "arquivos") : Formatador.Tamanho(valor); diff --git a/src/mapdisk.nucleo/painel/painel-principal.cs b/src/mapdisk.nucleo/painel/painel-principal.cs index 4eedf5c..a1014f0 100644 --- a/src/mapdisk.nucleo/painel/painel-principal.cs +++ b/src/mapdisk.nucleo/painel/painel-principal.cs @@ -19,6 +19,7 @@ public sealed class PainelPrincipal : INotifyPropertyChanged private readonly Func> _listarUnidades; private readonly IHistoricoAlvos _historico; private readonly Func _elevar; + private readonly IArmazemPreferencias _preferencias; private CancellationTokenSource? _cancelar; private Varredura? _varredura; private InfoVolume? _volume; @@ -36,6 +37,8 @@ public PainelPrincipal(DependenciasPainel dependencias) LocalDoRegistro = dependencias.Registro.Local; Locais = dependencias.Locais; Analises = new PainelAnalises(dependencias.Leitor); + _preferencias = dependencias.Preferencias; + Aplicar(_preferencias.Ler()); Unidades = _listarUnidades(); TextoAlvo = Unidades.FirstOrDefault()?.Raiz ?? string.Empty; MontarOpcoes(); @@ -57,8 +60,19 @@ public PainelPrincipal(IMotorVarredura motor, Func> li Elevar = Elevacao.Reabrir, Operacoes = new OperacoesArquivo(), Registro = RegistroAcoes.Padrao(), + Preferencias = ArquivoPreferencias.Padrao(), }); + public Preferencias Preferencias { get; private set; } = new(); + + /// Volume da última varredura, para o relatório do técnico. + public InfoVolume? Volume => _volume; + + /// A última varredura foi interrompida antes do fim. + public bool Interrompida { get; private set; } + + public IReadOnlyList UltimosUsados => _historico.Ler(); + /// O processo já roda como administrador. public bool Administrador { get; } @@ -381,6 +395,7 @@ private void Concluir() { var r = varredura.Conclusao.Result; _volume = r.Volume; + Interrompida = r.Cancelada; TextoEstado = r.Cancelada ? $"Varredura interrompida em {Formatador.Duracao(r.Duracao)}. Os números mostram só o que foi lido até ali." : $"Varredura concluída em {Formatador.Duracao(r.Duracao)}."; @@ -430,6 +445,33 @@ private void AtualizarTextos() Avisar(); } + public void GravarPreferencias(Preferencias preferencias) + { + _preferencias.Gravar(preferencias); + Aplicar(preferencias); + } + + public void EsquecerAlvo(string alvo) + { + _historico.Gravar(UltimosAlvos.Esquecer(_historico.Ler(), alvo)); + MontarOpcoes(); + Avisar(); + } + + public void LimparAlvos() + { + _historico.Gravar([]); + MontarOpcoes(); + Avisar(); + } + + private void Aplicar(Preferencias p) + { + Preferencias = p; + Analises.QuantosMaiores = p.MaioresArquivos; + Analises.Duplicados.TamanhoMinimoMb = p.DuplicadosMinimoMb; + } + private void MontarOpcoes() { var unidades = Unidades.Select(u => new ItemAlvo(u.Raiz, u.Descricao)).ToList(); diff --git a/src/mapdisk.nucleo/painel/preferencias.cs b/src/mapdisk.nucleo/painel/preferencias.cs new file mode 100644 index 0000000..4fdfe6d --- /dev/null +++ b/src/mapdisk.nucleo/painel/preferencias.cs @@ -0,0 +1,130 @@ +using System.Globalization; + +namespace MapDisk.Nucleo; + +/// Escolhas do técnico que valem entre sessões, só nesta máquina. +public sealed record Preferencias( + int MaioresArquivos = 100, + long DuplicadosMinimoMb = 1, + int SugerirPastas = 20, + int SugerirArquivos = 50, + int SugerirAnos = 2, + long SugerirMinimoMb = 100) +{ + private const long Mb = 1024 * 1024; + + private static readonly Dictionary Faixas = new() + { + ["maiores-arquivos"] = (10, 1000), + ["duplicados-minimo-mb"] = (1, 102400), + ["sugerir-pastas"] = (0, 1000), + ["sugerir-arquivos"] = (0, 1000), + ["sugerir-anos"] = (1, 50), + ["sugerir-minimo-mb"] = (0, 1048576), + }; + + public CriteriosSugestao Criterios => new(SugerirPastas, SugerirArquivos, SugerirAnos, SugerirMinimoMb * Mb); + + public Preferencias ComCriterios(CriteriosSugestao c) => this with + { + SugerirPastas = c.MaioresPastas, + SugerirArquivos = c.MaioresArquivos, + SugerirAnos = c.AnosSemAlteracao, + SugerirMinimoMb = c.TamanhoMinimo / Mb, + }; + + public IReadOnlyList ParaTexto() => + [ + $"maiores-arquivos={N(MaioresArquivos)}", + $"duplicados-minimo-mb={N(DuplicadosMinimoMb)}", + $"sugerir-pastas={N(SugerirPastas)}", + $"sugerir-arquivos={N(SugerirArquivos)}", + $"sugerir-anos={N(SugerirAnos)}", + $"sugerir-minimo-mb={N(SugerirMinimoMb)}", + ]; + + /// Linha que não se entende fica de fora; valor fora da faixa fica com o padrão. + public static Preferencias DeTexto(IEnumerable linhas) + { + var valores = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var linha in linhas) + { + var i = linha.IndexOf('='); + if (i > 0) + { + valores[linha[..i].Trim()] = linha[(i + 1)..].Trim(); + } + } + + var p = new Preferencias(); + return p with + { + MaioresArquivos = (int)Ler(valores, "maiores-arquivos", p.MaioresArquivos), + DuplicadosMinimoMb = Ler(valores, "duplicados-minimo-mb", p.DuplicadosMinimoMb), + SugerirPastas = (int)Ler(valores, "sugerir-pastas", p.SugerirPastas), + SugerirArquivos = (int)Ler(valores, "sugerir-arquivos", p.SugerirArquivos), + SugerirAnos = (int)Ler(valores, "sugerir-anos", p.SugerirAnos), + SugerirMinimoMb = Ler(valores, "sugerir-minimo-mb", p.SugerirMinimoMb), + }; + } + + /// Confere um valor digitado na tela de Opções. + public static bool Valido(string nome, string texto) => + Faixas.TryGetValue(nome, out var f) + && long.TryParse(texto.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var n) + && n >= f.Minimo && n <= f.Maximo; + + private static long Ler(Dictionary valores, string nome, long padrao) => + valores.TryGetValue(nome, out var texto) && Valido(nome, texto) + ? long.Parse(texto.Trim(), CultureInfo.InvariantCulture) + : padrao; + + private static string N(long n) => n.ToString(CultureInfo.InvariantCulture); +} + +public interface IArmazemPreferencias +{ + Preferencias Ler(); + + void Gravar(Preferencias preferencias); +} + +/// Opções num arquivo do perfil do usuário. Falha de leitura ou de gravação deixa os padrões. +public sealed class ArquivoPreferencias(string arquivo) : IArmazemPreferencias +{ + public static ArquivoPreferencias Padrao() => new(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MapDisk", "opcoes.txt")); + + public Preferencias Ler() + { + try + { + return File.Exists(arquivo) ? Preferencias.DeTexto(File.ReadAllLines(arquivo)) : new Preferencias(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return new Preferencias(); + } + } + + public void Gravar(Preferencias preferencias) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(arquivo)!); + File.WriteAllLines(arquivo, preferencias.ParaTexto()); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } + } +} + +public sealed class PreferenciasEmMemoria : IArmazemPreferencias +{ + private Preferencias _atual = new(); + + public Preferencias Ler() => _atual; + + public void Gravar(Preferencias preferencias) => _atual = preferencias; +} diff --git a/src/mapdisk.nucleo/painel/ultimos-alvos.cs b/src/mapdisk.nucleo/painel/ultimos-alvos.cs index f4a7d4b..6a7bbf0 100644 --- a/src/mapdisk.nucleo/painel/ultimos-alvos.cs +++ b/src/mapdisk.nucleo/painel/ultimos-alvos.cs @@ -64,4 +64,7 @@ public static IReadOnlyList Acrescentar(IReadOnlyList atuais, st .Concat(atuais.Where(a => !string.Equals(a, alvo, StringComparison.OrdinalIgnoreCase))) .Take(Maximo) .ToList(); + + public static IReadOnlyList Esquecer(IReadOnlyList atuais, string alvo) => + atuais.Where(a => !string.Equals(a, alvo, StringComparison.OrdinalIgnoreCase)).ToList(); } diff --git a/src/mapdisk.nucleo/relatorios/html.cs b/src/mapdisk.nucleo/relatorios/html.cs new file mode 100644 index 0000000..f8d8b5e --- /dev/null +++ b/src/mapdisk.nucleo/relatorios/html.cs @@ -0,0 +1,20 @@ +using System.Net; + +namespace MapDisk.Nucleo; + +/// O que os dois relatórios HTML usam: escape e o logo da MT embutido. +public static class Html +{ + public static string H(string texto) => WebUtility.HtmlEncode(texto); + + /// Texto dentro de aspas no JavaScript: barra, aspas e o fim de script escapados. + public static string Js(string texto) => texto.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("<", "\\u003c"); + + public static string Logo() + { + using var fluxo = typeof(Html).Assembly.GetManifestResourceStream("mt-logo.png")!; + using var memoria = new MemoryStream(); + fluxo.CopyTo(memoria); + return Convert.ToBase64String(memoria.ToArray()); + } +} diff --git a/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs b/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs index 6409400..2302d91 100644 --- a/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs +++ b/src/mapdisk.nucleo/relatorios/pagina-avaliacao.cs @@ -1,6 +1,6 @@ using System.Globalization; -using System.Net; using System.Text; +using static MapDisk.Nucleo.Html; namespace MapDisk.Nucleo; @@ -110,17 +110,4 @@ private static void Resumo(StringBuilder s, Avaliacao a) s.Append($"

Sem alteração há mais de 2 anos

{H(Formatador.Plural(antigos.Quantidade, "arquivo", "arquivos"))}, "); s.Append($"{H(Formatador.Tamanho(antigos.Tamanho))}

"); } - - private static string H(string texto) => WebUtility.HtmlEncode(texto); - - // Texto dentro de aspas no JavaScript: barra, aspas e o fim de script escapados. - private static string Js(string texto) => texto.Replace(@"\", @"\\").Replace("\"", "\\\"").Replace("<", "\\u003c"); - - private static string Logo() - { - using var fluxo = typeof(PaginaAvaliacao).Assembly.GetManifestResourceStream("mt-logo.png")!; - using var memoria = new MemoryStream(); - fluxo.CopyTo(memoria); - return Convert.ToBase64String(memoria.ToArray()); - } } diff --git a/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs b/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs new file mode 100644 index 0000000..ee9b08e --- /dev/null +++ b/src/mapdisk.nucleo/relatorios/relatorio-tecnico.cs @@ -0,0 +1,232 @@ +using System.Globalization; +using System.Text; +using static MapDisk.Nucleo.Html; + +namespace MapDisk.Nucleo; + +public sealed record DadosRelatorio(NoPasta Pasta, DateTime Gerado, string Computador, InfoVolume? Volume, bool Interrompida, int Quantos, ResultadoDuplicados? Duplicados); + +/// +/// Relatório do técnico (R17): um arquivo HTML só, com logo, estilo e gráfico dentro, sem nada +/// da internet (regra 6). Pasta sem leitura aparece com o motivo, nunca com zero (regra 3). +/// +public static class RelatorioTecnico +{ + private const int LarguraGrafico = 960; + private const int AlturaGrafico = 360; + private const int MaximoNoGrafico = 20; + + private const string Estilo = + "body{font-family:Segoe UI,Arial,sans-serif;color:#202020;background:#F4F4F4;margin:0}" + + "header{display:flex;gap:16px;align-items:center;background:#006B2D;color:#fff;padding:12px 20px}" + + "header img{height:56px;background:#fff;border-radius:8px;padding:4px}header h1{margin:0;font-size:22px}header p{margin:4px 0 0}" + + "main{max-width:1100px;margin:0 auto;padding:16px 20px}h2{color:#006B2D;border-left:4px solid #43A92C;padding-left:8px}" + + ".aviso{background:#fff4d6;border:1px solid #e0b100;padding:8px 12px}" + + "table{width:100%;border-collapse:collapse;background:#fff}th,td{border:1px solid #ddd;padding:6px;font-size:13px;vertical-align:top}" + + "th{background:#eef6ea;text-align:left}td.n{text-align:right;white-space:nowrap}svg{max-width:100%;height:auto;background:#fff}" + + ".cor{display:inline-block;width:12px;height:12px;margin-right:6px;vertical-align:middle}" + + "footer{margin-top:24px;font-size:12px;color:#666}" + + "@media print{body{background:#fff}header{background:#fff;color:#006B2D;border-bottom:2px solid #006B2D}tr{page-break-inside:avoid}svg{page-break-inside:avoid}}"; + + /// Nome sugerido, sem extensão: "espaco-" mais a pasta e a data. A raiz "C:\" vira "c". + public static string NomeDoArquivo(NoPasta pasta, DateTime agora) + { + var invalidos = Path.GetInvalidFileNameChars(); + var nome = new string(pasta.Nome.Where(c => !invalidos.Contains(c) && c != ':').ToArray()).Trim().ToLowerInvariant(); + return $"espaco-{(nome.Length > 0 ? nome : "pasta")}-{agora.ToString("yyyyMMdd-HHmm", CultureInfo.InvariantCulture)}"; + } + + public static void Gravar(DadosRelatorio dados, string arquivo) => + File.WriteAllText(arquivo, Gerar(dados), new UTF8Encoding(false)); + + public static string Gerar(DadosRelatorio d) + { + var p = d.Pasta; + var caminho = p.CaminhoCompleto(); + var s = new StringBuilder(); + s.Append(""); + s.Append(""); + s.Append($"Espaço em disco - {H(caminho)}"); + s.Append($"
\"MT

Relatório de espaço em disco

"); + s.Append($"

{H(d.Computador)} | {H(caminho)} | {d.Gerado.ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture)}

"); + if (d.Interrompida) + { + s.Append("

A varredura foi interrompida antes do fim. Os números mostram só o que foi lido até ali.

"); + } + + Resumo(s, d); + Grafico(s, p); + MaioresPastas(s, p, d.Quantos); + Arquivos(s, "Maiores arquivos", Analises.MaioresArquivos(p, d.Quantos)); + Tipos(s, p); + var antigos = Analises.ArquivosAntigos(p, Idade.DoisAnos, d.Gerado, d.Quantos); + s.Append($"

Sem alteração há mais de 2 anos

{H(Formatador.Plural(antigos.Quantidade, "arquivo", "arquivos"))}, {H(Formatador.Tamanho(antigos.Tamanho))}.

"); + Arquivos(s, null, antigos.Maiores); + Usuarios(s, p); + if (d.Duplicados is { } dup) + { + Duplicados(s, dup, d.Quantos); + } + + s.Append($"
Gerado pelo MapDisk - MT {H(ExecutorCli.Versao)}, da MT - Manfred Tecnologia (www.manfred.com.br).
"); + s.Append("
"); + return s.ToString(); + } + + private static void Resumo(StringBuilder s, DadosRelatorio d) + { + var p = d.Pasta; + s.Append($"

Resumo

Total lido: {H(Formatador.Tamanho(p.Tamanho))} ({H(Formatador.Tamanho(p.Alocado))} alocados) "); + s.Append($"em {H(Formatador.Plural(p.ArquivosTotal, "arquivo", "arquivos"))} e {H(Formatador.Plural(p.PastasTotal, "pasta", "pastas"))}."); + if (d.Volume is { } v) + { + s.Append($" Unidade {H(v.Raiz)}: {H(Formatador.Tamanho(v.Livre))} livres de {H(Formatador.Tamanho(v.Total))} ({H(v.SistemaArquivos)})."); + } + + s.Append("

"); + var semLeitura = p.PastasSemAcesso + p.PastasComErro; + if (semLeitura > 0) + { + 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.

"); + } + } + + private static void Grafico(StringBuilder s, NoPasta p) + { + var c = ItensGrafico.DaPasta(p, ModoExibicao.Tamanho, MaximoNoGrafico); + if (c.Itens.Count == 0) + { + return; + } + + var blocos = Treemap.Dispor(c.Itens.Select(i => i.Valor).ToList(), LarguraGrafico, AlturaGrafico); + var cores = Paleta.CoresDe(c.Itens); + s.Append($"

Gráfico

"); + for (var i = 0; i < blocos.Count; i++) + { + var b = blocos[i]; + s.Append(FormattableString.Invariant($"{H(c.Itens[i].Nome)}")); + if (b.Largura >= 90 && b.Altura >= 28) + { + s.Append(FormattableString.Invariant($"{H(c.Itens[i].Nome)}")); + } + } + + s.Append(""); + for (var i = 0; i < c.Itens.Count; i++) + { + var item = c.Itens[i]; + s.Append($""); + s.Append($""); + } + + s.Append("
ItemTamanho% da pasta
{H(item.Nome)}{H(Formatador.Tamanho(item.Valor))}{H(Formatador.Porcentagem(c.Total > 0 ? (double)item.Valor / c.Total : 0))}
"); + if (c.NaoLidas.Count > 0) + { + s.Append($"

Fora do gráfico, sem leitura: {H(string.Join(", ", c.NaoLidas))}.

"); + } + } + + 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); + foreach (var x in lidas) + { + s.Append($""); + s.Append($""); + s.Append($""); + } + + foreach (var x in semLeitura) + { + var estado = x.Estado switch + { + EstadoPasta.SemAcesso => "sem acesso", + EstadoPasta.ErroLeitura => "erro de leitura", + _ => "não lida", + }; + s.Append($""); + } + + s.Append("
PastaTamanho% da pastaArquivosÚltima alteração
{H(x.Nome)}{H(Formatador.Tamanho(x.Tamanho))}{H(Formatador.Porcentagem(p.Tamanho > 0 ? (double)x.Tamanho / p.Tamanho : 0))}{H(Formatador.Numero(x.ArquivosTotal))}{H(Formatador.Data(x.UltimaModificacao))}
{H(x.Nome)}{estado}
"); + } + + private static void Arquivos(StringBuilder s, string? titulo, IReadOnlyList arquivos) + { + if (titulo is not null) + { + s.Append($"

{H(titulo)}

"); + } + + if (arquivos.Count == 0) + { + s.Append("

Nenhum.

"); + return; + } + + s.Append(""); + foreach (var a in arquivos) + { + s.Append($""); + s.Append($""); + } + + s.Append("
ArquivoPastaTamanhoÚltima alteração
{H(a.Arquivo.Nome)}{H(a.Pasta.CaminhoCompleto())}{H(Formatador.Tamanho(a.Arquivo.Tamanho))}{H(Formatador.Data(a.Arquivo.Modificacao))}
"); + } + + private static void Tipos(StringBuilder s, NoPasta p) + { + s.Append("

Por tipo

"); + foreach (var t in Analises.PorCategoria(p)) + { + s.Append($""); + s.Append($""); + } + + s.Append("
TipoArquivosTamanho% da pasta
{H(t.Nome)}{H(Formatador.Numero(t.Quantidade))}{H(Formatador.Tamanho(t.Tamanho))}{H(Formatador.Porcentagem(p.Tamanho > 0 ? (double)t.Tamanho / p.Tamanho : 0))}
"); + } + + private static void Usuarios(StringBuilder s, NoPasta p) + { + var u = Analises.PorUsuario(p); + if (u.PastaDePerfis is null) + { + return; + } + + s.Append("

Por usuário

"); + foreach (var perfil in u.Perfis) + { + var valor = perfil.Estado == EstadoPasta.Lida ? Formatador.Tamanho(perfil.Tamanho) : "sem acesso"; + s.Append($""); + } + + s.Append("
PerfilTamanho
{H(perfil.Nome)}{H(valor)}
"); + } + + private static void Duplicados(StringBuilder s, ResultadoDuplicados r, int quantos) + { + s.Append("

Duplicados

"); + if (r.Grupos.Count == 0) + { + s.Append("

Nenhum arquivo repetido acima do tamanho mínimo da busca.

"); + return; + } + + var copias = r.Grupos.Sum(g => g.Repetido); + s.Append($"

{H(Formatador.Plural(r.Grupos.Count, "grupo", "grupos"))} com {H(Formatador.Tamanho(copias))} em cópias.

"); + s.Append(""); + foreach (var g in r.Grupos.Take(quantos)) + { + foreach (var a in g.Arquivos) + { + s.Append($""); + } + } + + s.Append("
GrupoArquivoTamanho
{g.Numero}{H(a.Caminho)}{H(Formatador.Tamanho(g.Tamanho))}
"); + } +} diff --git a/src/mapdisk.nucleo/sistema/abertura.cs b/src/mapdisk.nucleo/sistema/abertura.cs new file mode 100644 index 0000000..1884687 --- /dev/null +++ b/src/mapdisk.nucleo/sistema/abertura.cs @@ -0,0 +1,16 @@ +namespace MapDisk.Nucleo; + +/// Pedido do Explorer: abrir a janela já varrendo a pasta clicada. +public static class Abertura +{ + public const string Argumento = "--abrir"; + + /// O Explorer manda a raiz da unidade como "C:\", e o Windows lê a barra antes da aspa como aspa escapada. + public static string Corrigir(string alvo) => alvo.EndsWith('"') ? alvo[..^1] + "\\" : alvo; + + public static bool EhPedido(IReadOnlyList args, out string? alvo) + { + alvo = args is [var a, var b] && string.Equals(a, Argumento, StringComparison.OrdinalIgnoreCase) ? Corrigir(b) : null; + return alvo is not null; + } +} diff --git a/src/mapdisk/janela-opcoes.xaml b/src/mapdisk/janela-opcoes.xaml new file mode 100644 index 0000000..86a4f38 --- /dev/null +++ b/src/mapdisk/janela-opcoes.xaml @@ -0,0 +1,104 @@ + + + + + + + + + + + - diff --git a/src/mapdisk/janela-principal.xaml.cs b/src/mapdisk/janela-principal.xaml.cs index 4d6f43d..70320c4 100644 --- a/src/mapdisk/janela-principal.xaml.cs +++ b/src/mapdisk/janela-principal.xaml.cs @@ -172,8 +172,12 @@ private void AoGerarRelatorio(object sender, RoutedEventArgs e) } var relatorio = new PainelRelatorio(raiz, Volumes.Ler(raiz.CaminhoCompleto())?.Livre, Environment.UserName, _painel.Locais, - _painel.Analises.Duplicados.Resultado); - new JanelaRelatorio(relatorio, () => _ultimaSelecao?.Cast().ToList() ?? []) { Owner = this }.ShowDialog(); + _painel.Analises.Duplicados.Resultado) + { + Criterios = _painel.Preferencias.Criterios, + }; + new JanelaRelatorio(relatorio, () => _ultimaSelecao?.Cast().ToList() ?? [], + c => _painel.GravarPreferencias(_painel.Preferencias.ComCriterios(c))) { Owner = this }.ShowDialog(); } private void AoLerResposta(object sender, RoutedEventArgs e) @@ -226,16 +230,77 @@ private async Task AgirSobre(IReadOnlyList itens, bool mover) await Agir(new PedidoAcao(TipoAcao.Mover, selecionados, dialogo.FolderName)); } - private void AoAbrirRegistro(object sender, RoutedEventArgs e) + private void AoAbrirOpcoes(object sender, RoutedEventArgs e) { - if (File.Exists(_painel.LocalDoRegistro)) + if (new JanelaOpcoes(_painel) { Owner = this }.ShowDialog() == true) { - Shell.MostrarNoExplorer(_painel.LocalDoRegistro, ehArquivo: true); + // A quantidade de maiores arquivos pode ter mudado: calcula de novo. + _pastaAnalisada = null; + _ = AtualizarAnalises(); } - else + } + + private void AoExportarHtml(object sender, RoutedEventArgs e) + { + if (PastaParaExportar() is not { } pasta + || Destino("Onde gravar o relatório", RelatorioTecnico.NomeDoArquivo(pasta, DateTime.Now) + ".html", "Página HTML (*.html)|*.html") is not { } arquivo) { - MessageBox.Show(this, $"Nenhuma ação registrada ainda. O registro fica em {_painel.LocalDoRegistro}.", "MapDisk - MT", + return; + } + + var dados = new DadosRelatorio(pasta, DateTime.Now, Environment.MachineName, _painel.Volume, _painel.Interrompida, + _painel.Preferencias.MaioresArquivos, _painel.Analises.Duplicados.Resultado); + if (Gravar(() => RelatorioTecnico.Gravar(dados, arquivo)) + && MessageBox.Show(this, "Relatório gravado. Abrir agora?", "MapDisk - MT", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) + { + Shell.AbrirNoNavegador(arquivo); + } + } + + private void AoExportarCsv(object sender, RoutedEventArgs e) + { + if (PastaParaExportar() is not { } pasta + || Destino("Onde gravar a planilha", RelatorioTecnico.NomeDoArquivo(pasta, DateTime.Now) + ".csv", "CSV (*.csv)|*.csv") is not { } arquivo) + { + return; + } + + if (Gravar(() => ExportadorCsv.Gravar(pasta, arquivo))) + { + Shell.MostrarNoExplorer(arquivo, ehArquivo: true); + } + } + + // A mesma pasta das análises: a selecionada na árvore, ou a raiz mostrada. + private NoPasta? PastaParaExportar() + { + if (_painel.Estado != EstadoPainel.Parado || _painel.PastaDasAnalises(Tabela.SelectedItem as LinhaArvore) is not { } pasta) + { + MessageBox.Show(this, "Varra uma unidade ou pasta antes de exportar e espere o fim da varredura.", "MapDisk - MT", MessageBoxButton.OK, MessageBoxImage.Information); + return null; + } + + return pasta; + } + + private string? Destino(string titulo, string nome, string filtro) + { + var dialogo = new SaveFileDialog { Title = titulo, FileName = nome, Filter = filtro, OverwritePrompt = true }; + return dialogo.ShowDialog(this) == true ? dialogo.FileName : null; + } + + private bool Gravar(Action gravar) + { + try + { + gravar(); + return true; + } + catch (Exception erro) when (erro is IOException or UnauthorizedAccessException) + { + MessageBox.Show(this, $"Não foi possível gravar: {erro.Message}", "MapDisk - MT", MessageBoxButton.OK, MessageBoxImage.Warning); + return false; } } diff --git a/src/mapdisk/janela-relatorio.xaml.cs b/src/mapdisk/janela-relatorio.xaml.cs index aef7a9d..939f8ae 100644 --- a/src/mapdisk/janela-relatorio.xaml.cs +++ b/src/mapdisk/janela-relatorio.xaml.cs @@ -12,11 +12,14 @@ public partial class JanelaRelatorio : Window private const long Mb = 1024 * 1024; private readonly PainelRelatorio _painel; private readonly Func> _selecaoAtual; + private readonly Action _guardarCriterios; - public JanelaRelatorio(PainelRelatorio painel, Func> selecaoAtual) + /// Guarda os critérios usados no Sugerir para o próximo relatório. + public JanelaRelatorio(PainelRelatorio painel, Func> selecaoAtual, Action guardarCriterios) { _painel = painel; _selecaoAtual = selecaoAtual; + _guardarCriterios = guardarCriterios; DataContext = painel; InitializeComponent(); var c = painel.Criterios; @@ -38,6 +41,7 @@ private void AoSugerir(object sender, RoutedEventArgs e) } _painel.Criterios = new CriteriosSugestao(pastas, arquivos, anos, minimo * Mb); + _guardarCriterios(_painel.Criterios); _painel.Sugerir(DateTime.Now); } diff --git a/src/mapdisk/programa.cs b/src/mapdisk/programa.cs index 6259223..1bbfd65 100644 --- a/src/mapdisk/programa.cs +++ b/src/mapdisk/programa.cs @@ -24,7 +24,10 @@ private static int Main(string[] args) var demonstracao = args is [Demonstracao.Argumento]; var elevado = Elevacao.EhPedido(args, out var alvoElevado); - if (args.Length > 0 && !demonstracao && !elevado) + + // Pedido do item "Analisar com MapDisk" do Explorer: abre a janela já varrendo. + var aberto = Abertura.EhPedido(args, out var alvoAberto); + if (args.Length > 0 && !demonstracao && !elevado && !aberto) { return ModoLinhaDeComando.Executar(ArgumentosCli.Interpretar(args)); } @@ -41,12 +44,12 @@ private static int Main(string[] args) } var painel = PainelPrincipal.Padrao(); - if (elevado) + if (elevado || aberto) { - painel.TextoAlvo = alvoElevado!; + painel.TextoAlvo = (alvoElevado ?? alvoAberto)!; } - return aplicativo.Run(new JanelaPrincipal(painel, administrador ? " (administrador)" : string.Empty, varrerAoAbrir: elevado)); + return aplicativo.Run(new JanelaPrincipal(painel, administrador ? " (administrador)" : string.Empty, varrerAoAbrir: elevado || aberto)); } /// diff --git a/testes/mapdisk.testes/abertura-testes.cs b/testes/mapdisk.testes/abertura-testes.cs new file mode 100644 index 0000000..a06a6b6 --- /dev/null +++ b/testes/mapdisk.testes/abertura-testes.cs @@ -0,0 +1,19 @@ +namespace MapDisk.Testes; + +public class AberturaTestes +{ + [Theory] + [InlineData("C:\"", @"C:\")] + [InlineData(@"D:\Dados", @"D:\Dados")] + [InlineData(@"\\servidor\pasta", @"\\servidor\pasta")] + public void Corrige_a_aspa_que_o_explorer_deixa(string recebido, string esperado) => + Assert.Equal(esperado, Abertura.Corrigir(recebido)); + + [Fact] + public void Reconhece_o_pedido() + { + Assert.True(Abertura.EhPedido(["--abrir", "C:\""], out var alvo)); + Assert.Equal(@"C:\", alvo); + Assert.False(Abertura.EhPedido(["varrer", "C:"], out _)); + } +} diff --git a/testes/mapdisk.testes/argumentos-testes.cs b/testes/mapdisk.testes/argumentos-testes.cs index c39a922..3e6b1c8 100644 --- a/testes/mapdisk.testes/argumentos-testes.cs +++ b/testes/mapdisk.testes/argumentos-testes.cs @@ -63,4 +63,29 @@ public void A_ajuda_diz_que_a_linha_de_comando_so_le() { Assert.Contains("nunca apaga nem move", ArgumentosCli.TextoAjuda); } + + [Fact] + public void Le_o_relatorio_e_exige_html() + { + Assert.Equal("d.html", ArgumentosCli.Interpretar(["varrer", "D:", "--relatorio", "d.html"]).Relatorio); + Assert.Contains("O arquivo de --relatorio tem que terminar em .html.", ArgumentosCli.Interpretar(["varrer", "D:", "--relatorio", "d.txt"]).Erros); + Assert.Contains("As opções --csv, --relatorio e --top pedem o comando varrer.", ArgumentosCli.Interpretar(["--relatorio", "d.html"]).Erros); + } + + [Fact] + public void Le_integrar_e_remover() + { + Assert.Equal(ComandoCli.Integrar, ArgumentosCli.Interpretar(["--integrar"]).Comando); + Assert.Equal(ComandoCli.RemoverIntegracao, ArgumentosCli.Interpretar(["--remover-integracao"]).Comando); + Assert.False(ArgumentosCli.Interpretar(["--integrar", "varrer", "C:"]).Valido); + } + + [Fact] + public void Ajuda_lista_todas_as_opcoes() + { + foreach (var opcao in new[] { "--relatorio", "--integrar", "--remover-integracao", "--csv", "--top" }) + { + Assert.Contains(opcao, ArgumentosCli.TextoAjuda); + } + } } diff --git a/testes/mapdisk.testes/executor-cli-testes.cs b/testes/mapdisk.testes/executor-cli-testes.cs index a3f93d4..77d8f5d 100644 --- a/testes/mapdisk.testes/executor-cli-testes.cs +++ b/testes/mapdisk.testes/executor-cli-testes.cs @@ -128,4 +128,27 @@ public void Fim_com_pastas_nao_lidas_avisa_na_linha_de_comando() Assert.Contains("Atenção: 2 pastas não foram lidas", saida); } + + [Fact] + public void Grava_o_relatorio_pedido() + { + var arquivo = Path.Combine(AppContext.BaseDirectory, "cli-" + Guid.NewGuid().ToString("N") + ".html"); + var codigo = Rodar(Demonstracao.Motor(), out var saida, out _, "varrer", "C:", "--relatorio", arquivo); + Assert.Equal(ExecutorCli.CodigoSucesso, codigo); + Assert.Contains("Relatório gravado em", saida); + Assert.Contains("Relatório de espaço em disco", File.ReadAllText(arquivo)); + File.Delete(arquivo); + } + + [Fact] + public void Integrar_e_remover_mexem_so_nas_chaves() + { + var chaves = new ChavesEmMemoria(); + var s = new StringWriter(); + Assert.Equal(ExecutorCli.CodigoSucesso, ExecutorCli.Executar(ArgumentosCli.Interpretar(["--integrar"]), Demonstracao.Motor(), s, new StringWriter(), CancellationToken.None, chaves, @"E:\mapdisk.exe")); + Assert.Equal(EstadoIntegracao.Ligada, IntegracaoExplorer.Estado(chaves, @"E:\mapdisk.exe")); + Assert.Contains("Mostrar mais opções", s.ToString()); + ExecutorCli.Executar(ArgumentosCli.Interpretar(["--remover-integracao"]), Demonstracao.Motor(), s, new StringWriter(), CancellationToken.None, chaves, @"E:\mapdisk.exe"); + Assert.Empty(chaves.Todas); + } } diff --git a/testes/mapdisk.testes/integracao-explorer-testes.cs b/testes/mapdisk.testes/integracao-explorer-testes.cs new file mode 100644 index 0000000..ef0a64c --- /dev/null +++ b/testes/mapdisk.testes/integracao-explorer-testes.cs @@ -0,0 +1,44 @@ +namespace MapDisk.Testes; + +public class IntegracaoExplorerTestes +{ + private const string Exe = @"E:\Ferramentas\mapdisk.exe"; + + [Fact] + public void Ligar_grava_as_tres_chaves_no_usuario() + { + var chaves = new ChavesEmMemoria(); + IntegracaoExplorer.Ligar(chaves, Exe); + foreach (var raiz in IntegracaoExplorer.Chaves) + { + Assert.StartsWith(@"Software\Classes\", raiz); + Assert.Equal("Analisar com MapDisk", chaves.Ler(raiz, null)); + Assert.Equal(Exe + ",0", chaves.Ler(raiz, "Icon")); + Assert.Contains("--abrir", chaves.Ler(raiz + @"\command", null)); + } + + Assert.Equal("\"" + Exe + "\" --abrir \"%V\"", chaves.Ler(@"Software\Classes\Directory\Background\shell\MapDisk\command", null)); + Assert.Equal("\"" + Exe + "\" --abrir \"%1\"", chaves.Ler(@"Software\Classes\Drive\shell\MapDisk\command", null)); + } + + [Fact] + public void Desligar_tira_tudo() + { + var chaves = new ChavesEmMemoria(); + IntegracaoExplorer.Ligar(chaves, Exe); + IntegracaoExplorer.Desligar(chaves); + Assert.Empty(chaves.Todas); + Assert.Equal(EstadoIntegracao.Desligada, IntegracaoExplorer.Estado(chaves, Exe)); + } + + [Fact] + public void Estado_reconhece_outro_exe() + { + var chaves = new ChavesEmMemoria(); + IntegracaoExplorer.Ligar(chaves, @"F:\antigo\mapdisk.exe"); + Assert.Equal(EstadoIntegracao.OutroLocal, IntegracaoExplorer.Estado(chaves, Exe)); + Assert.Equal(@"F:\antigo\mapdisk.exe", IntegracaoExplorer.ExeRegistrado(chaves)); + IntegracaoExplorer.Ligar(chaves, Exe); + Assert.Equal(EstadoIntegracao.Ligada, IntegracaoExplorer.Estado(chaves, Exe)); + } +} diff --git a/testes/mapdisk.testes/pagina-testes.cs b/testes/mapdisk.testes/pagina-testes.cs new file mode 100644 index 0000000..48fe3ba --- /dev/null +++ b/testes/mapdisk.testes/pagina-testes.cs @@ -0,0 +1,36 @@ +namespace MapDisk.Testes; + +public class PaginaTestes +{ + private static string Publico(string relativo) => + Path.Combine(CaracteresProibidosTestes.RaizDoRepositorio(), "public", relativo); + + [Fact] + public void Pagina_nao_carrega_nada_de_fora() + { + var html = File.ReadAllText(Publico("index.html")); + Assert.DoesNotContain(" [], + Preferencias = armazem, + }); + Assert.Equal(20, painel.Analises.QuantosMaiores); + Assert.Equal(8, painel.Analises.Duplicados.TamanhoMinimoMb); + + painel.GravarPreferencias(new Preferencias(MaioresArquivos: 40)); + Assert.Equal(40, armazem.Ler().MaioresArquivos); + Assert.Equal(40, painel.Analises.QuantosMaiores); + } + + [Fact] + public void Painel_esquece_e_limpa_os_alvos() + { + var historico = new HistoricoEmMemoria(); + historico.Gravar([@"E:\Fotos", @"F:\"]); + var painel = new PainelPrincipal(new DependenciasPainel + { + Motor = Demonstracao.Motor(), + ListarUnidades = () => [], + Historico = historico, + }); + painel.EsquecerAlvo(@"E:\Fotos"); + Assert.Equal([@"F:\"], painel.UltimosUsados); + Assert.DoesNotContain(painel.Opcoes, o => o.Caminho == @"E:\Fotos"); + painel.LimparAlvos(); + Assert.Empty(painel.UltimosUsados); + } + + [Fact] + public async Task Analises_usam_a_quantidade_de_maiores() + { + var raiz = (await Demonstracao.Motor().Iniciar(@"C:\", CancellationToken.None).Conclusao).Raiz; + var analises = new PainelAnalises { QuantosMaiores = 3 }; + await analises.CalcularAsync(raiz, new DateTime(2026, 10, 1)); + Assert.Equal(3, analises.Maiores.Count); + } +} diff --git a/testes/mapdisk.testes/recursos-testes.cs b/testes/mapdisk.testes/recursos-testes.cs index d978c80..8223eca 100644 --- a/testes/mapdisk.testes/recursos-testes.cs +++ b/testes/mapdisk.testes/recursos-testes.cs @@ -69,7 +69,7 @@ public void Janela_tem_as_acoes_com_confirmacao() var janela = File.ReadAllText(App("janela-principal.xaml")); Assert.Contains("Click=\"AoRemover\"", janela); Assert.Contains("Click=\"AoMover\"", janela); - Assert.Contains("Click=\"AoAbrirRegistro\"", janela); + Assert.Contains("Click=\"AoAbrirRegistro\"", File.ReadAllText(App("janela-opcoes.xaml"))); Assert.Contains("SelectionMode=\"Extended\"", janela); Assert.Contains("ToolTipService.ShowOnDisabled=\"True\"", janela); Assert.Contains("{Binding TextoSessao}", janela); @@ -89,6 +89,22 @@ public void Janela_tem_o_relatorio_para_o_cliente() Assert.True(File.Exists(App("janela-resposta.xaml"))); } + [Fact] + public void Janela_tem_exportar_e_opcoes() + { + var janela = File.ReadAllText(App("janela-principal.xaml")); + Assert.Contains("Click=\"AoAbrirOpcoes\"", janela); + Assert.Contains("Click=\"AoExportarHtml\"", janela); + Assert.Contains("Click=\"AoExportarCsv\"", janela); + var opcoes = File.ReadAllText(App("janela-opcoes.xaml")); + foreach (var nome in new[] { "BotaoLigar", "BotaoDesligar", "CampoMaiores", "CampoMinimoDuplicados", "CampoPastas", "CampoArquivos", "CampoAnos", "CampoMinimo", "ListaAlvos", "TextoRegistro" }) + { + Assert.Contains($"x:Name=\"{nome}\"", opcoes); + } + + Assert.Contains("Abertura.EhPedido", File.ReadAllText(App("programa.cs"))); + } + [Fact] public void Janela_tem_a_aba_de_duplicados() { diff --git a/testes/mapdisk.testes/relatorio-tecnico-testes.cs b/testes/mapdisk.testes/relatorio-tecnico-testes.cs new file mode 100644 index 0000000..36a0486 --- /dev/null +++ b/testes/mapdisk.testes/relatorio-tecnico-testes.cs @@ -0,0 +1,91 @@ +using System.Net; + +namespace MapDisk.Testes; + +public class RelatorioTecnicoTestes +{ + private static NoPasta Raiz() => Demonstracao.Motor().Iniciar(@"C:\", CancellationToken.None).Conclusao.Result.Raiz; + + private static DadosRelatorio Dados(NoPasta pasta, bool interrompida = false, ResultadoDuplicados? duplicados = null) => + new(pasta, new DateTime(2026, 10, 1, 14, 30, 0), "ESTACAO-01", new InfoVolume(@"C:\", "Sistema", "NTFS", 50L << 30, 200L << 30, 4096), interrompida, 10, duplicados); + + // O HTML codifica os acentos; os testes de texto leem o que o navegador mostra. + private static string Texto(DadosRelatorio dados) => WebUtility.HtmlDecode(RelatorioTecnico.Gerar(dados)); + + [Fact] + public void Tem_marca_resumo_grafico_e_listas() + { + var html = Texto(Dados(Raiz())); + Assert.StartsWith("", html); + Assert.Contains("data:image/png;base64,", html); + Assert.Contains("ESTACAO-01", html); + Assert.Contains("01/10/2026 14:30", html); + Assert.Contains("{titulo}", html); + } + + Assert.Contains($"{Formatador.Tamanho(50L << 30)} livres de {Formatador.Tamanho(200L << 30)}", html); + } + + [Fact] + public void Pasta_sem_acesso_nunca_vira_zero() + { + var html = Texto(Dados(Raiz())); + Assert.Contains("System Volume Information", html); + Assert.Contains("sem acesso", html); + Assert.Contains("não entra nesta conta", html); + } + + [Fact] + public void Varredura_interrompida_avisa_no_topo() + { + var html = Texto(Dados(Raiz(), interrompida: true)); + Assert.Contains("A varredura foi interrompida", html); + } + + [Fact] + public void Nao_carrega_nada_de_fora() + { + var html = RelatorioTecnico.Gerar(Dados(Raiz())); + Assert.DoesNotContain("src=\"http", html); + Assert.DoesNotContain("pasta", raiz)]); + var html = RelatorioTecnico.Gerar(Dados(raiz)); + Assert.DoesNotContain("pasta", html); + Assert.Contains("<b>pasta</b>", html); + } + + [Fact] + public void Duplicados_entram_so_quando_houve_busca() + { + Assert.DoesNotContain("

Duplicados

", Texto(Dados(Raiz()))); + var vazio = new ResultadoDuplicados(Raiz(), [], 0, false); + Assert.Contains("

Duplicados

", Texto(Dados(Raiz(), duplicados: vazio))); + } + + [Fact] + public void Nome_do_arquivo_vem_da_pasta_e_da_data() + { + var quando = new DateTime(2026, 10, 1, 9, 5, 0); + Assert.Equal("espaco-c-20261001-0905", RelatorioTecnico.NomeDoArquivo(new NoPasta(@"C:\", null), quando)); + Assert.Equal("espaco-financeiro-20261001-0905", RelatorioTecnico.NomeDoArquivo(new NoPasta("Financeiro", null), quando)); + } + + [Fact] + public void Grava_em_utf8() + { + var arquivo = Path.Combine(AppContext.BaseDirectory, "relatorio-" + Guid.NewGuid().ToString("N") + ".html"); + RelatorioTecnico.Gravar(Dados(Raiz()), arquivo); + Assert.Contains("Relatório de espaço em disco", WebUtility.HtmlDecode(File.ReadAllText(arquivo))); + File.Delete(arquivo); + } +} diff --git a/testes/mapdisk.testes/sem-rede-testes.cs b/testes/mapdisk.testes/sem-rede-testes.cs new file mode 100644 index 0000000..7f8e43c --- /dev/null +++ b/testes/mapdisk.testes/sem-rede-testes.cs @@ -0,0 +1,22 @@ +namespace MapDisk.Testes; + +/// Regra 6 do produto: o programa não envia nada para fora da máquina. +public class SemRedeTestes +{ + private static readonly string[] ClassesDeRede = + [ + "HttpClient", "WebClient", "WebRequest", "HttpWebRequest", "TcpClient", "UdpClient", "Socket", "SmtpClient", + "System.Net.Http", "System.Net.Sockets", "System.Net.Mail", "Dns.", + ]; + + [Fact] + public void Programa_nao_usa_classe_de_rede() + { + var src = Path.Combine(CaracteresProibidosTestes.RaizDoRepositorio(), "src"); + var achados = Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Split(Path.DirectorySeparatorChar).Any(p => p is "bin" or "obj")) + .SelectMany(f => ClassesDeRede.Where(c => File.ReadAllText(f).Contains(c, StringComparison.Ordinal)).Select(c => $"{Path.GetFileName(f)}: {c}")) + .ToList(); + Assert.Empty(achados); + } +}