type, longest aliases first public function __construct(?string $file = null) { $file = $file ?? dirname(__DIR__) . '/data/rooms-global.json'; $this->d = json_decode(is_readable($file) ? (string)file_get_contents($file) : '', true) ?: []; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a file bundled with the plugin. if (empty($this->d['rooms'])) throw new \RuntimeException('Room rules missing'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- message is never printed. foreach ($this->d['rooms'] as $type => $r) { foreach ($r['aliases'] as $a) { $this->aliasIndex[$a] = $type; $this->aliasIndex[self::clean($a)] ??= $type; } } uksort($this->aliasIndex, fn($a, $b) => strlen($b) <=> strlen($a)); } public function version(): string { return $this->d['version'] ?? '?'; } public function room(string $type): ?array { return $this->d['rooms'][$type] ?? null; } public function entrance(): array { return $this->d['entrance']; } public function direction(string $dir): array { return $this->d['directions'][$dir] ?? []; } public function centre(): array { return $this->d['centre']; } public function basement(): array { return $this->d['basement']; } public function basisNote(string $basis): string { return $this->d['basis_note'][$basis] ?? ''; } public function zones16(): array { return $this->d['zones16']; } public function swaps(): array { return $this->d['swaps']; } public function allRooms(): array { return $this->d['rooms']; } /** Overall score ceiling when a home has a major defect (59 = top of "Needs attention"). */ public function majorCap(): int { return (int)($this->d['major_defects']['cap'] ?? 100); } public function majorNote(): string { return $this->d['major_defects']['note'] ?? ''; } /** The major-defect rule a room type in a direction breaks, or null. Type "entrance" is the main door. */ public function majorDefect(string $type, string $dir): ?array { foreach ($this->d['major_defects']['rules'] ?? [] as $rule) { if ($rule['type'] === $type && in_array($dir, $rule['directions'], true)) return $rule; } return null; } /** Clean a label read off a plan: "BED 2 12'x14'" → "bed 2". */ public static function clean(string $name): string { $s = mb_strtolower($name); $s = strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', // German, French, Italian, 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'å' => 'a', 'ç' => 'c', 'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i', // Portuguese and Swedish 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'õ' => 'o', 'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ñ' => 'n', '’' => "'"]); // plan labels static $cjk = [ // Japanese and Chinese (Hong Kong) plan labels, read as their English names 'ウォークインクローゼット' => ' walk-in closet ', 'ユニットバス' => ' unit bath ', 'クローゼット' => ' closet ', 'バルコニー' => ' balcony ', 'ベランダ' => ' balcony ', 'キッチン' => ' kitchen ', 'ダイニング' => ' dining ', 'リビング' => ' living room ', 'トイレ' => ' toilet ', 'ガレージ' => ' garage ', '洗面脱衣所' => ' senmenjo ', '洗面所' => ' senmenjo ', '脱衣所' => ' senmenjo ', '子供部屋' => ' kids room ', '主寝室' => ' master bedroom ', '寝室' => ' bedroom ', '洋室' => ' western-style room ', '和室' => ' tatami room ', '玄関' => ' genkan ', '廊下' => ' hallway ', '浴室' => ' bathroom ', '風呂' => ' bathroom ', '便所' => ' toilet ', '台所' => ' kitchen ', '居間' => ' living room ', '押入れ' => ' oshiire ', '押入' => ' oshiire ', '納戸' => ' nando ', '収納' => ' storage ', '物置' => ' storage ', '仏間' => ' butsuma ', '書斎' => ' study ', '駐車場' => ' parking ', '階段' => ' stairs ', '庭' => ' garden ', '客飯廳' => ' living / dining ', '客廳' => ' living room ', '飯廳' => ' dining ', '主人房' => ' master bedroom ', '睡房' => ' bedroom ', '廚房' => ' kitchen ', '洗手間' => ' toilet ', '工人房' => " maid's room ", '平台' => ' terrace ', '露台' => ' balcony ', '儲物室' => ' store room ', '儲物房' => ' store room ', '雜物房' => ' store room ', '書房' => ' study ', '衣帽間' => ' walk-in closet ', '玄關' => ' foyer ', '走廊' => ' corridor ', '洗衣房' => ' laundry ']; $s = strtr($s, $cjk); $s = preg_replace("/\d+\s*['′]\s*-?\s*\d*\s*(\"|″)?\s*[x×]\s*\d+\s*['′]?\s*-?\s*\d*\s*(\"|″)?/u", ' ', $s); // 12'x14' / 12'6" x 10' $s = preg_replace('/\d+(\.\d+)?\s*(m|sq ?ft|ft|sqm|m2|m²)\b/u', ' ', $s); $s = preg_replace('/[^a-z0-9\'\-\/& ]+/u', ' ', $s); $s = trim(preg_replace('/\s+/', ' ', $s)); // common plan abbreviations (US, Canada, Australia) static $abbr = ['rm' => 'room', 'mstr' => 'master', 'mbr' => 'master bedroom', 'pri' => 'primary', 'bdrm' => 'bedroom', 'bd' => 'bedroom', 'kit' => 'kitchen', 'ktchn' => 'kitchen', 'din' => 'dining', 'liv' => 'living', 'fam' => 'family', 'lndry' => 'laundry', 'ldry' => 'laundry', 'gar' => 'garage', 'ofc' => 'office', 'ofs' => 'office', 'pdr' => 'powder', 'pwdr' => 'powder', 'ba' => 'bath', 'clo' => 'closet', 'clos' => 'closet', 'stor' => 'storage', 'mech' => 'mechanical', 'ent' => 'entry', 'entr' => 'entry', 'vest' => 'vestibule', 'ens' => 'ensuite', 'alf' => 'alfresco', 'pty' => 'pantry', 'wip' => 'walk-in pantry', 'gst' => 'guest', 'brkfst' => 'breakfast', 'util' => 'utility', 'liv/din' => 'living']; $s = implode(' ', array_map(fn($w) => $abbr[$w] ?? $w, explode(' ', $s))); return $s; } /** Room type for a plan label, or null if we don't recognise it. */ public function match(string $name): ?string { $s = self::clean($name); if ($s === '') return null; if (isset($this->aliasIndex[$s])) return $this->aliasIndex[$s]; $s2 = trim(preg_replace('/\s*\d+$/', '', $s)); // "bedroom 3" → "bedroom" if (isset($this->aliasIndex[$s2])) return $this->aliasIndex[$s2]; foreach ($this->aliasIndex as $alias => $type) { // longest alias contained as whole words if (strlen($alias) >= 3 && preg_match('/(^|[\s\/&-])' . preg_quote($alias, '/') . '($|[\s\/&-])/', $s)) return $type; } return null; } public static function rating(int $score): array { if ($score >= 80) return ['id' => 'strong', 'label' => 'Strong']; if ($score >= 60) return ['id' => 'good', 'label' => 'Good']; if ($score >= 40) return ['id' => 'attention', 'label' => 'Needs attention']; return ['id' => 'priority', 'label' => 'Priority']; } /** Remedies allowed for the owner's situation: renters get no-cost and low-cost steps only. */ public static function remediesFor(array $remedies, string $ownership): array { $allowed = $ownership === 'rent' ? ['free', 'low'] : ['free', 'low', 'structural']; $out = []; foreach ($remedies as [$tier, $text]) { if (in_array($tier, $allowed, true)) $out[] = ['tier' => $tier, 'text' => $text]; } return $out; } }