├── .gitignore ├── scripts ├── requirements.txt ├── update-version.js ├── gen_silam_allergens.py ├── README_kleenex_testing.md ├── kleenex_quick_test.py └── gen_locales.py ├── src ├── pollenprognos-images.js ├── index.js ├── utils │ ├── level-names.js │ ├── confcompare.js │ ├── normalize.js │ ├── slugify.js │ ├── levels-defaults.js │ └── silam.js ├── images │ ├── no_allergens.svg │ ├── rye.svg │ ├── olive.svg │ ├── ash.svg │ ├── allergy_risk.svg │ ├── chenopod.svg │ ├── mold_spores.svg │ ├── plane.svg │ ├── cypress.svg │ ├── pine.svg │ ├── alder.svg │ ├── poplar.svg │ ├── beech.svg │ ├── elm.svg │ ├── willow.svg │ ├── nettle_and_pellitory.svg │ ├── grass.svg │ ├── poaceae.svg │ ├── mugwort.svg │ ├── ragweed.svg │ ├── hazel.svg │ ├── lime.svg │ ├── birch.svg │ └── oak.svg ├── i18n.js ├── pollenprognos-svgs.js ├── constants.js ├── adapters │ ├── silam_allergen_map.json │ ├── dwd.js │ └── pp.js └── locales │ ├── en.json │ ├── no.json │ ├── sv.json │ └── cs.json ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── hacs.json ├── package.json ├── vite.config.js ├── docs ├── localization.md ├── integrations.md └── related-projects.md ├── CONTRIBUTING.md ├── README.md ├── AGENTS.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | pollenprognos-card.js.gz 2 | node_modules/ 3 | dist/pollenprognos-card.js.map 4 | .DS_Store 5 | oversattning.json 6 | dist 7 | __pycache__/ 8 | *.pyc 9 | *.pyo 10 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | # Requirements for Kleenex Pollen Radar API Testing Suite 2 | # Install with: pip install -r requirements.txt 3 | 4 | aiohttp>=3.8.0 5 | beautifulsoup4>=4.12.0 -------------------------------------------------------------------------------- /src/pollenprognos-images.js: -------------------------------------------------------------------------------- 1 | // src/pollenprognos-images.js 2 | 3 | /** 4 | * This module previously exported PNG images but now SVG icons are loaded dynamically. 5 | * This file is kept for backwards compatibility but the images export is now empty. 6 | * All icon rendering is handled by the SVG system in pollenprognos-card.js. 7 | */ 8 | 9 | // Empty images export - PNG system has been replaced with SVG 10 | export const images = {}; -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Validate HACS 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate-hacs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Validate HACS manifest & structure 16 | uses: hacs/action@main 17 | with: 18 | category: "plugin" 19 | ignore: "hacsjson" 20 | 21 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pollenprognos-card", 3 | "filename": "pollenprognos-card.js", 4 | "country": [ 5 | "AL","AM","AT","AZ","BE","BG","BY","CH","CY","CZ", 6 | "DE","DK","DZ","EE","EG","EH","ES","FI","FR","GB", 7 | "GE","GR","HR","HU","IE","IL","IQ","IR","IS","IT", 8 | "JO","KZ","LB","LI","LT","LU","LV","LY","MA","MD", 9 | "ME","MK","MT","MR","NL","NO","PL","PS","PT","RO", 10 | "RS","RU","SA","SE","SI","SK","SY","TN","TR","UA", 11 | "US","UZ","VA" 12 | ], 13 | "render_readme": true 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // src/index.js 2 | 3 | // Import main card and editor so that custom elements are registered 4 | import "./pollenprognos-card.js"; 5 | import "./pollenprognos-editor.js"; 6 | 7 | // Register for Lovelace UI picker (HACS) 8 | window.customCards = window.customCards || []; 9 | window.customCards.push({ 10 | type: "pollenprognos-card", 11 | name: "Pollenprognos Card", 12 | preview: true, 13 | description: "Visar en grafisk prognos för pollenhalter", 14 | documentationURL: "https://github.com/krissen/pollenprognos-card", 15 | }); 16 | -------------------------------------------------------------------------------- /src/utils/level-names.js: -------------------------------------------------------------------------------- 1 | // src/utils/level-names.js 2 | // Helper to merge user provided level names with defaults. 3 | // Returns an array of seven names, falling back to translation 4 | // strings when user values are missing. 5 | import { t } from "../i18n.js"; 6 | 7 | export function buildLevelNames(userLevels, lang) { 8 | const defaults = Array.from({ length: 7 }, (_, i) => t(`card.levels.${i}`, lang)); 9 | if (!Array.isArray(userLevels)) return defaults; 10 | return defaults.map((def, idx) => { 11 | const val = userLevels[idx]; 12 | return val == null || val === "" ? def : val; 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pollenprognos-card", 3 | "version": "2.7.2", 4 | "description": "Custom card for Home Assistant showing pollen forecasts", 5 | "main": "dist/pollenprognos-card.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "preview": "vite preview", 11 | "update-version": "node scripts/update-version.js", 12 | "prebuild": "npm run update-version" 13 | }, 14 | "devDependencies": { 15 | "@vitejs/plugin-legacy": "^6.1.1", 16 | "esbuild": "^0.25.10", 17 | "rollup": "^4.52.3", 18 | "vite": "^6.3.5" 19 | }, 20 | "dependencies": { 21 | "chart.js": "^4.5.0", 22 | "intl-messageformat": "^10.7.16", 23 | "lit": "^2.8.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/images/no_allergens.svg: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Prepare release 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | # 1) Check out the code 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | # 2) Install dependencies and build 19 | - name: Build the file 20 | run: | 21 | npm ci 22 | npm run build 23 | 24 | # 3) Upload build file to the release as an asset 25 | - name: Upload dist/pollenprognos-card.js 26 | uses: svenstaro/upload-release-action@v2 27 | with: 28 | repo_token: ${{ secrets.GITHUB_TOKEN }} 29 | file: dist/pollenprognos-card.js 30 | asset_name: pollenprognos-card.js 31 | tag: ${{ github.event.release.tag_name }} 32 | overwrite: true 33 | 34 | -------------------------------------------------------------------------------- /src/utils/confcompare.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns true if a and b are deeply equal. 3 | * Arrays are compared shallowly and unordered (["a","b"] == ["b","a"]). 4 | */ 5 | export function deepEqual(a, b) { 6 | if (a === b) return true; 7 | if (typeof a !== "object" || typeof b !== "object" || !a || !b) return false; 8 | const aKeys = Object.keys(a), 9 | bKeys = Object.keys(b); 10 | if (aKeys.length !== bKeys.length) return false; 11 | for (let k of aKeys) { 12 | if (!(k in b)) return false; 13 | if (Array.isArray(a[k]) && Array.isArray(b[k])) { 14 | if (a[k].length !== b[k].length) return false; 15 | // Compare arrays unordered 16 | if ([...a[k]].sort().join(",") !== [...b[k]].sort().join(",")) 17 | return false; 18 | } else if (typeof a[k] === "object" && typeof b[k] === "object") { 19 | if (!deepEqual(a[k], b[k])) return false; 20 | } else if (a[k] !== b[k]) { 21 | return false; 22 | } 23 | } 24 | return true; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/normalize.js: -------------------------------------------------------------------------------- 1 | // src/utils/normalize.js 2 | 3 | /** 4 | * Tar en text som kan innehålla diakritiska tecken, 5 | * tar bort dem och mappar allt till [a–z0–9_] för nycklar. 6 | */ 7 | export function normalize(text) { 8 | return ( 9 | text 10 | // Dela upp accent (NFD) 11 | .normalize("NFD") 12 | // Ta bort alla diakritiska marks 13 | .replace(/[\u0300-\u036f]/g, "") 14 | // Till gemener och ersätt allt icke-alnum med underscore 15 | .toLowerCase() 16 | .replace(/[^a-z0-9]+/g, "_") 17 | // Trimma eventuella ledande/följande _ 18 | .replace(/^_+|_+$/g, "") 19 | ); 20 | } 21 | 22 | // Special-normalize för DWD-sensorer: 23 | export function normalizeDWD(text) { 24 | return ( 25 | text 26 | .toLowerCase() 27 | // Översätt tyska diakritiska tecken 28 | .replace(/ä/g, "ae") 29 | .replace(/ö/g, "oe") 30 | .replace(/ü/g, "ue") 31 | .replace(/ß/g, "ss") 32 | // Gör resten alfanumeriskt med underscore 33 | .replace(/[^a-z0-9]+/g, "_") 34 | // Trimma eventuella _ i början/slutet 35 | .replace(/^_+|_+$/g, "") 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/update-version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Update package.json and package-lock.json with the current git tag version 3 | import { execSync } from "child_process"; 4 | import { readFileSync, writeFileSync } from "fs"; 5 | 6 | // Get the latest git tag and sanitize it for npm 7 | function getVersion() { 8 | try { 9 | const tag = execSync("git describe --tags --abbrev=0", { 10 | stdio: ["pipe", "pipe", "ignore"], 11 | }) 12 | .toString() 13 | .trim(); 14 | // Remove leading 'v' and any suffix like '-beta1' 15 | return tag.replace(/^v/, "").replace(/-.*/, ""); 16 | } catch (e) { 17 | // No tag found; keep existing version 18 | return null; 19 | } 20 | } 21 | 22 | // Write the version to a given JSON file 23 | function writeVersion(file, version) { 24 | const data = JSON.parse(readFileSync(file, "utf8")); 25 | data.version = version; 26 | if (data.packages && data.packages[""]) { 27 | data.packages[""].version = version; 28 | } 29 | writeFileSync(file, JSON.stringify(data, null, 2) + "\n"); 30 | } 31 | 32 | const version = getVersion(); 33 | if (version) { 34 | writeVersion("package.json", version); 35 | writeVersion("package-lock.json", version); 36 | } 37 | -------------------------------------------------------------------------------- /src/images/rye.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 13 | 15 | 18 | 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/images/olive.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 18 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { defineConfig } from "vite"; 3 | import legacy from "@vitejs/plugin-legacy"; 4 | import { resolve } from "path"; 5 | import { execSync } from "child_process"; 6 | 7 | export default defineConfig(({ command }) => { 8 | const isServe = command === "serve"; 9 | 10 | let version = ""; 11 | try { 12 | version = execSync("git describe --exact-match --tags", { 13 | stdio: ["pipe", "pipe", "ignore"], 14 | }) 15 | .toString() 16 | .trim(); 17 | } catch (e) { 18 | version = execSync("git rev-parse --short HEAD").toString().trim(); 19 | } 20 | return { 21 | plugins: [ 22 | // Kör legacy-plugin endast i dev (vite serve), inte i build 23 | isServe && 24 | legacy({ 25 | targets: ["defaults", "not IE 11"], 26 | }), 27 | ].filter(Boolean), 28 | 29 | define: { 30 | __VERSION__: JSON.stringify(version), 31 | }, 32 | 33 | build: { 34 | lib: { 35 | entry: resolve(__dirname, "src/index.js"), 36 | name: "pollenprognosCard", 37 | // Skriv alltid ut .js-suffix 38 | fileName: () => "pollenprognos-card.js", 39 | formats: ["es"], 40 | }, 41 | rollupOptions: { 42 | // Inga externals behövs – allt bundlas 43 | external: [], 44 | output: { 45 | sourcemap: false, 46 | }, 47 | }, 48 | }, 49 | }; 50 | }); 51 | -------------------------------------------------------------------------------- /src/images/ash.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/images/allergy_risk.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 17 | 19 | 21 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/images/chenopod.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/images/mold_spores.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/images/plane.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/images/cypress.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/localization.md: -------------------------------------------------------------------------------- 1 | # Localization and custom phrases 2 | 3 | Since **v2.0.0** the card supports dynamic localization. It automatically follows the language set in Home Assistant and loads a matching file from `src/locales`. 4 | 5 | 1. The card first looks for an exact match such as `en-GB.json`. 6 | 2. If that is missing it falls back to the language part, e.g. `en.json`. 7 | 3. If no matching file is found it uses `en.json` as the default. 8 | 9 | ## Provided locales 10 | 11 | This repository includes: 12 | 13 | - `src/locales/cs.json` 14 | - `src/locales/da.json` 15 | - `src/locales/de.json` 16 | - `src/locales/en.json` 17 | - `src/locales/fi.json` 18 | - `src/locales/fr.json` 19 | - `src/locales/it.json` 20 | - `src/locales/nl.json` 21 | - `src/locales/no.json` 22 | - `src/locales/ru.json` 23 | - `src/locales/sk.json` 24 | - `src/locales/sv.json` 25 | 26 | ## Adding a new language 27 | 28 | 1. Copy `src/locales/en.json` to `src/locales/xx.json` (or `xx-YY.json` for a region variant). 29 | 2. Translate all values in the file. 30 | 3. Open a pull request or create an issue to share your translation. 31 | 32 | Because the card uses dynamic imports there is no further code change required—the new file will be picked up automatically. 33 | 34 | ## Custom phrases 35 | 36 | In minimal mode some allergens may be shortened in a way you do not like. You can override text strings with the `phrases` options. Only include the keys you want to customise: 37 | 38 | ```yaml 39 | # your current config 40 | phrases: 41 | short: 42 | Malörtsambrosia: Ambrs 43 | Sälg och viden: Sälg 44 | ``` 45 | 46 | See [docs/configuration.md](configuration.md) for all phrase options and other settings. 47 | -------------------------------------------------------------------------------- /src/utils/slugify.js: -------------------------------------------------------------------------------- 1 | // src/utils/slugify.js 2 | // from https://github.com/home-assistant/frontend/blob/dev/src/common/string/slugify.ts 3 | // https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1 4 | export const slugify = (value, delimiter = "_") => { 5 | const a = 6 | "àáâäæãåāăąабçćčđďдèéêëēėęěеёэфğǵгḧхîïíīįìıİийкłлḿмñńǹňнôöòóœøōõőоṕпŕřрßśšşșсťțтûüùúūǘůűųувẃẍÿýыžźżз·"; 7 | const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${delimiter}`; 8 | const p = new RegExp(a.split("").join("|"), "g"); 9 | const complex_cyrillic = { 10 | ж: "zh", 11 | х: "kh", 12 | ц: "ts", 13 | ч: "ch", 14 | ш: "sh", 15 | щ: "shch", 16 | ю: "iu", 17 | я: "ia", 18 | }; 19 | 20 | let slugified; 21 | 22 | if (value === "") { 23 | slugified = ""; 24 | } else { 25 | slugified = value 26 | .toString() 27 | .toLowerCase() 28 | .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters 29 | .replace(/[а-я]/g, (c) => complex_cyrillic[c] || "") // Replace some cyrillic characters 30 | .replace(/(\d),(?=\d)/g, "$1") // Remove Commas between numbers 31 | .replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters 32 | .replace(new RegExp(`(${delimiter})\\1+`, "g"), "$1") // Replace multiple delimiters with single delimiter 33 | .replace(new RegExp(`^${delimiter}+`), "") // Trim delimiter from start of text 34 | .replace(new RegExp(`${delimiter}+$`), ""); // Trim delimiter from end of text 35 | 36 | if (slugified === "") { 37 | slugified = "unknown"; 38 | } 39 | } 40 | 41 | return slugified; 42 | }; 43 | -------------------------------------------------------------------------------- /src/images/pine.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/images/alder.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/images/poplar.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import { IntlMessageFormat } from "intl-messageformat"; 2 | 3 | // Load all locale JSON files eagerly using Vite's import.meta.glob. 4 | const localeModules = import.meta.glob("./locales/*.json", { eager: true }); 5 | 6 | // Map of language code to translation object. 7 | const LOCALES = {}; 8 | for (const filePath in localeModules) { 9 | const match = filePath.match(/\.\/locales\/([\w-]+)\.json$/); 10 | if (match) { 11 | LOCALES[match[1]] = localeModules[filePath].default; 12 | } 13 | } 14 | 15 | // Default language when no suitable locale is found. 16 | const DEFAULT = "en"; 17 | 18 | // Helper to resolve dot separated keys inside nested objects. 19 | // Looks up flat translation keys like "card.header_prefix" 20 | function resolveKey(obj, path) { 21 | return obj[path]; 22 | } 23 | 24 | // Detect the best language based on Home Assistant settings and an optional override. 25 | export function detectLang(hass, userLocale) { 26 | let tag = userLocale || hass?.locale?.language || hass?.language || DEFAULT; 27 | if (LOCALES[tag]) return tag; 28 | const short = tag.slice(0, 2).toLowerCase(); 29 | if (LOCALES[short]) return short; 30 | return DEFAULT; 31 | } 32 | 33 | export const SUPPORTED_LOCALES = Object.keys(LOCALES); 34 | 35 | // Translate a given key to the requested language using IntlMessageFormat. 36 | export function t(key, lang, vars = {}) { 37 | const localeData = LOCALES[lang] || LOCALES[DEFAULT] || {}; 38 | let msg = resolveKey(localeData, key); 39 | if (msg === undefined) { 40 | const fallback = resolveKey(LOCALES[DEFAULT] || {}, key); 41 | msg = fallback === undefined ? key : fallback; 42 | } 43 | try { 44 | const formatter = new IntlMessageFormat(msg, lang); 45 | return formatter.format(vars); 46 | } catch (err) { 47 | console.warn(`Translation failed for key: ${key}`, err); 48 | return msg; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/levels-defaults.js: -------------------------------------------------------------------------------- 1 | // src/utils/levels-defaults.js 2 | export const LEVELS_DEFAULTS = { 3 | levels_colors: [ 4 | "#FFE55A", 5 | "#FFC84E", 6 | "#FFA53F", 7 | "#FF6E33", 8 | "#FF6140", 9 | "#FF001C", 10 | ], 11 | levels_empty_color: "rgba(200, 200, 200, 0.15)", 12 | levels_gap_color: "rgba(200, 200, 200, 1)", 13 | levels_thickness: 60, 14 | levels_gap: 1, 15 | levels_text_weight: "normal", 16 | levels_text_size: 0.2, 17 | levels_icon_ratio: 1, 18 | levels_text_color: "var(--primary-text-color)", 19 | 20 | // Default allergen colors: [empty_color, ...levels_colors] 21 | // This ensures both allergen icons and level circles use the same color mapping 22 | allergen_colors: [ 23 | "rgba(200, 200, 200, 0.15)", // Level 0 (empty) 24 | "#FFE55A", // Level 1 25 | "#FFC84E", // Level 2 26 | "#FFA53F", // Level 3 27 | "#FF6E33", // Level 4 28 | "#FF6140", // Level 5 29 | "#FF001C", // Level 6 30 | ], 31 | 32 | // Default allergen stroke width - changed from old default to 15 33 | allergen_stroke_width: 15, 34 | 35 | // Sync allergen stroke color with allergen level color 36 | allergen_stroke_color_synced: true, 37 | 38 | // Sync allergen stroke width with level circle gap 39 | allergen_levels_gap_synced: true, 40 | 41 | // Default color for no allergens icon 42 | no_allergens_color: "#a9cfe0", 43 | }; 44 | 45 | // Conversion factor for stroke width to gap conversion 46 | // This converts allergen stroke width (in pixels) to level gap units 47 | // The divisor of 30 provides appropriate scaling for the UI components 48 | export const STROKE_WIDTH_TO_GAP_RATIO = 30; 49 | 50 | /** 51 | * Converts stroke width to appropriate gap value for level circles 52 | * @param {number} strokeWidth - The stroke width in pixels 53 | * @returns {number} The calculated gap value 54 | */ 55 | export function convertStrokeWidthToGap(strokeWidth) { 56 | return Math.round(strokeWidth / STROKE_WIDTH_TO_GAP_RATIO); 57 | } 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Polleninformation EU 2 | 3 | First of all, **thank you for considering contributing to this project!** 4 | Everyone is welcome to participate, regardless of experience level, background, or where you are from. 5 | 6 | We appreciate all kinds of contributions, including code, documentation, translations, bug reports, feature requests, and ideas for improvements. 7 | 8 | --- 9 | 10 | ## How to Contribute 11 | 12 | - **Fork** the repository and create your own feature branch from the latest `master`. 13 | - **Do _not_ create pull requests (PRs) directly against `master`.** 14 | - Instead, open your PR against a development or feature branch (e.g., `dev`, or your own feature branch). 15 | - Pull requests targeting `master` will be closed. 16 | - **Describe your changes clearly** in your pull request. Include motivation and context where helpful. 17 | - **Follow the existing code style** and try to keep your changes focused (one thing per PR). 18 | - If you’re unsure about your change or want feedback before implementing, feel free to [open an issue](../../issues/new) for discussion. 19 | 20 | --- 21 | 22 | ## Code of Conduct 23 | 24 | We are committed to providing a welcoming, friendly, and harassment-free environment for all. Please treat everyone with respect and be constructive in discussions. 25 | 26 | --- 27 | 28 | ## Reporting Issues 29 | 30 | - If you find a bug or have an idea for an enhancement, please [open an issue](../../issues/new) and describe it as clearly as possible. 31 | - Include steps to reproduce, screenshots, logs, or any context that may help us understand and address the issue. 32 | 33 | --- 34 | 35 | ## Getting Help 36 | 37 | - If you have questions, suggestions, or need guidance, don’t hesitate to [open an issue](../../issues/new) or reach out in the project discussions. 38 | - We’re happy to help new contributors get started! 39 | 40 | --- 41 | 42 | ## Thank you 43 | 44 | Your feedback and contributions help make this project better for everyone. 45 | -------------------------------------------------------------------------------- /src/images/beech.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/utils/silam.js: -------------------------------------------------------------------------------- 1 | import silamAllergenMap from "../adapters/silam_allergen_map.json" assert { type: "json" }; 2 | 3 | // Skapa dynamisk reverse-map: masterAllergen => slug för rätt språk 4 | export function getSilamReverseMap(lang) { 5 | const mapping = 6 | silamAllergenMap.mapping?.[lang] || silamAllergenMap.mapping?.en || {}; 7 | const reverse = {}; 8 | for (const [slug, master] of Object.entries(mapping)) { 9 | reverse[master] = slug; 10 | } 11 | return reverse; 12 | } 13 | 14 | /** 15 | * Hitta rätt weather.silam_pollen_{location}_{suffix} i hass.states. 16 | * - Testar: locale-suffixar, engelska, alla, samt sista utväg: prefix-match. 17 | */ 18 | export function findSilamWeatherEntity(hass, location, locale) { 19 | if (!hass || !location) return null; 20 | const loc = location.toLowerCase(); 21 | let tried = new Set(); 22 | 23 | // 1. Testa suffixar för aktuell locale 24 | const suffixesLocale = 25 | silamAllergenMap.weather_suffixes?.[locale] || 26 | silamAllergenMap.weather_suffixes?.[locale?.split("-")[0]] || 27 | []; 28 | for (const suffix of suffixesLocale) { 29 | tried.add(suffix); 30 | const entityId = `weather.silam_pollen_${loc}_${suffix}`; 31 | if (entityId in hass.states) return entityId; 32 | } 33 | 34 | // 2. Testa engelska suffixar 35 | for (const suffix of silamAllergenMap.weather_suffixes?.en || []) { 36 | if (tried.has(suffix)) continue; 37 | tried.add(suffix); 38 | const entityId = `weather.silam_pollen_${loc}_${suffix}`; 39 | if (entityId in hass.states) return entityId; 40 | } 41 | 42 | // 3. Testa alla kända suffixar (alla språk) 43 | const allSuffixes = Array.from( 44 | new Set(Object.values(silamAllergenMap.weather_suffixes).flat()), 45 | ); 46 | for (const suffix of allSuffixes) { 47 | if (tried.has(suffix)) continue; 48 | const entityId = `weather.silam_pollen_${loc}_${suffix}`; 49 | if (entityId in hass.states) return entityId; 50 | } 51 | 52 | // 4. Fallback: första entity med rätt prefix 53 | const prefix = `weather.silam_pollen_${loc}_`; 54 | return Object.keys(hass.states).find((id) => id.startsWith(prefix)) || null; 55 | } 56 | -------------------------------------------------------------------------------- /src/images/elm.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/images/willow.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/integrations.md: -------------------------------------------------------------------------------- 1 | # Supported integrations and compatibility 2 | 3 | `pollenprognos-card` can display data from the following Home Assistant integrations: 4 | 5 | - [Pollenprognos](https://github.com/JohNan/homeassistant-pollenprognos) 6 | - [DWD Pollenflug](https://github.com/mampfes/hacs_dwd_pollenflug) 7 | - [Polleninformation EU](https://github.com/krissen/polleninformation) 8 | - [SILAM Pollen Allergy Sensor](https://github.com/danishru/silam_pollen) 9 | - [Kleenex Pollen Radar](https://github.com/MarcoGos/kleenex_pollenradar) 10 | 11 | The card tries to auto-detect which adapter to use based on your sensors. The table below lists version requirements and other notes for each integration. 12 | 13 | | Integration | Notes | 14 | |-------------|------| 15 | | **Pollenprognos** | For `homeassistant-pollenprognos` **v1.1.0** and higher you need **v1.0.6** or newer of this card. For older integration versions use **v1.0.5** or earlier. | 16 | | **Polleninformation EU** | For `polleninformation` **v0.4.0** or later you need **v2.4.2** or newer of this card. Versions **v0.3.1** and earlier require card version **v2.2.0–v2.4.1**. Forecast modes were added in card **v2.5.0** and require `polleninformation` **v0.4.4** or later. Only the `allergy_risk` sensor supports modes other than `daily`. | 17 | | **SILAM Pollen Allergy Sensor** | Do not rename the sensors created by the integration. For `silam_pollen` **v0.2.7** and newer you need **v2.4.1** or newer of this card. Older integration versions require **v2.3.0–v2.4.0**. | 18 | | **DWD Pollenflug** | Keep the default sensor names. You need card version **v2.0.0** or newer. | 19 | | **Kleenex Pollen Radar** | Keep the default sensor names. Supports forecasts for Netherlands, UK, France, Italy and USA. Added in card **v2.6.0**. **Note**: The integration reports level as "low" even when ppm values are 0, while the card interprets 0 ppm as "none" level. This different interpretation may affect which allergens appear in the card, and the level of the allergen, as compared to the integration. To confirm what the card shows, open up the relevant sensor in the integration (`trees`, `weeds`, or `grass`). Look at the attributes. Even though the ppm **value** is `0` (no particles in the air) the **level** is shown as `low`. The card would instead show a ppm level of `0` as `none`. | 20 | -------------------------------------------------------------------------------- /src/images/nettle_and_pellitory.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/related-projects.md: -------------------------------------------------------------------------------- 1 | # Related or similar projects 2 | 3 | Below are a few alternative Lovelace cards and integrations for pollen forecasts. Each has different features and limitations. 4 | 5 | ## lovelace-pollen-card 6 | 7 | 8 | 9 | A card that displays pollen sensor data (forked from `isabellaalstrom/lovelace-pollenprognos-card`). 10 | 11 | **Pros** 12 | - Makes `lovelace-pollenprognos-card` usable after integration changes 13 | - More features than the original card 14 | - Still relatively easy to set up 15 | 16 | **Cons** 17 | - Does not work with the latest `homeassistant-pollenprognos` releases 18 | - Supports only the Pollenprognos integration 19 | - No built-in localization or visual editor 20 | - Uses embedded PNG images instead of scalable SVG icons 21 | 22 | ## lovelace-pollenprognos-card 23 | 24 | 25 | 26 | The original card for the Pollenprognos integration. 27 | 28 | **Pros** 29 | - Simple display of Pollenprognos sensor data 30 | 31 | **Cons** 32 | - Unmaintained 33 | - No HACS support or UI editor 34 | - Requires manual copying of `pollen_img` to `www/` or setting `img_path` 35 | - Uses embedded PNG images instead of scalable SVG icons 36 | 37 | ## dwd-pollenprognos-card 38 | 39 | 40 | 41 | A custom card for the DWD Pollenflug integration. 42 | 43 | **Pros** 44 | - Focused only on DWD data 45 | - Uses the same styling as the upstream card 46 | 47 | **Cons** 48 | - No visual editor; configuration via YAML only 49 | - Requires manual copy of `pollen_img` or custom `img_path` 50 | - Works only with DWD sensors 51 | - Uses embedded PNG images instead of scalable SVG icons 52 | 53 | ## Summary 54 | 55 | `pollenprognos-card` distinguishes itself through comprehensive multi-integration support, working with all five major pollen data sources (Pollenprognos, DWD, Polleninformation EU, SILAM, and Kleenex). Unlike single-integration alternatives, it features: 56 | 57 | - **Built-in SVG icon system** with 24+ scalable allergen icons and customizable colors 58 | - **Visual Home Assistant editor** for complete configuration without YAML editing 59 | - **Dynamic localization** in 12+ languages that automatically follows Home Assistant's language setting 60 | - **Auto-detection** of available integrations and sensors 61 | - **HACS official repository** status with automatic updates 62 | - **Multiple display modes** including minimal, daily, hourly, and twice-daily forecast layouts 63 | 64 | These features make it a comprehensive solution for pollen forecast display that works seamlessly across different data sources and regions. 65 | 66 | -------------------------------------------------------------------------------- /src/pollenprognos-svgs.js: -------------------------------------------------------------------------------- 1 | // src/pollenprognos-svgs.js 2 | 3 | /** 4 | * This module imports all SVG icons as text strings at build time. 5 | * This follows Home Assistant best practices for custom cards. 6 | */ 7 | 8 | // Import SVGs as text strings (Vite will inline them at build time) 9 | import alderSvg from "./images/alder.svg?raw"; 10 | import allergyRiskSvg from "./images/allergy_risk.svg?raw"; 11 | import ashSvg from "./images/ash.svg?raw"; 12 | import beechSvg from "./images/beech.svg?raw"; 13 | import birchSvg from "./images/birch.svg?raw"; 14 | import chenopodSvg from "./images/chenopod.svg?raw"; 15 | import cypressSvg from "./images/cypress.svg?raw"; 16 | import elmSvg from "./images/elm.svg?raw"; 17 | import grassSvg from "./images/grass.svg?raw"; 18 | import hazelSvg from "./images/hazel.svg?raw"; 19 | import limeSvg from "./images/lime.svg?raw"; 20 | import moldSporesSvg from "./images/mold_spores.svg?raw"; 21 | import mugwortSvg from "./images/mugwort.svg?raw"; 22 | import nettleAndPellitorySvg from "./images/nettle_and_pellitory.svg?raw"; 23 | import noAllergensSvg from "./images/no_allergens.svg?raw"; 24 | import oakSvg from "./images/oak.svg?raw"; 25 | import oliveSvg from "./images/olive.svg?raw"; 26 | import pineSvg from "./images/pine.svg?raw"; 27 | import planeSvg from "./images/plane.svg?raw"; 28 | import poaceaeSvg from "./images/poaceae.svg?raw"; 29 | import poplarSvg from "./images/poplar.svg?raw"; 30 | import ragweedSvg from "./images/ragweed.svg?raw"; 31 | import ryeSvg from "./images/rye.svg?raw"; 32 | import willowSvg from "./images/willow.svg?raw"; 33 | 34 | // Export SVG map - all SVGs are available immediately, no async loading needed 35 | export const svgs = { 36 | alder: alderSvg, 37 | allergy_risk: allergyRiskSvg, 38 | ash: ashSvg, 39 | beech: beechSvg, 40 | birch: birchSvg, 41 | chenopod: chenopodSvg, 42 | cypress: cypressSvg, 43 | elm: elmSvg, 44 | grass: grassSvg, 45 | hazel: hazelSvg, 46 | lime: limeSvg, 47 | mold_spores: moldSporesSvg, 48 | mugwort: mugwortSvg, 49 | nettle_and_pellitory: nettleAndPellitorySvg, 50 | nettle: nettleAndPellitorySvg, // Alias for compatibility 51 | no_allergens: noAllergensSvg, 52 | oak: oakSvg, 53 | olive: oliveSvg, 54 | pine: pineSvg, 55 | plane: planeSvg, 56 | poaceae: poaceaeSvg, 57 | poplar: poplarSvg, 58 | ragweed: ragweedSvg, 59 | rye: ryeSvg, 60 | willow: willowSvg, 61 | }; 62 | 63 | /** 64 | * Get SVG content for a given key 65 | * @param {string} key - The allergen key 66 | * @returns {string|null} SVG content or null if not found 67 | */ 68 | export function getSvgContent(key) { 69 | if (!key || typeof key !== 'string') { 70 | return null; 71 | } 72 | return svgs[key] || null; 73 | } -------------------------------------------------------------------------------- /src/images/grass.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 12 | 18 | 27 | 30 | 36 | 39 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/images/poaceae.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 12 | 18 | 27 | 30 | 36 | 41 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/images/mugwort.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/images/ragweed.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 37 | 38 | 39 | 41 | 43 | 44 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/images/hazel.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/images/lime.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 32 | 40 | 44 | 47 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/images/birch.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/images/oak.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 39 | 43 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pollenprognos-card 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![License][license-shield]](LICENSE) 5 | [![hacs][hacsbadge]][hacs] 6 | [![Project Maintenance][maintenance-shield]][user_profile] 7 | [![BuyMeCoffee][buymecoffeebadge]][buymecoffee] 8 | 9 | A Lovelace card that shows pollen forecasts from several integrations. The card supports Home Assistant's visual editor and works with five adapters: 10 | 11 | - [Pollenprognos](https://github.com/JohNan/homeassistant-pollenprognos) 12 | - [DWD Pollenflug](https://github.com/mampfes/hacs_dwd_pollenflug) 13 | - [Polleninformation EU](https://github.com/krissen/polleninformation) 14 | - [SILAM Pollen Allergy Sensor](https://github.com/danishru/silam_pollen) 15 | - [Kleenex Pollen Radar](https://github.com/MarcoGos/kleenex_pollenradar) 16 | 17 | 18 | 19 | 22 | 25 | 26 | 27 | 30 | 33 | 34 | 35 | 36 | 37 | 38 |
20 | Skärmavbild 2025-06-09 kl  20 10 46 21 | 23 | Skärmavbild 2025-05-30 kl  12 38 11 24 |
28 | Forecast 29 | 31 | Skärmavbild 2025-08-04 kl  09 46 28 32 |
Two card instances combined, both using integration `polleninformation`. The top card shows `allergy_risk` in mode daily.Three card instances combined, all using integration `silam_pollen`. The top-most card uses mode `minimal`. The middle card `twice_daily`, and the bottom card `hourly`.
39 | 40 | ## Requirements 41 | 42 | Install one of the supported integrations above. The card auto-detects which adapter to use based on your sensors. 43 | 44 | ## Features 45 | 46 | - **Multi-Integration Support**: Works with 5 different pollen data sources (Pollenprognos, DWD Pollenflug, Polleninformation EU, SILAM, Kleenex Pollen Radar) 47 | - **Auto-Detection**: Automatically detects which integration to use based on your available sensors 48 | - **Visual Editor**: Full Home Assistant UI configuration support - no manual YAML editing required 49 | - **Scalable SVG Icons**: 24+ allergen icons rendered as lightweight, customizable SVG graphics 50 | - **Multiple Display Modes**: Support for minimal, daily, hourly, and twice-daily forecast layouts 51 | - **Full Localization**: Dynamic language support with 12+ translations following Home Assistant's language setting 52 | - **Extensive Customization**: Configure colors, layouts, text size, sorting, and display options through the visual editor 53 | - **HACS Integration**: Official HACS repository with automatic updates and easy installation 54 | 55 | ## Installation 56 | 57 | Add `https://github.com/krissen/pollenprognos-card` as a custom repository in HACS and install the card. Reload your browser cache after installation. 58 | 59 | ## Basic usage 60 | 61 | You can configure the card using the Lovelace editor. A minimal YAML configuration looks like this: 62 | 63 | ```yaml 64 | type: custom:pollenprognos-card 65 | integration: pp # auto-detected if omitted 66 | city: Stockholm # adapter specific option 67 | ``` 68 | 69 | ## Configuration reference 70 | 71 | More details, including all options and example snippets, are available in the documentation: 72 | 73 | - [Configuration reference](docs/configuration.md) 74 | - [Integrations and compatibility](docs/integrations.md) 75 | - [Localization and custom phrases](docs/localization.md) 76 | - [Related projects](docs/related-projects.md) 77 | 78 | ## Credits 79 | 80 | This project is based on [pollen-card](https://github.com/nidayand/lovelace-pollen-card), originally rewritten from [pollenprognos-card](https://github.com/isabellaalstrom/lovelace-pollenprognos-card). 81 | 82 | [Want to support development? Buy me a coffee!](https://coff.ee/krissen) 83 | 84 | [hacs]: https://hacs.xyz 85 | [hacsbadge]: https://img.shields.io/badge/HACS-Official-blue.svg?style=for-the-badge 86 | [license-shield]: https://img.shields.io/github/license/krissen/pollenprognos-card.svg?style=for-the-badge 87 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%40krissen-blue.svg?style=for-the-badge 88 | [releases-shield]: https://img.shields.io/github/release/krissen/pollenprognos-card.svg?style=for-the-badge 89 | [releases]: https://github.com/krissen/pollenprognos-card/releases 90 | [user_profile]: https://github.com/krissen 91 | [buymecoffee]: https://coff.ee/krissen 92 | [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge 93 | -------------------------------------------------------------------------------- /scripts/gen_silam_allergens.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unicodedata 3 | import json 4 | import os 5 | import re 6 | import unicodedata 7 | 8 | """ 9 | scripts/gen_silam_allergens.py 10 | 11 | Purpose: Generate possible allergen names for silam_pollen, 12 | using the translation files in the integration. Necessary 13 | to be able to automatically read in allergens for silam, 14 | regardless of the users locale. 15 | 16 | How to use: python3 scripts/gen_silam_allergens.py 17 | 18 | Result: Writes a JSON file with allergen names and slug mappings 19 | in OUT_FILE 20 | 21 | Prerequisite: have silam_pollen in ../silam_pollen -directory or 22 | adjust TRANSLATIONS_DIR according to your setup. 23 | """ 24 | 25 | 26 | TRANSLATIONS_DIR = "../silam_pollen/custom_components/silam_pollen/translations" 27 | OUT_FILE = "./src/adapters/silam_allergen_map.json" 28 | 29 | 30 | def slugify(text: str) -> str: 31 | try: 32 | from unidecode import unidecode 33 | text = unidecode(text) 34 | except ImportError: 35 | text = ( 36 | unicodedata.normalize("NFKD", text) 37 | .encode("ascii", "ignore") 38 | .decode("ascii") 39 | ) 40 | 41 | # Ta bort parentesinnehåll, men behåll resten av strängen 42 | text = re.sub(r'\(.*?\)', '', text) 43 | text = text.strip().lower() 44 | 45 | # Ersätt diakrit-variationer och specialfall 46 | text = ( 47 | text.replace("ö", "o") 48 | .replace("ä", "a") 49 | .replace("å", "a") 50 | .replace("ß", "ss") 51 | .replace("'", "") 52 | ) 53 | 54 | # Ersätt alla icke-alfanumeriska tecken med _ 55 | text = re.sub(r"[^\w]+", "_", text) 56 | 57 | # Ta bort inledande och avslutande _ 58 | text = text.strip("_") 59 | 60 | return text 61 | 62 | def main(): 63 | result = {} 64 | master_eng = {} 65 | with open(os.path.join(TRANSLATIONS_DIR, "en.json"), encoding="utf-8") as f: 66 | en_data = json.load(f) 67 | sensor = en_data["entity"]["sensor"] 68 | for key in sensor: 69 | if key in ("index", "fetch_duration"): 70 | continue 71 | eng_name = sensor[key].get("name", key) 72 | master_eng[key] = { 73 | "slug": key, 74 | "name": eng_name 75 | } 76 | 77 | # Bygg allergen-mapping 78 | for fname in os.listdir(TRANSLATIONS_DIR): 79 | if not fname.endswith(".json"): 80 | continue 81 | lang = fname.split(".")[0] 82 | with open(os.path.join(TRANSLATIONS_DIR, fname), encoding="utf-8") as f: 83 | data = json.load(f) 84 | sensor = data.get("entity", {}).get("sensor", {}) 85 | mapping = {} 86 | for key, info in sensor.items(): 87 | if key in ("index", "fetch_duration"): 88 | continue 89 | if lang == "en": 90 | # Bara engelska sluggar i engelska mappningen 91 | mapping[key] = key 92 | else: 93 | # Lokal slug 94 | local_name = info.get("name", key) 95 | local_slug = slugify(local_name) 96 | # Undvik dubletter: bara lokalt namn om det skiljer sig från engelsk slug 97 | if local_slug != key: 98 | mapping[local_slug] = key 99 | result[lang] = mapping 100 | 101 | # Bygg reverse-namn-lookup också 102 | names_by_lang = {} 103 | for fname in os.listdir(TRANSLATIONS_DIR): 104 | if not fname.endswith(".json"): 105 | continue 106 | lang = fname.split(".")[0] 107 | with open(os.path.join(TRANSLATIONS_DIR, fname), encoding="utf-8") as f: 108 | data = json.load(f) 109 | sensor = data.get("entity", {}).get("sensor", {}) 110 | for key, info in sensor.items(): 111 | if key in ("index", "fetch_duration"): 112 | continue 113 | if key not in names_by_lang: 114 | names_by_lang[key] = {} 115 | names_by_lang[key][lang] = info.get("name", key) 116 | 117 | # Hämta möjliga weather-suffixar per locale 118 | weather_suffixes = {} 119 | for fname in os.listdir(TRANSLATIONS_DIR): 120 | if not fname.endswith(".json"): 121 | continue 122 | lang = fname.split(".")[0] 123 | with open(os.path.join(TRANSLATIONS_DIR, fname), encoding="utf-8") as f: 124 | data = json.load(f) 125 | weather = data.get("entity", {}).get("weather", {}) 126 | suffixes = set() 127 | for key, info in weather.items(): 128 | name = info.get("name", key) 129 | slug = slugify(name) 130 | suffixes.add(slug) 131 | weather_suffixes[lang] = sorted(list(suffixes)) 132 | 133 | out = { 134 | "mapping": result, 135 | "names": names_by_lang, 136 | "weather_suffixes": weather_suffixes 137 | } 138 | os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True) 139 | with open(OUT_FILE, "w", encoding="utf-8") as f: 140 | json.dump(out, f, ensure_ascii=False, indent=2) 141 | print(f"Wrote {OUT_FILE}") 142 | if __name__ == "__main__": 143 | main() 144 | 145 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | // src/constants.js 2 | 3 | import * as PP from "./adapters/pp.js"; 4 | import * as DWD from "./adapters/dwd.js"; 5 | import * as PEU from "./adapters/peu.js"; 6 | import * as SILAM from "./adapters/silam.js"; 7 | import * as KLEENEX from "./adapters/kleenex.js"; 8 | 9 | export const ADAPTERS = { 10 | pp: PP, 11 | dwd: DWD, 12 | peu: PEU, 13 | silam: SILAM, 14 | kleenex: KLEENEX, 15 | }; 16 | 17 | export const DWD_REGIONS = { 18 | 11: "Schleswig-Holstein und Hamburg", 19 | 12: "Schleswig-Holstein und Hamburg", 20 | 20: "Mecklenburg-Vorpommern", 21 | 31: "Niedersachsen und Bremen", 22 | 32: "Niedersachsen und Bremen", 23 | 41: "Nordrhein-Westfalen", 24 | 42: "Nordrhein-Westfalen", 25 | 43: "Nordrhein-Westfalen", 26 | 50: "Brandenburg und Berlin", 27 | 61: "Sachsen-Anhalt", 28 | 62: "Sachsen-Anhalt", 29 | 71: "Thüringen", 30 | 72: "Thüringen", 31 | 81: "Sachsen", 32 | 82: "Sachsen", 33 | 91: "Hessen", 34 | 92: "Hessen", 35 | 101: "Rheinland-Pfalz und Saarland", 36 | 102: "Rheinland-Pfalz und Saarland", 37 | 103: "Rheinland-Pfalz und Saarland", 38 | 111: "Baden-Württemberg", 39 | 112: "Baden-Württemberg", 40 | 113: "Baden-Württemberg", 41 | 121: "Bayern", 42 | 122: "Bayern", 43 | 123: "Bayern", 44 | 124: "Bayern", 45 | }; 46 | 47 | export const ALLERGEN_TRANSLATION = { 48 | // Svenska 49 | al: "alder", 50 | alm: "elm", 51 | bok: "beech", 52 | bjork: "birch", 53 | ek: "oak", 54 | grabo: "mugwort", 55 | gras: "grass", 56 | hassel: "hazel", 57 | malortsambrosia: "ragweed", 58 | salg_och_viden: "willow", 59 | 60 | // Tyska (DWD), normaliserade via replaceAAO 61 | erle: "alder", 62 | ambrosia: "ragweed", 63 | esche: "ash", 64 | birke: "birch", 65 | buche: "beech", 66 | hasel: "hazel", 67 | graser: "grass", // från 'gräser' 68 | graeser: "grass", // från 'gräser' 69 | beifuss: "mugwort", // från 'beifuss' 70 | roggen: "rye", 71 | 72 | // Engelska (PEU) 73 | olive: "olive", 74 | plane: "plane", 75 | cypress: "cypress", 76 | lime: "lime", 77 | mold_spores: "mold_spores", 78 | nettle_and_pellitory: "nettle_and_pellitory", 79 | // Add PEU (new API) English names 80 | fungal_spores: "mold_spores", 81 | grasses: "grass", 82 | cypress_family: "cypress", 83 | nettle_family: "nettle_and_pellitory", 84 | plane_tree: "plane", 85 | rye: "rye", 86 | ragweed: "ragweed", 87 | birch: "birch", 88 | alder: "alder", 89 | hazel: "hazel", 90 | mugwort: "mugwort", 91 | olive: "olive", 92 | allergy_risk: "allergy_risk", 93 | index: "allergy_risk", 94 | 95 | // Kleenex pollen radar - individual allergens 96 | pine: "pine", 97 | poplar: "poplar", 98 | poaceae: "poaceae", 99 | chenopod: "chenopod", 100 | nettle: "nettle", 101 | // Kleenex pollen radar - category allergens (to distinguish from individual allergens) 102 | grass_cat: "grass_cat", 103 | trees_cat: "trees_cat", 104 | weeds_cat: "weeds_cat", 105 | // Note: Category allergens use _cat suffix to distinguish from individuals 106 | // Icon mapping is handled separately in the image system 107 | }; 108 | 109 | // Icon fallback mapping for allergens that don't have their own icons 110 | export const ALLERGEN_ICON_FALLBACK = { 111 | trees_cat: "birch", // Use birch icon for trees category 112 | grass_cat: "grass", // Use grass icon for grass category 113 | weeds_cat: "mugwort", // Use mugwort icon for weeds category 114 | trees: "birch", // Keep original for compatibility 115 | weeds: "mugwort", // Keep original for compatibility 116 | // grass has its own icon, no fallback needed 117 | }; 118 | 119 | // Mapping of localized category name prefixes to canonical names for Kleenex integration 120 | // The Kleenex integration creates sensors with localized category names based on HA language 121 | // Using prefixes to handle both singular and plural forms (e.g., onkruid/onkruiden) 122 | export const KLEENEX_LOCALIZED_CATEGORY_NAMES = { 123 | // English 124 | tree: "trees", // matches trees 125 | grass: "grass", 126 | weed: "weeds", // matches weeds 127 | // Dutch 128 | bomen: "trees", 129 | gras: "grass", 130 | onkruid: "weeds", // matches both onkruid and onkruiden 131 | kruid: "weeds", // matches kruid and kruiden 132 | // French 133 | arbre: "trees", // matches arbres 134 | graminee: "grass", // matches graminees, graminées 135 | herbacee: "weeds", // matches herbacees, herbacées 136 | // Italian 137 | alber: "trees", // matches alberi 138 | graminace: "grass", // matches graminacee 139 | erbace: "weeds", // matches erbacee 140 | }; 141 | 142 | export const PP_POSSIBLE_CITIES = [ 143 | "Borlänge", 144 | "Bräkne-Hoby", 145 | "Eskilstuna", 146 | "Forshaga", 147 | "Gävle", 148 | "Göteborg", 149 | "Hässleholm", 150 | "Jönköping", 151 | "Kristianstad", 152 | "Ljusdal", 153 | "Malmö", 154 | "Norrköping", 155 | "Nässjö", 156 | "Piteå", 157 | "Skövde", 158 | "Stockholm", 159 | "Storuman", 160 | "Sundsvall", 161 | "Umeå", 162 | "Visby", 163 | "Västervik", 164 | "Östersund", 165 | ]; 166 | 167 | export const COSMETIC_FIELDS = [ 168 | "icon_size", 169 | "icon_color_mode", 170 | "icon_color", 171 | "allergen_color_mode", 172 | "allergen_colors", 173 | "allergen_outline_color", 174 | "allergen_stroke_width", 175 | "allergen_stroke_color_synced", 176 | "allergen_levels_gap_synced", 177 | "levels_inherit_mode", 178 | "background_color", 179 | "levels_colors", 180 | "levels_empty_color", 181 | "levels_gap_color", 182 | "levels_thickness", 183 | "levels_gap", 184 | "levels_text_color", 185 | "levels_text_size", 186 | "levels_icon_ratio", 187 | "levels_text_weight", 188 | "minimal", 189 | "show_text_allergen", 190 | "show_value_text", 191 | "show_value_numeric", 192 | "show_value_numeric_in_circle", 193 | "allergens_abbreviated", 194 | "days_boldfaced", 195 | "text_size_ratio", 196 | "minimal_gap", 197 | "title", 198 | ]; 199 | -------------------------------------------------------------------------------- /src/adapters/silam_allergen_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "mapping": { 3 | "nl": { 4 | "els": "alder", 5 | "berk": "birch", 6 | "gras": "grass", 7 | "hazelaar": "hazel", 8 | "bijvoet": "mugwort", 9 | "olijf": "olive", 10 | "ambrosia": "ragweed", 11 | "index": "allergy_risk" 12 | }, 13 | "de": { 14 | "erle": "alder", 15 | "birke": "birch", 16 | "gras": "grass", 17 | "hasel": "hazel", 18 | "beifu": "mugwort", 19 | "ambrosia": "ragweed", 20 | "index": "allergy_risk" 21 | }, 22 | "ru": { 23 | "": "ragweed" 24 | }, 25 | "fi": { 26 | "leppa": "alder", 27 | "koivu": "birch", 28 | "heina": "grass", 29 | "pahkinaleppa": "hazel", 30 | "siankarsamo": "mugwort", 31 | "oliivi": "olive", 32 | "ambrosia": "ragweed", 33 | "index": "allergy_risk" 34 | }, 35 | "sk": { 36 | "jelsa": "alder", 37 | "breza": "birch", 38 | "trava": "grass", 39 | "lieska": "hazel", 40 | "palina": "mugwort", 41 | "olivovnik": "olive", 42 | "ambrozia": "ragweed", 43 | "index": "allergy_risk" 44 | }, 45 | "en": { 46 | "alder": "alder", 47 | "birch": "birch", 48 | "grass": "grass", 49 | "hazel": "hazel", 50 | "mugwort": "mugwort", 51 | "olive": "olive", 52 | "ragweed": "ragweed", 53 | "index": "allergy_risk" 54 | }, 55 | "it": { 56 | "ontano": "alder", 57 | "betulla": "birch", 58 | "erba": "grass", 59 | "nocciolo": "hazel", 60 | "artemisia": "mugwort", 61 | "oliva": "olive", 62 | "ambrosia": "ragweed", 63 | "index": "allergy_risk" 64 | }, 65 | "cs": { 66 | "olse": "alder", 67 | "briza": "birch", 68 | "trava": "grass", 69 | "liska": "hazel", 70 | "pelynek": "mugwort", 71 | "olivovnik": "olive", 72 | "ambrozie": "ragweed", 73 | "index": "allergy_risk" 74 | }, 75 | "no": { 76 | "al": "alder", 77 | "bjrk": "birch", 78 | "gress": "grass", 79 | "hassel": "hazel", 80 | "malurt": "mugwort", 81 | "oliven": "olive", 82 | "ambrosia": "ragweed", 83 | "index": "allergy_risk" 84 | }, 85 | "da": { 86 | "al": "alder", 87 | "birk": "birch", 88 | "grs": "grass", 89 | "hassel": "hazel", 90 | "malurt": "mugwort", 91 | "oliven": "olive", 92 | "ambrosia": "ragweed", 93 | "index": "allergy_risk" 94 | }, 95 | "sv": { 96 | "al": "alder", 97 | "bjork": "birch", 98 | "gras": "grass", 99 | "hassel": "hazel", 100 | "malort": "mugwort", 101 | "oliv": "olive", 102 | "ambrosia": "ragweed", 103 | "index": "allergy_risk" 104 | } 105 | }, 106 | "names": { 107 | "alder": { 108 | "nl": "Els", 109 | "de": "Erle", 110 | "ru": "Ольха", 111 | "fi": "Leppä", 112 | "sk": "Jelša", 113 | "en": "Alder", 114 | "it": "Ontano", 115 | "cs": "Olše", 116 | "no": "Al", 117 | "da": "Al", 118 | "sv": "Al" 119 | }, 120 | "birch": { 121 | "nl": "Berk", 122 | "de": "Birke", 123 | "ru": "Берёза", 124 | "fi": "Koivu", 125 | "sk": "Breza", 126 | "en": "Birch", 127 | "it": "Betulla", 128 | "cs": "Bříza", 129 | "no": "Bjørk", 130 | "da": "Birk", 131 | "sv": "Björk" 132 | }, 133 | "grass": { 134 | "nl": "Gras", 135 | "de": "Gras", 136 | "ru": "Трава", 137 | "fi": "Heinä", 138 | "sk": "Tráva", 139 | "en": "Grass", 140 | "it": "Erba", 141 | "cs": "Tráva", 142 | "no": "Gress", 143 | "da": "Græs", 144 | "sv": "Gräs" 145 | }, 146 | "hazel": { 147 | "nl": "Hazelaar", 148 | "de": "Hasel", 149 | "ru": "Лещина", 150 | "fi": "Pähkinäleppä", 151 | "sk": "Lieska", 152 | "en": "Hazel", 153 | "it": "Nocciolo", 154 | "cs": "Líska", 155 | "no": "Hassel", 156 | "da": "Hassel", 157 | "sv": "Hassel" 158 | }, 159 | "mugwort": { 160 | "nl": "Bijvoet", 161 | "de": "Beifuß", 162 | "ru": "Полынь", 163 | "fi": "Siankärsämö", 164 | "sk": "Palina", 165 | "en": "Mugwort", 166 | "it": "Artemisia", 167 | "cs": "Pelyněk", 168 | "no": "Malurt", 169 | "da": "Malurt", 170 | "sv": "Malört" 171 | }, 172 | "olive": { 173 | "nl": "Olijf", 174 | "de": "Olive", 175 | "ru": "Олива", 176 | "fi": "Oliivi", 177 | "sk": "Olivovník", 178 | "en": "Olive", 179 | "it": "Oliva", 180 | "cs": "Olivovník", 181 | "no": "Oliven", 182 | "da": "Oliven", 183 | "sv": "Oliv" 184 | }, 185 | "ragweed": { 186 | "nl": "Ambrosia", 187 | "de": "Ambrosia", 188 | "ru": "Амброзия", 189 | "fi": "Ambrosia", 190 | "sk": "Ambrózia", 191 | "en": "Ragweed", 192 | "it": "Ambrosia", 193 | "cs": "Ambrózie", 194 | "no": "Ambrosia", 195 | "da": "Ambrosia", 196 | "sv": "Ambrosia" 197 | }, 198 | "allergy_risk": { 199 | "nl": "Index", 200 | "de": "Index", 201 | "ru": "Index", 202 | "fi": "Index", 203 | "sk": "Index", 204 | "en": "Index", 205 | "it": "Index", 206 | "cs": "Index", 207 | "no": "Index", 208 | "da": "Index", 209 | "sv": "Index" 210 | } 211 | }, 212 | "weather_suffixes": { 213 | "nl": [ 214 | "forecast_beta" 215 | ], 216 | "de": [ 217 | "pollen_vorhersage_beta" 218 | ], 219 | "ru": [ 220 | "beta" 221 | ], 222 | "fi": [ 223 | "siitepolyennuste_beta" 224 | ], 225 | "sk": [ 226 | "predpoved_beta" 227 | ], 228 | "en": [ 229 | "pollen_forecast_beta" 230 | ], 231 | "it": [ 232 | "previsione_del_polline_beta" 233 | ], 234 | "cs": [ 235 | "predpoved_beta" 236 | ], 237 | "no": [ 238 | "pollenprognose_beta" 239 | ], 240 | "da": [ 241 | "pollenprognose_beta" 242 | ], 243 | "sv": [ 244 | "pollenprognos_beta" 245 | ] 246 | } 247 | } -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS.md 2 | 3 | ## Automated Agent Instructions for GitHub Copilot 4 | 5 | ### Project Overview 6 | This is a Home Assistant Lovelace card for displaying pollen forecasts. The card supports multiple pollen data integrations through adapters and provides a visual editor interface. 7 | 8 | ### Technology Stack 9 | - **Framework**: Lit Element (Web Components) 10 | - **Build System**: Vite 11 | - **Language**: Modern ES6+ JavaScript 12 | - **Dependencies**: lit, chart.js, intl-messageformat 13 | - **Target Environment**: Home Assistant frontend (modern browsers) 14 | 15 | ### Development Environment Setup 16 | 1. **Prerequisites**: Node.js and npm 17 | 2. **Install dependencies**: `npm install` 18 | 3. **Development build**: `npm run dev` 19 | 4. **Production build**: `npm run build` 20 | 5. **Version update**: `npm run update-version` (auto-runs before build) 21 | 22 | ### Directory and File Handling 23 | - **IGNORE COMPLETELY**: `dist` directory (build artifacts) 24 | - **IGNORE COMPLETELY**: `node_modules` directory (dependencies) 25 | - **LOCALIZATION CONSTRAINT**: In `src/locales/` directory, only process `en.json` file. All other language files (cs.json, da.json, de.json, etc.) must be ignored to prevent translation corruption. 26 | - **BUILD ARTIFACTS**: Files in `dist/` are auto-generated - never edit manually 27 | 28 | ### Project Architecture 29 | - **Main Entry**: `src/index.js` (exports the card components) 30 | - **Card Component**: `src/pollenprognos-card.js` (main card implementation) 31 | - **Editor Component**: `src/pollenprognos-editor.js` (visual configuration editor) 32 | - **Adapters**: `src/adapters/` (integration-specific data handlers for pp, dwd, peu, silam) 33 | - **Utilities**: `src/utils/` (shared helper functions) 34 | - **Constants**: `src/constants.js` (configuration and data constants) 35 | - **Internationalization**: `src/i18n.js` (language detection and text loading) 36 | - **Images**: `src/pollenprognos-images.js` (embedded SVG icons) 37 | 38 | ### Coding Standards and Patterns 39 | 40 | #### General Principles 41 | - Follow KISS (Keep It Simple, Stupid) and DRY (Don't Repeat Yourself) principles 42 | - Use modern ES6+ JavaScript syntax (modules, arrow functions, destructuring) 43 | - Prefer `const` and `let` over `var` 44 | - Use template literals for string interpolation 45 | - Comment complex logic and public methods in English 46 | 47 | #### Lit Element Patterns 48 | - Use `static get styles()` for CSS-in-JS styling 49 | - Implement `render()` method returning `html` template literals 50 | - Use `static get properties()` for reactive properties 51 | - Handle property changes in `updated(changedProperties)` lifecycle method 52 | - Use `this.requestUpdate()` to trigger re-rendering when needed 53 | 54 | #### Chart.js Integration 55 | - Register required Chart.js components before use 56 | - Cache chart instances in `_chartCache` Map to prevent memory leaks 57 | - Properly destroy charts when component unmounts 58 | 59 | #### Error Handling 60 | - Store error states in `_error` property with translation keys 61 | - Display user-friendly translated error messages 62 | - Use try-catch blocks around external API calls and data processing 63 | 64 | #### Adapter Pattern 65 | - Each integration has its own adapter in `src/adapters/` 66 | - Adapters implement consistent interface: `stubConfig`, `findSensors`, `getData` 67 | - Auto-detection of available integrations based on sensor entities 68 | 69 | ### Configuration and Validation 70 | - Configuration validation happens in editor component 71 | - Use deep equality checks (`deepEqual`) to prevent unnecessary re-renders 72 | - Cosmetic-only changes should not trigger card reloads 73 | - Provide sensible defaults for all configuration options 74 | 75 | ### Internationalization (i18n) 76 | - **CRITICAL**: Only modify `src/locales/en.json` for English text changes 77 | - Use `t(key, hass)` function for all user-visible text 78 | - Language auto-detection follows Home Assistant's language setting 79 | - Fallback to English if translation key is missing 80 | 81 | ### Data Processing 82 | - Normalize data from different integrations using adapter pattern 83 | - Handle missing or malformed data gracefully 84 | - Support multiple forecast modes (daily, hourly, twice_daily) 85 | - Validate sensor availability before attempting data access 86 | 87 | ### Styling Guidelines 88 | - Use CSS-in-JS with Lit's `css` template literal 89 | - Follow CSS custom properties for theming integration with Home Assistant 90 | - Responsive design considerations for various Home Assistant dashboard sizes 91 | - Use CSS Grid and Flexbox for layouts 92 | 93 | ### Testing and Validation 94 | - **Build Validation**: Always run `npm run build` to verify changes 95 | - **Manual Testing**: Test in Home Assistant environment when possible 96 | - **HACS Validation**: CI validates HACS compatibility automatically 97 | - **Integration Testing**: Test with different pollen integrations if available 98 | 99 | ### Version Management 100 | - Version is auto-updated from git tags via `scripts/update-version.js` 101 | - Do not manually edit version numbers in `package.json` 102 | - Build process automatically includes version in output 103 | 104 | ### Dependencies and External APIs 105 | - Minimize external dependencies 106 | - Bundle all dependencies (no externals in build) 107 | - Handle network errors gracefully for pollen data fetching 108 | - Respect Home Assistant's entity state update patterns 109 | 110 | ### Performance Considerations 111 | - Use efficient rendering patterns in Lit 112 | - Cache expensive computations 113 | - Minimize DOM updates through proper reactive property usage 114 | - Dispose of resources (charts, subscriptions) properly 115 | 116 | ### Common Pitfalls to Avoid 117 | 1. **Never edit generated files** in `dist/` 118 | 2. **Never modify non-English locale files** in `src/locales/` 119 | 3. **Don't break adapter interface consistency** when modifying integrations 120 | 4. **Avoid direct DOM manipulation** - use Lit's reactive rendering 121 | 5. **Don't forget to register Chart.js components** before use 122 | 6. **Always handle missing entities gracefully** in adapters 123 | 124 | ### Comments and Documentation 125 | - All code should be commented for clarity, especially complex logic 126 | - All documentation, including comments, must be written in English 127 | - Document public methods and complex algorithms 128 | - Include JSDoc comments for important functions when appropriate -------------------------------------------------------------------------------- /scripts/README_kleenex_testing.md: -------------------------------------------------------------------------------- 1 | # Kleenex Pollen Radar API Testing Suite 2 | 3 | This directory contains a comprehensive testing suite for discovering all possible allergens from the Kleenex Pollen Radar API across different geographic regions. 4 | 5 | ## ⚠️ Network Requirements 6 | 7 | **IMPORTANT**: These testing scripts require internet access to external Kleenex API endpoints: 8 | - www.kleenex.co.uk (United Kingdom) 9 | - www.kleenex.fr (France) 10 | - www.kleenex.nl (Netherlands) 11 | - www.kleenex.com (United States) 12 | - www.it.scottex.com (Italy) 13 | 14 | The scripts will not work in restricted network environments where external domains are blocked. If you encounter DNS resolution errors like "No address associated with hostname", this indicates that your network environment blocks external API access. 15 | 16 | ## 🎯 Purpose 17 | 18 | The Kleenex integration in pollenprognos-card supports multiple allergens, but the exact set of allergens available depends on geographic location and season. This testing suite systematically explores the API to discover: 19 | 20 | - All possible individual allergens across different regions 21 | - Regional variations in allergen availability 22 | - Frequency and priority of newly discovered allergens 23 | - Implementation recommendations for expanding card support 24 | 25 | ## 📁 Files 26 | 27 | ### Core Testing Scripts 28 | 29 | **`kleenex_allergen_tester.py`** - Main discovery tool 30 | - Tests 5 geographic regions (France, Italy, Netherlands, UK, USA) 31 | - Generates random coordinates within each region's boundaries 32 | - Makes 5-10 API calls per region with courtesy delays 33 | - Outputs hierarchical JSON data showing discovered allergens 34 | 35 | **`kleenex_allergen_analyzer.py`** - Analysis tool 36 | - Processes test data to identify new vs currently supported allergens 37 | - Provides regional coverage analysis and frequency statistics 38 | - Generates priority-ranked implementation recommendations 39 | - Outputs detailed analysis report 40 | 41 | **`kleenex_quick_test.py`** - Validation tool 42 | - Lightweight tester for API verification 43 | - Tests just 5 strategic locations (major cities) 44 | - Quick way to verify API integration is working 45 | - Useful for debugging and development 46 | 47 | ### Output Files (Generated) 48 | 49 | **`kleenex_allergen_test_data.json`** - Raw test data 50 | - Hierarchical structure: region → location → allergens → details 51 | - Includes geographic coordinates, timestamps, allergen values/levels 52 | - Appends to existing data when script is re-run 53 | 54 | **`kleenex_allergen_test_data_analysis.json`** - Analysis results 55 | - Coverage analysis (new vs supported allergens) 56 | - Regional breakdown and allergen-specific statistics 57 | - Priority-ranked recommendations for implementation 58 | 59 | **`kleenex_quick_test_results.json`** - Quick test results 60 | - Summary of quick validation test 61 | - List of allergens found in major cities 62 | 63 | ## 🚀 Usage 64 | 65 | ### Prerequisites 66 | 67 | Install required Python packages: 68 | ```bash 69 | pip install aiohttp beautifulsoup4 70 | ``` 71 | 72 | ### Quick Test (2-3 minutes) 73 | 74 | Verify API integration with a few strategic locations: 75 | ```bash 76 | cd scripts/ 77 | python3 kleenex_quick_test.py 78 | ``` 79 | 80 | ### Full Discovery Test (20-45 minutes) 81 | 82 | Comprehensive allergen discovery across all regions: 83 | ```bash 84 | cd scripts/ 85 | python3 kleenex_allergen_tester.py 86 | ``` 87 | 88 | ### Analysis 89 | 90 | Process test data to identify implementation opportunities: 91 | ```bash 92 | cd scripts/ 93 | python3 kleenex_allergen_analyzer.py 94 | ``` 95 | 96 | ## 🌍 Tested Regions 97 | 98 | The suite tests these geographic regions with the following call frequencies: 99 | 100 | | Region | Code | Calls | Rationale | 101 | |--------|------|-------|-----------| 102 | | France | FR | 5 | Smaller European region | 103 | | Italy | IT | 5 | Smaller European region | 104 | | Netherlands | NL | 5 | Smallest region | 105 | | United Kingdom | UK | 10 | Larger European region | 106 | | United States | US | 10 | Largest region | 107 | 108 | **Total API calls:** 35 per full test run 109 | **Estimated runtime:** 20-45 minutes (with courtesy delays) 110 | 111 | ## 🎲 Random Coordinate Generation 112 | 113 | Each test run generates fresh random coordinates within precise geographic boundaries: 114 | 115 | - **France:** 41.3°N-51.1°N, 5.1°W-9.6°E 116 | - **Italy:** 35.5°N-47.1°N, 6.6°E-18.5°E 117 | - **Netherlands:** 50.8°N-53.6°N, 3.4°E-7.2°E 118 | - **United Kingdom:** 49.9°N-60.8°N, 10.7°W-1.8°E 119 | - **United States:** 24.4°N-49.4°N, 125.0°W-66.9°W 120 | 121 | Coordinates are rounded to 4 decimal places (~11m precision) for realistic location testing. 122 | 123 | ## 📊 Expected Discoveries 124 | 125 | Based on Kleenex documentation and European/North American flora, the testing may discover these additional allergens: 126 | 127 | ### Trees 128 | - ash, beech, willow, lime, chestnut, walnut, maple, cedar 129 | 130 | ### Weeds 131 | - nettle, plantain, chenopod, dock 132 | 133 | ### Grass Types 134 | - poaceae, timothy, bermuda, rye grass 135 | 136 | ### Regional Variations 137 | Different regions may have unique allergens not found elsewhere, particularly: 138 | - Mediterranean species (Italy, Southern France) 139 | - Northern European species (Netherlands, UK) 140 | - North American species (US) 141 | 142 | ## ⚠️ API Courtesy 143 | 144 | The testing suite implements several courtesy measures: 145 | 146 | - **Random delays:** 3-12 seconds between API calls 147 | - **Reasonable request volume:** Maximum 35 calls per full test 148 | - **Standard User-Agent:** Uses common browser identification 149 | - **Error handling:** Graceful failure handling without retry storms 150 | - **Appending data:** Re-runs append to existing data rather than duplicating calls 151 | 152 | ## 📈 Analysis Output 153 | 154 | The analyzer provides: 155 | 156 | ### Priority Rankings 157 | Allergens ranked by implementation priority based on: 158 | - Frequency of appearance across tests 159 | - Regional coverage (how many regions have it) 160 | - Value ranges (higher values = more medically significant) 161 | - Level ranges (higher levels = more severe reactions) 162 | 163 | ### Implementation Recommendations 164 | - **High Priority:** Allergens found frequently across multiple regions 165 | - **Medium Priority:** Regionally common allergens 166 | - **Low Priority:** Rare or region-specific allergens 167 | 168 | ### Regional Analysis 169 | - Which allergens are specific to certain regions 170 | - Coverage gaps in current implementation 171 | - Frequency statistics for decision making 172 | 173 | ## 🔧 Integration with pollenprognos-card 174 | 175 | Test results directly inform expansion of the card's allergen support: 176 | 177 | 1. **`src/constants.js`** - Add new allergens to `ALLERGEN_TRANSLATION` 178 | 2. **`src/locales/en.json`** - Add English translations for new allergens 179 | 3. **`src/adapters/kleenex.js`** - Update allergen detection patterns if needed 180 | 4. **`src/pollenprognos-images.js`** - Add SVG icons for new allergens 181 | 182 | The analyzer output provides specific recommendations for which allergens to prioritize based on real-world API data. 183 | 184 | ## 🐛 Troubleshooting 185 | 186 | ### Network Issues 187 | - Check internet connectivity 188 | - Verify Kleenex website accessibility 189 | - Try the quick test first to isolate issues 190 | 191 | ### API Changes 192 | - Kleenex may update their API structure 193 | - HTML parsing patterns may need updates 194 | - Check browser dev tools for current API format 195 | 196 | ### Data Issues 197 | - Empty results may indicate API blocking or rate limiting 198 | - Try reducing call frequency or changing User-Agent 199 | - Check output JSON for parsing errors in raw_details 200 | 201 | ## 📄 License 202 | 203 | This testing suite is part of the pollenprognos-card project and follows the same MIT license terms. -------------------------------------------------------------------------------- /src/adapters/dwd.js: -------------------------------------------------------------------------------- 1 | import { t, detectLang } from "../i18n.js"; 2 | import { ALLERGEN_TRANSLATION } from "../constants.js"; 3 | import { normalizeDWD } from "../utils/normalize.js"; 4 | import { LEVELS_DEFAULTS } from "../utils/levels-defaults.js"; 5 | import { buildLevelNames } from "../utils/level-names.js"; 6 | 7 | const DOMAIN = "dwd_pollenflug"; 8 | const ATTR_VAL_TOMORROW = "state_tomorrow"; 9 | const ATTR_VAL_IN_2_DAYS = "state_in_2_days"; 10 | const ATTR_DESC_TODAY = "state_today_desc"; 11 | const ATTR_DESC_TOMORROW = "state_tomorrow_desc"; 12 | const ATTR_DESC_IN_2_DAYS = "state_in_2_days_desc"; 13 | 14 | export const stubConfigDWD = { 15 | integration: "dwd", 16 | region_id: "", 17 | // Optional entity naming used when region_id is "manual" 18 | entity_prefix: "", 19 | entity_suffix: "", 20 | allergens: [ 21 | "erle", 22 | "ambrosia", 23 | "esche", 24 | "birke", 25 | "hasel", 26 | "gräser", 27 | "beifuss", 28 | "roggen", 29 | ], 30 | minimal: false, 31 | minimal_gap: 35, 32 | background_color: "", 33 | icon_size: "48", 34 | text_size_ratio: 1, 35 | ...LEVELS_DEFAULTS, 36 | show_text_allergen: true, 37 | show_value_text: true, 38 | show_value_numeric: false, 39 | show_value_numeric_in_circle: false, 40 | show_empty_days: false, 41 | debug: false, 42 | show_version: true, 43 | days_to_show: 2, 44 | days_relative: true, 45 | days_abbreviated: false, 46 | days_uppercase: false, 47 | days_boldfaced: false, 48 | pollen_threshold: 0.5, 49 | sort: "value_descending", 50 | allergy_risk_top: true, 51 | allergens_abbreviated: false, 52 | link_to_sensors: true, 53 | date_locale: undefined, 54 | title: undefined, 55 | phrases: { 56 | full: {}, 57 | short: {}, 58 | levels: [], 59 | days: {}, 60 | no_information: "", 61 | }, 62 | }; 63 | 64 | export async function fetchForecast(hass, config) { 65 | const debug = Boolean(config.debug); 66 | const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); 67 | 68 | // Språk- och lokaliseringsinställningar 69 | const lang = detectLang(hass, config.date_locale); 70 | const locale = config.date_locale || stubConfigDWD.date_locale; 71 | const daysRelative = config.days_relative !== false; 72 | const dayAbbrev = Boolean(config.days_abbreviated); 73 | const daysUppercase = Boolean(config.days_uppercase); 74 | 75 | // Phrases från användar-config har förträde 76 | const phrases = config.phrases || {}; 77 | const fullPhrases = phrases.full || {}; 78 | const shortPhrases = phrases.short || {}; 79 | const userLevels = phrases.levels; 80 | const levelNames = buildLevelNames(userLevels, lang); 81 | const noInfoLabel = phrases.no_information || t("card.no_information", lang); 82 | const userDays = phrases.days || {}; 83 | const days_to_show = config.days_to_show ?? stubConfigDWD.days_to_show; 84 | const pollen_threshold = 85 | config.pollen_threshold ?? stubConfigDWD.pollen_threshold; 86 | 87 | if (debug) 88 | console.debug("DWD adapter: start fetchForecast", { config, lang }); 89 | 90 | const testVal = (val) => { 91 | const n = Number(val); 92 | return isNaN(n) ? -1 : n; 93 | }; 94 | 95 | const today = new Date(); 96 | today.setHours(0, 0, 0, 0); 97 | 98 | const sensors = []; 99 | 100 | for (const allergen of config.allergens) { 101 | try { 102 | const dict = {}; 103 | const rawKey = normalizeDWD(allergen); 104 | dict.allergenReplaced = rawKey; 105 | // Canonical key for lookup in locales 106 | const canonKey = ALLERGEN_TRANSLATION[rawKey] || rawKey; 107 | 108 | // Allergen-namn: använd user phrase, annars i18n, annars default 109 | const userFull = fullPhrases[allergen]; 110 | if (userFull) { 111 | dict.allergenCapitalized = userFull; 112 | } else { 113 | const transKey = ALLERGEN_TRANSLATION[rawKey] || rawKey; 114 | const nameKey = `card.allergen.${transKey}`; 115 | const i18nName = t(nameKey, lang); 116 | dict.allergenCapitalized = 117 | i18nName !== nameKey ? i18nName : capitalize(allergen); 118 | } 119 | 120 | // Kortnamn beroende på config.allergens_abbreviated 121 | if (config.allergens_abbreviated) { 122 | const userShort = shortPhrases[allergen]; 123 | dict.allergenShort = 124 | userShort || 125 | t(`editor.phrases_short.${canonKey}`, lang) || 126 | dict.allergenCapitalized; 127 | } else { 128 | dict.allergenShort = dict.allergenCapitalized; 129 | } 130 | 131 | // Find sensor entity 132 | let sensorId; 133 | if (config.region_id === "manual") { 134 | const prefix = config.entity_prefix || ""; 135 | const suffix = config.entity_suffix || ""; 136 | sensorId = `sensor.${prefix}${rawKey}${suffix}`; 137 | if (!hass.states[sensorId]) { 138 | if (suffix === "") { 139 | // Fallback: search for a unique candidate starting with prefix and slug 140 | const base = `sensor.${prefix}${rawKey}`; 141 | const candidates = Object.keys(hass.states).filter((id) => 142 | id.startsWith(base), 143 | ); 144 | if (candidates.length === 1) sensorId = candidates[0]; 145 | else continue; 146 | } else continue; 147 | } 148 | } else { 149 | sensorId = config.region_id 150 | ? `sensor.pollenflug_${rawKey}_${config.region_id}` 151 | : null; 152 | if (!sensorId || !hass.states[sensorId]) { 153 | const candidates = Object.keys(hass.states).filter((id) => 154 | id.startsWith(`sensor.pollenflug_${rawKey}_`), 155 | ); 156 | if (candidates.length === 1) sensorId = candidates[0]; 157 | else continue; 158 | } 159 | } 160 | const sensor = hass.states[sensorId]; 161 | dict.entity_id = sensorId; 162 | 163 | // Råvärden 164 | const todayVal = testVal(sensor.state); 165 | const tomVal = testVal(sensor.attributes[ATTR_VAL_TOMORROW]); 166 | const twoVal = testVal(sensor.attributes[ATTR_VAL_IN_2_DAYS]); 167 | 168 | // Nivåer-datum 169 | const levels = [ 170 | { date: today, level: todayVal }, 171 | { date: new Date(today.getTime() + 86400000), level: tomVal }, 172 | { date: new Date(today.getTime() + 2 * 86400000), level: twoVal }, 173 | ]; 174 | while (levels.length < days_to_show) { 175 | const idx = levels.length; 176 | levels.push({ 177 | date: new Date(today.getTime() + idx * 86400000), 178 | level: -1, 179 | }); 180 | } 181 | 182 | // Bygg dict.dayN 183 | dict.days = []; 184 | // Bygg dict.dayN och dict.days[] 185 | levels.forEach((entry, idx) => { 186 | if (entry.level !== null && entry.level >= 0) { 187 | const diff = Math.round((entry.date - today) / 86400000); 188 | let dayLabel; 189 | 190 | if (!daysRelative) { 191 | // Visa absoluta veckodagar med versal 192 | dayLabel = entry.date.toLocaleDateString(locale, { 193 | weekday: dayAbbrev ? "short" : "long", 194 | }); 195 | dayLabel = dayLabel.charAt(0).toUpperCase() + dayLabel.slice(1); 196 | } else if (userDays[diff] !== undefined) { 197 | // Relativa dagar från användarens config 198 | dayLabel = userDays[diff]; 199 | } else if (diff >= 0 && diff <= 2) { 200 | // Standard relative days från i18n 201 | dayLabel = t(`card.days.${diff}`, lang); 202 | } else { 203 | // Datum som dag mån 204 | dayLabel = entry.date.toLocaleDateString(locale, { 205 | day: "numeric", 206 | month: "short", 207 | }); 208 | } 209 | if (daysUppercase) dayLabel = dayLabel.toUpperCase(); 210 | 211 | const sensorDesc = 212 | sensor.attributes[ 213 | idx === 0 214 | ? ATTR_DESC_TODAY 215 | : idx === 1 216 | ? ATTR_DESC_TOMORROW 217 | : ATTR_DESC_IN_2_DAYS 218 | ] || ""; 219 | 220 | const scaled = entry.level * 2; 221 | const lvlIndex = Math.min(Math.max(Math.round(scaled), 0), 6); 222 | const stateText = 223 | lvlIndex < 0 ? noInfoLabel : levelNames[lvlIndex] || sensorDesc; 224 | 225 | dict[`day${idx}`] = { 226 | name: dict.allergenCapitalized, 227 | day: dayLabel, 228 | state: entry.level, 229 | display_state: scaled, 230 | state_text: stateText, 231 | }; 232 | dict.days.push(dict[`day${idx}`]); 233 | } 234 | }); 235 | 236 | // Filtrera med tröskel 237 | const meets = levels 238 | .slice(0, days_to_show) 239 | .some((l) => l.level >= pollen_threshold); 240 | if (meets || pollen_threshold === 0) sensors.push(dict); 241 | } catch (e) { 242 | console.warn(`DWD adapter error for allergen ${allergen}:`, e); 243 | } 244 | } 245 | 246 | // Sortering 247 | if (config.sort !== "none") { 248 | sensors.sort( 249 | { 250 | value_ascending: (a, b) => (a.day0?.state ?? 0) - (b.day0?.state ?? 0), 251 | value_descending: (a, b) => (b.day0?.state ?? 0) - (a.day0?.state ?? 0), 252 | name_ascending: (a, b) => 253 | a.allergenCapitalized.localeCompare(b.allergenCapitalized), 254 | name_descending: (a, b) => 255 | b.allergenCapitalized.localeCompare(a.allergenCapitalized), 256 | }[config.sort] || ((a, b) => (b.day0?.state ?? 0) - (a.day0?.state ?? 0)), 257 | ); 258 | } 259 | 260 | if (debug) console.debug("DWD adapter complete sensors:", sensors); 261 | return sensors; 262 | } 263 | -------------------------------------------------------------------------------- /src/adapters/pp.js: -------------------------------------------------------------------------------- 1 | import { t, detectLang } from "../i18n.js"; 2 | import { ALLERGEN_TRANSLATION } from "../constants.js"; 3 | import { normalize } from "../utils/normalize.js"; 4 | import { LEVELS_DEFAULTS } from "../utils/levels-defaults.js"; 5 | import { buildLevelNames } from "../utils/level-names.js"; 6 | 7 | export const stubConfigPP = { 8 | integration: "pp", 9 | city: "", 10 | // Optional entity naming used when city is "manual" 11 | entity_prefix: "", 12 | entity_suffix: "", 13 | allergens: [ 14 | "Al", 15 | "Alm", 16 | "Bok", 17 | "Björk", 18 | "Ek", 19 | "Malörtsambrosia", 20 | "Gråbo", 21 | "Gräs", 22 | "Hassel", 23 | "Sälg och viden", 24 | ], 25 | minimal: false, 26 | minimal_gap: 35, 27 | background_color: "", 28 | icon_size: "48", 29 | text_size_ratio: 1, 30 | ...LEVELS_DEFAULTS, 31 | show_text_allergen: true, 32 | show_value_text: true, 33 | show_value_numeric: false, 34 | show_value_numeric_in_circle: false, 35 | show_empty_days: false, 36 | debug: false, 37 | show_version: true, 38 | days_to_show: 4, 39 | days_relative: true, 40 | days_abbreviated: false, 41 | days_uppercase: false, 42 | days_boldfaced: false, 43 | pollen_threshold: 1, 44 | sort: "value_descending", 45 | allergy_risk_top: true, 46 | allergens_abbreviated: false, 47 | link_to_sensors: true, 48 | date_locale: undefined, 49 | title: undefined, 50 | phrases: { full: {}, short: {}, levels: [], days: {}, no_information: "" }, 51 | }; 52 | 53 | export async function fetchForecast(hass, config) { 54 | const sensors = []; 55 | const debug = Boolean(config.debug); 56 | const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); 57 | const parseLocal = (s) => { 58 | const [ymd] = s.split("T"); 59 | const [y, m, d] = ymd.split("-").map(Number); 60 | return new Date(y, m - 1, d); 61 | }; 62 | 63 | const today = new Date(); 64 | today.setHours(0, 0, 0, 0); 65 | 66 | // Language and locale 67 | // Figure out the base language code ("sv", "en", "de", …) 68 | const lang = detectLang(hass, config.date_locale); 69 | // Turn it into a full locale tag for dates (sv-SE, de-DE, fi-FI, en-US) 70 | const locale = 71 | config.date_locale || 72 | hass.locale?.language || 73 | hass.language || 74 | `${lang}-${lang.toUpperCase()}`; 75 | const daysRelative = config.days_relative !== false; 76 | const dayAbbrev = Boolean(config.days_abbreviated); 77 | const daysUppercase = Boolean(config.days_uppercase); 78 | 79 | // Phrases with user overrides 80 | const phrases = { 81 | full: {}, 82 | short: {}, 83 | levels: [], 84 | days: {}, 85 | no_information: "", 86 | ...(config.phrases || {}), 87 | }; 88 | const fullPhrases = phrases.full; 89 | const shortPhrases = phrases.short; 90 | const userLevels = phrases.levels; 91 | const levelNames = buildLevelNames(userLevels, lang); 92 | const noInfoLabel = phrases.no_information || t("card.no_information", lang); 93 | const userDays = phrases.days; 94 | 95 | if (debug) 96 | console.debug("PP.fetchForecast — start", { city: config.city, lang }); 97 | 98 | const testVal = (v) => { 99 | const n = Number(v); 100 | return isNaN(n) || n < 0 ? null : n > 6 ? 6 : n; 101 | }; 102 | 103 | const days_to_show = config.days_to_show ?? stubConfigPP.days_to_show; 104 | const pollen_threshold = 105 | config.pollen_threshold ?? stubConfigPP.pollen_threshold; 106 | 107 | for (const allergen of config.allergens) { 108 | try { 109 | const dict = {}; 110 | dict.days = []; // Initialize a days array 111 | const rawKey = normalize(allergen); 112 | dict.allergenReplaced = rawKey; 113 | 114 | // Allergen name resolution 115 | if (this.debug) { 116 | console.log( 117 | "[PP] allergen", 118 | allergen, 119 | "fullPhrases keys", 120 | Object.keys(fullPhrases), 121 | ); 122 | } 123 | if (fullPhrases[allergen]) { 124 | dict.allergenCapitalized = fullPhrases[allergen]; 125 | } else { 126 | const transKey = ALLERGEN_TRANSLATION[rawKey] || rawKey; 127 | const lookup = t(`card.allergen.${transKey}`, lang); 128 | dict.allergenCapitalized = 129 | lookup !== `card.allergen.${transKey}` 130 | ? lookup 131 | : capitalize(allergen); 132 | } 133 | // Kolla om vi ska använda kortnamn 134 | if (config.allergens_abbreviated) { 135 | // Canonical key för lookup i phrases_short 136 | const canonKey = ALLERGEN_TRANSLATION[rawKey] || rawKey; 137 | const userShort = shortPhrases[allergen]; 138 | dict.allergenShort = 139 | userShort || 140 | t(`editor.phrases_short.${canonKey}`, lang) || 141 | dict.allergenCapitalized; 142 | } else { 143 | dict.allergenShort = dict.allergenCapitalized; 144 | } 145 | // Sensor lookup 146 | // Normalize city key unless manual mode is selected. 147 | let cityKey = 148 | config.city === "manual" ? "" : normalize(config.city || ""); 149 | // Autodetect city from existing sensors if not provided. 150 | if (!cityKey) { 151 | const ppStates = Object.keys(hass.states).filter( 152 | (id) => 153 | id.startsWith("sensor.pollen_") && 154 | /^sensor\.pollen_(.+)_[^_]+$/.test(id), 155 | ); 156 | if (ppStates.length) { 157 | const m = ppStates[0].match(/^sensor\.pollen_(.+)_[^_]+$/); 158 | cityKey = m ? m[1] : ""; 159 | } 160 | } 161 | let sensorId; 162 | if (config.city === "manual") { 163 | const prefix = config.entity_prefix || ""; 164 | const suffix = config.entity_suffix || ""; 165 | sensorId = `sensor.${prefix}${rawKey}${suffix}`; 166 | if (!hass.states[sensorId]) continue; 167 | } else { 168 | sensorId = cityKey ? `sensor.pollen_${cityKey}_${rawKey}` : null; 169 | if (!sensorId || !hass.states[sensorId]) { 170 | const base = cityKey 171 | ? `sensor.pollen_${cityKey}_` 172 | : "sensor.pollen_"; 173 | const cands = Object.keys(hass.states).filter( 174 | (id) => id.startsWith(base) && id.endsWith(`_${rawKey}`), 175 | ); 176 | if (cands.length === 1) sensorId = cands[0]; 177 | else continue; 178 | } 179 | } 180 | const sensor = hass.states[sensorId]; 181 | if (!sensor?.attributes?.forecast) throw "Missing forecast"; 182 | dict.entity_id = sensorId; 183 | 184 | // Build forecastMap 185 | const rawForecast = sensor.attributes.forecast; 186 | const forecastMap = Array.isArray(rawForecast) 187 | ? rawForecast.reduce((o, entry) => { 188 | const key = entry.time || entry.datetime; 189 | o[key] = entry; 190 | return o; 191 | }, {}) 192 | : rawForecast; 193 | 194 | // Sort and slice dates 195 | // Hämta och sortera prognosdatum 196 | const rawDates = Object.keys(forecastMap).sort( 197 | (a, b) => parseLocal(a) - parseLocal(b), 198 | ); 199 | const upcoming = rawDates.filter((d) => parseLocal(d) >= today); 200 | 201 | // Padda ut till exactly days_to_show datum 202 | let forecastDates = []; 203 | if (upcoming.length >= days_to_show) { 204 | // Tillräckligt många kommande – ta de första N 205 | forecastDates = upcoming.slice(0, days_to_show); 206 | } else { 207 | // Lägg först in vad som finns 208 | forecastDates = upcoming.slice(); 209 | // Sedan bygg på dag för dag framåt 210 | // (antingen från senast i upcoming, eller från idag) 211 | let lastDate = 212 | upcoming.length > 0 213 | ? parseLocal(upcoming[upcoming.length - 1]) 214 | : today; 215 | 216 | while (forecastDates.length < days_to_show) { 217 | lastDate = new Date(lastDate.getTime() + 86400000); 218 | const yyyy = lastDate.getFullYear(); 219 | const mm = String(lastDate.getMonth() + 1).padStart(2, "0"); 220 | const dd = String(lastDate.getDate()).padStart(2, "0"); 221 | forecastDates.push(`${yyyy}-${mm}-${dd}T00:00:00`); 222 | } 223 | } 224 | // Iterate forecast days 225 | forecastDates.forEach((dateStr, idx) => { 226 | const raw = forecastMap[dateStr] || {}; 227 | const level = testVal(raw.level); 228 | const d = parseLocal(dateStr); 229 | const diff = Math.round((d - today) / 86400000); 230 | let label; 231 | 232 | if (!daysRelative) { 233 | label = d.toLocaleDateString(locale, { 234 | weekday: dayAbbrev ? "short" : "long", 235 | }); 236 | label = label.charAt(0).toUpperCase() + label.slice(1); 237 | } else if (userDays[diff] != null) { 238 | label = userDays[diff]; 239 | } else if (diff >= 0 && diff <= 2) { 240 | label = t(`card.days.${diff}`, lang); 241 | } else { 242 | label = d.toLocaleDateString(locale, { 243 | day: "numeric", 244 | month: "short", 245 | }); 246 | } 247 | if (daysUppercase) label = label.toUpperCase(); 248 | 249 | if (level !== null) { 250 | const dayObj = { 251 | name: dict.allergenCapitalized, 252 | day: label, 253 | state: level, 254 | state_text: levelNames[level], 255 | }; 256 | dict[`day${idx}`] = dayObj; 257 | dict.days.push(dayObj); 258 | } else if (pollen_threshold === 0) { 259 | // When threshold is 0, show all allergens even with no data 260 | const dayObj = { 261 | name: dict.allergenCapitalized, 262 | day: label, 263 | state: 0, 264 | state_text: noInfoLabel, 265 | }; 266 | dict[`day${idx}`] = dayObj; 267 | dict.days.push(dayObj); 268 | } 269 | }); 270 | 271 | // Threshold filtering 272 | const meets = dict.days.some((d) => d.state >= pollen_threshold); 273 | if (meets || pollen_threshold === 0) sensors.push(dict); 274 | } catch (e) { 275 | console.warn(`[PP] Fel vid allergen ${allergen}:`, e); 276 | } 277 | } 278 | 279 | // Sorting 280 | if (config.sort !== "none") { 281 | sensors.sort( 282 | { 283 | value_ascending: (a, b) => (a.day0?.state ?? 0) - (b.day0?.state ?? 0), 284 | value_descending: (a, b) => (b.day0?.state ?? 0) - (a.day0?.state ?? 0), 285 | name_ascending: (a, b) => 286 | a.allergenCapitalized.localeCompare(b.allergenCapitalized), 287 | name_descending: (a, b) => 288 | b.allergenCapitalized.localeCompare(a.allergenCapitalized), 289 | }[config.sort] || ((a, b) => (b.day0?.state ?? 0) - (a.day0?.state ?? 0)), 290 | ); 291 | } 292 | 293 | if (debug) console.debug("PP.fetchForecast — done", sensors); 294 | return sensors; 295 | } 296 | -------------------------------------------------------------------------------- /scripts/kleenex_quick_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Kleenex Quick Test Script 4 | 5 | A lightweight version of the allergen tester for quick validation and debugging. 6 | Tests just a few locations to verify the API integration is working correctly. 7 | 8 | Author: GitHub Copilot Agent 9 | """ 10 | 11 | import asyncio 12 | import aiohttp 13 | import json 14 | import random 15 | from datetime import datetime 16 | from typing import Dict, Optional, Tuple 17 | from bs4 import BeautifulSoup 18 | 19 | # Quick test configuration - just a few strategic locations 20 | TEST_LOCATIONS = [ 21 | {"name": "London, UK", "lat": 51.5074, "lon": -0.1278, "region": "uk"}, 22 | {"name": "Paris, France", "lat": 48.8566, "lon": 2.3522, "region": "fr"}, 23 | {"name": "Rome, Italy", "lat": 41.9028, "lon": 12.4964, "region": "it"}, 24 | {"name": "Amsterdam, Netherlands", "lat": 52.3676, "lon": 4.9041, "region": "nl"}, 25 | {"name": "New York, USA", "lat": 40.7128, "lon": -74.0060, "region": "us"}, 26 | ] 27 | 28 | # Kleenex API regions configuration (from the actual integration) 29 | REGIONS = { 30 | "fr": { 31 | "name": "France", 32 | "url": "https://www.kleenex.fr/api/sitecore/Pollen/GetPollenContent", 33 | "method": "get" 34 | }, 35 | "it": { 36 | "name": "Italy", 37 | "url": "https://www.it.scottex.com/api/sitecore/Pollen/GetPollenContent", 38 | "method": "post" 39 | }, 40 | "nl": { 41 | "name": "Netherlands", 42 | "url": "https://www.kleenex.nl/api/sitecore/Pollen/GetPollenContent", 43 | "method": "get" 44 | }, 45 | "uk": { 46 | "name": "United Kingdom", 47 | "url": "https://www.kleenex.co.uk/api/sitecore/Pollen/GetPollenContent", 48 | "method": "get" 49 | }, 50 | "us": { 51 | "name": "United States of America", 52 | "url": "https://www.kleenex.com/api/sitecore/Pollen/GetPollenContent", 53 | "method": "get" 54 | }, 55 | } 56 | 57 | class KleenexQuickTester: 58 | def __init__(self): 59 | self.session = None 60 | 61 | async def __aenter__(self): 62 | timeout = aiohttp.ClientTimeout(total=30) 63 | headers = { 64 | "User-Agent": "Home Assistant (kleenex_pollenradar)", 65 | "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 66 | } 67 | self.session = aiohttp.ClientSession( 68 | timeout=timeout, 69 | headers=headers 70 | ) 71 | return self 72 | 73 | async def __aexit__(self, exc_type, exc_val, exc_tb): 74 | if self.session: 75 | await self.session.close() 76 | 77 | async def fetch_pollen_data(self, lat: float, lon: float, region_code: str) -> Optional[Dict]: 78 | """Fetch pollen data from Kleenex API using correct endpoints and methods.""" 79 | if region_code not in REGIONS: 80 | print(f"❌ Unknown region: {region_code}") 81 | return None 82 | 83 | region_config = REGIONS[region_code] 84 | url = region_config["url"] 85 | method = region_config["method"] 86 | params = {"lat": lat, "lng": lon} 87 | 88 | try: 89 | if method == "get": 90 | async with self.session.get(url, params=params) as response: 91 | if response.status == 200: 92 | html = await response.text() 93 | return self.parse_pollen_data(html, lat, lon, region_code) 94 | else: 95 | print(f"❌ HTTP {response.status} for {lat},{lon}") 96 | return None 97 | else: # POST method for Italy 98 | async with self.session.post(url, data=params) as response: 99 | if response.status == 200: 100 | html = await response.text() 101 | return self.parse_pollen_data(html, lat, lon, region_code) 102 | else: 103 | print(f"❌ HTTP {response.status} for {lat},{lon}") 104 | return None 105 | 106 | except Exception as e: 107 | error_msg = str(e) 108 | if "No address associated with hostname" in error_msg: 109 | print(f"❌ Network blocked: Cannot resolve {region_code} API endpoint") 110 | print(f" This is likely due to restricted network environment") 111 | print(f" The scripts should work in environments with internet access") 112 | else: 113 | print(f"❌ Error: {e}") 114 | return None 115 | 116 | def parse_pollen_data(self, html: str, lat: float, lon: float, region_code: str) -> Dict: 117 | """Parse pollen data from HTML response using the actual integration logic.""" 118 | soup = BeautifulSoup(html, 'html.parser') 119 | 120 | result = { 121 | "location": {"lat": lat, "lon": lon, "region": region_code}, 122 | "timestamp": datetime.now().isoformat(), 123 | "allergens": {"trees": {}, "grass": {}, "weeds": {}}, 124 | "raw_details": {}, 125 | "days_found": 0 126 | } 127 | 128 | # Parse day buttons (actual integration logic) 129 | day_results = soup.find_all("button", class_="day-link") 130 | result["days_found"] = len(day_results) 131 | 132 | if not day_results: 133 | print(f" ⚠️ No day-link buttons found in HTML") 134 | return result 135 | 136 | # Process first day for allergen discovery 137 | first_day = day_results[0] 138 | 139 | # Parse individual allergen details using actual integration attribute mapping 140 | pollen_detail_types = { 141 | "trees": "tree", 142 | "weeds": "weed", 143 | "grass": "grass", 144 | } 145 | 146 | for pollen_type, detail_type in pollen_detail_types.items(): 147 | detail_attr = f"data-{detail_type}-detail" 148 | detail_string = first_day.get(detail_attr, "") 149 | result["raw_details"][pollen_type] = detail_string 150 | 151 | if detail_string: 152 | allergen_details = detail_string.split("|") 153 | for detail in allergen_details: 154 | parts = detail.split(",") 155 | if len(parts) >= 3: 156 | name = parts[0].strip() 157 | try: 158 | value = float(parts[1]) if parts[1].strip() else 0 159 | except (ValueError, TypeError): 160 | value = 0 161 | try: 162 | level = int(parts[2]) if parts[2].strip().isdigit() else 0 163 | except (ValueError, TypeError): 164 | level = 0 165 | 166 | result["allergens"][pollen_type][name] = { 167 | "value": value, 168 | "level": level 169 | } 170 | 171 | return result 172 | 173 | async def test_location(self, location: Dict) -> Optional[Dict]: 174 | """Test a single location.""" 175 | print(f"📍 Testing {location['name']} ({location['lat']}, {location['lon']})") 176 | 177 | data = await self.fetch_pollen_data( 178 | location["lat"], 179 | location["lon"], 180 | location["region"] 181 | ) 182 | 183 | if data: 184 | # Count discovered allergens 185 | total_allergens = sum(len(category) for category in data["allergens"].values()) 186 | print(f" ✅ Found {total_allergens} allergens in {data['days_found']} days") 187 | 188 | # Show allergens by category 189 | for category, allergens in data["allergens"].items(): 190 | if allergens: 191 | allergen_names = list(allergens.keys()) 192 | print(f" {category.capitalize():6} {allergen_names}") 193 | 194 | # Show raw details for debugging 195 | if any(data["raw_details"].values()): 196 | print(f" Raw details found: {[k for k, v in data['raw_details'].items() if v]}") 197 | else: 198 | print(f" ❌ Failed to get data") 199 | 200 | return data 201 | 202 | async def run_quick_test(self): 203 | """Run quick test of a few strategic locations.""" 204 | print("⚡ KLEENEX QUICK TEST") 205 | print("=" * 50) 206 | print("Testing a few strategic locations to verify API integration...") 207 | 208 | results = [] 209 | all_allergens = set() 210 | 211 | for i, location in enumerate(TEST_LOCATIONS): 212 | if i > 0: 213 | # Short delay between requests 214 | delay = random.randint(2, 5) 215 | print(f"⏳ Waiting {delay}s...") 216 | await asyncio.sleep(delay) 217 | 218 | data = await self.test_location(location) 219 | if data: 220 | results.append(data) 221 | 222 | # Collect all unique allergens 223 | for category in data["allergens"].values(): 224 | all_allergens.update(category.keys()) 225 | 226 | print(f"\n📊 SUMMARY") 227 | print(f"Successful tests: {len(results)}/{len(TEST_LOCATIONS)}") 228 | print(f"Total unique allergens: {len(all_allergens)}") 229 | print(f"Allergens found: {sorted(list(all_allergens))}") 230 | 231 | if len(results) == 0: 232 | print(f"\n⚠️ No successful API calls were made.") 233 | print(f"This is likely due to network restrictions in the current environment.") 234 | print(f"The Kleenex API testing scripts require internet access to external domains.") 235 | print(f"In a normal environment with internet access, these scripts should work correctly.") 236 | 237 | # Save results 238 | output_file = "kleenex_quick_test_results.json" 239 | with open(output_file, 'w') as f: 240 | json.dump({ 241 | "test_results": results, 242 | "summary": { 243 | "total_allergens": len(all_allergens), 244 | "allergens": sorted(list(all_allergens)), 245 | "successful_tests": len(results), 246 | "total_tests": len(TEST_LOCATIONS) 247 | }, 248 | "timestamp": datetime.now().isoformat() 249 | }, f, indent=2, ensure_ascii=False) 250 | 251 | print(f"📁 Results saved to {output_file}") 252 | 253 | async def main(): 254 | """Main entry point.""" 255 | print("This is a quick test to verify the Kleenex API integration works correctly.") 256 | print("For full allergen discovery, use kleenex_allergen_tester.py instead.\n") 257 | 258 | async with KleenexQuickTester() as tester: 259 | await tester.run_quick_test() 260 | 261 | if __name__ == "__main__": 262 | asyncio.run(main()) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /scripts/gen_locales.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import re 4 | import subprocess 5 | import sys 6 | from collections import defaultdict 7 | from pathlib import Path 8 | 9 | LOCALES_DIR = Path(__file__).parent.parent / "src/locales" 10 | MASTER = "en.json" 11 | 12 | ICON_OK = "✅" 13 | ICON_WARN = "⚠️" 14 | ICON_ADD = "➕" 15 | ICON_DEL = "❌" 16 | 17 | JS_FILES_TO_SCAN = [ 18 | Path(__file__).parent.parent / "src/pollenprognos-card.js", 19 | Path(__file__).parent.parent / "src/pollenprognos-editor.js", 20 | ] 21 | 22 | # Also scan adapter files for t() calls with dynamic keys 23 | ADAPTER_FILES = list((Path(__file__).parent.parent / "src/adapters").glob("*.js")) 24 | 25 | def load_json(path): 26 | with open(path, encoding="utf-8") as f: 27 | return json.load(f) 28 | 29 | def save_json(path, data): 30 | with open(path, "w", encoding="utf-8") as f: 31 | json.dump(dict(sorted(data.items())), f, ensure_ascii=False, indent=2) 32 | 33 | def find_missing_and_redundant(): 34 | files = sorted([f for f in LOCALES_DIR.glob("*.json")]) 35 | master_path = LOCALES_DIR / MASTER 36 | if not master_path.exists(): 37 | print(f"{ICON_WARN} Master file {master_path} not found.") 38 | sys.exit(1) 39 | master = load_json(master_path) 40 | missing_per_lang = defaultdict(list) 41 | redundant_per_lang = defaultdict(list) 42 | for file in files: 43 | if file.name == MASTER: 44 | continue 45 | data = load_json(file) 46 | for key in master: 47 | if key not in data: 48 | missing_per_lang[file.stem].append(key) 49 | for key in data: 50 | if key not in master: 51 | redundant_per_lang[file.stem].append(key) 52 | return master, missing_per_lang, redundant_per_lang 53 | 54 | def find_used_keys_in_js(): 55 | used_keys = set() 56 | for js_file in JS_FILES_TO_SCAN: 57 | if js_file.exists(): 58 | content = js_file.read_text(encoding="utf-8") 59 | # Prefix: editor. för -editor.js, card. för -card.js, annars ingen 60 | if js_file.name.endswith("-editor.js"): 61 | prefix = "editor." 62 | elif js_file.name.endswith("-card.js"): 63 | prefix = "card." 64 | else: 65 | prefix = "" 66 | # Hitta alla this._t("key") och this._t('key') 67 | matches = re.findall(r'this\._t\(\s*["\']([a-zA-Z0-9_.-]+)["\']\s*\)', content) 68 | for match in matches: 69 | if not match.startswith("editor.") and not match.startswith("card."): 70 | used_keys.add(f"{prefix}{match}") 71 | else: 72 | used_keys.add(match) 73 | 74 | # Scan adapter files for dynamic t() calls like t(`card.allergen.${key}`, lang) 75 | for adapter_file in ADAPTER_FILES: 76 | if adapter_file.exists(): 77 | content = adapter_file.read_text(encoding="utf-8") 78 | # Find patterns like t(`card.allergen.${...}`, lang) or t(`editor.phrases_short.${...}`, lang) 79 | dynamic_matches = re.findall( 80 | r't\(\s*[`"\']((?:card|editor)\.[a-zA-Z0-9_.]+)\.\$\{[^}]+\}[`"\']', 81 | content 82 | ) 83 | for match in dynamic_matches: 84 | # Add a comment noting these are dynamic keys that need manual verification 85 | # We'll just note the pattern, not add specific keys 86 | pass 87 | 88 | # Also find static t() calls in adapters 89 | static_matches = re.findall(r't\(\s*["\']([a-zA-Z0-9_.-]+)["\']\s*,', content) 90 | for match in static_matches: 91 | used_keys.add(match) 92 | 93 | return used_keys 94 | 95 | def find_dynamic_translation_patterns(): 96 | """ 97 | Find dynamic translation patterns in adapter files and check if all 98 | possible values are covered in locale files. 99 | Returns a list of warnings about potentially missing keys. 100 | """ 101 | warnings = [] 102 | 103 | for adapter_file in ADAPTER_FILES: 104 | if not adapter_file.exists(): 105 | continue 106 | 107 | content = adapter_file.read_text(encoding="utf-8") 108 | 109 | # Find patterns like t(`editor.phrases_short.${canonKey}`, lang) 110 | # or t(`card.allergen.${transKey}`, lang) 111 | dynamic_patterns = re.findall( 112 | r't\(\s*[`"\']((?:card|editor)\.[a-zA-Z0-9_.]+)\.\$\{([^}]+)\}[`"\']', 113 | content 114 | ) 115 | 116 | if dynamic_patterns: 117 | warnings.append(f"\n{ICON_WARN} Dynamiska översättningsnycklar i {adapter_file.name}:") 118 | for prefix, var in dynamic_patterns: 119 | warnings.append(f" {prefix}.${{...}} (variabel: {var})") 120 | 121 | return warnings 122 | 123 | def scan_missing(): 124 | master, missing_per_lang, redundant_per_lang = find_missing_and_redundant() 125 | 126 | # Kolla vilka nycklar som används i JS men saknas i en.json 127 | used_keys = find_used_keys_in_js() 128 | missing_in_master = sorted(used_keys - set(master.keys())) 129 | if missing_in_master: 130 | print(f"{ICON_WARN} Nycklar som används i JS men saknas i {MASTER}:") 131 | for key in missing_in_master: 132 | print(f" {ICON_WARN} '{key}' används i JS-filer men finns ej i {MASTER}") 133 | 134 | # Rapportera saknade per språk 135 | if not missing_per_lang: 136 | print(f"{ICON_OK} Alla språkfiler har alla nycklar från master.") 137 | else: 138 | print(f"{ICON_ADD} Saknade nycklar:") 139 | for key in master: 140 | saknas_i = [lang for lang, keys in missing_per_lang.items() if key in keys] 141 | if saknas_i: 142 | print( 143 | f" {ICON_WARN} '{key}' (\"{master[key]}\" i {MASTER}) saknas i:\n {', '.join(saknas_i)}" 144 | ) 145 | # Rapportera redundanta (överflödiga) 146 | all_redundant_keys = defaultdict(list) 147 | for lang, keys in redundant_per_lang.items(): 148 | for key in keys: 149 | all_redundant_keys[key].append(lang) 150 | if all_redundant_keys: 151 | print(f"\n{ICON_DEL} Överflödiga nycklar (finns ej i {MASTER}):") 152 | for key, langs in all_redundant_keys.items(): 153 | print(f" {ICON_DEL} '{key}' finns i: {', '.join(langs)}") 154 | 155 | # Visa varningar om dynamiska översättningar 156 | dynamic_warnings = find_dynamic_translation_patterns() 157 | if dynamic_warnings: 158 | for warning in dynamic_warnings: 159 | print(warning) 160 | print(f"\n{ICON_WARN} OBS: Dynamiska nycklar kräver manuell verifiering!") 161 | print(f" Kontrollera att alla värden för variablerna har motsvarande översättningar.") 162 | 163 | def gen_translation_json(): 164 | master, missing_per_lang, _ = find_missing_and_redundant() 165 | output = defaultdict(dict) 166 | for lang, keys in missing_per_lang.items(): 167 | for key in keys: 168 | output[lang][key] = master[key] 169 | if output: 170 | output_text = ( 171 | "\n# Översätt nedan till respektive språk:\n\n" 172 | + json.dumps(output, ensure_ascii=False, indent=2) 173 | + "\n\n---\n" 174 | + "Spara översättningarna till fil och kör:\n\n" 175 | + f" python3 {Path(__file__).name} update path/till/oversattning.json\n" 176 | ) 177 | print(output_text) 178 | try: 179 | subprocess.run("pbcopy", input=output_text, text=True, check=True) 180 | print(f"{ICON_OK} JSON + instruktion kopierad till clipboard (pbcopy)") 181 | except Exception as e: 182 | print(f"{ICON_WARN} Kunde inte kopiera till clipboard: {e}") 183 | else: 184 | print(f"{ICON_OK} Alla språkfiler har redan alla nycklar från master.") 185 | def update_with_translation(json_path, force=False): 186 | with open(json_path, encoding="utf-8") as f: 187 | translation = json.load(f) 188 | for lang, keys in translation.items(): 189 | loc_file = LOCALES_DIR / (lang + ".json") 190 | if not loc_file.exists(): 191 | print(f"{ICON_WARN} Språkfil saknas: {loc_file}") 192 | continue 193 | data = load_json(loc_file) 194 | count_new = 0 195 | count_updated = 0 196 | for key, val in keys.items(): 197 | if key not in data: 198 | data[key] = val 199 | count_new += 1 200 | elif force: 201 | old_val = data[key] 202 | if data[key] != val: 203 | data[key] = val 204 | count_updated += 1 205 | if count_new or (force and count_updated): 206 | save_json(loc_file, data) 207 | msg = f"{ICON_OK} {lang}.json: {count_new} nya nycklar inlagda" 208 | if force and count_updated: 209 | msg += f", {count_updated} uppdaterade (force=True)" 210 | print(msg) 211 | else: 212 | msg = f"{ICON_OK} {lang}.json: inga nya nycklar inlagda" 213 | if force: 214 | msg += " (force=True)" 215 | print(msg) 216 | 217 | def delete_redundant(): 218 | _, _, redundant_per_lang = find_missing_and_redundant() 219 | files = sorted([f for f in LOCALES_DIR.glob("*.json")]) 220 | master_path = LOCALES_DIR / MASTER 221 | master = load_json(master_path) 222 | total_removed = 0 223 | for file in files: 224 | if file.name == MASTER: 225 | continue 226 | data = load_json(file) 227 | redundant = [key for key in data if key not in master] 228 | if redundant: 229 | for key in redundant: 230 | del data[key] 231 | save_json(file, data) 232 | print(f"{ICON_DEL} {file.name}: tog bort {len(redundant)} överflödiga nycklar: {', '.join(redundant)}") 233 | total_removed += len(redundant) 234 | else: 235 | print(f"{ICON_OK} {file.name}: inga överflödiga nycklar.") 236 | if total_removed == 0: 237 | print(f"{ICON_OK} Inga överflödiga nycklar att ta bort.") 238 | else: 239 | print(f"{ICON_DEL} Totalt borttagna nycklar: {total_removed}") 240 | 241 | if __name__ == "__main__": 242 | # Argumenthantering: identifiera vilka kommandon som ska köras 243 | cmds = [] 244 | update_file = None 245 | force = False 246 | args = sys.argv[1:] # Ta bort scriptnamnet 247 | 248 | # Identifiera kommandon och filargument 249 | for i, arg in enumerate(args): 250 | arg_l = arg.lower() 251 | if arg_l in ("scan", "gen", "update", "clean"): 252 | cmds.append(arg_l) 253 | elif arg_l in ("--force", "-f"): 254 | force = True 255 | elif arg.endswith(".json"): 256 | update_file = arg 257 | # Ignorera okända, så vi kan lägga till fler i framtiden 258 | 259 | if not cmds: 260 | cmds = ["scan"] # default 261 | 262 | # Alltid i given ordning 263 | for cmd in cmds: 264 | if cmd == "scan": 265 | scan_missing() 266 | elif cmd == "gen": 267 | gen_translation_json() 268 | elif cmd == "update": 269 | if update_file: 270 | update_with_translation(update_file, force=force) 271 | else: 272 | print(f"{ICON_WARN} Ingen översättningsfil angiven till update.") 273 | elif cmd == "clean": 274 | delete_redundant() 275 | else: 276 | print(f"{ICON_WARN} Okänt kommando: {cmd}") 277 | 278 | if not cmds or all(cmd not in ("scan", "gen", "update", "clean") for cmd in cmds): 279 | print( 280 | "\nUsage:\n" 281 | f" python3 {Path(__file__).name} scan\n" 282 | f" python3 {Path(__file__).name} gen\n" 283 | f" python3 {Path(__file__).name} update oversattning.json [--force|-f]\n" 284 | f" python3 {Path(__file__).name} clean\n" 285 | "\nDu kan kombinera flera kommandon i valfri ordning, t.ex.:\n" 286 | f" python3 {Path(__file__).name} update oversattning.json clean\n" 287 | ) 288 | 289 | -------------------------------------------------------------------------------- /src/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "card.allergen.alder": "Alder", 3 | "card.allergen.allergy_risk": "Allergy risk", 4 | "card.allergen.ash": "Ash", 5 | "card.allergen.beech": "Beech", 6 | "card.allergen.birch": "Birch", 7 | "card.allergen.chenopod": "Chenopod", 8 | "card.allergen.cypress": "Cypress", 9 | "card.allergen.elm": "Elm", 10 | "card.allergen.grass": "Grass", 11 | "card.allergen.grass_cat": "Grasses", 12 | "card.allergen.hazel": "Hazel", 13 | "card.allergen.index": "Index", 14 | "card.allergen.lime": "Lime", 15 | "card.allergen.mold_spores": "Mold spores", 16 | "card.allergen.mugwort": "Mugwort", 17 | "card.allergen.nettle": "Nettle", 18 | "card.allergen.nettle_and_pellitory": "Nettle and pellitory", 19 | "card.allergen.oak": "Oak", 20 | "card.allergen.olive": "Olive", 21 | "card.allergen.pine": "Pine", 22 | "card.allergen.plane": "Plane", 23 | "card.allergen.poaceae": "Poaceae", 24 | "card.allergen.poplar": "Poplar", 25 | "card.allergen.ragweed": "Ragweed", 26 | "card.allergen.rye": "Rye", 27 | "card.allergen.trees": "Trees", 28 | "card.allergen.trees_cat": "Trees", 29 | "card.allergen.weeds": "Weeds", 30 | "card.allergen.weeds_cat": "Weeds", 31 | "card.allergen.willow": "Willow", 32 | "card.days.0": "Today", 33 | "card.days.1": "Tomorrow", 34 | "card.days.2": "Day after tomorrow", 35 | "card.error": "No pollen sensors found. Have you installed the correct integration and selected a region in the card configuration?", 36 | "card.error_filtered_sensors": "No sensors match your filters. Check selected allergens and threshold.", 37 | "card.error_location_not_found": "Location not found. Check the location name in the card configuration.", 38 | "card.error_no_sensors": "No pollen sensors found. Have you installed the correct integration and selected a region in the card configuration?", 39 | "card.header_prefix": "Pollen forecast for", 40 | "card.integration.dwd": "DWD Pollenflug", 41 | "card.integration.kleenex": "Kleenex Pollen Radar", 42 | "card.integration.peu": "Polleninformation EU", 43 | "card.integration.pp": "PollenPrognos", 44 | "card.integration.silam": "SILAM Pollen", 45 | "card.integration.undefined": "No pollen sensor integration found", 46 | "card.levels.0": "No pollen", 47 | "card.levels.1": "Low levels", 48 | "card.levels.2": "Low–moderate levels", 49 | "card.levels.3": "Moderate levels", 50 | "card.levels.4": "Moderate–high levels", 51 | "card.levels.5": "High levels", 52 | "card.levels.6": "Very high levels", 53 | "card.loading_forecast": "Loading forecast...", 54 | "card.no_allergens": "No allergens", 55 | "card.no_information": "(No information)", 56 | "editor.allergen_color_custom": "Custom colors", 57 | "editor.allergen_color_default_colors": "Default colors", 58 | "editor.allergen_color_mode": "Allergen color mode", 59 | "editor.allergen_colors": "Allergen colors (by level)", 60 | "editor.allergen_colors_header": "Allergen appearance", 61 | "editor.allergen_colors_placeholder": "#ffcc00", 62 | "editor.allergen_colors_reset": "Reset to default", 63 | "editor.allergen_empty_placeholder": "rgba(200,200,200,0.15)", 64 | "editor.allergen_outline_color": "Outline color", 65 | "editor.allergen_outline_placeholder": "#000000", 66 | "editor.allergen_outline_reset": "Reset outline", 67 | "editor.allergen_stroke_width": "Stroke width", 68 | "editor.allergen_stroke_width_reset": "Reset stroke width", 69 | "editor.allergen_stroke_color_synced": "Sync stroke color with level", 70 | "editor.allergen_levels_gap_synced": "Sync gap with allergen stroke width", 71 | "editor.allergens": "Allergens", 72 | "editor.allergens_abbreviated": "Abbreviate allergens", 73 | "editor.allergens_header_category": "Category allergens (general)", 74 | "editor.allergens_header_specific": "Individual allergens (specific)", 75 | "editor.allergy_risk_top": "Allergy risk top of list", 76 | "editor.background_color": "Background color", 77 | "editor.background_color_picker": "Choose color", 78 | "editor.background_color_placeholder": "e.g. #ffeecc or var(--my-color)", 79 | "editor.card_version": "Pollenprognos Card version", 80 | "editor.city": "City", 81 | "editor.days_abbreviated": "Abbreviate weekdays", 82 | "editor.days_boldfaced": "Bold weekdays", 83 | "editor.days_relative": "Relative days (today/tomorrow)", 84 | "editor.days_uppercase": "Uppercase weekdays", 85 | "editor.debug": "Debug", 86 | "editor.entity_prefix": "Entity prefix", 87 | "editor.entity_prefix_placeholder": "e.g. pollen_", 88 | "editor.entity_suffix": "Entity suffix", 89 | "editor.entity_suffix_placeholder": "e.g. _home", 90 | "editor.icon_color_custom": "Custom color", 91 | "editor.icon_color_inherit": "Inherit from chart", 92 | "editor.icon_color_mode": "Icon color mode", 93 | "editor.icon_color_picker": "Pick icon color", 94 | "editor.icon_size": "Icon size (px)", 95 | "editor.index_top": "Index top of list", 96 | "editor.integration": "Integration", 97 | "editor.integration.dwd": "DWD Pollenflug", 98 | "editor.integration.kleenex": "Kleenex Pollen Radar", 99 | "editor.integration.peu": "Polleninformation EU", 100 | "editor.integration.pp": "PollenPrognos", 101 | "editor.integration.silam": "SILAM Pollen", 102 | "editor.levels_colors": "Segment colors", 103 | "editor.levels_colors_placeholder": "e.g. #ffeecc or var(--my-color)", 104 | "editor.levels_custom": "Use custom level colors", 105 | "editor.levels_empty_color": "Empty segment color", 106 | "editor.levels_gap": "Gap (px)", 107 | "editor.levels_gap_color": "Gap color", 108 | "editor.levels_gap_inherited": "Gap (inherited from allergen)", 109 | "editor.levels_header": "Level circle appearance", 110 | "editor.levels_icon_ratio": "Levels icon ratio", 111 | "editor.levels_inherit_allergen": "Inherit from allergen colors", 112 | "editor.levels_inherit_header": "Level Circle Inheritance", 113 | "editor.levels_inherit_mode": "Level circle color mode", 114 | "editor.levels_reset": "Reset to default", 115 | "editor.levels_text_color": "Text color (inner circle)", 116 | "editor.levels_text_size": "Text size (inner circle, % of normal)", 117 | "editor.levels_text_weight": "Text weight (inner circle)", 118 | "editor.levels_thickness": "Thickness (%)", 119 | "editor.link_to_sensors": "Link allergens to sensors", 120 | "editor.locale": "Locale", 121 | "editor.location": "Location", 122 | "editor.location_autodetect": "Auto-detect", 123 | "editor.location_manual": "Manual", 124 | "editor.minimal": "Minimal mode", 125 | "editor.minimal_gap": "Gap between allergens (px)", 126 | "editor.mode": "Mode", 127 | "editor.mode_daily": "Daily", 128 | "editor.mode_hourly": "Hourly", 129 | "editor.mode_hourly_eighth": "Hourly (every 8h)", 130 | "editor.mode_hourly_fourth": "Hourly (every 4h)", 131 | "editor.mode_hourly_second": "Hourly (every 2h)", 132 | "editor.mode_hourly_sixth": "Hourly (every 6h)", 133 | "editor.mode_hourly_third": "Hourly (every 3h)", 134 | "editor.mode_twice_daily": "Twice daily", 135 | "editor.no_allergens_color": "No Allergens", 136 | "editor.no_allergens_color_placeholder": "#a9cfe0", 137 | "editor.no_allergens_color_reset": "Reset no allergens color", 138 | "editor.no_information": "No information", 139 | "editor.numeric_state_raw_risk": "Show raw value (allergy risk)", 140 | "editor.peu_nondaily_expl": "Only 'allergen_risk' is available in non-daily modes.", 141 | "editor.phrases": "Phrases", 142 | "editor.phrases_apply": "Apply", 143 | "editor.phrases_days": "Relative days", 144 | "editor.phrases_days.0": "Today", 145 | "editor.phrases_days.1": "Tomorrow", 146 | "editor.phrases_days.2": "Day after tomorrow", 147 | "editor.phrases_full": "Allergens", 148 | "editor.phrases_full.alder": "Alder", 149 | "editor.phrases_full.allergy_risk": "Allergy risk", 150 | "editor.phrases_full.ash": "Ash", 151 | "editor.phrases_full.beech": "Beech", 152 | "editor.phrases_full.birch": "Birch", 153 | "editor.phrases_full.chenopod": "Chenopod", 154 | "editor.phrases_full.cypress": "Cypress", 155 | "editor.phrases_full.elm": "Elm", 156 | "editor.phrases_full.grass": "Grass", 157 | "editor.phrases_full.grass_cat": "Grasses", 158 | "editor.phrases_full.hazel": "Hazel", 159 | "editor.phrases_full.index": "Index", 160 | "editor.phrases_full.lime": "Lime", 161 | "editor.phrases_full.mold_spores": "Mold spores", 162 | "editor.phrases_full.mugwort": "Mugwort", 163 | "editor.phrases_full.nettle": "Nettle", 164 | "editor.phrases_full.nettle_and_pellitory": "Nettle and pellitory", 165 | "editor.phrases_full.oak": "Oak", 166 | "editor.phrases_full.olive": "Olive", 167 | "editor.phrases_full.pine": "Pine", 168 | "editor.phrases_full.plane": "Plane", 169 | "editor.phrases_full.poaceae": "Poaceae", 170 | "editor.phrases_full.poplar": "Poplar", 171 | "editor.phrases_full.ragweed": "Ragweed", 172 | "editor.phrases_full.rye": "Rye", 173 | "editor.phrases_full.trees": "Trees", 174 | "editor.phrases_full.trees_cat": "Trees", 175 | "editor.phrases_full.weeds": "Weeds", 176 | "editor.phrases_full.weeds_cat": "Weeds", 177 | "editor.phrases_full.willow": "Willow", 178 | "editor.phrases_levels": "Allergen levels", 179 | "editor.phrases_levels.0": "No pollen", 180 | "editor.phrases_levels.1": "Low levels", 181 | "editor.phrases_levels.2": "Low–moderate levels", 182 | "editor.phrases_levels.3": "Moderate levels", 183 | "editor.phrases_levels.4": "Moderate–high levels", 184 | "editor.phrases_levels.5": "High levels", 185 | "editor.phrases_levels.6": "Very high levels", 186 | "editor.phrases_short": "Allergens, short", 187 | "editor.phrases_short.alder": "Aldr", 188 | "editor.phrases_short.allergy_risk": "Risk", 189 | "editor.phrases_short.ash": "Ash", 190 | "editor.phrases_short.beech": "Beech", 191 | "editor.phrases_short.birch": "Birch", 192 | "editor.phrases_short.chenopod": "Chnopd", 193 | "editor.phrases_short.cypress": "Cypress", 194 | "editor.phrases_short.elm": "Elm", 195 | "editor.phrases_short.grass": "Grass", 196 | "editor.phrases_short.grass_cat": "Grass", 197 | "editor.phrases_short.grasses": "Grass", 198 | "editor.phrases_short.hazel": "Hazel", 199 | "editor.phrases_short.index": "Index", 200 | "editor.phrases_short.lime": "Lime", 201 | "editor.phrases_short.mold_spores": "Mold", 202 | "editor.phrases_short.mugwort": "Mgwrt", 203 | "editor.phrases_short.nettle": "Nettle", 204 | "editor.phrases_short.nettle_and_pellitory": "Nettle", 205 | "editor.phrases_short.oak": "Oak", 206 | "editor.phrases_short.olive": "Olive", 207 | "editor.phrases_short.pine": "Pine", 208 | "editor.phrases_short.plane": "Plane", 209 | "editor.phrases_short.poaceae": "Poaceae", 210 | "editor.phrases_short.poplar": "Poplar", 211 | "editor.phrases_short.ragweed": "Rgwd", 212 | "editor.phrases_short.rye": "Rye", 213 | "editor.phrases_short.trees": "Trees", 214 | "editor.phrases_short.trees_cat": "Trees", 215 | "editor.phrases_short.weeds": "Weeds", 216 | "editor.phrases_short.weeds_cat": "Weeds", 217 | "editor.phrases_short.willow": "Wllw", 218 | "editor.phrases_translate_all": "Translate all", 219 | "editor.pollen_threshold": "Threshold:", 220 | "editor.preset_reset_all": "Reset all settings", 221 | "editor.region_id": "Region ID", 222 | "editor.select_all_allergens": "Select all allergens", 223 | "editor.show_empty_days": "Show empty days", 224 | "editor.show_text_allergen": "Show text, allergen", 225 | "editor.show_value_numeric": "Show value, numeric", 226 | "editor.show_value_numeric_in_circle": "Show numeric value in the circles", 227 | "editor.show_value_text": "Show value, text", 228 | "editor.show_version": "Log version to console", 229 | "editor.sort": "Sort order", 230 | "editor.sort_category_allergens_first": "Sort category allergens at the top", 231 | "editor.sort_name_ascending": "name, ascending", 232 | "editor.sort_name_descending": "name, descending", 233 | "editor.sort_none": "none (config order)", 234 | "editor.sort_value_ascending": "value, ascending", 235 | "editor.sort_value_descending": "value, descending", 236 | "editor.summary_advanced": "Advanced", 237 | "editor.summary_allergens": "Allergens", 238 | "editor.summary_appearance_and_layout": "Appearance and layout", 239 | "editor.summary_card_interactivity": "Card interactivity", 240 | "editor.summary_card_layout_and_colors": "Card layout and colors", 241 | "editor.summary_data_view_settings": "Data view settings", 242 | "editor.summary_day_view_settings": "Day view settings", 243 | "editor.summary_entity_prefix_suffix": "Custom prefix and suffix", 244 | "editor.summary_functional_settings": "Functional settings", 245 | "editor.summary_integration_and_place": "Integration and place", 246 | "editor.summary_minimal": "Minimal", 247 | "editor.summary_title_and_header": "Title and header", 248 | "editor.summary_translation_and_strings": "Translation and strings", 249 | "editor.tap_action": "Tap action", 250 | "editor.tap_action_enable": "Enable tap action", 251 | "editor.text_size_ratio": "Text size ratio (%)", 252 | "editor.title": "Card title", 253 | "editor.title_automatic": "Automatic title", 254 | "editor.title_hide": "Hide title", 255 | "editor.title_placeholder": "(automatic)", 256 | "editor.to_show_columns": "Columns to show", 257 | "editor.to_show_days": "Days to show", 258 | "editor.to_show_hours": "Hours to show" 259 | } 260 | -------------------------------------------------------------------------------- /src/locales/no.json: -------------------------------------------------------------------------------- 1 | { 2 | "card.allergen.alder": "Al", 3 | "card.allergen.allergy_risk": "Allergirisiko", 4 | "card.allergen.ash": "Ask", 5 | "card.allergen.beech": "Bøk", 6 | "card.allergen.birch": "Bjørk", 7 | "card.allergen.chenopod": "Melde", 8 | "card.allergen.cypress": "Sypress", 9 | "card.allergen.elm": "Alm", 10 | "card.allergen.grass": "Gress", 11 | "card.allergen.grass_cat": "Gressarter", 12 | "card.allergen.hazel": "Hassel", 13 | "card.allergen.index": "Indeks", 14 | "card.allergen.lime": "Lind", 15 | "card.allergen.mold_spores": "Muggsporer", 16 | "card.allergen.mugwort": "Malurt", 17 | "card.allergen.nettle": "Brennesle", 18 | "card.allergen.nettle_and_pellitory": "Brennesle og murgrønn", 19 | "card.allergen.oak": "Eik", 20 | "card.allergen.olive": "Oliven", 21 | "card.allergen.pine": "Furu", 22 | "card.allergen.plane": "Platan", 23 | "card.allergen.poaceae": "Gress", 24 | "card.allergen.poplar": "Poppel", 25 | "card.allergen.ragweed": "Ambrosia", 26 | "card.allergen.rye": "Rug", 27 | "card.allergen.trees": "Trær", 28 | "card.allergen.trees_cat": "Trær", 29 | "card.allergen.weeds": "Ugress", 30 | "card.allergen.weeds_cat": "Ugress", 31 | "card.allergen.willow": "Selje", 32 | "card.days.0": "I dag", 33 | "card.days.1": "I morgen", 34 | "card.days.2": "Overimorgen", 35 | "card.error": "Ingen pollensensor funnet. Har du installert riktig integrasjon og valgt region i kortoppsettet?", 36 | "card.error_filtered_sensors": "Ingen sensorer samsvarer med filteret. Sjekk utvalg av allergener og terskelverdi.", 37 | "card.error_location_not_found": "Plassering ikke funnet. Sjekk plasseringen i kortkonfigurasjonen.", 38 | "card.error_no_sensors": "Ingen pollensensor funnet. Har du installert riktig integrasjon og valgt region i kortoppsettet?", 39 | "card.header_prefix": "Pollenvarsel for", 40 | "card.integration.dwd": "DWD Pollenflug", 41 | "card.integration.kleenex": "Kleenex Pollen Radar", 42 | "card.integration.peu": "Polleninformation EU", 43 | "card.integration.pp": "PollenPrognos", 44 | "card.integration.silam": "SILAM Pollen", 45 | "card.integration.undefined": "Ingen pollensensor-integrasjon funnet", 46 | "card.levels.0": "Ingen pollen", 47 | "card.levels.1": "Lave nivåer", 48 | "card.levels.2": "Lav–moderat", 49 | "card.levels.3": "Moderat nivå", 50 | "card.levels.4": "Moderat–høyt", 51 | "card.levels.5": "Høye nivåer", 52 | "card.levels.6": "Svært høye nivåer", 53 | "card.loading_forecast": "Laster prognose...", 54 | "card.no_allergens": "Ingen allergener", 55 | "card.no_information": "(Ingen informasjon)", 56 | "editor.allergen_color_custom": "Egendefinerte farger", 57 | "editor.allergen_color_default_colors": "Standardfarger", 58 | "editor.allergen_color_mode": "Allergen fargemodus", 59 | "editor.allergen_colors": "Allergenfarger (per nivå)", 60 | "editor.allergen_colors_header": "Allergen utseende", 61 | "editor.allergen_colors_placeholder": "#ffcc00", 62 | "editor.allergen_colors_reset": "Tilbakestill til standard", 63 | "editor.allergen_empty_placeholder": "rgba(200,200,200,0.15)", 64 | "editor.allergen_levels_gap_synced": "Synkroniser mellomrom med allergenets strekbredde", 65 | "editor.allergen_outline_color": "Omrissfarge", 66 | "editor.allergen_outline_placeholder": "#000000", 67 | "editor.allergen_outline_reset": "Tilbakestill omriss", 68 | "editor.allergen_stroke_color_synced": "Synkroniser strekfarge med nivå", 69 | "editor.allergen_stroke_width": "Strekbredde", 70 | "editor.allergen_stroke_width_reset": "Tilbakestill strekbredde", 71 | "editor.allergens": "Allergener", 72 | "editor.allergens_abbreviated": "Forkort allergener", 73 | "editor.allergens_header_category": "Kategori-allergener (generelt)", 74 | "editor.allergens_header_specific": "Individuelle allergener (spesifikke)", 75 | "editor.allergy_risk_top": "Allergirisiko øverst i listen", 76 | "editor.background_color": "Bakgrunnsfarge", 77 | "editor.background_color_picker": "Velg farge", 78 | "editor.background_color_placeholder": "f.eks. #ffeecc eller var(--my-color)", 79 | "editor.card_version": "Versjon av pollenprognosekortet", 80 | "editor.city": "By", 81 | "editor.days_abbreviated": "Forkort ukedager", 82 | "editor.days_boldfaced": "Uthev ukedager", 83 | "editor.days_relative": "Relative dager (i dag/i morgen)", 84 | "editor.days_uppercase": "Store bokstaver på ukedager", 85 | "editor.debug": "Debug", 86 | "editor.entity_prefix": "Entity-prefiks", 87 | "editor.entity_prefix_placeholder": "f.eks. pollen_", 88 | "editor.entity_suffix": "Entity-suffiks", 89 | "editor.entity_suffix_placeholder": "f.eks. _home", 90 | "editor.icon_color_custom": "Egendefinert farge", 91 | "editor.icon_color_inherit": "Arv fra diagram", 92 | "editor.icon_color_mode": "Ikonfargemodus", 93 | "editor.icon_color_picker": "Velg ikonfarge", 94 | "editor.icon_size": "Ikonstørrelse (px)", 95 | "editor.index_top": "Indeks øverst i listen", 96 | "editor.integration": "Integrasjon", 97 | "editor.integration.dwd": "DWD Pollenflug", 98 | "editor.integration.kleenex": "Kleenex Pollen Radar", 99 | "editor.integration.peu": "Polleninformation EU", 100 | "editor.integration.pp": "PollenPrognos", 101 | "editor.integration.silam": "SILAM Pollen", 102 | "editor.levels_colors": "Segmentfarger", 103 | "editor.levels_colors_placeholder": "f.eks. #ffeecc eller var(--my-color)", 104 | "editor.levels_custom": "Bruk egendefinerte nivåfarger", 105 | "editor.levels_empty_color": "Farge for tomt segment", 106 | "editor.levels_gap": "Mellomrom (px)", 107 | "editor.levels_gap_color": "Mellomromsfarge", 108 | "editor.levels_gap_inherited": "Mellomrom (arvet fra allergen)", 109 | "editor.levels_header": "Nivå-sirklenes utseende", 110 | "editor.levels_icon_ratio": "Nivå ikonforhold", 111 | "editor.levels_inherit_allergen": "Arv fra allergenfarger", 112 | "editor.levels_inherit_header": "Arv av nivå-sirkler", 113 | "editor.levels_inherit_mode": "Nivåsirklers fargemodus", 114 | "editor.levels_reset": "Tilbakestill til standard", 115 | "editor.levels_text_color": "Tekstfarge (indre sirkel)", 116 | "editor.levels_text_size": "Tekststørrelse (indre sirkel, % av normal)", 117 | "editor.levels_text_weight": "Teksttykkelse (indre sirkel)", 118 | "editor.levels_thickness": "Tykkelse (%)", 119 | "editor.link_to_sensors": "Koble allergener til sensorer", 120 | "editor.locale": "Språk", 121 | "editor.location": "Sted", 122 | "editor.location_autodetect": "Automatisk oppdag", 123 | "editor.location_manual": "Manuell", 124 | "editor.minimal": "Minimal modus", 125 | "editor.minimal_gap": "Avstand mellom allergener (px)", 126 | "editor.mode": "Modus", 127 | "editor.mode_daily": "Daglig", 128 | "editor.mode_hourly": "Hver time", 129 | "editor.mode_hourly_eighth": "Hver 8. time", 130 | "editor.mode_hourly_fourth": "Hver 4. time", 131 | "editor.mode_hourly_second": "Hver 2. time", 132 | "editor.mode_hourly_sixth": "Hver 6. time", 133 | "editor.mode_hourly_third": "Hver 3. time", 134 | "editor.mode_twice_daily": "To ganger daglig", 135 | "editor.no_allergens_color": "Ingen allergener", 136 | "editor.no_allergens_color_placeholder": "#a9cfe0", 137 | "editor.no_allergens_color_reset": "Tilbakestill farge uten allergener", 138 | "editor.no_information": "Ingen informasjon", 139 | "editor.numeric_state_raw_risk": "Vis rå verdi (allergirisiko)", 140 | "editor.peu_nondaily_expl": "Kun 'allergen_risk' er tilgjengelig i ikke-daglige moduser.", 141 | "editor.phrases": "Fraser", 142 | "editor.phrases_apply": "Bruk", 143 | "editor.phrases_days": "Relative dager", 144 | "editor.phrases_days.0": "I dag", 145 | "editor.phrases_days.1": "I morgen", 146 | "editor.phrases_days.2": "Overimorgen", 147 | "editor.phrases_full": "Allergener", 148 | "editor.phrases_full.alder": "Al", 149 | "editor.phrases_full.allergy_risk": "Allergirisiko", 150 | "editor.phrases_full.ash": "Ask", 151 | "editor.phrases_full.beech": "Bøk", 152 | "editor.phrases_full.birch": "Bjørk", 153 | "editor.phrases_full.chenopod": "Melde", 154 | "editor.phrases_full.cypress": "Sypress", 155 | "editor.phrases_full.elm": "Alm", 156 | "editor.phrases_full.grass": "Gress", 157 | "editor.phrases_full.grass_cat": "Gressarter", 158 | "editor.phrases_full.hazel": "Hassel", 159 | "editor.phrases_full.index": "Indeks", 160 | "editor.phrases_full.lime": "Lind", 161 | "editor.phrases_full.mold_spores": "Muggsporer", 162 | "editor.phrases_full.mugwort": "Malurt", 163 | "editor.phrases_full.nettle": "Brennesle", 164 | "editor.phrases_full.nettle_and_pellitory": "Brennesle og murgrønn", 165 | "editor.phrases_full.oak": "Eik", 166 | "editor.phrases_full.olive": "Oliven", 167 | "editor.phrases_full.pine": "Furu", 168 | "editor.phrases_full.plane": "Platan", 169 | "editor.phrases_full.poaceae": "Gress", 170 | "editor.phrases_full.poplar": "Poppel", 171 | "editor.phrases_full.ragweed": "Ambrosia", 172 | "editor.phrases_full.rye": "Rug", 173 | "editor.phrases_full.trees": "Trær", 174 | "editor.phrases_full.trees_cat": "Trær", 175 | "editor.phrases_full.weeds": "Ugress", 176 | "editor.phrases_full.weeds_cat": "Ugress", 177 | "editor.phrases_full.willow": "Selje", 178 | "editor.phrases_levels": "Allergennivåer", 179 | "editor.phrases_levels.0": "Ingen pollen", 180 | "editor.phrases_levels.1": "Lave nivåer", 181 | "editor.phrases_levels.2": "Lav–moderat", 182 | "editor.phrases_levels.3": "Moderat nivå", 183 | "editor.phrases_levels.4": "Moderat–høyt", 184 | "editor.phrases_levels.5": "Høye nivåer", 185 | "editor.phrases_levels.6": "Svært høye nivåer", 186 | "editor.phrases_short": "Allergener, kort", 187 | "editor.phrases_short.alder": "Al", 188 | "editor.phrases_short.allergy_risk": "Risiko", 189 | "editor.phrases_short.ash": "Ask", 190 | "editor.phrases_short.beech": "Bøk", 191 | "editor.phrases_short.birch": "Bjørk", 192 | "editor.phrases_short.chenopod": "Melde", 193 | "editor.phrases_short.cypress": "Syp.", 194 | "editor.phrases_short.elm": "Alm", 195 | "editor.phrases_short.grass": "Gress", 196 | "editor.phrases_short.grass_cat": "Gress", 197 | "editor.phrases_short.grasses": "Gress", 198 | "editor.phrases_short.hazel": "Hassel", 199 | "editor.phrases_short.index": "Indeks", 200 | "editor.phrases_short.lime": "Lind", 201 | "editor.phrases_short.mold_spores": "Mugg", 202 | "editor.phrases_short.mugwort": "Malurt", 203 | "editor.phrases_short.nettle": "Brenns", 204 | "editor.phrases_short.nettle_and_pellitory": "Brennesle", 205 | "editor.phrases_short.oak": "Eik", 206 | "editor.phrases_short.olive": "Oliven", 207 | "editor.phrases_short.pine": "Furu", 208 | "editor.phrases_short.plane": "Platan", 209 | "editor.phrases_short.poaceae": "Gress", 210 | "editor.phrases_short.poplar": "Poppel", 211 | "editor.phrases_short.ragweed": "Ambrosia", 212 | "editor.phrases_short.rye": "Rug", 213 | "editor.phrases_short.trees": "Trær", 214 | "editor.phrases_short.trees_cat": "Trær", 215 | "editor.phrases_short.weeds": "Ugress", 216 | "editor.phrases_short.weeds_cat": "Ugress", 217 | "editor.phrases_short.willow": "Selje", 218 | "editor.phrases_translate_all": "Oversett alt", 219 | "editor.pollen_threshold": "Terskelverdi:", 220 | "editor.preset_reset_all": "Tilbakestill alle innstillinger", 221 | "editor.region_id": "Region-ID", 222 | "editor.select_all_allergens": "Velg alle allergener", 223 | "editor.show_empty_days": "Vis tomme dager", 224 | "editor.show_text_allergen": "Vis tekst, allergen", 225 | "editor.show_value_numeric": "Vis tallverdi", 226 | "editor.show_value_numeric_in_circle": "Vis tallverdi i sirkel", 227 | "editor.show_value_text": "Vis verdi som tekst", 228 | "editor.show_version": "Logg versjon til konsollen", 229 | "editor.sort": "Sortering", 230 | "editor.sort_category_allergens_first": "Sorter kategori-allergener øverst", 231 | "editor.sort_name_ascending": "navn, stigende", 232 | "editor.sort_name_descending": "navn, synkende", 233 | "editor.sort_none": "ingen (konfigurasjonsrekkefølge)", 234 | "editor.sort_value_ascending": "verdi, stigende", 235 | "editor.sort_value_descending": "verdi, synkende", 236 | "editor.summary_advanced": "Avansert", 237 | "editor.summary_allergens": "Allergener", 238 | "editor.summary_appearance_and_layout": "Utseende og oppsett", 239 | "editor.summary_card_interactivity": "Kortinteraktivitet", 240 | "editor.summary_card_layout_and_colors": "Kortoppsett og farger", 241 | "editor.summary_data_view_settings": "Datavisningsinnstillinger", 242 | "editor.summary_day_view_settings": "Dagsvisningsinnstillinger", 243 | "editor.summary_entity_prefix_suffix": "Egendefinert prefiks og suffiks", 244 | "editor.summary_functional_settings": "Funksjonsinnstillinger", 245 | "editor.summary_integration_and_place": "Integrasjon og sted", 246 | "editor.summary_minimal": "Minimal", 247 | "editor.summary_title_and_header": "Tittel og overskrift", 248 | "editor.summary_translation_and_strings": "Oversettelse og strenger", 249 | "editor.tap_action": "Trykkhandling", 250 | "editor.tap_action_enable": "Aktiver trykkhandling", 251 | "editor.text_size_ratio": "Tekststørrelsesforhold (%)", 252 | "editor.title": "Korttittel", 253 | "editor.title_automatic": "Automatisk tittel", 254 | "editor.title_hide": "Skjul tittel", 255 | "editor.title_placeholder": "(automatisk)", 256 | "editor.to_show_columns": "Antall kolonner som vises", 257 | "editor.to_show_days": "Antall dager som vises", 258 | "editor.to_show_hours": "Antall timer som vises" 259 | } -------------------------------------------------------------------------------- /src/locales/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "card.allergen.alder": "Al", 3 | "card.allergen.allergy_risk": "Allergirisk", 4 | "card.allergen.ash": "Asp", 5 | "card.allergen.beech": "Bok", 6 | "card.allergen.birch": "Björk", 7 | "card.allergen.chenopod": "Svinmålla", 8 | "card.allergen.cypress": "Cypress", 9 | "card.allergen.elm": "Alm", 10 | "card.allergen.grass": "Gräs", 11 | "card.allergen.grass_cat": "Gräsarter", 12 | "card.allergen.hazel": "Hassel", 13 | "card.allergen.index": "Index", 14 | "card.allergen.lime": "Lind", 15 | "card.allergen.mold_spores": "Mögelsporer", 16 | "card.allergen.mugwort": "Gråbo", 17 | "card.allergen.nettle": "Brännässla", 18 | "card.allergen.nettle_and_pellitory": "Nässla & parietaria", 19 | "card.allergen.oak": "Ek", 20 | "card.allergen.olive": "Oliv", 21 | "card.allergen.pine": "Tall", 22 | "card.allergen.plane": "Platan", 23 | "card.allergen.poaceae": "Gräs", 24 | "card.allergen.poplar": "Poppel", 25 | "card.allergen.ragweed": "Malörtsambrosia", 26 | "card.allergen.rye": "Råg", 27 | "card.allergen.trees": "Träd", 28 | "card.allergen.trees_cat": "Träd", 29 | "card.allergen.weeds": "Ogräs", 30 | "card.allergen.weeds_cat": "Ogräs", 31 | "card.allergen.willow": "Sälg och viden", 32 | "card.days.0": "Idag", 33 | "card.days.1": "Imorgon", 34 | "card.days.2": "I övermorgon", 35 | "card.error": "Inga pollen-sensorer hittades. Har du installerat rätt integration och valt region i kortets konfiguration?", 36 | "card.error_filtered_sensors": "Inga sensorer matchar din filtrering. Kontrollera valda allergener och tröskel.", 37 | "card.error_location_not_found": "Platsen hittades inte. Kontrollera platsnamnet i kortkonfigurationen.", 38 | "card.error_no_sensors": "Inga pollen-sensorer hittades. Har du installerat rätt integration och valt region i kortets konfiguration?", 39 | "card.header_prefix": "Pollenprognos för", 40 | "card.integration.dwd": "DWD Pollenflug", 41 | "card.integration.kleenex": "Kleenex Pollen Radar", 42 | "card.integration.peu": "Polleninformation EU", 43 | "card.integration.pp": "PollenPrognos", 44 | "card.integration.silam": "SILAM Pollen", 45 | "card.integration.undefined": "Ingen pollen-sensor-integration hittades", 46 | "card.levels.0": "Ingen pollen", 47 | "card.levels.1": "Låga halter", 48 | "card.levels.2": "Låga–måttliga halter", 49 | "card.levels.3": "Måttliga halter", 50 | "card.levels.4": "Måttliga–höga halter", 51 | "card.levels.5": "Höga halter", 52 | "card.levels.6": "Mycket höga halter", 53 | "card.loading_forecast": "Laddar prognos...", 54 | "card.no_allergens": "Inga allergener", 55 | "card.no_information": "(Ingen information)", 56 | "editor.allergen_color_custom": "Anpassade färger", 57 | "editor.allergen_color_default_colors": "Standardfärger", 58 | "editor.allergen_color_mode": "Allergen färgläge", 59 | "editor.allergen_colors": "Allergenfärger (per nivå)", 60 | "editor.allergen_colors_header": "Allergenutseende", 61 | "editor.allergen_colors_placeholder": "#ffcc00", 62 | "editor.allergen_colors_reset": "Återställ till standard", 63 | "editor.allergen_empty_placeholder": "rgba(200,200,200,0.15)", 64 | "editor.allergen_levels_gap_synced": "Synkronisera mellanrum med allergenets linjetjocklek", 65 | "editor.allergen_outline_color": "Konturfärg", 66 | "editor.allergen_outline_placeholder": "#000000", 67 | "editor.allergen_outline_reset": "Återställ kontur", 68 | "editor.allergen_stroke_color_synced": "Synkronisera linjefärg med nivå", 69 | "editor.allergen_stroke_width": "Linjetjocklek", 70 | "editor.allergen_stroke_width_reset": "Återställ linjetjocklek", 71 | "editor.allergens": "Allergener", 72 | "editor.allergens_abbreviated": "Förkorta allergener", 73 | "editor.allergens_header_category": "Kategori-allergener (generellt)", 74 | "editor.allergens_header_specific": "Individuella allergener (specifika)", 75 | "editor.allergy_risk_top": "Allergirisk överst i listan", 76 | "editor.background_color": "Bakgrundsfärg", 77 | "editor.background_color_picker": "Välj färg", 78 | "editor.background_color_placeholder": "t.ex. #ffeecc eller var(--my-color)", 79 | "editor.card_version": "Version av pollenprognoskortet", 80 | "editor.city": "Stad", 81 | "editor.days_abbreviated": "Förkorta veckodagar", 82 | "editor.days_boldfaced": "Fetstil veckodagar", 83 | "editor.days_relative": "Relativa dagar (idag/imorgon)", 84 | "editor.days_uppercase": "Versaler veckodagar", 85 | "editor.debug": "Debug", 86 | "editor.entity_prefix": "Entitetprefix", 87 | "editor.entity_prefix_placeholder": "t.ex. pollen_", 88 | "editor.entity_suffix": "Entitetsuffix", 89 | "editor.entity_suffix_placeholder": "t.ex. _home", 90 | "editor.icon_color_custom": "Anpassad färg", 91 | "editor.icon_color_inherit": "Ärv från diagram", 92 | "editor.icon_color_mode": "Ikonfärgläge", 93 | "editor.icon_color_picker": "Välj ikonfärg", 94 | "editor.icon_size": "Ikonstorlek (px)", 95 | "editor.index_top": "Index överst i listan", 96 | "editor.integration": "Integration", 97 | "editor.integration.dwd": "DWD Pollenflug", 98 | "editor.integration.kleenex": "Kleenex Pollen Radar", 99 | "editor.integration.peu": "Polleninformation EU", 100 | "editor.integration.pp": "PollenPrognos", 101 | "editor.integration.silam": "SILAM Pollen", 102 | "editor.levels_colors": "Segmentfärger", 103 | "editor.levels_colors_placeholder": "t.ex. #ffeecc eller var(--my-color)", 104 | "editor.levels_custom": "Använd anpassade nivåfärger", 105 | "editor.levels_empty_color": "Färg för tomt segment", 106 | "editor.levels_gap": "Gap (px)", 107 | "editor.levels_gap_color": "Gapfärg", 108 | "editor.levels_gap_inherited": "Mellanrum (ärvt från allergen)", 109 | "editor.levels_header": "Nivåcirklars utseende", 110 | "editor.levels_icon_ratio": "Ikonförhållande för nivåer", 111 | "editor.levels_inherit_allergen": "Ärv från allergenfärger", 112 | "editor.levels_inherit_header": "Nivåcirklar arv", 113 | "editor.levels_inherit_mode": "Nivåcirkelfärgläge", 114 | "editor.levels_reset": "Återställ till standard", 115 | "editor.levels_text_color": "Textfärg (inre cirkel)", 116 | "editor.levels_text_size": "Textstorlek (inre cirkel, % av normal)", 117 | "editor.levels_text_weight": "Texttjocklek (inre cirkel)", 118 | "editor.levels_thickness": "Tjocklek (%)", 119 | "editor.link_to_sensors": "Koppla allergener till sensorer", 120 | "editor.locale": "Locale", 121 | "editor.location": "Plats", 122 | "editor.location_autodetect": "Autoidentifiera", 123 | "editor.location_manual": "Manuell", 124 | "editor.minimal": "Minimalt läge", 125 | "editor.minimal_gap": "Avstånd mellan allergener (px)", 126 | "editor.mode": "Läge", 127 | "editor.mode_daily": "Dagligen", 128 | "editor.mode_hourly": "Varje timme", 129 | "editor.mode_hourly_eighth": "Var åttonde timme", 130 | "editor.mode_hourly_fourth": "Var fjärde timme", 131 | "editor.mode_hourly_second": "Varannan timme", 132 | "editor.mode_hourly_sixth": "Var sjätte timme", 133 | "editor.mode_hourly_third": "Var tredje timme", 134 | "editor.mode_twice_daily": "Två gånger dagligen", 135 | "editor.no_allergens_color": "Inga allergener", 136 | "editor.no_allergens_color_placeholder": "#a9cfe0", 137 | "editor.no_allergens_color_reset": "Återställ färg utan allergener", 138 | "editor.no_information": "Ingen information", 139 | "editor.numeric_state_raw_risk": "Visa råvärde (allergirisk)", 140 | "editor.peu_nondaily_expl": "Endast 'allergen_risk' är tillgänglig i icke-dagliga lägen.", 141 | "editor.phrases": "Fraser", 142 | "editor.phrases_apply": "Utför", 143 | "editor.phrases_days": "Relativa dagar", 144 | "editor.phrases_days.0": "Idag", 145 | "editor.phrases_days.1": "Imorgon", 146 | "editor.phrases_days.2": "I övermorgon", 147 | "editor.phrases_full": "Allergener", 148 | "editor.phrases_full.alder": "Al", 149 | "editor.phrases_full.allergy_risk": "Allergirisk", 150 | "editor.phrases_full.ash": "Asp", 151 | "editor.phrases_full.beech": "Bok", 152 | "editor.phrases_full.birch": "Björk", 153 | "editor.phrases_full.chenopod": "Svinmålla", 154 | "editor.phrases_full.cypress": "Cypress", 155 | "editor.phrases_full.elm": "Alm", 156 | "editor.phrases_full.grass": "Gräs", 157 | "editor.phrases_full.grass_cat": "Gräsarter", 158 | "editor.phrases_full.hazel": "Hassel", 159 | "editor.phrases_full.index": "Index", 160 | "editor.phrases_full.lime": "Lind", 161 | "editor.phrases_full.mold_spores": "Mögelsporer", 162 | "editor.phrases_full.mugwort": "Gråbo", 163 | "editor.phrases_full.nettle": "Brännässla", 164 | "editor.phrases_full.nettle_and_pellitory": "Nässla & parietaria", 165 | "editor.phrases_full.oak": "Ek", 166 | "editor.phrases_full.olive": "Oliv", 167 | "editor.phrases_full.pine": "Tall", 168 | "editor.phrases_full.plane": "Platan", 169 | "editor.phrases_full.poaceae": "Gräs", 170 | "editor.phrases_full.poplar": "Poppel", 171 | "editor.phrases_full.ragweed": "Malörtsambrosia", 172 | "editor.phrases_full.rye": "Råg", 173 | "editor.phrases_full.trees": "Träd", 174 | "editor.phrases_full.trees_cat": "Träd", 175 | "editor.phrases_full.weeds": "Ogräs", 176 | "editor.phrases_full.weeds_cat": "Ogräs", 177 | "editor.phrases_full.willow": "Sälg och viden", 178 | "editor.phrases_levels": "Allergennivåer", 179 | "editor.phrases_levels.0": "Ingen pollen", 180 | "editor.phrases_levels.1": "Låga halter", 181 | "editor.phrases_levels.2": "Låga–måttliga halter", 182 | "editor.phrases_levels.3": "Måttliga halter", 183 | "editor.phrases_levels.4": "Måttliga–höga halter", 184 | "editor.phrases_levels.5": "Höga halter", 185 | "editor.phrases_levels.6": "Mycket höga halter", 186 | "editor.phrases_short": "Allergener, kort", 187 | "editor.phrases_short.alder": "Al", 188 | "editor.phrases_short.allergy_risk": "Risk", 189 | "editor.phrases_short.ash": "Ask", 190 | "editor.phrases_short.beech": "Bok", 191 | "editor.phrases_short.birch": "Björk", 192 | "editor.phrases_short.chenopod": "Svinm", 193 | "editor.phrases_short.cypress": "Cyp.", 194 | "editor.phrases_short.elm": "Alm", 195 | "editor.phrases_short.grass": "Gräs", 196 | "editor.phrases_short.grass_cat": "Gräs", 197 | "editor.phrases_short.grasses": "Gräs", 198 | "editor.phrases_short.hazel": "Hass", 199 | "editor.phrases_short.index": "Index", 200 | "editor.phrases_short.lime": "Lind", 201 | "editor.phrases_short.mold_spores": "Mögel", 202 | "editor.phrases_short.mugwort": "Gråbo", 203 | "editor.phrases_short.nettle": "Bränns", 204 | "editor.phrases_short.nettle_and_pellitory": "Nässla", 205 | "editor.phrases_short.oak": "Ek", 206 | "editor.phrases_short.olive": "Oliv", 207 | "editor.phrases_short.pine": "Tall", 208 | "editor.phrases_short.plane": "Platan", 209 | "editor.phrases_short.poaceae": "Gräs", 210 | "editor.phrases_short.poplar": "Poppel", 211 | "editor.phrases_short.ragweed": "Ambro", 212 | "editor.phrases_short.rye": "Råg", 213 | "editor.phrases_short.trees": "Träd", 214 | "editor.phrases_short.trees_cat": "Träd", 215 | "editor.phrases_short.weeds": "Ogräs", 216 | "editor.phrases_short.weeds_cat": "Ogräs", 217 | "editor.phrases_short.willow": "Vide", 218 | "editor.phrases_translate_all": "Översätt allt", 219 | "editor.pollen_threshold": "Tröskelvärde:", 220 | "editor.preset_reset_all": "Återställ allt", 221 | "editor.region_id": "Region ID", 222 | "editor.select_all_allergens": "Välj alla allergener", 223 | "editor.show_empty_days": "Visa tomma dagar", 224 | "editor.show_text_allergen": "Visa text, allergen", 225 | "editor.show_value_numeric": "Visa värde, numeriskt", 226 | "editor.show_value_numeric_in_circle": "Visa numeriskt värde inuti cirklarna", 227 | "editor.show_value_text": "Visa värde, text", 228 | "editor.show_version": "Logga version i konsolen", 229 | "editor.sort": "Sortering", 230 | "editor.sort_category_allergens_first": "Sortera kategoriallergener överst", 231 | "editor.sort_name_ascending": "namn, stigande", 232 | "editor.sort_name_descending": "namn, fallande", 233 | "editor.sort_none": "ingen (konfigurationsordning)", 234 | "editor.sort_value_ascending": "värde, stigande", 235 | "editor.sort_value_descending": "värde, fallande", 236 | "editor.summary_advanced": "Avancerat", 237 | "editor.summary_allergens": "Allergener", 238 | "editor.summary_appearance_and_layout": "Utseende och layout", 239 | "editor.summary_card_interactivity": "Kortinteraktivitet", 240 | "editor.summary_card_layout_and_colors": "Kortlayout och färger", 241 | "editor.summary_data_view_settings": "Datavisningsinställningar", 242 | "editor.summary_day_view_settings": "Dagvisningsinställningar", 243 | "editor.summary_entity_prefix_suffix": "Eget prefix och suffix", 244 | "editor.summary_functional_settings": "Funktionella inställningar", 245 | "editor.summary_integration_and_place": "Integration och plats", 246 | "editor.summary_minimal": "Minimal", 247 | "editor.summary_title_and_header": "Titel och rubrik", 248 | "editor.summary_translation_and_strings": "Översättning och texter", 249 | "editor.tap_action": "Tryckåtgärd", 250 | "editor.tap_action_enable": "Aktivera tryckåtgärd", 251 | "editor.text_size_ratio": "Textstorlek (%)", 252 | "editor.title": "Rubrik på kortet", 253 | "editor.title_automatic": "Automatisk rubrik", 254 | "editor.title_hide": "Göm rubrik", 255 | "editor.title_placeholder": "(automatisk)", 256 | "editor.to_show_columns": "Antal kolumner som visas", 257 | "editor.to_show_days": "Antal dagar som visas", 258 | "editor.to_show_hours": "Antal timmar som visas" 259 | } -------------------------------------------------------------------------------- /src/locales/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "card.allergen.alder": "Olše", 3 | "card.allergen.allergy_risk": "Riziko alergie", 4 | "card.allergen.ash": "Jasan", 5 | "card.allergen.beech": "Buk", 6 | "card.allergen.birch": "Bříza", 7 | "card.allergen.chenopod": "Laskavec", 8 | "card.allergen.cypress": "Cypřiš", 9 | "card.allergen.elm": "Jilm", 10 | "card.allergen.grass": "Tráva", 11 | "card.allergen.grass_cat": "Traviny", 12 | "card.allergen.hazel": "Líska", 13 | "card.allergen.index": "Index", 14 | "card.allergen.lime": "Lípa", 15 | "card.allergen.mold_spores": "Plísňové spory", 16 | "card.allergen.mugwort": "Pelyněk", 17 | "card.allergen.nettle": "Kopřiva", 18 | "card.allergen.nettle_and_pellitory": "Kopřiva a parietárie", 19 | "card.allergen.oak": "Dub", 20 | "card.allergen.olive": "Olivovník", 21 | "card.allergen.pine": "Borovice", 22 | "card.allergen.plane": "Platan", 23 | "card.allergen.poaceae": "Traviny", 24 | "card.allergen.poplar": "Topol", 25 | "card.allergen.ragweed": "Ambrozie", 26 | "card.allergen.rye": "Žito", 27 | "card.allergen.trees": "Stromy", 28 | "card.allergen.trees_cat": "Stromy", 29 | "card.allergen.weeds": "Plevely", 30 | "card.allergen.weeds_cat": "Plevely", 31 | "card.allergen.willow": "Vrba", 32 | "card.days.0": "Dnes", 33 | "card.days.1": "Zítra", 34 | "card.days.2": "Pozítří", 35 | "card.error": "Nenalezeny žádné pylové senzory. Je správná integrace nainstalována a vybrán region v nastavení karty?", 36 | "card.error_filtered_sensors": "Žádné senzory neodpovídají filtrům. Zkontrolujte vybrané alergeny a práh.", 37 | "card.error_location_not_found": "Umístění nebylo nalezeno. Zkontrolujte název umístění v konfiguraci karty.", 38 | "card.error_no_sensors": "Nenalezeny žádné pylové senzory. Je správná integrace nainstalována a vybrán region v nastavení karty?", 39 | "card.header_prefix": "Pylová předpověď pro", 40 | "card.integration.dwd": "DWD Pollenflug", 41 | "card.integration.kleenex": "Kleenex Pollen Radar", 42 | "card.integration.peu": "Polleninformation EU", 43 | "card.integration.pp": "PollenPrognos", 44 | "card.integration.silam": "SILAM Pollen", 45 | "card.integration.undefined": "Nenalezena žádná pylová integrace", 46 | "card.levels.0": "Žádný pyl", 47 | "card.levels.1": "Nízké úrovně", 48 | "card.levels.2": "Nízké–střední úrovně", 49 | "card.levels.3": "Střední úrovně", 50 | "card.levels.4": "Střední–vysoké úrovně", 51 | "card.levels.5": "Vysoké úrovně", 52 | "card.levels.6": "Velmi vysoké úrovně", 53 | "card.loading_forecast": "Načítání předpovědi...", 54 | "card.no_allergens": "Žádné alergeny", 55 | "card.no_information": "(Žádné informace)", 56 | "editor.allergen_color_custom": "Vlastní barvy", 57 | "editor.allergen_color_default_colors": "Výchozí barvy", 58 | "editor.allergen_color_mode": "Režim barev alergenů", 59 | "editor.allergen_colors": "Barvy alergenů (podle úrovně)", 60 | "editor.allergen_colors_header": "Vzhled alergenů", 61 | "editor.allergen_colors_placeholder": "#ffcc00", 62 | "editor.allergen_colors_reset": "Obnovit výchozí", 63 | "editor.allergen_empty_placeholder": "rgba(200,200,200,0.15)", 64 | "editor.allergen_levels_gap_synced": "Synchronizovat mezeru s tloušťkou čáry alergenu", 65 | "editor.allergen_outline_color": "Barva obrysu", 66 | "editor.allergen_outline_placeholder": "#000000", 67 | "editor.allergen_outline_reset": "Obnovit obrys", 68 | "editor.allergen_stroke_color_synced": "Synchronizovat barvu čáry s úrovní", 69 | "editor.allergen_stroke_width": "Tloušťka čáry", 70 | "editor.allergen_stroke_width_reset": "Obnovit tloušťku čáry", 71 | "editor.allergens": "Alergeny", 72 | "editor.allergens_abbreviated": "Zkrátit alergeny", 73 | "editor.allergens_header_category": "Kategorie alergenů (obecné)", 74 | "editor.allergens_header_specific": "Jednotlivé alergeny (specifické)", 75 | "editor.allergy_risk_top": "Riziko alergie navrchu seznamu", 76 | "editor.background_color": "Barva pozadí", 77 | "editor.background_color_picker": "Vybrat barvu", 78 | "editor.background_color_placeholder": "např. #ffeecc nebo var(--my-color)", 79 | "editor.card_version": "Verze karty s pylovou předpovědí", 80 | "editor.city": "Město", 81 | "editor.days_abbreviated": "Zkrátit dny v týdnu", 82 | "editor.days_boldfaced": "Zvýraznit dny v týdnu", 83 | "editor.days_relative": "Relativní dny (dnes/zítra)", 84 | "editor.days_uppercase": "Velká písmena dny v týdnu", 85 | "editor.debug": "Ladění", 86 | "editor.entity_prefix": "Prefix entity", 87 | "editor.entity_prefix_placeholder": "např. pollen_", 88 | "editor.entity_suffix": "Suffix entity", 89 | "editor.entity_suffix_placeholder": "např. _home", 90 | "editor.icon_color_custom": "Vlastní barva", 91 | "editor.icon_color_inherit": "Dědit z grafu", 92 | "editor.icon_color_mode": "Režim barvy ikony", 93 | "editor.icon_color_picker": "Vybrat barvu ikony", 94 | "editor.icon_size": "Velikost ikony (px)", 95 | "editor.index_top": "Index navrchu seznamu", 96 | "editor.integration": "Integrace", 97 | "editor.integration.dwd": "DWD Pollenflug", 98 | "editor.integration.kleenex": "Kleenex Pollen Radar", 99 | "editor.integration.peu": "Polleninformation EU", 100 | "editor.integration.pp": "PollenPrognos", 101 | "editor.integration.silam": "SILAM Pollen", 102 | "editor.levels_colors": "Barvy segmentů", 103 | "editor.levels_colors_placeholder": "např. #ffeecc nebo var(--my-color)", 104 | "editor.levels_custom": "Použít vlastní barvy úrovní", 105 | "editor.levels_empty_color": "Barva prázdného segmentu", 106 | "editor.levels_gap": "Mezera (px)", 107 | "editor.levels_gap_color": "Barva mezery", 108 | "editor.levels_gap_inherited": "Mezera (zděděná z alergenu)", 109 | "editor.levels_header": "Vzhled kruhů úrovní", 110 | "editor.levels_icon_ratio": "Poměr ikon úrovní", 111 | "editor.levels_inherit_allergen": "Dědit z barev alergenů", 112 | "editor.levels_inherit_header": "Dědění kruhů úrovní", 113 | "editor.levels_inherit_mode": "Režim barev kruhů úrovní", 114 | "editor.levels_reset": "Obnovit výchozí", 115 | "editor.levels_text_color": "Barva textu (vnitřní kruh)", 116 | "editor.levels_text_size": "Velikost textu (vnitřní kruh, % z normálu)", 117 | "editor.levels_text_weight": "Tloušťka textu (vnitřní kruh)", 118 | "editor.levels_thickness": "Tloušťka (%)", 119 | "editor.link_to_sensors": "Propojit alergeny se senzory", 120 | "editor.locale": "Jazyk", 121 | "editor.location": "Poloha", 122 | "editor.location_autodetect": "Automatická detekce", 123 | "editor.location_manual": "Manuálně", 124 | "editor.minimal": "Minimální režim", 125 | "editor.minimal_gap": "Mezera mezi alergeny (px)", 126 | "editor.mode": "Režim", 127 | "editor.mode_daily": "Denně", 128 | "editor.mode_hourly": "Hodinově", 129 | "editor.mode_hourly_eighth": "Hodinově (každé 8 h)", 130 | "editor.mode_hourly_fourth": "Hodinově (každé 4 h)", 131 | "editor.mode_hourly_second": "Hodinově (každé 2 h)", 132 | "editor.mode_hourly_sixth": "Hodinově (každé 6 h)", 133 | "editor.mode_hourly_third": "Hodinově (každé 3 h)", 134 | "editor.mode_twice_daily": "Dvakrát denně", 135 | "editor.no_allergens_color": "Bez alergenů", 136 | "editor.no_allergens_color_placeholder": "#a9cfe0", 137 | "editor.no_allergens_color_reset": "Obnovit barvu bez alergenů", 138 | "editor.no_information": "Žádné informace", 139 | "editor.numeric_state_raw_risk": "Zobrazit surovou hodnotu (riziko alergie)", 140 | "editor.peu_nondaily_expl": "Pouze 'allergen_risk' je dostupný v nedenních režimech.", 141 | "editor.phrases": "Fráze", 142 | "editor.phrases_apply": "Použít", 143 | "editor.phrases_days": "Relativní dny", 144 | "editor.phrases_days.0": "Dnes", 145 | "editor.phrases_days.1": "Zítra", 146 | "editor.phrases_days.2": "Pozítří", 147 | "editor.phrases_full": "Alergeny", 148 | "editor.phrases_full.alder": "Olše", 149 | "editor.phrases_full.allergy_risk": "Riziko alergie", 150 | "editor.phrases_full.ash": "Jasan", 151 | "editor.phrases_full.beech": "Buk", 152 | "editor.phrases_full.birch": "Bříza", 153 | "editor.phrases_full.chenopod": "Laskavec", 154 | "editor.phrases_full.cypress": "Cypřiš", 155 | "editor.phrases_full.elm": "Jilm", 156 | "editor.phrases_full.grass": "Tráva", 157 | "editor.phrases_full.grass_cat": "Traviny", 158 | "editor.phrases_full.hazel": "Líska", 159 | "editor.phrases_full.index": "Index", 160 | "editor.phrases_full.lime": "Lípa", 161 | "editor.phrases_full.mold_spores": "Plísňové spory", 162 | "editor.phrases_full.mugwort": "Pelyněk", 163 | "editor.phrases_full.nettle": "Kopřiva", 164 | "editor.phrases_full.nettle_and_pellitory": "Kopřiva a parietárie", 165 | "editor.phrases_full.oak": "Dub", 166 | "editor.phrases_full.olive": "Olivovník", 167 | "editor.phrases_full.pine": "Borovice", 168 | "editor.phrases_full.plane": "Platan", 169 | "editor.phrases_full.poaceae": "Traviny", 170 | "editor.phrases_full.poplar": "Topol", 171 | "editor.phrases_full.ragweed": "Ambrozie", 172 | "editor.phrases_full.rye": "Žito", 173 | "editor.phrases_full.trees": "Stromy", 174 | "editor.phrases_full.trees_cat": "Stromy", 175 | "editor.phrases_full.weeds": "Plevely", 176 | "editor.phrases_full.weeds_cat": "Plevely", 177 | "editor.phrases_full.willow": "Vrba", 178 | "editor.phrases_levels": "Úrovně alergenů", 179 | "editor.phrases_levels.0": "Žádný pyl", 180 | "editor.phrases_levels.1": "Nízké úrovně", 181 | "editor.phrases_levels.2": "Nízké–střední úrovně", 182 | "editor.phrases_levels.3": "Střední úrovně", 183 | "editor.phrases_levels.4": "Střední–vysoké úrovně", 184 | "editor.phrases_levels.5": "Vysoké úrovně", 185 | "editor.phrases_levels.6": "Velmi vysoké úrovně", 186 | "editor.phrases_short": "Alergeny, krátce", 187 | "editor.phrases_short.alder": "Olše", 188 | "editor.phrases_short.allergy_risk": "Riziko", 189 | "editor.phrases_short.ash": "Jas.", 190 | "editor.phrases_short.beech": "Buk", 191 | "editor.phrases_short.birch": "Bříza", 192 | "editor.phrases_short.chenopod": "Laskav", 193 | "editor.phrases_short.cypress": "Cypř.", 194 | "editor.phrases_short.elm": "Jilm", 195 | "editor.phrases_short.grass": "Tráva", 196 | "editor.phrases_short.grass_cat": "Tráva", 197 | "editor.phrases_short.grasses": "Trávy", 198 | "editor.phrases_short.hazel": "Líska", 199 | "editor.phrases_short.index": "Index", 200 | "editor.phrases_short.lime": "Lípa", 201 | "editor.phrases_short.mold_spores": "Plísně", 202 | "editor.phrases_short.mugwort": "Pelyněk", 203 | "editor.phrases_short.nettle": "Kopřiv", 204 | "editor.phrases_short.nettle_and_pellitory": "Kopřiva", 205 | "editor.phrases_short.oak": "Dub", 206 | "editor.phrases_short.olive": "Oliv.", 207 | "editor.phrases_short.pine": "Borovi", 208 | "editor.phrases_short.plane": "Platan", 209 | "editor.phrases_short.poaceae": "Travin", 210 | "editor.phrases_short.poplar": "Topol", 211 | "editor.phrases_short.ragweed": "Ambr.", 212 | "editor.phrases_short.rye": "Žito", 213 | "editor.phrases_short.trees": "Stromy", 214 | "editor.phrases_short.trees_cat": "Stromy", 215 | "editor.phrases_short.weeds": "Plevely", 216 | "editor.phrases_short.weeds_cat": "Plevely", 217 | "editor.phrases_short.willow": "Vrba", 218 | "editor.phrases_translate_all": "Přeložit vše", 219 | "editor.pollen_threshold": "Práh:", 220 | "editor.preset_reset_all": "Obnovit všechna nastavení", 221 | "editor.region_id": "ID regionu", 222 | "editor.select_all_allergens": "Vybrat všechny alergeny", 223 | "editor.show_empty_days": "Zobrazit prázdné dny", 224 | "editor.show_text_allergen": "Zobrazit text, alergen", 225 | "editor.show_value_numeric": "Zobrazit číselnou hodnotu", 226 | "editor.show_value_numeric_in_circle": "Zobrazit číslo v kruhu", 227 | "editor.show_value_text": "Zobrazit hodnotu jako text", 228 | "editor.show_version": "Zapisovat verzi do konzole", 229 | "editor.sort": "Řazení", 230 | "editor.sort_category_allergens_first": "Seřadit kategorie alergenů nahoře", 231 | "editor.sort_name_ascending": "název, vzestupně", 232 | "editor.sort_name_descending": "název, sestupně", 233 | "editor.sort_none": "žádné (pořadí konfigurace)", 234 | "editor.sort_value_ascending": "hodnota, vzestupně", 235 | "editor.sort_value_descending": "hodnota, sestupně", 236 | "editor.summary_advanced": "Pokročilé", 237 | "editor.summary_allergens": "Alergeny", 238 | "editor.summary_appearance_and_layout": "Vzhled a rozvržení", 239 | "editor.summary_card_interactivity": "Interaktivita karty", 240 | "editor.summary_card_layout_and_colors": "Rozvržení a barvy karty", 241 | "editor.summary_data_view_settings": "Nastavení zobrazení dat", 242 | "editor.summary_day_view_settings": "Nastavení zobrazení dnů", 243 | "editor.summary_entity_prefix_suffix": "Vlastní prefix a suffix", 244 | "editor.summary_functional_settings": "Funkční nastavení", 245 | "editor.summary_integration_and_place": "Integrace a místo", 246 | "editor.summary_minimal": "Minimální", 247 | "editor.summary_title_and_header": "Název a hlavička", 248 | "editor.summary_translation_and_strings": "Překlad a řetězce", 249 | "editor.tap_action": "Akce na klepnutí", 250 | "editor.tap_action_enable": "Povolit akci na klepnutí", 251 | "editor.text_size_ratio": "Poměr velikosti textu (%)", 252 | "editor.title": "Název karty", 253 | "editor.title_automatic": "Automatický název", 254 | "editor.title_hide": "Skrýt název", 255 | "editor.title_placeholder": "(automaticky)", 256 | "editor.to_show_columns": "Počet sloupců k zobrazení", 257 | "editor.to_show_days": "Počet dní k zobrazení", 258 | "editor.to_show_hours": "Počet hodin k zobrazení" 259 | } --------------------------------------------------------------------------------