@noe-teritorio/opening_hours 3.14.1

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