From 860fe44d3558f35eec3daa47af3f932a23299eb9 Mon Sep 17 00:00:00 2001 From: dkwolf1 <102389032+dkwolf1@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:06:55 +0200 Subject: [PATCH] Harden Hass-Cleaner 1.0.2 --- .github/workflows/ci.yml | 2 + README.md | 10 +- ROADMAP.md | 6 +- hass_cleaner/CHANGELOG.md | 13 ++ hass_cleaner/DOCS.md | 112 +++++----- hass_cleaner/config.yaml | 4 +- hass_cleaner/hass_cleaner/__init__.py | 2 +- hass_cleaner/hass_cleaner/export_text.py | 121 ++++++++++ hass_cleaner/hass_cleaner/plans.py | 6 +- hass_cleaner/hass_cleaner/policy.py | 4 + hass_cleaner/hass_cleaner/quarantine.py | 146 +++++++++++- hass_cleaner/hass_cleaner/recorder.py | 2 +- hass_cleaner/hass_cleaner/registry_cleanup.py | 84 +++++-- hass_cleaner/hass_cleaner/reporting.py | 16 +- hass_cleaner/hass_cleaner/scanner.py | 53 +++-- hass_cleaner/hass_cleaner/server.py | 117 +++++++++- hass_cleaner/hass_cleaner/settings.py | 28 ++- hass_cleaner/hass_cleaner/storage.py | 6 + hass_cleaner/run.sh | 13 ++ hass_cleaner/tests/test_policy.py | 1 + hass_cleaner/tests/test_quarantine.py | 7 +- hass_cleaner/tests/test_recovery_journals.py | 211 ++++++++++++++++++ hass_cleaner/tests/test_registry_cleanup.py | 12 +- hass_cleaner/tests/test_reporting.py | 8 +- hass_cleaner/tests/test_review_fixes.py | 150 +++++++++++++ hass_cleaner/tests/test_server.py | 64 +++++- hass_cleaner/web/assets/app.js | 4 +- hass_cleaner/web/assets/i18n.js | 104 ++++++++- hass_cleaner/web/index.html | 6 +- hass_cleaner/web/tests/i18n.test.cjs | 52 +++++ 30 files changed, 1182 insertions(+), 182 deletions(-) create mode 100644 hass_cleaner/hass_cleaner/export_text.py create mode 100644 hass_cleaner/tests/test_recovery_journals.py create mode 100644 hass_cleaner/tests/test_review_fixes.py create mode 100644 hass_cleaner/web/tests/i18n.test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b33d517..200b56b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,6 @@ jobs: - run: pip install -r requirements.txt - run: python -m unittest discover -s tests -v - run: node --check web/assets/app.js + - run: node --check web/assets/i18n.js + - run: node web/tests/i18n.test.cjs - run: python -m compileall -q hass_cleaner tests diff --git a/README.md b/README.md index 03a2768..260a7b6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Hass-Cleaner is a Home Assistant App for inspecting storage, stale entities and registry relationships before anything is cleaned up. Safety, informed user choice and recovery come before reclaimed space. -> **Release status:** version 1.0.1 is the current maintenance release for controlled early use. Hass-Cleaner provides facts, advice, backup options and recovery guidance; the user makes the final cleanup decision. +> **Release status:** version 1.0.2 is the current maintenance release for controlled early use. Hass-Cleaner provides facts, advice, backup options and recovery guidance; the user makes the final cleanup decision. ## English @@ -67,7 +67,7 @@ For version-specific changes, see [CHANGELOG.md](hass_cleaner/CHANGELOG.md). For - Project documentation and GitHub communication use English as the primary language and Dutch as the secondary language. - Interface and Home Assistant App settings support Automatic, English and Nederlands. - Automatic uses the browser or Home Assistant display language and falls back to English for unsupported languages. -- The language selected inside Hass-Cleaner takes precedence over the Home Assistant App configuration. +- Both configuration locations work: changed App configuration fields override their saved UI values; unchanged fields retain your UI choices. Saving in Hass-Cleaner applies your new choices again. Supervisor options are not rewritten by UI saves. ### Install from GitHub @@ -77,7 +77,7 @@ For version-specific changes, see [CHANGELOG.md](hass_cleaner/CHANGELOG.md). For 4. Start the App and open its web interface. 5. Run a scan first and review the report before preparing an action. -GitHub Actions builds version 1.0.1 for `amd64` and `aarch64`. After the container is published, Home Assistant can install or update the release through this repository. +GitHub Actions builds version 1.0.2 for `amd64` and `aarch64`. After the container is published, Home Assistant can install or update the release through this repository. ### Local development and tests @@ -160,7 +160,7 @@ Versiegebonden wijzigingen staan in [CHANGELOG.md](hass_cleaner/CHANGELOG.md). G - Projectdocumentatie en GitHub-communicatie gebruiken Engels als primaire taal en Nederlands als tweede taal. - De interface en Home Assistant App-instellingen ondersteunen Automatisch, English en Nederlands. - Automatisch gebruikt de browser- of Home Assistant-weergavetaal en valt bij een niet-ondersteunde taal terug op Engels. -- De taalkeuze binnen Hass-Cleaner heeft voorrang op de Home Assistant App-configuratie. +- Beide configuratieplekken werken: gewijzigde App-instellingen krijgen per veld voorrang; overige UI-keuzes blijven behouden. Opnieuw opslaan binnen Hass-Cleaner past je nieuwe keuze toe. ### Installeren vanuit GitHub @@ -170,7 +170,7 @@ Versiegebonden wijzigingen staan in [CHANGELOG.md](hass_cleaner/CHANGELOG.md). G 4. Start de App en open de webinterface. 5. Voer eerst een scan uit en beoordeel het rapport voordat je een actie voorbereidt. -GitHub Actions bouwt versie 1.0.1 voor `amd64` en `aarch64`. Na publicatie van de container kan Home Assistant de release via deze repository installeren of bijwerken. +GitHub Actions bouwt versie 1.0.2 voor `amd64` en `aarch64`. Na publicatie van de container kan Home Assistant de release via deze repository installeren of bijwerken. ### Lokaal ontwikkelen en testen diff --git a/ROADMAP.md b/ROADMAP.md index 7f7a9cb..fbe4931 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,10 +19,11 @@ Veiligheid gaat vóór extra opruimwinst. Een release gaat pas door wanneer risi | 0.9.1 | Afgerond | Toegestane back-upcontrole, hergebruik van back-upbewijs, bewuste optionele back-upkeuze en selecteerbare entitybeoordeling. | | 1.0.0 | Uitgebracht | Gebruikersgestuurde cleanup, registeruitvoering, persoonlijke inhoud, schone start en behoud van harde systeembescherming. | | 1.0.1 | Voorbereid | Technische hardening: crashbestendige uitvoering, paginering, atomaire opslag, toegankelijkheid en betrouwbaar frontendcachebeleid. | +| 1.0.2 | Voorbereid | Herstel- en registerjournals, veilige configuratiesynchronisatie, begrensd scangeheugen, robuuste verzoekvalidatie en verbeterde tweetalige exports. | ### Praktijkcontrole voor vroege testers -- 1.0.1 installeren of bijwerken via de echte GitHub/GHCR-workflow. +- 1.0.2 installeren of bijwerken via de echte GitHub/GHCR-workflow. - Volledige back-up starten en voltooiing op Home Assistant OS verifiëren. - Zowel een veilige als een bewust gekozen reviewkandidaat naar quarantaine verplaatsen. - Hersteltest uitvoeren en hetzelfde bestand werkelijk terugplaatsen. @@ -69,10 +70,11 @@ Safety takes priority over reclaimed space. A release proceeds only when risky s | 0.9.1 | Completed | Permitted backup verification, reusable evidence, an explicit optional backup choice and selectable entity reviews. | | 1.0.0 | Released | User-directed cleanup, registry execution, personal content, clean-start controls and hard core-system protection. | | 1.0.1 | Prepared | Technical hardening: crash-safe execution, pagination, atomic storage, accessibility and reliable frontend caching. | +| 1.0.2 | Prepared | Recovery and registry journals, safe configuration synchronization, bounded scan memory, robust request validation and improved bilingual exports. | ### Practical checks for early testers -- Install or update 1.0.1 through the real GitHub/GHCR workflow. +- Install or update 1.0.2 through the real GitHub/GHCR workflow. - Start a full backup and verify completion on Home Assistant OS. - Move both a safe file and an explicitly accepted review candidate into quarantine. - Run the restore test and restore that file to its original location. diff --git a/hass_cleaner/CHANGELOG.md b/hass_cleaner/CHANGELOG.md index 0546276..9ce2b7b 100644 --- a/hass_cleaner/CHANGELOG.md +++ b/hass_cleaner/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.2 — 2026-09-06 + +- Apply changed Supervisor configuration fields without discarding unrelated UI choices; migrate the obsolete deletion_mode option at startup. +- Reject history clearing during active work and prevent new scans during clearing; include the entity comparison snapshot in a clean start. +- Reject malformed, non-object and oversized request bodies without changing settings; return structured operation errors and bound request-read time. +- Share Python-cache classification between scanning and quarantine validation, including reviewed bytecode without source files. +- Bound retained in-memory scans and avoid serializing the full registry for summary requests; preserve registry error details. +- Correct dynamic English text and accessibility labels, support language-specific Markdown downloads and document configuration precedence in English first. +- Restore files without overwriting targets created concurrently by integrations. +- Journal restore and purge intent and recover interrupted operations on startup, retaining recovery copies until completion is recorded. +- Persist registry cleanup intent and per-command progress; stop on journal errors and always release the operation lock. Interrupted outcomes remain visible for review. +- Preserve unreadable or damaged quarantine manifests and report errors instead of treating existing recovery data as empty. + ## 1.0.1 — 2026-08-26 - Quarantaine en registeropschoning crashbestendiger en volledig geaudit gemaakt. diff --git a/hass_cleaner/DOCS.md b/hass_cleaner/DOCS.md index 88aced4..70a450e 100644 --- a/hass_cleaner/DOCS.md +++ b/hass_cleaner/DOCS.md @@ -1,86 +1,82 @@ # Hass-Cleaner -Versie 1.0.1 toont geregistreerde entities en runtime-only states apart. Status en meetduur zijn advies en filters, geen verwijdertoestemming. De gebruiker kan geregistreerde entities selecteren en na risico- en back-upkeuze via de officiële Home Assistant-API verwijderen. Runtime-only states hebben geen registeritem en blijven uitgesloten. +Storage auditing and user-directed cleanup for Home Assistant OS. Handle with care: review every selection and keep a complete Home Assistant backup. Availability states and observation periods are guidance, not proof that an object is unused. -Deze versie inventariseert veilig bestanden en Home Assistant-registers en kan afzonderlijk de officiële Recorder-purgeactie uitvoeren. +## Getting started -## Veiligheidswaarborg +1. Start the app, enable **Show in sidebar**, and open its interface. +2. Run **New scan**. Scanning does not change your Home Assistant files or registries. +3. Review **Scan results**, **Entities** and **Bundles**. Nothing is selected automatically. +4. Select what you want to change and choose **Prepare cleanup**. Preparation itself changes nothing. +5. Read the consequences and recovery advice. Choose whether to create/verify a backup, confirm one manually or proceed without one. +6. Confirm the separate execution step only when you understand the selection. -- Scannen en registeronderzoek zijn read-only. De beschrijfbare configuratiemount wordt uitsluitend gebruikt voor exact geselecteerde, opnieuw gevalideerde veilige bestanden. -- De volledige batch wordt vóór de eerste wijziging gevalideerd op pad, type, grootte, wijzigingstijd en actuele beleidsclassificatie. -- Quarantainekopieën en herstel worden met SHA-256 gecontroleerd; een bestaand doelbestand wordt nooit overschreven. -- Na de bewaartermijn wordt niets automatisch gewist: definitief verwijderen vereist een nieuwe checksumcontrole en de exacte bevestiging `VERWIJDER`. -- Permanent verwijderen van bestanden buiten verlopen quarantaine is technisch uitgesloten. -- Registeruitvoering gebruikt uitsluitend officiële Home Assistant WebSocket-opdrachten en vereist een exacte aantalsbevestiging. -- Scannen verandert geen bestanden of metadata. -- Niets wordt vooraf geselecteerd. -- Beschermde bestanden zijn niet selecteerbaar. -- Registergegevens worden alleen via de officiële read-only WebSocket-commando's opgevraagd. -- Alleen expliciet geselecteerde entities en apparaat/config-entryrelaties kunnen worden gewijzigd; gebieden en config-entries zelf blijven buiten scope. -- `recorder.purge`, bestandsquarantaine en expliciet gekozen registeropschoning hebben ieder hun eigen waarschuwing en bevestiging. -- Inhoudsadvies toont nooit ruwe geheime waarden en verandert geen bestanden. -- Geavanceerde beoordeling toont alleen technische analyse, geen uitvoering. +Registered entities can be selected; runtime-only states have no registry entry and are excluded. Filters and grouped temporary signals help you review large installations. Local choices to monitor, expect or defer a signal do not disable entities or change Home Assistant. -## Gebruik +## File safety and recovery -1. Start de app. -2. Schakel **Tonen in zijbalk** in. -3. Open **Cleanup**. -4. Kies **Nieuwe scan**. -5. Controleer de bestandscategorieën veilig, beoordeling en beschermd. -6. Open **Entiteiten**. De veilige standaard toont alleen bewezen aandachtspunten; kies **Tijdelijke signalen gegroepeerd bekijken** om de nulmeting en gevolgde signalen per integratie te openen. -7. Filter zo nodig op `unavailable`, `unknown`, duur, integratie, apparaat of ruimte. Selecteer geregistreerde entities en kies **Opschoning voorbereiden**; controleer advies en relaties en maak bewust een back-upkeuze. -8. Open **Bundels** om apparaten, entities en integraties samen te beoordelen. -9. Open **Database** alleen wanneer je bewust Recorder-historie wilt opschonen. -10. Kies **Exporteren** bij Scanstatus of Scanresultaten. Het venster legt uit welk formaat geschikt is. -11. Deel het rapport voor controle voordat register- of bestandsopschoning wordt overwogen. +- Protected system files cannot be selected. Personal and uncertain content requires additional risk acknowledgement. +- Before moving files, the whole selection is revalidated against current paths, types, sizes, modification times, checksums and classification. +- Selected files go to quarantine first. Restore checks SHA-256 and never overwrites an existing target file. +- Interrupted restore and purge operations are reconciled at startup. Damaged journals are reported, not replaced with empty history. +- Expired quarantine files are not deleted automatically. Permanent removal requires a separate confirmation. +- Use a full backup if recovery through quarantine is unavailable. -## Impact- en hersteladvies +Python bytecode without matching source is a review item, not a safe candidate. Removing it can prevent a custom integration from loading. -Klik in **Scanresultaten** op een bestandsnaam om te zien: +## Entities and bundles -- hoe sterk het bewijs voor opschoning is; -- welke veilige structuur uit het bestand is herkend; -- wat mogelijk kan stoppen of verloren gaan; -- hoe het onderdeel hersteld kan worden; -- welke eerste stap Hass-Cleaner adviseert. +The app reads entity, device and area registries, configuration entries and current states through official Home Assistant APIs. Missing relationships and unavailable states need your interpretation. -JSON- en YAML-previews bevatten alleen sleutelnamen en tellingen. Wachtwoorden, tokens, API-sleutels en andere waarden worden niet in de scanresultaten of impactplannen opgenomen. +Execution removes explicitly selected entity entries or device/config-entry relationships through official APIs. It does not delete areas or configuration entries themselves. Changes can break dashboards, scripts and automations; integrations may recreate objects. There is no individual registry undo: recovery requires a Home Assistant backup. -## Geavanceerde beoordeling +Registry execution records intent and per-command progress. An interrupted or uncertain outcome must be reviewed before trying again. -Review-items en persoonlijke inhoud zijn selecteerbaar bij **Opschoning voorbereiden** en vereisen extra risicobevestiging voor quarantaine. Beschermde items blijven altijd geblokkeerd. Het overzicht kan als JSON of Markdown worden gedownload en bevat risico en herstelstappen. +## Database -## Registercontrole +**Database** uses the official `recorder.purge` service. This permanently removes historical data, not current entities or devices. Choose the days to retain and confirm `PURGE`. -De app vergelijkt read-only: +Repacking is off by default: it can require substantial processing and temporary disk space. Applying the Recorder filter also removes historical data excluded by the current Recorder configuration. -- entity-, device- en area-registers; -- configuratie-entries; -- de momenteel geladen entity-states. +## Backups -Entities zonder apparaat, apparaten zonder entities, uitgeschakelde entities en statussignalen worden als feiten en advies getoond. De gebruiker bepaalt de functionele noodzaak. Iedere geregistreerde entity kan aan de voorbereide opschoning worden toegevoegd; runtime-only states niet. Verwijderen kan relaties breken of door een integratie ongedaan worden gemaakt en heeft geen individuele undo. Herstel gebeurt via een Home Assistant-back-up. +Scanning and preparation do not require a backup. Before execution, a verified full backup is strongly recommended. The app can request one through Supervisor and reuse recent verification for up to 24 hours. -De lokale keuzes **Volgen**, **Verwacht** en **Uitstellen** verbergen alleen een melding in Hass-Cleaner. Ze schakelen geen entity uit en wijzigen geen Home Assistant-register. In **Historie** zie je wat sinds de voorgaande scan nieuw, gewijzigd, hersteld of verdwenen is. +You may confirm a manually checked backup or deliberately continue without one. This requires explicit acknowledgement and is logged; it does not guarantee that changes can be undone. -Tijdelijke signalen worden standaard ingeklapt per integratie. Iedere groep toont afzonderlijk hoeveel entities `unavailable`, `unknown` of `problem` zijn, de langste gevolgde duur en het hoogste aantal metingen. Open een groep om de afzonderlijke apparaten en entities te beoordelen. Bij grote registerafwijkingen toont de app maximaal 100 apparaten in het dialoogvenster; JSON en CSV blijven volledig. +## Exports and privacy -## Rapportbestanden +**Export** explains the available formats: -Iedere voltooide scan levert drie rapporten: +- **Markdown:** a readable summary with risks and recovery guidance, in the selected interface language. +- **CSV:** tabular results for filtering and sorting in a spreadsheet. +- **JSON:** complete structured results for technical investigation. -- Markdown voor menselijke beoordeling; -- CSV voor filteren en sorteren; -- JSON voor technische controle. +Readable reports limit large tables; JSON and CSV retain detailed results. User-defined names and technical identifiers are not translated. Technical exports can contain original diagnostic text. -In de interface staan deze onder één knop **Exporteren**. Markdown is bedoeld om te lezen of met ondersteuning te delen, CSV om grote resultaten in Excel te filteren en JSON voor foutonderzoek, automatisering en volledige technische details. +Content previews expose structure and counts rather than raw secret values. Reports still contain potentially private paths, object names and identifiers: review them before sharing. -## Taal +## Language and configuration -Onder **Instellingen → Interfacetaal** kies je **Automatisch**, **Nederlands** of **English**. Automatisch gebruikt de taalvoorkeur van de browser waarin Home Assistant draait en valt bij een andere taal terug op Engels. Dezelfde optie staat in de Home Assistant App-configuratie. Een opgeslagen keuze in Hass-Cleaner zelf heeft voorrang op de App-configuratie. +Choose **Automatic**, **English** or **Dutch** in **Settings → Interface language**, or in the Home Assistant App configuration. Automatic follows the first supported browser language preference and falls back to English; it does not independently read your Home Assistant account language. -Rapporten leggen scanresultaten en keuzes vast. Onder **Instellingen** bepaal je hoeveel complete rapportsets Hass-Cleaner bewaart. Alleen bestanden met de eigen naamstructuur in `/data/reports` worden beheerd. Via **Historie → Schone start** kun je scan-, plan-, register- en Recorder-logboeken wissen; actieve quarantainebestanden blijven altijd behouden. +Changed App configuration fields override the corresponding saved UI fields. Other UI choices remain unchanged. Saving in the app applies your new choices again. UI saves never rewrite Supervisor's options file. -## Back-up +If upgrading from an older version with no configuration baseline, newer Supervisor options take precedence once. Obsolete `deletion_mode` options are removed through Supervisor during startup when permitted. -Een scan vereist geen back-up. Vóór bestandsquarantaine of Recorder-purge kan de app via de officiële Supervisor API een volledige back-up starten. Hass-Cleaner controleert voltooiing via de toegestane back-uplijst en kan recent bewijs maximaal 24 uur hergebruiken. Een geverifieerde back-up is sterk aanbevolen, maar de gebruiker kan ook een zelf gecontroleerde back-up bevestigen of bewust zonder back-up doorgaan; die keuze wordt geaudit. Recorder-purge vereist daarnaast exact `PURGE`. `repack` is standaard uitgeschakeld omdat dit een zware bewerking is en tijdelijk extra schijfruimte kan gebruiken. +## History and storage + +Set the number of report sets to retain in **Settings**. Only Hass-Cleaner's own report filenames are managed. Completed scan data in memory is bounded independently of disk report retention. + +**History → Clean start** clears local reports, observations, entity choices, comparison snapshots, preparations and completed operation history. Wait for running operations to finish first. It does not change Home Assistant or remove active quarantine files. + +**Quarantine → Clear completed log** removes completed log entries while retaining active recovery records. Interrupted or uncertain registry outcomes remain available for review. + +## Nederlands — kort + +- Begin met **Nieuwe scan**; scannen en **Opschoning voorbereiden** wijzigen niets. +- Beoordeel zelf of bestanden, entiteiten en apparaten nog nodig zijn. Maak bij voorkeur een volledige Home Assistant-back-up. +- Bestanden gaan eerst naar quarantaine. Registerverwijdering heeft geen individuele herstelknop; daarvoor is een back-up nodig. +- Kies **Instellingen → Interfacetaal** voor Nederlands. Gewijzigde App-instellingen krijgen per veld voorrang; overige UI-keuzes blijven behouden. +- **Exporteren** biedt een leesbaar rapport, CSV voor filteren en JSON voor technische details. Controleer exports voordat je ze deelt. +- **Historie → Schone start** wist lokale historie zodra actieve bewerkingen klaar zijn. Actieve quarantaine en onzekere herstelgegevens blijven behouden. diff --git a/hass_cleaner/config.yaml b/hass_cleaner/config.yaml index 275137c..8793e36 100644 --- a/hass_cleaner/config.yaml +++ b/hass_cleaner/config.yaml @@ -1,7 +1,7 @@ name: Hass-Cleaner -version: "1.0.1" +version: "1.0.2" slug: hass_cleaner -description: Veilige audit en gecontroleerde opschoning voor Home Assistant OS +description: Storage audit and controlled cleanup for Home Assistant OS url: https://github.com/dkwolf1/Hass-Cleaner arch: - amd64 diff --git a/hass_cleaner/hass_cleaner/__init__.py b/hass_cleaner/hass_cleaner/__init__.py index f05ca5c..651b326 100644 --- a/hass_cleaner/hass_cleaner/__init__.py +++ b/hass_cleaner/hass_cleaner/__init__.py @@ -1,3 +1,3 @@ """Hass-Cleaner Home Assistant App.""" -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/hass_cleaner/hass_cleaner/export_text.py b/hass_cleaner/hass_cleaner/export_text.py new file mode 100644 index 0000000..0d28ded --- /dev/null +++ b/hass_cleaner/hass_cleaner/export_text.py @@ -0,0 +1,121 @@ +"""English readable exports. Object names and identifiers are kept verbatim.""" + +CATEGORIES = { + "python_cache": "Generated Python bytecode; the matching source file is present.", + "python_cache_without_source": "Python bytecode has no matching source file. Removing it may prevent the integration from loading.", + "old_log": "Old diagnostic log; removing it discards diagnostic history.", + "editor_artifact": "Editor or operating-system metadata.", + "brand_cache": "Home Assistant icon cache; icons may need to be downloaded again.", + "personal_media": "Personal recordings, snapshots or timelapses. Moving them may break links or remove access to personal content.", + "integration_cache_candidate": "Cache-like path. Active use and automatic regeneration have not been established.", + "temporary_or_backup": "Possible temporary or backup file. The name alone does not establish that it is unused.", + "large_orphan_device_group": "Large group of devices without entities. Integration ownership and active use must be checked before removal.", + "long_unavailable_entities": "Persistent availability problems. Check the integration, device and dependencies before removal.", +} + + +def cell(value): + return str(value).replace("|", "\\|").replace("\n", " ") + + +def table(headers, rows): + return ["", "| " + " | ".join(headers) + " |", "|" + "---|" * len(headers), + *["| " + " | ".join(cell(value) for value in row) + " |" for row in rows], ""] + + +def guidance(category, risk="review"): + if risk == "protected": + return "Protected system content; excluded from cleanup." + return CATEGORIES.get(category, "Review ownership, dependencies and recovery before proceeding. Status alone does not prove that removal is safe.") + + +def report_markdown(report): + scan = report["scan"] + summary = report["review_summary"] + lines = ["# Hass-Cleaner audit report", "", "> AUDIT-ONLY: this report has not deleted, moved or changed anything.", "", + f"- Scan ID: `{scan['id']}`", f"- Started: {scan.get('started_at')}", f"- Finished: {scan.get('finished_at')}", + f"- Files checked: {scan.get('visited_files', 0)}", f"- Files ignored by policy: {scan.get('ignored_files', 0)}", + f"- Suggested candidates: {summary['proposed_for_cleanup_count']}", + f"- Review yourself: {summary['requires_manual_review_count']}", f"- Protected: {summary['protected_count']}", + "", "## Cleanup categories", ""] + cleanup = report.get("cleanup_guidance", {}) + recipes = cleanup.get("safe_recipes", []) + cleanup.get("investigation_recipes", []) + lines += table(["Category", "Files", "Bytes", "Assessment", "Guidance"], + [(item["category"], item["file_count"], item["size_bytes"], + "Suggested candidate" if item["kind"] == "safe" else "Review yourself", guidance(item["category"])) for item in recipes]) + lines += ["", "Restore moved files from quarantine. Review items require explicit acceptance of the content risk.", + "", "### System inventory — retained", ""] + lines += table(["Category", "Files", "Bytes"], [(item["category"], item["count"], item["size_bytes"]) for item in cleanup.get("inventory", [])]) + lines += ["", "The complete file inventory and original technical details are available in the JSON and CSV exports.", + "", "## Home Assistant registry audit", ""] + registry = scan.get("registry_audit", {}) + lines += [f"Status: {cell(registry.get('status', 'unavailable'))}"] + if registry.get("status") == "completed": + lines += table(["Metric", "Count"], sorted(registry.get("summary", {}).items())) + lines += ["", "### Findings requiring review", ""] + anomalies = registry.get("anomalies", []) + lines += table(["Category", "Integration", "Sample device IDs", "Guidance"], + [(item.get("category", ""), item.get("domain", ""), ", ".join(item.get("sample_device_ids", [])), + guidance(item.get("category", ""))) for item in anomalies[:100]]) + findings = registry.get("findings", []) + review = [item for item in findings if item.get("severity") == "review"] + lines += table(["Type", "Name", "ID", "Category", "Related ID"], + [(item.get("subject_type", ""), item.get("name", ""), item.get("subject_id", ""), item.get("category", ""), item.get("related_id", "")) for item in review[:100]]) + lines += ["", "### Informational findings", ""] + counts = {} + for item in findings: + if item.get("severity") == "info": + key = item.get("category", "unknown") + counts[key] = counts.get(key, 0) + 1 + lines += table(["Category", "Count"], sorted(counts.items())) + lines += ["", "### Bundles with warnings", ""] + bundles = [item for item in registry.get("bundles", []) if item.get("review_count", 0)] + lines += table(["Integration", "Domain", "Devices", "Entities", "Warnings", "Config entry"], + [(item["title"], item["domain"], len(item["devices"]), len(item["entities"]), item["review_count"], item["config_entry_id"]) for item in bundles[:50]]) + workspace = registry.get("entity_workspace", {}) + lines += ["", "### Entity review", ""] + lines += table(["State", "Count"], sorted(workspace.get("summary", {}).get("by_status", {}).items())) + entities = [item for item in workspace.get("items", []) if item.get("selectable_for_plan")] + lines += table(["Entity", "State", "Days", "Integration", "Device", "Area"], + [(item["entity_id"], item.get("status", ""), item.get("duration_days", 0), item.get("integration", ""), item.get("device_name", ""), item.get("area_name", "")) for item in entities[:100]]) + lines += ["", "### Temporary and persistent signals", ""] + lines += table(["Integration", "Total", "Needs attention", "Monitored", "Longest observation (hours)"], + [(item["integration"], item.get("total", 0), item.get("attention", 0), item.get("watch", 0), + round(float(item.get("max_duration_seconds", 0)) / 3600, 1)) for item in workspace.get("signal_groups", [])[:50]]) + lines += ["", "Tables show up to 100 findings/entities and 50 bundles/signal groups. JSON and CSV contain all results."] + else: + lines += ["Registry information could not be loaded. This does not mean the registry is empty; check the connection and technical report."] + lines += ["", "## Recovery and execution", "", + "- Only explicitly selected, non-protected files may enter quarantine after revalidation.", + "- Review personal or uncertain content and accept its risks before proceeding.", + "- A completed Home Assistant backup is strongly recommended; the user confirms their backup choice.", + "- Restore files from quarantine. Existing target files are never overwritten.", + "- Registry changes may break dashboards and automations; integrations may recreate objects. Recovery requires a Home Assistant backup.", + "- Permanent file deletion requires an expired quarantine period and a separate confirmation.", ""] + return "\n".join(lines) + + +def plan_markdown(plan): + summary = plan["summary"] + lines = ["# Hass-Cleaner cleanup preparation", "", + "> PREPARED CLEANUP: nothing has changed. Review the consequences and your backup choice before confirming execution.", "", + f"- Plan ID: `{plan['id']}`", f"- Scan ID: `{plan['scan_id']}`", + f"- Files: {summary['file_count']}", f"- Entities: {summary['entity_count']}", + f"- Devices: {summary.get('device_count', 0)}", f"- Executable actions: {summary['executable_actions']}", + "", "## Files", ""] + lines += table(["Path", "Bytes", "Assessment", "Action", "Guidance"], + [(item["path"], item["before"]["size_bytes"], item.get("risk", "review"), "Quarantine", guidance(item["before"]["category"], item.get("risk", "review"))) for item in plan["files"]]) + lines += ["", f"Restore from quarantine; retention: {plan['settings']['retention_days']} days. Existing target files are never overwritten.", + "", "## Bundles", ""] + lines += table(["Integration", "Domain", "Devices", "Entities"], + [(item["title"], item["domain"], item["before"]["device_count"], item["before"]["entity_count"]) for item in plan["bundles"]]) + lines += ["", "## Entities", ""] + lines += table(["Entity", "State", "Days", "Integration", "Device"], + [(item["entity_id"], item.get("status", ""), item.get("duration_days", 0), item.get("integration", ""), item.get("device_name", "")) for item in plan["entities"]]) + lines += ["", "## Recovery and risks", "", + "- Check official relationships, dashboards, scripts and automations before removal.", + "- Removing registry objects has no individual undo. Integrations may recreate objects and references may break.", + "- Use a complete Home Assistant backup for registry recovery. Creating and checking one is strongly recommended.", + "- Personal content may be lost or unavailable after moving it. Quarantine does not prove that content is unused.", + "- Protected system files remain excluded. Cancel if the consequences are unclear.", ""] + return "\n".join(lines) diff --git a/hass_cleaner/hass_cleaner/plans.py b/hass_cleaner/hass_cleaner/plans.py index 406fc3c..fa24ce9 100644 --- a/hass_cleaner/hass_cleaner/plans.py +++ b/hass_cleaner/hass_cleaner/plans.py @@ -180,7 +180,11 @@ def _path(self, plan_id: str, extension: str) -> Path: return self.root / f"hass-cleaner-plan-{plan_id}.{extension}" -def _markdown(plan: dict[str, Any]) -> str: +def _markdown(plan: dict[str, Any], language: str | None = None) -> str: + language = language or plan.get("settings", {}).get("language", "en") + if language != "nl": + from .export_text import plan_markdown + return plan_markdown(plan) summary = plan["summary"] lines = [ "# Hass-Cleaner - impact- en herstelplan", diff --git a/hass_cleaner/hass_cleaner/policy.py b/hass_cleaner/hass_cleaner/policy.py index 620fd74..c7d5de9 100644 --- a/hass_cleaner/hass_cleaner/policy.py +++ b/hass_cleaner/hass_cleaner/policy.py @@ -64,6 +64,10 @@ def classify(root: Path, path: Path, mode: int, modified: float, *, min_temp_age in_custom_components = "custom_components" in parts if "__pycache__" in parts and suffix in {".pyc", ".pyo"}: if item_age >= min_temp_age_days: + stem = path.name.split(".cpython-", 1)[0] + if path.parent.name != "__pycache__" or stem == path.name or not (path.parent.parent / f"{stem}.py").is_file(): + return Classification("python_cache_without_source", RISK_REVIEW, + "Python-cache heeft geen aantoonbaar bijbehorend .py-bronbestand", "review") return Classification("python_cache", RISK_SAFE, f"Gegenereerde Python-bytecode van {item_age} dagen oud", "delete") return None if in_custom_components: diff --git a/hass_cleaner/hass_cleaner/quarantine.py b/hass_cleaner/hass_cleaner/quarantine.py index e2580f0..b053586 100644 --- a/hass_cleaner/hass_cleaner/quarantine.py +++ b/hass_cleaner/hass_cleaner/quarantine.py @@ -25,7 +25,10 @@ def _serialized(method): @wraps(method) def wrapper(self, *args, **kwargs): with self._lock: - return method(self, *args, **kwargs) + try: + return method(self, *args, **kwargs) + except OSError as exc: + raise QuarantineError(f"Quarantine storage operation failed: {exc}") from exc return wrapper @@ -39,7 +42,12 @@ def __init__(self, config_root: Path, data_root: Path): self.manifest_path = self.root / "manifest.json" self._lock = threading.RLock() with self._lock: - self._reconcile_incomplete_operations() + try: + self._reconcile_incomplete_operations() + except (QuarantineError, OSError) as exc: + # Keep the interface available to report the error. Every read + # and mutation still validates the manifest before proceeding. + print(f"Hass-Cleaner quarantine recovery error: {exc}", flush=True) def execute( self, @@ -70,6 +78,8 @@ def execute( confirmation=confirmation, requested_by=requested_by, ) + except OSError as exc: + raise QuarantineError(f"Quarantine storage operation failed: {exc}") from exc finally: self._lock.release() @@ -196,6 +206,7 @@ def _execute( def restore(self, operation_id: str, file_id: str, *, confirmation: str, requested_by: str) -> dict[str, Any]: if confirmation not in {"HERSTEL", "RESTORE"}: raise QuarantineError("Typ HERSTEL of RESTORE om terugplaatsen te bevestigen") + self._reconcile_incomplete_operations() operation = self._find_operation(operation_id) record = next((item for item in operation.get("files", []) if item.get("id") == file_id), None) if record is None or record.get("status") != "quarantined": @@ -208,7 +219,12 @@ def restore(self, operation_id: str, file_id: str, *, confirmation: str, request if source.is_symlink() or not source.is_file() or _sha256(source) != record.get("sha256"): raise QuarantineError("Herstel is gestopt: checksum van het quarantainebestand klopt niet") target.parent.mkdir(parents=True, exist_ok=True) - temporary = target.with_name(target.name + ".hass-cleaner-restore") + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.hass-cleaner-restore") + record.update({ + "status": "restoring", "restore_temporary": temporary.name, + "restored_at": datetime.now(timezone.utc).isoformat(), "restored_by": requested_by, + }) + self._replace_operation(operation) with source.open("rb") as input_file, temporary.open("xb") as output_file: shutil.copyfileobj(input_file, output_file, 1024 * 1024) output_file.flush() @@ -216,13 +232,19 @@ def restore(self, operation_id: str, file_id: str, *, confirmation: str, request if _sha256(temporary) != record["sha256"]: temporary.unlink(missing_ok=True) raise QuarantineError("Hersteltest is mislukt; bronbestand blijft in quarantaine") - temporary.replace(target) - os.chmod(target, int(record.get("mode", 0o644))) + os.chmod(temporary, int(record.get("mode", 0o644))) restored_mtime = datetime.fromisoformat(str(record["modified_at"]).replace("Z", "+00:00")).timestamp() - os.utime(target, (restored_mtime, restored_mtime)) - source.unlink() - record.update({"status": "restored", "restored_at": datetime.now(timezone.utc).isoformat(), "restored_by": requested_by}) + os.utime(temporary, (restored_mtime, restored_mtime)) + if os.name != "nt": + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + # A hard link publishes the complete file atomically and fails if a + # concurrent writer has created the target. Never fall back to replace. + os.link(temporary, target) + _sync_directory(target.parent) + record["status"] = "restored" self._replace_operation(operation) + self._cleanup_restored(operation, record) return operation @_serialized @@ -259,8 +281,11 @@ def purge_expired(self, operation_id: str, file_id: str, *, confirmation: str, r source = self._contained_path(self._operation_files_root(operation_id), relative, "Ongeldig quarantainepad") if source.is_symlink() or not source.is_file() or _sha256(source) != record.get("sha256"): raise QuarantineError("Definitief verwijderen is gestopt: checksumcontrole mislukt") + record.update({"status": "purging", "deleted_at": datetime.now(timezone.utc).isoformat(), "deleted_by": requested_by}) + self._replace_operation(operation) source.unlink() - record.update({"status": "deleted", "deleted_at": datetime.now(timezone.utc).isoformat(), "deleted_by": requested_by}) + _sync_directory(source.parent) + record["status"] = "deleted" self._replace_operation(operation) return operation @@ -270,8 +295,9 @@ def list(self) -> list[dict[str, Any]]: @_serialized def clear_completed_history(self) -> int: + self._reconcile_incomplete_operations() operations = self._load() - retained_statuses = {"quarantined", "copied", "recovery_required"} + retained_statuses = {"planned", "quarantined", "copied", "restoring", "purging", "recovery_required"} active = [ item for item in operations if item.get("status") == "recovery_required" @@ -323,11 +349,42 @@ def _replace_operation(self, operation: dict[str, Any]) -> None: def _load(self) -> list[dict[str, Any]]: try: value = json.loads(self.manifest_path.read_text(encoding="utf-8")) - return value if isinstance(value, list) else [] - except (FileNotFoundError, OSError, json.JSONDecodeError): + except FileNotFoundError as exc: + if self.root.exists() and any(path.is_dir() for path in self.root.iterdir()): + raise QuarantineError("Quarantine manifest is missing; existing recovery data has been preserved") from exc return [] + except (OSError, ValueError) as exc: + raise QuarantineError("Quarantine manifest cannot be read; existing recovery data has been preserved") from exc + if not isinstance(value, list): + raise QuarantineError("Quarantine manifest has an invalid structure") + ids = set() + for operation in value: + if (not isinstance(operation, dict) or not isinstance(operation.get("id"), str) + or not operation["id"].isalnum() or operation["id"] in ids + or not isinstance(operation.get("files"), list) + or not isinstance(operation.get("status"), str)): + raise QuarantineError("Quarantine manifest has an invalid operation") + ids.add(operation["id"]) + file_ids = set() + file_paths = set() + for record in operation["files"]: + if (not isinstance(record, dict) or not isinstance(record.get("id"), str) + or not record["id"].isalnum() or record["id"] in file_ids + or not isinstance(record.get("status"), str) + or not isinstance(record.get("relative_path"), str) + or not isinstance(record.get("sha256"), str) + or len(record["sha256"]) != 64 + or any(c not in "0123456789abcdef" for c in record["sha256"]) + or record.get("status") not in {"planned", "copied", "quarantined", "restoring", "restored", "purging", "deleted", "rolled_back", "recovery_required"} + or record["relative_path"] in file_paths): + raise QuarantineError("Quarantine manifest has an invalid file record") + self._validated_relative_path(record["relative_path"]) + file_ids.add(record["id"]) + file_paths.add(record["relative_path"]) + return value def _save(self, operations: list[dict[str, Any]]) -> None: + self._load() # Never replace a damaged journal with an empty/new one. self.root.mkdir(parents=True, exist_ok=True) temporary = self.manifest_path.with_suffix(".tmp") with temporary.open("w", encoding="utf-8") as stream: @@ -350,6 +407,9 @@ def _reconcile_incomplete_operations(self) -> None: operations = self._load() changed = False for operation in operations: + for record in operation["files"]: + if record["status"] in {"restoring", "purging", "quarantined"}: + changed = self._recover_file(operation, record) or changed if operation.get("status") not in {"preparing", "running", "partial", "failed", "recovery_required"}: continue operation_id = str(operation.get("id", "")) @@ -419,6 +479,58 @@ def _reconcile_incomplete_operations(self) -> None: operation["status"] = "rolled_back" if changed: self._save(operations) + for operation in operations: + for record in operation["files"]: + if record["status"] == "restored" and record.get("restore_temporary"): + self._cleanup_restored(operation, record) + + def _restore_temporary(self, target: Path, record: dict[str, Any]) -> Path: + name = record.get("restore_temporary", "") + prefix = f".{target.name}." + suffix = ".hass-cleaner-restore" + if (not isinstance(name, str) or not name.startswith(prefix) or not name.endswith(suffix) + or len(name[len(prefix):-len(suffix)]) != 32 + or any(c not in "0123456789abcdef" for c in name[len(prefix):-len(suffix)])): + raise QuarantineError("Invalid restore temporary path; recovery data preserved") + return target.with_name(name) + + def _recover_file(self, operation: dict[str, Any], record: dict[str, Any]) -> bool: + relative = self._validated_relative_path(record["relative_path"]) + source = self._contained_path(self._operation_files_root(operation["id"]), relative, "Invalid quarantine path") + target = self._contained_path(self.config_root, relative, "Invalid restore path") + status = record["status"] + if status == "quarantined" and (source.exists() or source.is_symlink()): + return False + if status == "purging": + # If unlink did not happen, let the user explicitly retry it. + record["status"] = "quarantined" if self._matches_regular_file(source, record["sha256"]) else ( + "recovery_required" if source.exists() or source.is_symlink() else "deleted") + elif status == "restoring": + temporary = self._restore_temporary(target, record) + published = (not target.is_symlink() and not temporary.is_symlink() + and target.is_file() and temporary.is_file() and target.samefile(temporary)) + if published: + record["status"] = "restored" + elif self._matches_regular_file(source, record["sha256"]): + temporary.unlink(missing_ok=True) + record["status"] = "quarantined" + record.pop("restore_temporary", None) + else: + record["status"] = "recovery_required" + else: + # Recover older releases interrupted after deleting the quarantine + # copy but before recording a completed restore. + record["status"] = "restored" if self._matches_regular_file(target, record["sha256"]) else "recovery_required" + return True + + def _cleanup_restored(self, operation: dict[str, Any], record: dict[str, Any]) -> None: + relative = self._validated_relative_path(record["relative_path"]) + source = self._contained_path(self._operation_files_root(operation["id"]), relative, "Invalid quarantine path") + target = self._contained_path(self.config_root, relative, "Invalid restore path") + temporary = self._restore_temporary(target, record) + temporary.unlink(missing_ok=True) + source.unlink(missing_ok=True) + _sync_directory(source.parent) @staticmethod def _validated_relative_path(value: str) -> Path: @@ -460,3 +572,13 @@ def _sha256(path: Path) -> str: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() + + +def _sync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/hass_cleaner/hass_cleaner/recorder.py b/hass_cleaner/hass_cleaner/recorder.py index 2d159ce..c5ef51e 100644 --- a/hass_cleaner/hass_cleaner/recorder.py +++ b/hass_cleaner/hass_cleaner/recorder.py @@ -78,7 +78,7 @@ class PurgeManager: def __init__(self, data_root: Path, purge_caller: Callable[[int, bool, bool], None] = call_recorder_purge): self.history_path = data_root / "recorder-purge-history.json" self.purge_caller = purge_caller - self._lock = threading.Lock() + self._lock = threading.RLock() def history(self) -> list[dict[str, object]]: try: diff --git a/hass_cleaner/hass_cleaner/registry_cleanup.py b/hass_cleaner/hass_cleaner/registry_cleanup.py index a4b459b..2b22588 100644 --- a/hass_cleaner/hass_cleaner/registry_cleanup.py +++ b/hass_cleaner/hass_cleaner/registry_cleanup.py @@ -9,6 +9,7 @@ from typing import Any, Callable from .registry_audit import HomeAssistantApiError, WEBSOCKET_URL, _receive_json, _receive_result +from .storage import atomic_write_json, StorageError class RegistryCleanupError(RuntimeError): @@ -30,6 +31,7 @@ def execute_registry_commands( token: str | None = None, connect: Callable[..., Any] | None = None, url: str = WEBSOCKET_URL, + progress: Callable[[list[dict[str, str]], dict[str, str] | None], None] | None = None, ) -> list[dict[str, str]]: access_token = token or os.environ.get("SUPERVISOR_TOKEN") if not access_token: @@ -53,11 +55,17 @@ def execute_registry_commands( raise RegistryCleanupError(authentication.get("message", "WebSocket-authenticatie geweigerd")) command_id = 1 for entity_id in entities: + if progress: + progress(completed, {"type": "entity", "id": entity_id}) connection.send(json.dumps({"id": command_id, "type": "config/entity_registry/remove", "entity_id": entity_id})) _receive_result(connection, command_id) completed.append({"type": "entity", "id": entity_id, "status": "removed"}) + if progress: + progress(completed, None) command_id += 1 for device in devices: + if progress: + progress(completed, {"type": "device", "id": device["device_id"], "config_entry_id": device["config_entry_id"]}) connection.send(json.dumps({ "id": command_id, "type": "config/device_registry/remove_config_entry", @@ -66,6 +74,8 @@ def execute_registry_commands( })) _receive_result(connection, command_id) completed.append({"type": "device", "id": device["device_id"], "status": "config_entry_removed"}) + if progress: + progress(completed, None) command_id += 1 except Exception as exc: if isinstance(exc, RegistryExecutionError): @@ -85,14 +95,27 @@ class RegistryCleanupManager: def __init__(self, data_root: Path, executor=execute_registry_commands): self.history_path = data_root / "registry-cleanup-history.json" self.executor = executor - self._lock = threading.Lock() + self._lock = threading.RLock() + try: + records = self.history() + interrupted = [record for record in records if record.get("status") == "running"] + for record in interrupted: + record.update(status="interrupted", error="Execution was interrupted; verify pending commands in Home Assistant before retrying.") + if interrupted: + self._save(records) + except RegistryCleanupError as exc: + print(f"Hass-Cleaner registry journal error: {exc}", flush=True) def history(self) -> list[dict[str, Any]]: try: value = json.loads(self.history_path.read_text(encoding="utf-8")) - return value if isinstance(value, list) else [] - except (FileNotFoundError, OSError, json.JSONDecodeError): + except FileNotFoundError: return [] + except (OSError, ValueError) as exc: + raise RegistryCleanupError("Registry journal cannot be read; no changes were made to it") from exc + if not isinstance(value, list) or any(not isinstance(record, dict) or not record.get("id") for record in value): + raise RegistryCleanupError("Registry journal has an invalid structure") + return value def execute(self, scan, plan: dict[str, Any], *, backup_choice: str, backup_token: str, backup_valid: bool, risk_acknowledged: bool, confirmation: str, requested_by: str) -> dict[str, Any]: @@ -149,29 +172,48 @@ def execute(self, scan, plan: dict[str, Any], *, backup_choice: str, backup_toke "completed": [], } try: - record["completed"] = self.executor(entities, devices) - record["status"] = "completed" - except RegistryExecutionError as exc: - record["completed"] = exc.completed - record["status"] = "partial" if exc.completed else "failed" - record["error"] = f"{type(exc).__name__}: {exc}" - raise RegistryCleanupError(str(exc)) from exc - except Exception as exc: - record["status"] = "failed" - record["error"] = f"{type(exc).__name__}: {exc}" - raise RegistryCleanupError(str(exc)) from exc - finally: history = self.history() history.insert(0, record) - self._save(history[:50]) + # An unavailable journal must prevent the first external command. + self._save(history) + + def progress(completed, pending): + record["completed"] = list(completed) + record["pending"] = pending + self._save(history) + + try: + record["completed"] = self.executor(entities, devices, progress=progress) + record["pending"] = None + record["status"] = "completed" + except Exception as exc: + if isinstance(exc, RegistryExecutionError): + record["completed"] = exc.completed + record["status"] = "partial" if record["completed"] else "failed" + record["outcome_uncertain"] = bool(record.get("pending")) + record["error"] = f"{type(exc).__name__}: {exc}" + self._save(history) + raise RegistryCleanupError(str(exc)) from exc + self._save(history) + finally: self._lock.release() return record def clear_history(self) -> None: - self.history_path.unlink(missing_ok=True) + if not self._lock.acquire(blocking=False): + raise RegistryCleanupError("Registry cleanup is running; history cannot be cleared yet") + try: + records = self.history() + # Keep unresolved external outcomes available for investigation. + self._save([record for record in records if record.get("status") in {"running", "interrupted"} or record.get("outcome_uncertain")]) + finally: + self._lock.release() def _save(self, history: list[dict[str, Any]]) -> None: - self.history_path.parent.mkdir(parents=True, exist_ok=True) - temporary = self.history_path.with_suffix(".tmp") - temporary.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(self.history_path) + self.history() + unresolved = [record for record in history if record.get("status") in {"running", "interrupted"} or record.get("outcome_uncertain")] + resolved = [record for record in history if record not in unresolved] + try: + atomic_write_json(self.history_path, unresolved + resolved[:50]) + except StorageError as exc: + raise RegistryCleanupError("Registry journal could not be saved; execution stopped") from exc diff --git a/hass_cleaner/hass_cleaner/reporting.py b/hass_cleaner/hass_cleaner/reporting.py index 33c1450..2020e37 100644 --- a/hass_cleaner/hass_cleaner/reporting.py +++ b/hass_cleaner/hass_cleaner/reporting.py @@ -243,7 +243,11 @@ def _write_csv(scan: ScanResult, path: Path) -> None: ) -def _markdown(report: dict[str, object]) -> str: +def _markdown(report: dict[str, object], language: str | None = None) -> str: + language = language or report.get("settings", {}).get("language", "en") + if language != "nl": + from .export_text import report_markdown + return report_markdown(report) scan = report["scan"] summary = report["review_summary"] assert isinstance(scan, dict) @@ -274,7 +278,7 @@ def _markdown(report: dict[str, object]) -> str: if isinstance(guidance, dict): lines.extend(_markdown_recipes(guidance.get("safe_recipes", []), "Volledig bewezen")) lines.extend(["", "### Eerst nader onderzoeken", ""]) - lines.extend(_markdown_recipes(guidance.get("investigation_recipes", []), "Geblokkeerd")) + lines.extend(_markdown_recipes(guidance.get("investigation_recipes", []), "Zelf beoordelen")) lines.extend(["", "### Systeeminventaris - behouden", ""]) lines.extend(_markdown_inventory(guidance.get("inventory", []))) safe_items = [item for item in items if isinstance(item, dict) and item.get("risk") == "safe"] @@ -282,7 +286,7 @@ def _markdown(report: dict[str, object]) -> str: "", "## Afzonderlijke bestanden", "", - f"{len(safe_items)} bestanden voldoen aan een bewezen veilig recept. Het Markdownrapport toont bewust geen duizenden losse paden.", + f"{len(safe_items)} bestanden vallen binnen de aanbevolen opruimcategorieën. De volledige lijst staat in JSON en CSV.", "", "> De volledige bestandsinventaris en alle technische details staan in de JSON- en CSV-export.", ]) @@ -345,7 +349,7 @@ def _markdown(report: dict[str, object]) -> str: f"- Runtime-only: {entity_summary.get('state_only_total', 0)}", f"- Statusproblemen: {entity_summary.get('attention', 0)}", f"- Uitgeschakeld (informatief): {entity_summary.get('disabled', 0)}", - f"- Selecteerbaar voor geblokkeerd onderzoek: {entity_summary.get('selectable_for_plan', 0)}", + f"- Selecteerbaar voor opschoning: {entity_summary.get('selectable_for_plan', 0)}", ]) lines.extend(["", "#### Statusverdeling", ""]) lines.extend(_markdown_status_summary(entity_summary.get("by_status", {}))) @@ -427,7 +431,7 @@ def _markdown_entity_table(items: list[dict[str, object]]) -> list[str]: if not items: return ["Geen entiteiten met een aandachtspunt gevonden."] lines = [ - "| Entity | Status | Duur | Integratie | Apparaat | Ruimte | Selecteerbaar voor onderzoek | Reden |", + "| Entity | Status | Duur | Integratie | Apparaat | Ruimte | Selecteerbaar voor opschoning | Reden |", "|---|---|---:|---|---|---|---|---|", ] for item in items: @@ -503,7 +507,7 @@ def _markdown_anomalies(value: object) -> list[str]: ] for item in value: if isinstance(item, dict): - lines.append("| {title} | `{domain}` | {evidence} | {risk} | {step} | Geblokkeerd |".format( + lines.append("| {title} | `{domain}` | {evidence} | {risk} | {step} | Zelf beoordelen |".format( title=str(item.get("title", "")).replace("|", "\\|"), domain=str(item.get("domain", "")).replace("|", "\\|"), evidence=str(item.get("evidence_summary") or item.get("summary", "")).replace("|", "\\|"), diff --git a/hass_cleaner/hass_cleaner/scanner.py b/hass_cleaner/hass_cleaner/scanner.py index e2b341d..6b98946 100644 --- a/hass_cleaner/hass_cleaner/scanner.py +++ b/hass_cleaner/hass_cleaner/scanner.py @@ -44,7 +44,7 @@ class ScanResult: registry_audit: RegistryAudit = field(default_factory=lambda: RegistryAudit(status="not_run")) error: str | None = None - def to_dict(self, *, include_items: bool = True) -> dict[str, object]: + def to_dict(self, *, include_items: bool = True, include_registry: bool = True) -> dict[str, object]: totals = {RISK_SAFE: 0, RISK_REVIEW: 0, RISK_PROTECTED: 0} counts = {RISK_SAFE: 0, RISK_REVIEW: 0, RISK_PROTECTED: 0} for item in self.items: @@ -64,7 +64,8 @@ def to_dict(self, *, include_items: bool = True) -> dict[str, object]: } if include_items: payload["items"] = [asdict(item) for item in self.items] - payload["registry_audit"] = self.registry_audit.to_dict() + if include_registry: + payload["registry_audit"] = self.registry_audit.to_dict() payload["cleanup_guidance"] = build_cleanup_guidance(self.items) return payload @@ -109,13 +110,6 @@ def scan_tree(root: Path, settings: Settings, scan_id: str | None = None) -> Sca if decision is None: result.ignored_files += 1 continue - if decision.category == "python_cache" and not _python_source_exists(path): - decision = Classification( - "python_cache_without_source", - RISK_REVIEW, - "Python-cache heeft geen aantoonbaar bijbehorend .py-bronbestand", - "review", - ) content_hash = _sha256(path) if decision.risk != RISK_PROTECTED else "" if decision.risk != RISK_PROTECTED and not content_hash: decision = Classification( @@ -159,15 +153,6 @@ def _display_path(root: Path, path: Path) -> str: return "/homeassistant" if not relative.parts else "/homeassistant/" + relative.as_posix() -def _python_source_exists(path: Path) -> bool: - if path.parent.name != "__pycache__": - return False - stem = path.name.split(".cpython-", 1)[0] - if stem == path.name: - return False - return (path.parent.parent / f"{stem}.py").is_file() - - def _sha256(path: Path) -> str: digest = hashlib.sha256() try: @@ -180,6 +165,7 @@ def _sha256(path: Path) -> str: class ScanManager: + MAX_MEMORY_SCANS = 2 def __init__(self, root: Path, settings_loader, report_dir: Path | None = None, registry_scanner=scan_home_assistant_registries): self.root = root self.settings_loader = settings_loader @@ -188,14 +174,16 @@ def __init__(self, root: Path, settings_loader, report_dir: Path | None = None, self._lock = threading.Lock() self._scans: dict[str, ScanResult] = {} self._latest_id: str | None = None + self._clearing = False def start(self) -> ScanResult: scan = ScanResult(id=uuid.uuid4().hex) with self._lock: - if any(item.status in {"queued", "running"} for item in self._scans.values()): + if self._clearing or any(item.status in {"queued", "running"} for item in self._scans.values()): raise RuntimeError("Er loopt al een scan") self._scans[scan.id] = scan self._latest_id = scan.id + self._prune_memory() threading.Thread(target=self._run, args=(scan.id,), daemon=True, name=f"scan-{scan.id[:8]}").start() return scan @@ -229,6 +217,16 @@ def _run(self, scan_id: str) -> None: completed.finished_at = completed.finished_at or datetime.now(timezone.utc).isoformat() with self._lock: self._scans[scan_id] = completed + self._prune_memory() + + def _prune_memory(self) -> None: + # Called only under _lock. Retain latest and one prior result for UI + # requests finishing while the next scan runs; disk reports are separate. + for scan_id in list(self._scans): + if len(self._scans) <= self.MAX_MEMORY_SCANS: + break + if scan_id != self._latest_id and self._scans[scan_id].status not in {"queued", "running"}: + del self._scans[scan_id] def get(self, scan_id: str) -> ScanResult | None: with self._lock: @@ -252,10 +250,21 @@ def clear_history(self) -> None: if self.report_dir is None: return with self._lock: - self._scans.clear() - self._latest_id = None + if self._clearing or any(item.status in {"queued", "running"} for item in self._scans.values()): + raise RuntimeError("Wait for the current scan to finish before clearing history") + self._clearing = True + try: + self._clear_history_files() + with self._lock: + self._scans.clear() + self._latest_id = None + finally: + with self._lock: + self._clearing = False + + def _clear_history_files(self) -> None: data_root = self.report_dir.parent - for name in ("scan-history.json", "availability-history.json", "entity-decisions.json"): + for name in ("scan-history.json", "availability-history.json", "entity-decisions.json", "entity-snapshot.json"): (data_root / name).unlink(missing_ok=True) for pattern in ("hass-cleaner-audit-*.*",): for path in self.report_dir.glob(pattern): diff --git a/hass_cleaner/hass_cleaner/server.py b/hass_cleaner/hass_cleaner/server.py index da4022f..312e442 100644 --- a/hass_cleaner/hass_cleaner/server.py +++ b/hass_cleaner/hass_cleaner/server.py @@ -6,6 +6,9 @@ import re import secrets import time +from functools import wraps +from contextlib import ExitStack +from itertools import islice from dataclasses import asdict from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -46,13 +49,39 @@ def __init__(self, config_root: Path, data_root: Path, web_root: Path): self.rate_limits: dict[tuple[str, str], list[float]] = {} +class RequestError(ValueError): + def __init__(self, message, status=HTTPStatus.BAD_REQUEST): + super().__init__(message) + self.status = status + + +def operation_errors(method): + @wraps(method) + def wrapped(self): + try: + return method(self) + except RequestError as exc: + self.close_connection = True + self._json({"error": str(exc)}, exc.status) + except (QuarantineError, RegistryCleanupError, PlanError, StorageError, OSError, json.JSONDecodeError) as exc: + self._json({"error": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE) + except RuntimeError as exc: + self._json({"error": str(exc)}, HTTPStatus.CONFLICT) + return wrapped + + class CleanupHandler(BaseHTTPRequestHandler): server_version = f"HassCleaner/{__version__}" + def setup(self): + super().setup() + self.connection.settimeout(15) + @property def state(self) -> AppState: return self.server.state # type: ignore[attr-defined] + @operation_errors def do_GET(self) -> None: parsed = urlsplit(self.path) path = parsed.path @@ -136,14 +165,26 @@ def do_GET(self) -> None: if file_path is None: self._json({"error": "Rapport niet gevonden"}, HTTPStatus.NOT_FOUND) else: - self._download(file_path) + if match.group(2) == "md": + from .reporting import _markdown + source = report_path(self.state.report_root, match.group(1), "json") + if source is None: + raise RequestError("The source report is no longer available", HTTPStatus.NOT_FOUND) + report = json.loads(source.read_text(encoding="utf-8")) + self._text_download(file_path.name, _markdown(report, self._export_language(parsed.query))) + else: + self._download(file_path) elif path.startswith("/api/plans/"): match = re.fullmatch(r"/api/plans/([a-zA-Z0-9]+)\.(json|md)", path) file_path = self.state.plan_manager.path(match.group(1), match.group(2)) if match else None if file_path is None: self._json({"error": "Plan niet gevonden"}, HTTPStatus.NOT_FOUND) else: - self._download(file_path) + if match.group(2) == "md": + from .plans import _markdown + self._text_download(file_path.name, _markdown(self.state.plan_manager.get(match.group(1)), self._export_language(parsed.query))) + else: + self._download(file_path) elif path == "/" or path.endswith("/index.html"): self._file(self.state.web_root / "index.html") elif "/assets/" in path: @@ -157,11 +198,13 @@ def do_GET(self) -> None: # keeps client-side navigation prefix-safe. self._file(self.state.web_root / "index.html") + @operation_errors def do_POST(self) -> None: path = urlsplit(self.path).path if not self._ingress_allowed(): self._json({"error": "Alleen toegang via Home Assistant-ingress is toegestaan"}, HTTPStatus.FORBIDDEN) return + self._read_json() if not self._mutation_allowed(path): return if path == "/api/scans": @@ -248,9 +291,14 @@ def do_POST(self) -> None: if str(body.get("confirmation", "")) not in {"WIS HISTORIE", "CLEAR HISTORY"}: self._json({"error": "Typ exact WIS HISTORIE of CLEAR HISTORY"}, HTTPStatus.BAD_REQUEST) return - self.state.scan_manager.clear_history() - self.state.registry_cleanup_manager.clear_history() - self.state.purge_manager.clear_history() + with ExitStack() as locks: + for manager in (self.state.registry_cleanup_manager, self.state.purge_manager): + if not manager._lock.acquire(blocking=False): + raise RuntimeError("Wait for active cleanup operations before clearing history") + locks.callback(manager._lock.release) + self.state.scan_manager.clear_history() + self.state.registry_cleanup_manager.clear_history() + self.state.purge_manager.clear_history() self._json({"status": "cleared"}) elif path == "/api/quarantine/history/clear": body = self._read_json() @@ -383,13 +431,32 @@ def do_POST(self) -> None: self._json({"error": "Endpoint niet gevonden"}, HTTPStatus.NOT_FOUND) def _read_json(self) -> dict[str, object]: + if hasattr(self, "_request_json"): + return self._request_json + if self.headers.get("Transfer-Encoding"): + raise RequestError("Transfer-Encoding is not supported") try: length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(min(length, 1_000_000)) - value = json.loads(raw or b"{}") - return value if isinstance(value, dict) else {} - except (ValueError, json.JSONDecodeError): - return {} + except ValueError as exc: + raise RequestError("Invalid Content-Length") from exc + if length < 0: + raise RequestError("Invalid Content-Length") + if length > 1_000_000: + raise RequestError("Request body exceeds 1 MB", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) + try: + raw = self.rfile.read(length) + except TimeoutError as exc: + raise RequestError("Request body timed out", HTTPStatus.REQUEST_TIMEOUT) from exc + if len(raw) != length: + raise RequestError("Incomplete request body") + try: + value = json.loads(raw) if length else {} + except (ValueError, UnicodeError) as exc: + raise RequestError("Request body must contain valid JSON") from exc + if not isinstance(value, dict): + raise RequestError("Request body must be a JSON object") + self._request_json = value + return value def _ingress_allowed(self) -> bool: if not supervisor_available(): @@ -460,7 +527,28 @@ def _download(self, path: Path) -> None: self.end_headers() self.wfile.write(data) + def _export_language(self, query: str) -> str: + selected = parse_qs(query).get("language", [load_effective_settings(self.state.data_root).language])[0] + if selected not in {"auto", "en", "nl"}: + raise RequestError("Unsupported export language") + return "nl" if selected == "nl" else "en" + + def _text_download(self, name: str, value: str) -> None: + data = value.encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/markdown; charset=utf-8") + self.send_header("Content-Disposition", f'attachment; filename="{name}"') + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def log_message(self, fmt: str, *args: object) -> None: + # Docker polls this endpoint every 30 seconds. Successful health checks + # are expected and would otherwise drown useful application messages. + status = str(args[1]) if len(args) > 1 else "" + if urlsplit(self.path).path == "/health" and status.startswith("2"): + return print(f"{self.address_string()} - {fmt % args}", flush=True) @@ -473,11 +561,16 @@ def create_server(host: str, port: int, config_root: Path, data_root: Path) -> T def _scan_summary(scan) -> dict[str, object]: - payload = scan.to_dict(include_items=False) - audit = scan.registry_audit.to_dict() + payload = scan.to_dict(include_items=False, include_registry=False) + source = scan.registry_audit + audit = {"status": source.status, "error": source.error, "summary": source.summary, + "anomalies": source.anomalies, "entity_workspace": source.entity_workspace, + "findings": [asdict(item) for item in source.findings if item.severity == "review"] + + [asdict(item) for item in islice((item for item in source.findings if item.severity != "review"), 250)]} workspace = audit.get("entity_workspace", {}) payload["registry_audit"] = { "status": audit.get("status"), + "error": audit.get("error"), "summary": audit.get("summary", {}), "anomalies": audit.get("anomalies", []), "findings": [ diff --git a/hass_cleaner/hass_cleaner/settings.py b/hass_cleaner/hass_cleaner/settings.py index ca228bd..a68526a 100644 --- a/hass_cleaner/hass_cleaner/settings.py +++ b/hass_cleaner/hass_cleaner/settings.py @@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass from pathlib import Path -from .storage import atomic_write_json, read_json_object +from .storage import atomic_write_json, read_json_object, json_file_lock @dataclass(frozen=True) @@ -66,20 +66,34 @@ def save_local_settings(data_root: Path, settings: Settings) -> None: """ data_root.mkdir(parents=True, exist_ok=True) path = data_root / "ui-settings.json" - atomic_write_json(path, asdict(settings)) + with json_file_lock(path): + atomic_write_json(path, {**asdict(settings.validated()), "_supervisor_snapshot": asdict(load_settings(data_root))}) def load_effective_settings(data_root: Path) -> Settings: override = data_root / "ui-settings.json" - if override.exists(): + with json_file_lock(override): + base = load_settings(data_root) + if not override.exists(): + return base try: values = read_json_object(override) - # Ignore the removed legacy option so old installations migrate safely. values.pop("deletion_mode", None) - return Settings(**values).validated() + snapshot = values.pop("_supervisor_snapshot", None) + current = asdict(base) + if isinstance(snapshot, dict): + # Only changed Supervisor fields supersede UI choices. Persist + # the new baseline so subsequent UI changes can win again. + values.update({key: value for key, value in current.items() if snapshot.get(key) != value}) + elif _options_path(data_root).exists() and _options_path(data_root).stat().st_mtime_ns > override.stat().st_mtime_ns: + values = current + effective = Settings(**values).validated() + stored = {**asdict(effective), "_supervisor_snapshot": current} + if snapshot != current or values != asdict(effective): + atomic_write_json(override, stored) + return effective except (TypeError, ValueError): - pass - return load_settings(data_root) + return base def environment() -> tuple[str, int, Path, Path]: diff --git a/hass_cleaner/hass_cleaner/storage.py b/hass_cleaner/hass_cleaner/storage.py index 907c303..da62af8 100644 --- a/hass_cleaner/hass_cleaner/storage.py +++ b/hass_cleaner/hass_cleaner/storage.py @@ -52,6 +52,12 @@ def atomic_write_json(path: Path, value: Any) -> None: handle.flush() os.fsync(handle.fileno()) temporary.replace(path) + if os.name != "nt": + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) except (OSError, TypeError, ValueError) as exc: try: temporary.unlink(missing_ok=True) diff --git a/hass_cleaner/run.sh b/hass_cleaner/run.sh index 9be242c..002ab8e 100644 --- a/hass_cleaner/run.sh +++ b/hass_cleaner/run.sh @@ -1,4 +1,17 @@ #!/usr/bin/with-contenv bashio set -euo pipefail +# Remove the retired option through Supervisor, never by rewriting options.json. +if cleaner_options=$(bashio::addon.options); then + if bashio::jq.exists "${cleaner_options}" '.deletion_mode'; then + if bashio::addon.option 'deletion_mode'; then + bashio::log.info "Removed obsolete deletion_mode option" + else + bashio::log.warning "Could not remove obsolete deletion_mode; save the app configuration and restart" + fi + fi +else + bashio::log.warning "Could not inspect legacy options; continuing startup" +fi + exec python3 -m hass_cleaner diff --git a/hass_cleaner/tests/test_policy.py b/hass_cleaner/tests/test_policy.py index 066ce90..8fc5116 100644 --- a/hass_cleaner/tests/test_policy.py +++ b/hass_cleaner/tests/test_policy.py @@ -48,6 +48,7 @@ def test_custom_component_source_is_protected_inventory(self) -> None: self.assertEqual(RISK_PROTECTED, decision.risk) def test_python_cache_inside_custom_component_is_safe(self) -> None: + self.classify_file("custom_components/example/code.py") decision = self.classify_file("custom_components/example/__pycache__/code.cpython-313.pyc") self.assertEqual(RISK_SAFE, decision.risk) self.assertEqual("python_cache", decision.category) diff --git a/hass_cleaner/tests/test_quarantine.py b/hass_cleaner/tests/test_quarantine.py index b9fdb31..b02a49a 100644 --- a/hass_cleaner/tests/test_quarantine.py +++ b/hass_cleaner/tests/test_quarantine.py @@ -6,6 +6,7 @@ import time import unittest import hashlib +import json from dataclasses import replace from datetime import datetime, timezone from datetime import timedelta @@ -165,8 +166,7 @@ def test_startup_reconciles_completed_copy_after_interrupted_journal_update(self "sha256": hashlib.sha256(payload).hexdigest(), "status": "planned", }], } - first = QuarantineManager(config, data) - first._save([operation]) + (data / "quarantine" / "manifest.json").write_text(json.dumps([operation]), encoding="utf-8") recovered = QuarantineManager(config, data).list()[0] self.assertEqual("quarantined", recovered["status"]) @@ -191,8 +191,7 @@ def test_startup_rolls_back_duplicate_copy_when_original_still_exists(self) -> N "sha256": hashlib.sha256(payload).hexdigest(), "status": "copied", }], } - first = QuarantineManager(config, data) - first._save([operation]) + (data / "quarantine" / "manifest.json").write_text(json.dumps([operation]), encoding="utf-8") recovered = QuarantineManager(config, data).list()[0] self.assertEqual("rolled_back", recovered["status"]) diff --git a/hass_cleaner/tests/test_recovery_journals.py b/hass_cleaner/tests/test_recovery_journals.py new file mode 100644 index 0000000..1a1c6bb --- /dev/null +++ b/hass_cleaner/tests/test_recovery_journals.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import os +import tempfile +import threading +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +from hass_cleaner.quarantine import QuarantineError, QuarantineManager +from hass_cleaner.registry_cleanup import RegistryCleanupError, RegistryCleanupManager, execute_registry_commands +from hass_cleaner.settings import Settings +from tests import test_quarantine, test_registry_cleanup + + +class RecoveryJournalTests(unittest.TestCase): + def assert_unlocked(self, lock): + acquired = [] + def check(): + success = lock.acquire(blocking=False) + acquired.append(success) + if success: + lock.release() + worker = threading.Thread(target=check) + worker.start() + worker.join(timeout=2) + self.assertEqual([True], acquired) + + def fixture(self): + config = Path(self.enterContext(tempfile.TemporaryDirectory())) + data = Path(self.enterContext(tempfile.TemporaryDirectory())) + source, scan, plan = test_quarantine.QuarantineTests()._fixture(config) + manager = QuarantineManager(config, data) + operation = manager.execute(scan, Settings(), plan=plan, backup_token="ok", backup_valid=True, + backup_choice="verified", risk_acknowledged=False, + confirmation="QUARANTINE", requested_by="test") + return config, data, source, manager, operation + + def test_concurrent_restore_target_is_never_overwritten(self): + config, data, source, manager, operation = self.fixture() + link = os.link + + def concurrent_writer(temporary, target): + Path(target).write_bytes(b"new integration data") + link(temporary, target) + + with patch("hass_cleaner.quarantine.os.link", side_effect=concurrent_writer): + with self.assertRaises(QuarantineError): + manager.restore(operation["id"], "file1", confirmation="RESTORE", requested_by="test") + restarted = QuarantineManager(config, data) + self.assertEqual(b"new integration data", source.read_bytes()) + self.assertEqual("quarantined", restarted.list()[0]["files"][0]["status"]) + self.assertTrue(restarted.test_restore(operation["id"], "file1")["passed"]) + + def test_interrupted_copy_is_removed_and_restore_can_be_retried(self): + config, data, source, manager, operation = self.fixture() + + def interrupted_copy(input_file, output_file, *args): + output_file.write(b"partial") + raise KeyboardInterrupt() + + with patch("hass_cleaner.quarantine.shutil.copyfileobj", side_effect=interrupted_copy): + with self.assertRaises(KeyboardInterrupt): + manager.restore(operation["id"], "file1", confirmation="RESTORE", requested_by="test") + restarted = QuarantineManager(config, data) + self.assertFalse(source.exists()) + self.assertFalse(list(source.parent.glob("*.hass-cleaner-restore"))) + restarted.restore(operation["id"], "file1", confirmation="RESTORE", requested_by="test") + self.assertEqual(b"safe generated cache", source.read_bytes()) + + def test_restore_commit_failure_recovers_published_file(self): + config, data, source, manager, operation = self.fixture() + save = manager._replace_operation + + def fail_commit(value): + if value["files"][0]["status"] == "restored": + raise OSError("disk full") + save(value) + + with patch.object(manager, "_replace_operation", side_effect=fail_commit): + with self.assertRaises(QuarantineError): + manager.restore(operation["id"], "file1", confirmation="RESTORE", requested_by="test") + self.assertTrue(source.exists()) + restarted = QuarantineManager(config, data) + record = restarted.list()[0]["files"][0] + self.assertEqual("restored", record["status"]) + self.assertFalse((restarted.root / operation["id"] / "files" / record["relative_path"]).exists()) + self.assertFalse(list(source.parent.glob("*.hass-cleaner-restore"))) + + def test_restore_intent_failure_keeps_source_in_quarantine(self): + _, _, source, manager, operation = self.fixture() + with patch.object(manager, "_replace_operation", side_effect=OSError("disk full")): + with self.assertRaises(QuarantineError): + manager.restore(operation["id"], "file1", confirmation="RESTORE", requested_by="test") + self.assertFalse(source.exists()) + self.assertTrue(manager.test_restore(operation["id"], "file1")["passed"]) + + def test_interrupted_purge_is_reconciled(self): + config, data, _, manager, operation = self.fixture() + operation["expires_at"] = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + manager._save([operation]) + save = manager._replace_operation + + def fail_commit(value): + if value["files"][0]["status"] == "deleted": + raise OSError("disk full") + save(value) + + with patch.object(manager, "_replace_operation", side_effect=fail_commit): + with self.assertRaises(QuarantineError): + manager.purge_expired(operation["id"], "file1", confirmation="DELETE", requested_by="test") + restarted = QuarantineManager(config, data) + self.assertEqual("deleted", restarted.list()[0]["files"][0]["status"]) + + def test_corrupt_manifests_are_preserved_and_block_clear(self): + for contents in ("{broken", "{}", '[{"id":"x","status":"quarantined","files":[null]}]'): + with self.subTest(contents=contents): + config, data, _, manager, operation = self.fixture() + manager.manifest_path.write_text(contents, encoding="utf-8") + restarted = QuarantineManager(config, data) + with self.assertRaises(QuarantineError): + restarted.clear_completed_history() + with self.assertRaises(QuarantineError): + restarted._insert_operation(operation) + self.assertEqual(contents, manager.manifest_path.read_text(encoding="utf-8")) + self.assertTrue((manager.root / operation["id"]).exists()) + + def test_unreadable_manifest_is_not_empty(self): + _, _, _, manager, _ = self.fixture() + original = manager.manifest_path.read_bytes() + with patch.object(Path, "read_text", side_effect=PermissionError("unreadable")): + with self.assertRaises(QuarantineError): + manager.clear_completed_history() + self.assertEqual(original, manager.manifest_path.read_bytes()) + + def test_missing_manifest_with_existing_data_is_blocked(self): + config, data, _, manager, _ = self.fixture() + manager.manifest_path.unlink() + restarted = QuarantineManager(config, data) + with self.assertRaises(QuarantineError): + restarted.clear_completed_history() + self.assertFalse(manager.manifest_path.exists()) + + def registry_fixture(self, executor): + data = Path(self.enterContext(tempfile.TemporaryDirectory())) + manager = RegistryCleanupManager(data, executor=executor) + plan = {"scan_id": "scan1", "entities": [{"entity_id": "sensor.old", "execution_allowed": True}], + "devices": [{"device_id": "device1", "config_entry_id": "entry1", "execution_allowed": True}]} + def execute(): + return manager.execute(test_registry_cleanup.RegistryCleanupTests._scan(), plan, backup_choice="none", backup_token="", + backup_valid=False, risk_acknowledged=True, confirmation="DELETE 2", requested_by="test") + return data, manager, execute + + def test_registry_intent_storage_failure_prevents_execution_and_unlocks(self): + calls = [] + _, manager, execute = self.registry_fixture(lambda *args, **kwargs: calls.append(args) or []) + with patch.object(manager, "_save", side_effect=RegistryCleanupError("disk full")): + with self.assertRaises(RegistryCleanupError): + execute() + self.assertFalse(calls) + self.assert_unlocked(manager._lock) + execute() + self.assertEqual(1, len(calls)) + + def test_registry_progress_failure_stops_batch_and_recovers_pending_command(self): + connection = test_registry_cleanup.FakeConnection() + def executor(entities, devices, **kwargs): + return execute_registry_commands(entities, devices, token="test", connect=lambda *args, **opts: connection, **kwargs) + data, manager, execute = self.registry_fixture(executor) + save = manager._save + + def fail_after_command(history): + if history[0]["completed"]: + raise RegistryCleanupError("disk full") + save(history) + + with patch.object(manager, "_save", side_effect=fail_after_command): + with self.assertRaises(RegistryCleanupError): + execute() + self.assert_unlocked(manager._lock) + self.assertTrue(connection.closed) + self.assertEqual(2, len(connection.sent)) # auth and first entity command only + restarted = RegistryCleanupManager(data) + record = restarted.history()[0] + self.assertEqual("interrupted", record["status"]) + self.assertEqual("sensor.old", record["pending"]["id"]) + restarted.clear_history() + self.assertEqual(1, len(restarted.history())) + + def test_registry_progress_is_saved_before_next_command(self): + snapshots = [] + connection = test_registry_cleanup.FakeConnection() + def executor(entities, devices, **kwargs): + return execute_registry_commands(entities, devices, token="test", connect=lambda *args, **opts: connection, **kwargs) + _, manager, execute = self.registry_fixture(executor) + send = connection.send + def inspect_send(payload): + snapshots.append(manager.history()[0]) + send(payload) + connection.send = inspect_send + execute() + self.assertEqual("sensor.old", snapshots[1]["pending"]["id"]) + self.assertEqual("sensor.old", snapshots[2]["completed"][0]["id"]) + self.assertEqual("device1", snapshots[2]["pending"]["id"]) + self.assertEqual("completed", manager.history()[0]["status"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/hass_cleaner/tests/test_registry_cleanup.py b/hass_cleaner/tests/test_registry_cleanup.py index 75b1910..874169f 100644 --- a/hass_cleaner/tests/test_registry_cleanup.py +++ b/hass_cleaner/tests/test_registry_cleanup.py @@ -55,7 +55,7 @@ def _scan() -> SimpleNamespace: def test_user_directed_cleanup_requires_count_and_risk_confirmation(self) -> None: calls = [] with tempfile.TemporaryDirectory() as folder: - manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices: calls.append((entities, devices)) or []) + manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices, **kwargs: calls.append((entities, devices)) or []) scan = self._scan() plan = { "scan_id": "scan1", @@ -72,7 +72,7 @@ def test_user_directed_cleanup_requires_count_and_risk_confirmation(self) -> Non def test_verified_choice_requires_valid_backup(self) -> None: with tempfile.TemporaryDirectory() as folder: - manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices: []) + manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices, **kwargs: []) scan = self._scan() plan = {"scan_id": "scan1", "entities": [{"entity_id": "sensor.old", "execution_allowed": True}]} with self.assertRaises(RegistryCleanupError): @@ -83,7 +83,7 @@ def test_english_delete_confirmation_is_accepted(self) -> None: with tempfile.TemporaryDirectory() as folder: calls = [] manager = RegistryCleanupManager( - Path(folder), executor=lambda entities, devices: calls.append((entities, devices)) or [], + Path(folder), executor=lambda entities, devices, **kwargs: calls.append((entities, devices)) or [], ) plan = {"scan_id": "scan1", "entities": [{"entity_id": "sensor.old", "execution_allowed": True}]} record = manager.execute( @@ -95,7 +95,7 @@ def test_english_delete_confirmation_is_accepted(self) -> None: def test_tampered_plan_object_is_rejected_against_latest_scan(self) -> None: with tempfile.TemporaryDirectory() as folder: - manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices: self.fail("must not execute")) + manager = RegistryCleanupManager(Path(folder), executor=lambda entities, devices, **kwargs: self.fail("must not execute")) plan = {"scan_id": "scan1", "entities": [{"entity_id": "sensor.not_scanned", "execution_allowed": True}]} with self.assertRaises(RegistryCleanupError): manager.execute(self._scan(), plan, backup_choice="none", backup_token="", backup_valid=False, @@ -118,9 +118,9 @@ def test_official_registry_commands_are_sent_with_exact_identifiers(self) -> Non def test_partial_failure_records_commands_that_already_completed(self) -> None: connection = PartiallyFailingConnection() - def executor(entities, devices): + def executor(entities, devices, **kwargs): return execute_registry_commands( - entities, devices, token="token", connect=lambda *args, **kwargs: connection, + entities, devices, token="token", connect=lambda *args, **options: connection, **kwargs, ) with tempfile.TemporaryDirectory() as folder: diff --git a/hass_cleaner/tests/test_reporting.py b/hass_cleaner/tests/test_reporting.py index 34066cf..747b789 100644 --- a/hass_cleaner/tests/test_reporting.py +++ b/hass_cleaner/tests/test_reporting.py @@ -80,7 +80,7 @@ def test_all_report_formats_are_written_with_execution_metadata(self) -> None: }]}, ) - paths = write_report_files(result, Settings(), Path(output_folder)) + paths = write_report_files(result, Settings(language="nl"), Path(output_folder)) self.assertEqual({"json", "csv", "md"}, set(paths)) payload = json.loads(paths["json"].read_text(encoding="utf-8")) @@ -157,9 +157,9 @@ def test_073_ui_defaults_to_action_and_caps_large_bundle_details(self) -> None: self.assertEqual(9, html.count('aria-labelledby="')) self.assertIn("initializeTabAccessibility", javascript) self.assertIn('event.key === "ArrowRight"', javascript) - self.assertIn('assets/styles.css?v=1.0.1', html) - self.assertIn('assets/i18n.js?v=1.0.1', html) - self.assertIn('assets/app.js?v=1.0.1', html) + self.assertIn('assets/styles.css?v=1.0.2', html) + self.assertIn('assets/i18n.js?v=1.0.2', html) + self.assertIn('assets/app.js?v=1.0.2', html) self.assertIn("Persistently unavailable", translations) self.assertIn("Local entity choice saved", translations) diff --git a/hass_cleaner/tests/test_review_fixes.py b/hass_cleaner/tests/test_review_fixes.py new file mode 100644 index 0000000..c7f6f5f --- /dev/null +++ b/hass_cleaner/tests/test_review_fixes.py @@ -0,0 +1,150 @@ +"""Regression coverage for configuration, request, scan and export fixes.""" +import http.client +import json +import os +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +from hass_cleaner.plans import PlanManager, _markdown as plan_markdown +from hass_cleaner.quarantine import QuarantineManager, QuarantineError +from hass_cleaner.registry_audit import RegistryAudit +from hass_cleaner.reporting import write_report_files +from hass_cleaner.scanner import ScanManager, scan_tree +from hass_cleaner.server import create_server, _scan_summary +from hass_cleaner.settings import Settings, load_effective_settings, save_local_settings + + +class ReviewFixTests(unittest.TestCase): + def root(self): + return Path(self.enterContext(tempfile.TemporaryDirectory())) + + def test_supervisor_changes_only_replace_changed_fields(self): + root = self.root() + options = root / "options.json" + options.write_text(json.dumps({"language": "en", "retention_days": 7})) + save_local_settings(root, Settings(language="nl", retention_days=3)) + options.write_text(json.dumps({"language": "en", "retention_days": 2})) + result = load_effective_settings(root) + self.assertEqual(("nl", 2), (result.language, result.retention_days)) + self.assertEqual(result, load_effective_settings(root)) + save_local_settings(root, Settings(language="nl", retention_days=5)) + self.assertEqual(5, load_effective_settings(root).retention_days) + self.assertEqual(2, json.loads(options.read_text())["retention_days"]) + + def test_clear_rejects_active_scan_and_blocks_new_scan_during_clear(self): + root = self.root() + entered, release = threading.Event(), threading.Event() + def registry(): + entered.set() + release.wait(5) + return RegistryAudit(status="completed") + manager = ScanManager(root, Settings, root / "reports", registry) + scan = manager.start() + self.assertTrue(entered.wait(3)) + try: + with self.assertRaises(RuntimeError): + manager.clear_history() + finally: + release.set() + self.wait_scan(manager, scan.id) + (root / "entity-snapshot.json").write_text("{}") + original = manager._clear_history_files + def clearing(): + with self.assertRaises(RuntimeError): + manager.start() + original() + with patch.object(manager, "_clear_history_files", side_effect=clearing): + manager.clear_history() + self.assertIsNone(manager.latest()) + self.assertEqual([], manager.history()) + self.assertFalse((root / "entity-snapshot.json").exists()) + + def wait_scan(self, manager, scan_id): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if manager.get(scan_id).status not in {"queued", "running"}: + self.assertEqual("completed", manager.get(scan_id).status, manager.get(scan_id).error) + return + time.sleep(0.01) + self.fail("Scan did not finish") + + def test_memory_is_bounded_without_removing_retained_reports(self): + root = self.root() + manager = ScanManager(root / "config", Settings, root / "reports", lambda: RegistryAudit(status="completed")) + manager.root.mkdir() + for _ in range(5): + scan = manager.start() + self.wait_scan(manager, scan.id) + self.assertLessEqual(len(manager._scans), 2) + self.assertEqual(5, len(list((root / "reports").glob("*.json")))) + self.assertEqual(5, len(manager.history())) + + def test_summary_does_not_serialize_full_registry_and_keeps_error(self): + scan = scan_tree(self.root(), Settings()) + scan.registry_audit.error = "Connection failed" + with patch.object(RegistryAudit, "to_dict", side_effect=AssertionError("full serialization")): + self.assertEqual("Connection failed", _scan_summary(scan)["registry_audit"]["error"]) + + def orphan(self): + root, data = self.root(), self.root() + cache = root / "custom_components/demo/__pycache__/demo.cpython-313.pyc" + cache.parent.mkdir(parents=True) + cache.write_bytes(b"cache") + old = time.time() - 45 * 86400 + os.utime(cache, (old, old)) + scan = scan_tree(root, Settings()) + plan = PlanManager(data).create(scan, Settings(), selected_ids=[scan.items[0].id], selected_bundle_ids=[], selected_entity_ids=[], backup_choice="skip") + return root, data, cache, scan, plan + + def test_orphan_cache_can_be_quarantined_with_explicit_risk_acceptance(self): + root, data, cache, scan, plan = self.orphan() + self.assertEqual("python_cache_without_source", scan.items[0].category) + QuarantineManager(root, data).execute(scan, Settings(), plan=plan, backup_token="", backup_valid=False, + backup_choice="none", risk_acknowledged=True, content_risk_acknowledged=True, confirmation="QUARANTINE", requested_by="test") + self.assertFalse(cache.exists()) + + def test_changed_orphan_classification_still_blocks_execution(self): + root, data, cache, scan, plan = self.orphan() + (cache.parent.parent / "demo.py").write_text("# new source") + with self.assertRaises(QuarantineError): + QuarantineManager(root, data).execute(scan, Settings(), plan=plan, backup_token="", backup_valid=False, + backup_choice="none", risk_acknowledged=True, content_risk_acknowledged=True, confirmation="QUARANTINE", requested_by="test") + self.assertTrue(cache.exists()) + + def test_readable_exports_follow_language_and_preserve_risk_guidance(self): + root, data, cache, scan, plan = self.orphan() + english = write_report_files(scan, Settings(language="en"), data / "reports")["md"].read_text(encoding="utf-8") + self.assertIn("cleanup", english.lower()) + self.assertIn("may prevent the integration from loading", english) + self.assertIn("cleanup preparation", plan_markdown(plan, "en")) + self.assertIn("herstelplan", plan_markdown(plan, "nl")) + + def test_invalid_bodies_do_not_mutate_settings(self): + config, data = self.root(), self.root() + save_local_settings(data, Settings(language="en", retention_days=2)) + before = (data / "ui-settings.json").read_bytes() + server = create_server("127.0.0.1", 0, config, data) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + for body, headers, expected in [(b"{broken", {}, 400), (b"[]", {}, 400), + (b"null", {}, 400), (b"", {"Content-Length": "-1"}, 400), + (b"", {"Content-Length": "1000001"}, 413), (b"", {"Transfer-Encoding": "chunked"}, 400)]: + with self.subTest(body=body, headers=headers): + connection = http.client.HTTPConnection(*server.server_address, timeout=3) + try: + connection.request("POST", "/api/settings", body, headers) + response = connection.getresponse() + self.assertEqual(expected, response.status) + self.assertIn("error", json.loads(response.read())) + finally: + connection.close() + self.assertEqual(before, (data / "ui-settings.json").read_bytes()) + finally: + server.shutdown() + server.server_close() + worker.join(2) diff --git a/hass_cleaner/tests/test_server.py b/hass_cleaner/tests/test_server.py index f99a226..286c1f9 100644 --- a/hass_cleaner/tests/test_server.py +++ b/hass_cleaner/tests/test_server.py @@ -1,12 +1,14 @@ from __future__ import annotations import json +import io import tempfile import threading import time import unittest import urllib.error import urllib.request +from contextlib import redirect_stdout from pathlib import Path from datetime import datetime, timezone @@ -58,12 +60,68 @@ def test_health_and_status(self) -> None: self.assertFalse(payload["file_execution_enabled"]) self.assertFalse(payload["registry_execution_enabled"]) + def test_health_check_is_silent_but_other_requests_are_logged(self) -> None: + output = io.StringIO() + with redirect_stdout(output): + self.request("/health") + self.request("/api/status") + + access_log = output.getvalue() + self.assertNotIn('GET /health HTTP/1.1', access_log) + self.assertIn('GET /api/status HTTP/1.1', access_log) + + def test_damaged_quarantine_manifest_returns_visible_error(self) -> None: + path = self.server.state.quarantine_manager.manifest_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{broken", encoding="utf-8") + with self.assertRaises(urllib.error.HTTPError) as raised: + self.request("/api/quarantine") + self.assertEqual(503, raised.exception.code) + self.assertIn("manifest", json.loads(raised.exception.read())["error"]) + self.assertEqual("{broken", path.read_text(encoding="utf-8")) + self.assertEqual(200, self.request("/health")[0]) + + def test_markdown_download_language_can_be_changed_after_scan(self) -> None: + from hass_cleaner.scanner import scan_tree + from hass_cleaner.reporting import write_report_files + from hass_cleaner.settings import Settings + scan = scan_tree(Path(self.config_temp.name), Settings()) + write_report_files(scan, Settings(), self.server.state.report_root) + for language, title in [("en", "audit report"), ("nl", "Hass-Cleaner")]: + with urllib.request.urlopen(f"{self.base}/api/reports/{scan.id}.md?language={language}") as response: + report = response.read().decode("utf-8") + self.assertIn(title, report) + if language == "nl": + self.assertIn("bestandsinventaris", report) + else: + self.assertIn("complete file inventory", report) + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(f"{self.base}/api/reports/{scan.id}.md?language=invalid") + self.assertEqual(400, raised.exception.code) + + def test_history_clear_conflicts_with_registry_execution(self) -> None: + acquired, release = threading.Event(), threading.Event() + def running(): + with self.server.state.registry_cleanup_manager._lock: + acquired.set() + release.wait(5) + worker = threading.Thread(target=running) + worker.start() + self.assertTrue(acquired.wait(2)) + try: + with self.assertRaises(urllib.error.HTTPError) as raised: + self.request("/api/history/clear", "POST", {"confirmation": "CLEAR HISTORY"}) + self.assertEqual(409, raised.exception.code) + finally: + release.set() + worker.join(2) + def test_frontend_cache_policy_separates_shell_and_versioned_assets(self) -> None: with urllib.request.urlopen(f"{self.base}/", timeout=5) as response: html = response.read().decode("utf-8") self.assertEqual("no-cache", response.headers["Cache-Control"]) - self.assertIn("assets/app.js?v=1.0.1", html) - with urllib.request.urlopen(f"{self.base}/assets/app.js?v=1.0.1", timeout=5) as response: + self.assertIn("assets/app.js?v=1.0.2", html) + with urllib.request.urlopen(f"{self.base}/assets/app.js?v=1.0.2", timeout=5) as response: response.read() self.assertEqual("public, max-age=31536000, immutable", response.headers["Cache-Control"]) @@ -141,7 +199,7 @@ def test_plan_endpoint_allows_only_verified_quarantine_followup(self) -> None: plan_id = payload["plan"]["id"] with urllib.request.urlopen(f"{self.base}/api/plans/{plan_id}.md", timeout=5) as response: markdown = response.read().decode("utf-8") - self.assertIn("impact- en herstelplan", markdown) + self.assertIn("cleanup preparation", markdown) self.server.state.backup_manager._save([{ "token": "verified-plan", "status": "completed", "requested_at": datetime.now(timezone.utc).isoformat() }]) diff --git a/hass_cleaner/web/assets/app.js b/hass_cleaner/web/assets/app.js index e07967b..c7af34f 100644 --- a/hass_cleaner/web/assets/app.js +++ b/hass_cleaner/web/assets/app.js @@ -1151,7 +1151,7 @@ function downloadPlan(format) { showToast("Maak eerst een impactplan", true); return; } - window.location.assign(apiUrl(path)); + window.location.assign(apiUrl(`${path}?language=${englishInterface() ? "en" : "nl"}`)); } async function loadPurgeHistory() { @@ -1281,7 +1281,7 @@ function downloadReport(extension) { showToast("Voer eerst een scan uit", true); return; } - window.location.assign(apiUrl(`api/reports/${state.scan.id}.${extension}`)); + window.location.assign(apiUrl(`api/reports/${state.scan.id}.${extension}?language=${englishInterface() ? "en" : "nl"}`)); } async function saveSettings() { diff --git a/hass_cleaner/web/assets/i18n.js b/hass_cleaner/web/assets/i18n.js index c1f5189..77838e9 100644 --- a/hass_cleaner/web/assets/i18n.js +++ b/hass_cleaner/web/assets/i18n.js @@ -42,6 +42,73 @@ }; Object.assign(english, { + "Risico-indicatie": "Risk indication", + "Sterk bewijs": "Strong evidence", + "Waarschijnlijk veilig": "Likely safe", + "Hoog risico": "High risk", + "Meer bewijs nodig": "Review required", + "Behouden": "Retain", + "Geblokkeerd": "Blocked", + "Wat is dit?": "What is this?", + "Wat kan er gebeuren?": "What could happen?", + "Hoe herstel je dit?": "How do you recover?", + "Geen gevolgadvies beschikbaar.": "No consequence guidance is available.", + "Hersteladvies ontbreekt; niet uitvoeren.": "Recovery guidance is missing; do not execute.", + "Geen beschrijving beschikbaar.": "No description is available.", + "Niet wijzigen zonder aanvullende controle.": "Do not change without further review.", + "Samenhangende Home Assistant-objecten van dezelfde integratie of hetzelfde platform.": "Related Home Assistant objects belonging to the same integration or platform.", + "Entities kunnen uit dashboards, automatiseringen en scripts verdwijnen.": "Entities may disappear from dashboards, automations and scripts.", + "Een apparaat kan opnieuw door de integratie worden aangemaakt.": "The integration may recreate a device.", + "Het verwijderen van een config-entry kan de volledige integratie uitschakelen.": "Removing a config entry can disable the entire integration.", + "Herstel de Home Assistant-back-up als registrygegevens verloren zijn.": "Restore the Home Assistant backup if registry data is lost.", + "Voeg de eigenaar-integratie opnieuw toe en configureer het apparaat opnieuw.": "Add the owning integration again and reconfigure the device.", + "Herstel daarna afhankelijke automatiseringen en dashboards uit het planrapport.": "Then restore dependent automations and dashboards using the preparation report.", + "Beoordeel eerst alle officiële search/related-verwijzingen; schakel losse entities zo mogelijk tijdelijk uit.": "Review official search/related references first; temporarily disable individual entities where possible.", + "Gegenereerde Python-bytecode met aantoonbare broncode.": "Generated Python bytecode with matching source code.", + "De cache wordt bij gebruik opnieuw opgebouwd; de eerste start kan iets langer duren.": "The cache is rebuilt when used; the first startup may take longer.", + "Herstart de integratie of Home Assistant zodat Python de cache opnieuw maakt.": "Restart the integration or Home Assistant so Python recreates the cache.", + "Plaats eerst in quarantaine; permanent verwijderen is doorgaans herstelbaar.": "Quarantine first; generated cache can usually be rebuilt after permanent removal.", + "Gegenereerde Home Assistant-pictogramcache op het exact bekende cachepad.": "Generated Home Assistant icons at the known cache path.", + "Pictogrammen kunnen kort ontbreken terwijl Home Assistant ze opnieuw ophaalt.": "Icons may briefly be missing while Home Assistant downloads them again.", + "Herlaad Home Assistant; de pictogramcache wordt opnieuw opgebouwd.": "Reload Home Assistant to rebuild the icon cache.", + "Kies Opschoning voorbereiden en gebruik daarna quarantaine.": "Choose Prepare cleanup, then use quarantine.", + "Bekend editor- of besturingssysteemrestant.": "Known editor or operating-system leftover.", + "Een editor of besturingssysteem kan het bestand opnieuw aanmaken.": "An editor or operating system may recreate the file.", + "Geen herstel nodig; plaats het bestand desgewenst terug uit quarantaine.": "Recovery is not required; restore from quarantine if needed.", + "Quarantaine is de veiligste eerste stap.": "Quarantine is the safest first step.", + "Niet-actief oud Home Assistant-logbestand.": "Inactive old Home Assistant log file.", + "Oude diagnose-informatie gaat verloren.": "Old diagnostic information will be lost.", + "Zet het bestand vanuit quarantaine terug als oude logregels nodig zijn.": "Restore from quarantine if the old log entries are needed.", + "Controleer of er geen lopend onderzoek is en gebruik daarna quarantaine.": "Check that no investigation needs the log, then use quarantine.", + "Bestandsnaam wijst op een tijdelijke kopie of handmatige back-up.": "The filename suggests a temporary copy or manual backup.", + "Dit kan de enige werkende kopie van configuratie of code zijn.": "This may be the only working copy of configuration or code.", + "Zet het bestand terug uit quarantaine en vergelijk het met de actieve versie.": "Restore from quarantine and compare it with the active version.", + "Vergelijk inhoud en wijzigingsdatum met het actieve bestand.": "Compare its contents and modification date with the active file.", + "Bytecode zonder aantoonbaar bijbehorend bronbestand.": "Bytecode without a matching source file.", + "Een custom integratie kan hierdoor niet meer laden.": "This can prevent a custom integration from loading.", + "Zet de bytecode terug of installeer de bijbehorende integratie opnieuw.": "Restore the bytecode or reinstall its integration.", + "Niet verwijderen totdat de eigenaar van de bytecode bekend is.": "Identify the owner of the bytecode before removing it.", + "Padnaam wijst op cache van een integratie of toepassing.": "The path suggests an integration or application cache.", + "Actieve previews, afdrukken, camerabeelden of indexen kunnen verdwijnen.": "Active previews, prints, camera images or indexes may disappear.", + "Laat de eigenaar-integratie de cache opnieuw opbouwen of herstel een back-up.": "Let the owning integration rebuild the cache or restore a backup.", + "Controleer producent, verwijzingen en herbouwgedrag; nog niet verwijderen.": "Check ownership, references and rebuilding behaviour before removing it.", + "Opname, snapshot of timelapse is gebruikersdata.": "Recordings, snapshots and time-lapses are user data.", + "De opname of afbeelding kan permanent verloren gaan.": "The recording or image may be permanently lost.", + "Herstel uit een volledige back-up of externe mediakopie.": "Restore from a full backup or an external media copy.", + "Alleen bewust beoordelen; nooit als cache behandelen.": "Review deliberately; never treat this as cache.", + "Bestands- en registeracties beschikbaar": "File and registry actions available", + "Bestandsquarantaine beschikbaar": "File quarantine available", + "Registeropschoning beschikbaar": "Registry cleanup available", + "Een back-up is sterk aanbevolen. Ieder bestand wordt vlak vóór verplaatsing opnieuw gecontroleerd.": "A backup is strongly recommended. Every file is rechecked immediately before moving.", + "Entities en apparaten zijn registerobjecten. De gebruiker kan ze na advies, back-upkeuze en zware bevestiging verwijderen.": "Entities and devices are registry objects. Removal requires reviewing guidance, choosing a backup option and explicit confirmation.", + "Filter op risico": "Filter by risk", + "Zoek entiteiten": "Search entities", + "Filter op status": "Filter by state", + "Filter op integratie": "Filter by integration", + "Filter op ruimte": "Filter by area", + "Groepeer entiteiten": "Group entities", + "Zoek bundels": "Search bundles", + "Filter bundels": "Filter bundles", "Scan kon niet worden voltooid": "The scan could not be completed", "Onbekende scanfout": "Unknown scan error", "Deze scan is de nulmeting. Vanaf de volgende scan worden nieuw, hersteld en gewijzigd apart getoond.": "This scan is the baseline. New, recovered and changed entities will be shown separately from the next scan onward.", @@ -87,6 +154,11 @@ }); const patterns = [ + [/^(\d+) entiteiten · (\d+) actie nodig · (\d+) tijdelijk · maximaal (.+) \/ (\d+) meting\(en\)$/, (match, total, attention, temporary, duration, observations) => `${total} entities · ${attention} need attention · ${temporary} temporary · up to ${translated(duration)} / ${observations} observations`], + [/^(\d+) meting\(en\) · (.+)$/, (match, count, status) => `${count} observations · ${translated(status)}`], + [/^Back-up voltooid en geverifieerd(.*)$/, "Backup completed and verified$1"], + [/^Back-upstatus controleren \((\d+)%\)$/, "Check backup status ($1%)"], + [/^Back-upstatus: (.+)$/, "Backup status: $1"], [/^(\d+) dagen herstelbaar$/, "$1 days recoverable"], [/^(\d+) bestanden bekeken$/, "$1 files checked"], [/^(\d+) bestanden gecontroleerd$/, "$1 files checked"], @@ -102,8 +174,6 @@ [/^(\d+) bestanden · (.+)$/, (match, count, rest) => `${count} ${count === "1" ? "file" : "files"} · ${rest}`], [/^producer: (.+)$/, "producer: $1"], [/^(\d+) apparaten · (\d+) entities$/, "$1 devices · $2 entities"], - [/^(\d+) apparaten en (\d+) entities\.(.*)$/, "$1 devices and $2 entities.$3"], - [/^(\d+) resultaten · (\d+) geselecteerd · (.*)$/, "$1 results · $2 selected · $3"], [/^Nog (\d+) apparaten$/, "$1 more devices"], [/^(\d+) resultaten · (\d+) geselecteerd$/, "$1 results · $2 selected"], [/^(\d+) geregistreerde entities, (\d+) runtime-only states en (\d+) apparaten read-only gecontroleerd\. (\d+) langdurig onbeschikbaar; (\d+) voorlopig alleen informatief\.$/, "$1 registered entities, $2 runtime-only states and $3 devices checked read-only. $4 persistently unavailable; $5 currently informational only."], @@ -121,6 +191,8 @@ [/^Scanroot bestaat niet of is geen directory: (.+)$/, "Scan root does not exist or is not a directory: $1"], ]; const originals = new WeakMap(); + const rendered = new WeakMap(); + const observationOptions = { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["placeholder", "aria-label", "title"] }; let preference = "auto"; let locale = "en"; let observer; @@ -128,7 +200,11 @@ function resolve(value) { if (value === "nl" || value === "en") return value; const languages = navigator.languages?.length ? navigator.languages : [navigator.language || "en"]; - return languages.some((language) => String(language).toLowerCase().startsWith("nl")) ? "nl" : "en"; + for (const language of languages) { + const base = String(language).toLowerCase().split("-")[0]; + if (base === "nl" || base === "en") return base; + } + return "en"; } function translated(value) { @@ -145,20 +221,25 @@ function localizeNode(node) { if (node.nodeType === Node.TEXT_NODE) { - if (!originals.has(node)) originals.set(node, node.nodeValue); + if (!originals.has(node) || node.nodeValue !== rendered.get(node)) originals.set(node, node.nodeValue); const source = originals.get(node); const wanted = locale === "en" ? translated(source) : source; if (node.nodeValue !== wanted) node.nodeValue = wanted; + rendered.set(node, wanted); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; + if (["SCRIPT", "STYLE", "CODE", "PRE"].includes(node.tagName)) return; for (const attribute of ["placeholder", "aria-label", "title"]) { if (!node.hasAttribute(attribute)) continue; - const key = `${attribute}:${node.getAttribute(attribute)}`; if (!originals.has(node) || typeof originals.get(node) !== "object") originals.set(node, {}); + if (!rendered.has(node)) rendered.set(node, {}); const record = originals.get(node); - if (!(attribute in record)) record[attribute] = node.getAttribute(attribute); - node.setAttribute(attribute, locale === "en" ? translated(record[attribute]) : record[attribute]); + const last = rendered.get(node); + if (!(attribute in record) || node.getAttribute(attribute) !== last[attribute]) record[attribute] = node.getAttribute(attribute); + const wanted = locale === "en" ? translated(record[attribute]) : record[attribute]; + if (node.getAttribute(attribute) !== wanted) node.setAttribute(attribute, wanted); + last[attribute] = wanted; } node.childNodes.forEach(localizeNode); } @@ -167,7 +248,7 @@ observer?.disconnect(); document.documentElement.lang = locale; localizeNode(root); - observer?.observe(document.body, { childList: true, subtree: true }); + observer?.observe(document.body, observationOptions); } function setPreference(value) { @@ -183,8 +264,11 @@ observer = new MutationObserver((mutations) => { observer.disconnect(); - for (const mutation of mutations) mutation.addedNodes.forEach(localizeNode); - observer.observe(document.body, { childList: true, subtree: true }); + for (const mutation of mutations) { + if (mutation.type === "childList") mutation.addedNodes.forEach(localizeNode); + else if (!["SCRIPT", "STYLE", "CODE", "PRE"].includes(mutation.target.parentElement?.tagName)) localizeNode(mutation.target); + } + observer.observe(document.body, observationOptions); }); document.addEventListener("DOMContentLoaded", () => { locale = resolve(preference); diff --git a/hass_cleaner/web/index.html b/hass_cleaner/web/index.html index 84b957e..71aa596 100644 --- a/hass_cleaner/web/index.html +++ b/hass_cleaner/web/index.html @@ -5,7 +5,7 @@ Hass-Cleaner - +
@@ -455,7 +455,7 @@

Recorder-database opschonen

- - + + diff --git a/hass_cleaner/web/tests/i18n.test.cjs b/hass_cleaner/web/tests/i18n.test.cjs new file mode 100644 index 0000000..b3fe13b --- /dev/null +++ b/hass_cleaner/web/tests/i18n.test.cjs @@ -0,0 +1,52 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); + +const textNode = value => ({nodeType: 3, nodeValue: value}); +function element(children = [], attrs = {}, tagName = 'DIV') { + const node = {nodeType: 1, tagName, childNodes: children, + hasAttribute: name => name in attrs, getAttribute: name => attrs[name], + setAttribute: (name, value) => { attrs[name] = value; }}; + children.forEach(child => { child.parentElement = node; }); + return node; +} +const dynamic = textNode('Nieuwe scan'); +const label = element([], {'aria-label': 'Filter op status'}); +const source = textNode('Nieuwe scan'); +const code = element([source], {}, 'PRE'); +const root = element([dynamic, label, code]); +let mutationCallback; +const context = {Node: {TEXT_NODE: 3, ELEMENT_NODE: 1}, navigator: {languages: ['en-GB', 'nl-NL']}, + document: {documentElement: root, body: root, addEventListener() {}}, + window: {dispatchEvent() {}}, CustomEvent: class {}, + MutationObserver: class {constructor(callback) {mutationCallback = callback;} disconnect() {} observe() {}}}; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../assets/i18n.js'), 'utf8'), context); +const i18n = context.window.HassCleanerI18n; +i18n.setPreference('auto'); +assert.equal(i18n.locale, 'en'); +assert.equal(dynamic.nodeValue, 'New scan'); +assert.equal(label.getAttribute('aria-label'), 'Filter by state'); +assert.equal(source.nodeValue, 'Nieuwe scan'); +assert.equal(i18n.text('0 resultaten · 0 geselecteerd · jij beslist na advies en back-upkeuze'), + '0 results · 0 selected · you decide after reviewing guidance and the backup choice'); +assert.equal(i18n.text('2 apparaten en 4 entities. 1 registerafwijking voor eigen beoordeling.'), + '2 devices and 4 entities. 1 registry anomaly for your review.'); +dynamic.nodeValue = 'Instellingen'; +label.setAttribute('aria-label', 'Zoek entiteiten'); +mutationCallback([{type: 'characterData', target: dynamic}, {type: 'attributes', target: label}]); +assert.equal(dynamic.nodeValue, 'Settings'); +assert.equal(label.getAttribute('aria-label'), 'Search entities'); +i18n.setPreference('nl'); +assert.equal(dynamic.nodeValue, 'Instellingen'); +assert.equal(label.getAttribute('aria-label'), 'Zoek entiteiten'); +i18n.setPreference('en'); +assert.equal(dynamic.nodeValue, 'Settings'); +context.navigator.languages = ['nl-NL', 'en-GB']; +i18n.setPreference('auto'); +assert.equal(i18n.locale, 'nl'); +context.navigator.languages = ['fr-FR']; +i18n.setPreference('auto'); +assert.equal(i18n.locale, 'en'); +assert.equal(i18n.text('sensor.my_identifier'), 'sensor.my_identifier'); +console.log('i18n regression checks passed');