opening_hours 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ // Import all required modules
2
+ import i18next from '../../node_modules/i18next/dist/esm/i18next.bundled.js';
3
+ import { resources, detectLanguage, getUserSelectTranslateHTMLCode, changeLanguage } from './i18n-resources.js';
4
+ import { Evaluate, EX, josm, toggle, dateAtWeek, newValue, currentDateTime } from './helpers.js';
5
+
6
+ // Configuration constants
7
+ window.default_lat = 48.7769;
8
+ window.default_lon = 9.1844;
9
+ window.repo_url = 'https://github.com/opening-hours/opening_hours.js';
10
+
11
+ // Helper function to generate time navigation buttons
12
+ function generateTimeButtons() {
13
+ const buttons = [
14
+ [ 3600 * 24 * 365, 1, 'words.time.year' , 'year' ],
15
+ [ null , 1, 'words.time.month' , 'month' ], // Special handling for month
16
+ [ 3600 * 24 , 1, 'words.time.day' , 'day' ],
17
+ [ 3600 , 1, 'words.time.hour' , 'hour' ],
18
+ [ 60 , 1, 'words.time.minute' , 'minute' ],
19
+ [ 3600 * 24 * 7, 1, 'words.time.week' , 'week' ],
20
+ [ 0 , 0, 'words.time.now' , null ],
21
+ ];
22
+ let html = '';
23
+ for (let i = 0; i < buttons.length; i++) {
24
+ if (buttons[i][1] !== 0) {
25
+ const offset = buttons[i][0];
26
+ const field = buttons[i][3];
27
+ const isEditable = field === 'year' || field === 'day' || field === 'hour' || field === 'minute';
28
+ const labelText = i18next.t(buttons[i][2]);
29
+ const dataAttr = field === 'month' ? 'data-month-offset' : 'data-offset';
30
+ const minusVal = field === 'month' ? -1 : -offset;
31
+ const plusVal = field === 'month' ? 1 : offset;
32
+
33
+ html += `<div class="time-btn-wrapper">
34
+ <label class="time-btn-label-top">${labelText}</label>
35
+ <div class="time-btn-group">
36
+ <button type="button" class="time-btn time-btn-minus" ${dataAttr}="${minusVal}" title="-1 ${labelText}">−</button>
37
+ <span class="time-btn-label${isEditable ? ' editable' : ''}" id="time-btn-value-${field}" ${isEditable ? 'contenteditable="true" spellcheck="false"' : ''} data-field="${field}"></span>
38
+ <button type="button" class="time-btn time-btn-plus" ${dataAttr}="${plusVal}" title="+1 ${labelText}">+</button>
39
+ </div>`;
40
+ // Add weekday display below day button
41
+ if (buttons[i][3] === 'day') {
42
+ html += '<span class="time-btn-label-bottom" id="time-display-wday"></span>';
43
+ }
44
+ html += '</div>';
45
+ } else {
46
+ html += `<button type="button" class="time-btn time-btn-now">${i18next.t(buttons[i][2])}</button>`;
47
+ }
48
+ }
49
+ return html;
50
+ }
51
+
52
+ // Helper function to update time button labels with current values
53
+
54
+ export function updateTimeButtonLabels(date) {
55
+ const yearLabel = document.getElementById('time-btn-value-year');
56
+ const monthLabel = document.getElementById('time-btn-value-month');
57
+ const dayLabel = document.getElementById('time-btn-value-day');
58
+ const hourLabel = document.getElementById('time-btn-value-hour');
59
+ const minuteLabel = document.getElementById('time-btn-value-minute');
60
+ const weekLabel = document.getElementById('time-btn-value-week');
61
+ const wdayDisplay = document.getElementById('time-display-wday');
62
+
63
+ function u2(v) { return v >= 0 && v < 10 ? `0${v}` : v; }
64
+
65
+ if (yearLabel) yearLabel.textContent = currentDateTime.year;
66
+ if (monthLabel) {
67
+ monthLabel.textContent = new Date(2018, currentDateTime.month, 1)
68
+ .toLocaleString(i18next.language, {month: 'short'});
69
+ }
70
+ if (dayLabel) dayLabel.textContent = u2(currentDateTime.day);
71
+ if (hourLabel) hourLabel.textContent = u2(currentDateTime.hour);
72
+ if (minuteLabel) minuteLabel.textContent = u2(currentDateTime.minute);
73
+
74
+ if (weekLabel && date) {
75
+ weekLabel.textContent = `W${u2(dateAtWeek(date, 0) + 1)}`;
76
+ }
77
+
78
+ if (wdayDisplay && date) {
79
+ wdayDisplay.textContent = date.toLocaleString(i18next.language, {weekday: 'short'});
80
+ }
81
+ }
82
+
83
+ // Helper function to generate mode selector options
84
+ function generateModeOptions() {
85
+ let options = '';
86
+ for (let i = 0; i <= 2; i++) {
87
+ options += `<option value="${i}">${i18next.t(`texts.mode ${i}`)}</option>`;
88
+ }
89
+ return options;
90
+ }
91
+
92
+ // Populate all dynamic content with localized text
93
+ function initializeUI() {
94
+ // Page title
95
+ document.getElementById('page-title').textContent = i18next.t('texts.title');
96
+
97
+ // Language selector
98
+ document.getElementById('language-selector').innerHTML = getUserSelectTranslateHTMLCode();
99
+
100
+ // Date and time inputs with navigation buttons only
101
+ document.getElementById('date-time-inputs').innerHTML = `
102
+ <h2>${i18next.t('words.date')} ${i18next.t('words.and')} ${i18next.t('words.time.time')}</h2>
103
+ ` + generateTimeButtons();
104
+
105
+ // Position inputs
106
+ document.getElementById('position-inputs').innerHTML = `
107
+ <h2>${i18next.t('words.position')}</h2>
108
+ ${i18next.t('words.lat')} <input type="number" class="input__coordinate" id="lat" value="${window.default_lat}" />
109
+ ${i18next.t('words.lon')} <input type="number" class="input__coordinate" id="lon" value="${window.default_lon}" />
110
+ ${i18next.t('words.country')} <input size="3" id="cc" readonly="readonly" />
111
+ ${i18next.t('words.state')} <input size="20" id="state" readonly="readonly" /><br />
112
+ `;
113
+
114
+ // Mode selector
115
+ document.getElementById('mode-selector').innerHTML = `
116
+ <h2>${i18next.t('words.mode')}</h2>
117
+ <select id="mode" name="mode" style="max-width:100%;">
118
+ ${generateModeOptions()}
119
+ </select>
120
+ `;
121
+
122
+ // Value section heading
123
+ document.getElementById('value-section-heading').textContent = i18next.t('texts.value for') + ' „opening_hours“';
124
+
125
+ // Value label (hide - redundant with section heading)
126
+ document.getElementById('value-label').style.display = 'none';
127
+
128
+ // Actions section
129
+ document.getElementById('actions-heading').textContent = i18next.t('words.actions');
130
+ document.getElementById('compare-label').textContent = i18next.t('texts.value to compare');
131
+ document.getElementById('action-permalink-label').textContent = i18next.t('texts.share config');
132
+ document.getElementById('permalink-with-timestamp-label').textContent = i18next.t('texts.with timestamp');
133
+ document.getElementById('permalink-without-timestamp-label').textContent = i18next.t('texts.without timestamp');
134
+
135
+ // Set title attributes for all buttons with data-i18n-title
136
+ document.querySelectorAll('[data-i18n-title]').forEach(btn => {
137
+ const key = btn.getAttribute('data-i18n-title');
138
+ btn.title = i18next.t('texts.' + key);
139
+ });
140
+
141
+ // Copy button handlers for permalink copy buttons
142
+ document.querySelectorAll('.copy-permalink-btn').forEach(btn => {
143
+ btn.addEventListener('click', function() {
144
+ const targetId = this.getAttribute('data-target');
145
+ const targetLink = document.getElementById(targetId);
146
+ if (targetLink) {
147
+ navigator.clipboard.writeText(targetLink.href).catch(() => {});
148
+ }
149
+ });
150
+ });
151
+
152
+ // Copy and clear buttons for expression input
153
+ document.getElementById('copy-expression-btn').addEventListener('click', function() {
154
+ const expressionInput = document.getElementById('expression');
155
+ navigator.clipboard.writeText(expressionInput.value).catch(() => {});
156
+ });
157
+
158
+ document.getElementById('clear-expression-btn').addEventListener('click', function() {
159
+ document.getElementById('expression').value = '';
160
+ Evaluate();
161
+ });
162
+
163
+ document.getElementById('clear-diff-btn').addEventListener('click', function() {
164
+ document.getElementById('diff_value').value = '';
165
+ Evaluate();
166
+ });
167
+
168
+ // Results section heading
169
+ document.getElementById('results-heading').textContent = i18next.t('words.results');
170
+
171
+ // Examples header
172
+ document.getElementById('examples').textContent = i18next.t('words.examples');
173
+
174
+ // Year ranges documentation link
175
+ const yearRangesDocu = document.getElementById('year-ranges-docu');
176
+ yearRangesDocu.href = `${window.repo_url}/tree/main#year-ranges`;
177
+ yearRangesDocu.textContent = i18next.t('words.docu');
178
+
179
+ // Example hints
180
+ document.getElementById('hint-error-correction-1').textContent = `(${i18next.t('texts.check out error correction, prettify')})`;
181
+ document.getElementById('hint-error-correction-2').textContent = `(${i18next.t('texts.check out error correction, prettify')})`;
182
+ document.getElementById('hint-ph-mo-fr').textContent = `(${i18next.t('texts.if PH is between Mo and Fr')})`;
183
+ document.getElementById('hint-sh-ph').textContent = `(${i18next.t('texts.SH,PH or PH,SH')})`;
184
+
185
+ // Footer content
186
+ document.getElementById('footer').innerHTML = i18next.t('texts.more information',
187
+ { href: 'https://wiki.openstreetmap.org/wiki/Key:opening_hours' }) + '<br />' +
188
+ i18next.t('texts.this website', { url: window.repo_url, hoster: 'GitHub' });
189
+
190
+ document.body.parentElement.lang = i18next.language;
191
+ }
192
+
193
+ // Set up event listeners using event delegation (only for repetitive handlers)
194
+ function setupEventListeners() {
195
+ const main = document.getElementById('user');
196
+
197
+ // Input field listeners
198
+ document.getElementById('expression').addEventListener('keyup', () => Evaluate());
199
+ document.getElementById('expression').addEventListener('blur', () => Evaluate());
200
+ document.getElementById('diff_value').addEventListener('keyup', () => Evaluate());
201
+ document.getElementById('diff_value').addEventListener('blur', () => Evaluate());
202
+ document.getElementById('lat').addEventListener('blur', () => Evaluate());
203
+ document.getElementById('lon').addEventListener('blur', () => Evaluate());
204
+ document.getElementById('mode').addEventListener('change', () => Evaluate());
205
+
206
+ // Language selector
207
+ document.getElementById('language-select').addEventListener('change', function() {
208
+ changeLanguage(this.value);
209
+ });
210
+
211
+ // Use mousedown instead of click for prettified value elements
212
+ // This prevents interference with browser's text selection behavior
213
+ main.addEventListener('mousedown', (e) => {
214
+ // Prettified value display (code element)
215
+ if (e.target.closest('.prettified-value-display')) {
216
+ e.preventDefault();
217
+ e.stopImmediatePropagation();
218
+ const code = e.target.closest('.prettified-value-display');
219
+ newValue(code.dataset.value);
220
+ }
221
+ // Copy button for prettified value
222
+ else if (e.target.closest('.copy-prettified-btn')) {
223
+ e.preventDefault();
224
+ e.stopImmediatePropagation();
225
+ const btn = e.target.closest('.copy-prettified-btn');
226
+ navigator.clipboard.writeText(btn.dataset.value).catch(() => {});
227
+ }
228
+ // Copy prettified value link
229
+ else if (e.target.closest('.copy-prettified-value')) {
230
+ e.preventDefault();
231
+ e.stopImmediatePropagation();
232
+ const link = e.target.closest('.copy-prettified-value');
233
+ newValue(link.dataset.value);
234
+ }
235
+ // Time jump links/buttons (from opening_hours_table.js)
236
+ else if (e.target.closest('.time-jump, .time-jump-btn')) {
237
+ e.preventDefault();
238
+ e.stopImmediatePropagation();
239
+ const link = e.target.closest('.time-jump, .time-jump-btn');
240
+ Evaluate(parseInt(link.dataset.offset, 10), false);
241
+ }
242
+ });
243
+
244
+ main.addEventListener('click', (e) => {
245
+ // Examples toggle
246
+ if (e.target.closest('#examples-toggle')) {
247
+ e.preventDefault();
248
+ const toggleBtn = e.target.closest('#examples-toggle');
249
+ toggleBtn.classList.toggle('collapsed');
250
+ toggle('user_examples');
251
+ }
252
+ // Example links (60+ handlers → 1 listener)
253
+ else if (e.target.closest('.example-link')) {
254
+ e.preventDefault();
255
+ EX(e.target.closest('.example-link'));
256
+ }
257
+ // Time buttons
258
+ else if (e.target.closest('.time-btn')) {
259
+ const btn = e.target.closest('.time-btn');
260
+ if (btn.classList.contains('time-btn-now')) {
261
+ Evaluate(0, true);
262
+ } else if (btn.hasAttribute('data-month-offset')) {
263
+ // Month: use Date to handle day overflow automatically
264
+ const dt = currentDateTime;
265
+ const targetMonth = dt.month + parseInt(btn.dataset.monthOffset, 10);
266
+ const lastDay = new Date(dt.year, targetMonth + 1, 0).getDate();
267
+ const newDate = new Date(dt.year, targetMonth, Math.min(dt.day, lastDay), dt.hour, dt.minute);
268
+ currentDateTime.year = newDate.getFullYear();
269
+ currentDateTime.month = newDate.getMonth();
270
+ currentDateTime.day = newDate.getDate();
271
+ currentDateTime.hour = newDate.getHours();
272
+ currentDateTime.minute = newDate.getMinutes();
273
+ Evaluate();
274
+ } else {
275
+ Evaluate(parseInt(btn.dataset.offset, 10));
276
+ }
277
+ }
278
+ // JOSM link
279
+ else if (e.target.closest('.josm-link')) {
280
+ e.preventDefault();
281
+ const link = e.target.closest('.josm-link');
282
+ josm(link.dataset.url);
283
+ }
284
+ });
285
+
286
+ // Editable time values
287
+ main.addEventListener('keydown', (e) => {
288
+ if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
289
+ // Allow Enter to commit the value
290
+ if (e.key === 'Enter') {
291
+ e.preventDefault();
292
+ e.target.blur();
293
+ }
294
+ // Allow Escape to cancel
295
+ else if (e.key === 'Escape') {
296
+ e.preventDefault();
297
+ e.target.textContent = e.target.dataset.originalValue || e.target.textContent;
298
+ e.target.blur();
299
+ }
300
+ }
301
+ });
302
+
303
+ main.addEventListener('focus', (e) => {
304
+ if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
305
+ // Store original value for Escape key
306
+ e.target.dataset.originalValue = e.target.textContent;
307
+ // Select all text for easy replacement
308
+ const range = document.createRange();
309
+ range.selectNodeContents(e.target);
310
+ const selection = window.getSelection();
311
+ selection.removeAllRanges();
312
+ selection.addRange(range);
313
+ }
314
+ }, true);
315
+
316
+ main.addEventListener('blur', (e) => {
317
+ if (e.target.classList.contains('time-btn-label') && e.target.hasAttribute('contenteditable')) {
318
+ const field = e.target.dataset.field;
319
+ const value = e.target.textContent.trim();
320
+
321
+ // Validate and update based on field type
322
+ if (field === 'year') {
323
+ const year = parseInt(value, 10);
324
+ if (!isNaN(year) && year >= 1970 && year <= 2100) {
325
+ currentDateTime.year = year;
326
+ Evaluate();
327
+ } else {
328
+ updateTimeButtonLabels();
329
+ }
330
+ } else if (field === 'day') {
331
+ const day = parseInt(value, 10);
332
+ if (!isNaN(day) && day >= 1 && day <= 31) {
333
+ currentDateTime.day = day;
334
+ Evaluate();
335
+ } else {
336
+ updateTimeButtonLabels();
337
+ }
338
+ } else if (field === 'hour') {
339
+ const hour = parseInt(value, 10);
340
+ if (!isNaN(hour) && hour >= 0 && hour <= 23) {
341
+ currentDateTime.hour = hour;
342
+ Evaluate();
343
+ } else {
344
+ updateTimeButtonLabels();
345
+ }
346
+ } else if (field === 'minute') {
347
+ const minute = parseInt(value, 10);
348
+ if (!isNaN(minute) && minute >= 0 && minute <= 59) {
349
+ currentDateTime.minute = minute;
350
+ Evaluate();
351
+ } else {
352
+ updateTimeButtonLabels();
353
+ }
354
+ }
355
+
356
+ delete e.target.dataset.originalValue;
357
+ }
358
+ }, true);
359
+ }
360
+
361
+ /* Initialize application (ES6 modules execute after DOM parsing) */
362
+ await i18next.init({
363
+ lng: detectLanguage(),
364
+ fallbackLng: 'en',
365
+ resources: resources,
366
+ debug: false
367
+ });
368
+
369
+ // Update specification_url after i18next is ready
370
+ window.specification_url = `https://wiki.openstreetmap.org/wiki/${i18next.language === 'de' ? 'DE:' : ''}Key:opening_hours/specification`;
371
+
372
+ // Set page title
373
+ if (document.title !== i18next.t('texts.title')) {
374
+ document.title = i18next.t('texts.title');
375
+ }
376
+
377
+ initializeUI();
378
+ setupEventListeners();
379
+ Evaluate();