/** * Parse a subset of the OSM opening_hours format into FarmaClic's internal * shape: { mon: ["09:00", "21:00"], ..., sun: null }. * * Supports the patterns commonly seen on amenity=pharmacy nodes: * "24/7" * "Mo-Fr 09:00-21:00" * "Mo-Fr 09:00-21:00; Sa 09:00-14:00; Su closed" * "Mo,We,Fr 09:00-14:00" * "Mo-Fr 09:00-13:30,16:30-20:00" (split shifts: opens first, closes last) * * Public-holiday rules (PH/SH) and weeknumber/date filters are ignored. * Returns null if no usable rule was found. * * @param {string} input * @returns {{mon: string[]|null, tue: string[]|null, wed: string[]|null, thu: string[]|null, fri: string[]|null, sat: string[]|null, sun: string[]|null}|null} */ export function parseOsmOpeningHours(input) { if (!input || typeof input !== 'string') return null; const cleaned = input.trim().replace(/\s+/g, ' '); if (!cleaned) return null; const result = { mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null }; if (/^24\s*\/\s*7$/i.test(cleaned)) { for (const k of Object.keys(result)) result[k] = ['00:00', '24:00']; return result; } const rules = cleaned.split(';').map(r => r.trim()).filter(Boolean); let anySet = false; for (const rule of rules) { const ruleNoComment = rule.replace(/\([^)]*\)/g, '').trim(); if (!ruleNoComment) continue; const lower = ruleNoComment.toLowerCase(); if (lower === 'closed' || lower === 'off') continue; const tokens = ruleNoComment.split(/\s+/); const dayPart = tokens.shift(); const timePart = tokens.join(' ').trim(); if (!dayPart) continue; const days = expandDayPart(dayPart); if (!days.length) continue; if (!timePart || /^(off|closed)$/i.test(timePart)) { for (const d of days) result[d] = null; anySet = true; continue; } const range = collapseTimeRanges(timePart); if (!range) continue; for (const d of days) result[d] = range; anySet = true; } return anySet ? result : null; } const DAY_CODE_TO_KEY = { mo: 'mon', tu: 'tue', we: 'wed', th: 'thu', fr: 'fri', sa: 'sat', su: 'sun' }; const DAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; function expandDayPart(dayPart) { const out = new Set(); for (const segment of dayPart.split(',')) { const seg = segment.trim(); if (!seg) continue; if (/^(ph|sh)(-(ph|sh))?$/i.test(seg)) continue; const rangeMatch = seg.match(/^([A-Za-z]{2})-([A-Za-z]{2})$/); if (rangeMatch) { const a = DAY_CODE_TO_KEY[rangeMatch[1].toLowerCase()]; const b = DAY_CODE_TO_KEY[rangeMatch[2].toLowerCase()]; if (!a || !b) continue; const ai = DAY_ORDER.indexOf(a); const bi = DAY_ORDER.indexOf(b); if (ai === -1 || bi === -1) continue; if (ai <= bi) { for (let i = ai; i <= bi; i++) out.add(DAY_ORDER[i]); } else { for (let i = ai; i < DAY_ORDER.length; i++) out.add(DAY_ORDER[i]); for (let i = 0; i <= bi; i++) out.add(DAY_ORDER[i]); } continue; } const single = DAY_CODE_TO_KEY[seg.toLowerCase()]; if (single) out.add(single); } return [...out]; } function collapseTimeRanges(timePart) { const matches = [...timePart.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; if (matches.length === 0) return null; const first = matches[0]; const last = matches[matches.length - 1]; const open = `${first[1].padStart(2, '0')}:${first[2]}`; const close = `${last[3].padStart(2, '0')}:${last[4]}`; return [open, close]; }