PageSourceSearch

https://www.juditvarga.com/content-renderers.js?v=9bd54f379a0eaaf4

js juditvarga.com collected 2026-10-07 21:25:19 UTC 36,781 bytes, 744 lines download raw bytes

1/* Shared rendering functions. The static build uses the same templates as the browser. */
2function searchValueText(value){
3  if(value == null) return '';
4  if(Array.isArray(value)) return value.map(searchValueText).join(' ');
5  if(typeof value === 'object'){
6    return [value.label, value.url, value.text].filter(Boolean).join(' ');
7  }
8  return String(value);
9}
10
11function catalogueSlug(work){
12  return work.slug || work.title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
13}
14
15function filmSlug(work){
16  return work.slug || work.title.toLowerCase()
17    .normalize('NFD').replace(/[\u0300-\u036f]/g,'')
18    .replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
19}
20
21function flatCatalogueWorks(){
22  const seen = new Set();
23  const all = [];
24  Object.entries(MUSIC_WORKS).forEach(([category, works]) => {
25    works.forEach(work => {
26      // De-duplicate cross-listed works by title+year for the full catalogue
27      const key = `${work.title}__${work.year || ''}`;
28      if(seen.has(key)) return;
29      seen.add(key);
30      all.push({...work, __category: category});
31    });
32  });
33  return all;
34}
35
36function filmUniqueWorks(){
37  const map = new Map();
38  Object.entries(FILM_WORKS).forEach(([category,works]) => {
39    works.forEach(work => {
40      const slug = filmSlug(work);
41      if(!map.has(slug)){
42        map.set(slug, {...work, __categories:[category], __primaryCategory:category});
43      } else {
44        const item = map.get(slug);
45        if(!item.__categories.includes(category)) item.__categories.push(category);
46      }
47    });
48  });
49  return [...map.values()];
50}
51
52function fieldHTML(value, field){
53  const escapeHTML = str => String(str)
54    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
55    .replace(/"/g,'&quot;').replace(/'/g,'&#039;');
56
57  const linkHTML = item => {
58    const label = escapeHTML(item.label || 'Open ↗');
59    const url = escapeHTML(item.url || '#');
60    return `<a class="media-fallback" href="${url}" target="_blank" rel="noreferrer">${label}</a>`;
61  };
62
63  const performanceDate = label => {
64    const head = String(label || '').split('·')[0].trim();
65    const months = {january:0,february:1,march:2,april:3,may:4,june:5,july:6,august:7,september:8,october:9,november:10,december:11};
66    const monthPattern = Object.keys(months).join('|');
67    let match = head.match(new RegExp(`^(?:\\d{1,2}[–-])?(\\d{1,2}) (${monthPattern}) (\\d{4})$`,'i'));
68    if(match){
69      const year=Number(match[3]),month=months[match[2].toLowerCase()],day=Number(match[1]);
70      const date=new Date(Date.UTC(year,month,day)); return {start:date,end:date};
71    }
72    match = head.match(new RegExp(`^(${monthPattern}) (\\d{4})$`,'i'));
73    if(match){
74      const year=Number(match[2]),month=months[match[1].toLowerCase()];
75      return {start:new Date(Date.UTC(year,month,1)),end:new Date(Date.UTC(year,month+1,0))};
76    }
77    match = head.match(/^(\\d{4})$/);
78    if(match){const year=Number(match[1]);return {start:new Date(Date.UTC(year,0,1)),end:new Date(Date.UTC(year,11,31))};}
79    return null;
80  };
81
82  const performanceHTML = entries => {
83    const todayParts = new Intl.DateTimeFormat('en-GB',{timeZone:'Europe/Vienna',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date());
84    const part = type => Number(todayParts.find(item=>item.type===type)?.value || 0);
85    const today = new Date(Date.UTC(part('year'),part('month')-1,part('day')));
86    const rows = entries.map((item,index)=>{
87      const object = item && typeof item === 'object' ? item : {label:item};
88      const label = String(object.label || '');
89      return {object,label,index,bounds:performanceDate(label)};
90    });
91    const sort = group => group.sort((a,b)=>(a.bounds?.start-a.bounds?.start)||a.index-b.index);
92    const upcoming = sort(rows.filter(row=>row.bounds && row.bounds.end>=today));
93    const past = sort(rows.filter(row=>row.bounds && row.bounds.end<today));
94    const undated = rows.filter(row=>!row.bounds);
95    const rowHTML = (row,isUpcoming) => {
96      const parts = row.label.split('·').map(text=>text.trim()).filter(Boolean);
97      const date = parts.shift() || row.label;
98      const body = `<span class="performance-date">${escapeHTML(date)}</span>${isUpcoming?'<small class="performance-status">Upcoming performance</small>':''}${parts.length?`<span class="performance-details">${escapeHTML(parts.join(' · '))}</span>`:''}`;
99      return row.object.url
100        ? `<a class="performance-row" href="${escapeHTML(row.object.url)}" target="_blank" rel="noreferrer">${body}</a>`
101        : `<span class="performance-row">${body}</span>`;
102    };
103    return `<div class="performance-list">${upcoming.map(row=>rowHTML(row,true)).join('')}${past.map(row=>rowHTML(row,false)).join('')}${undated.map(row=>rowHTML(row,false)).join('')}</div>`;
104  };
105
106
107  if(field === 'gallery'){
108    const images = value && Array.isArray(value.images) ? value.images : [];
109    if(!images.length) return '';
110    const credit = value.credit ? `<p class="work-gallery-credit">${escapeHTML(value.credit)}</p>` : '';
111    return `<div class="work-gallery${value.kind === 'score' ? ' work-gallery--score' : value.kind === 'mixed' ? ' work-gallery--mixed' : ''}">${images.map((img, i) => {
112      const image = window.PrivacyMedia.galleryItem(img.src || '', img.alt || `Gallery image ${i+1}`, 'work-gallery-item' + (img.kind === 'score' ? ' work-gallery-item--score' : ''));
113      if(!img.credit) return image;
114      const caption = img.creditUrl ? `<a href="${escapeHTML(img.creditUrl)}" target="_blank" rel="noreferrer">${escapeHTML(img.credit)}</a>` : escapeHTML(img.credit);
115      return `<figure class="work-gallery-figure">${image}<figcaption>${caption}</figcaption></figure>`;
116    }).join('')}</div>${credit}`;
117  }
118
119  if(field === 'score'){
120    if(Array.isArray(value) && value.length){
121      return `<div class="score-links">${value.map(linkHTML).join('')}</div>`;
122    }
123    if(value && typeof value === 'object' && value.url){
124      return `<div class="score-links">${linkHTML(value)}</div>`;
125    }
126    const existing = value ? `<div class="score-existing">${escapeHTML(value)}</div>` : '';
127    return `${existing}<a class="composer-material-link" href="mailto:[email protected]">${composerMaterialNote}</a>`;
128  }
129
130  if(!value) return '';
131  if(field === 'movements'){
132    if(!Array.isArray(value) || !value.length) return '';
133    return `<ol class="work-movements" type="I">${value.map(title => `<li>${escapeHTML(title)}</li>`).join('')}</ol>`;
134  }
135  if(field === 'performances'){
136    const entries = Array.isArray(value) ? value : [value];
137    return performanceHTML(entries.filter(Boolean));
138  }
139  if(field === 'description' || field === 'instrumentation' || field === 'production') return String(value);
140
141  function youtubeEmbed(u){
142    try{
143      const parsed = new URL(u);
144      const playlist = parsed.searchParams.get('list') || '';
145      if(playlist) return `https://www.youtube-nocookie.com/embed/videoseries?list=${encodeURIComponent(playlist)}`;
146      let id = '';
147      if(parsed.hostname.includes('youtu.be')) id = parsed.pathname.replace('/','');
148      else if(parsed.hostname.includes('youtube.com')) id = parsed.searchParams.get('v') || '';
149      if(!id && parsed.pathname.includes('/embed/')) id = parsed.pathname.split('/embed/')[1].split('/')[0];
150      return id ? `https://www.youtube-nocookie.com/embed/${id}` : '';
151    }catch(e){ return ''; }
152  }
153
154  function vimeoEmbed(u){
155    try{
156      const parsed = new URL(u);
157      if(!parsed.hostname.includes('vimeo.com')) return '';
158      const id = parsed.pathname.split('/').filter(Boolean).find(part => /^\d+$/.test(part));
159      return id ? `https://player.vimeo.com/video/${id}` : '';
160    }catch(e){ return ''; }
161  }
162
163  function mediaItemHTML(item, mediaField){
164    if(typeof item === 'string') item = {label:item};
165    const url = item.url || '';
166    const embedUrl = item.embedUrl || '';
167    const label = item.label || String(item);
168
169    if(item.linkOnly && url){
170      return `<a class="media-fallback" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">${escapeHTML(label || 'Open ↗')}</a>`;
171    }
172
173    if(mediaField === 'audio' && Array.isArray(item.tracks) && item.tracks.length){
174      const duration = seconds => { const n = Math.max(0, Math.round(Number(seconds) || 0)); return `${Math.floor(n / 60)}:${String(n % 60).padStart(2, '0')}`; };
175      return `<section class="soundtrack-playlist media-embed" aria-label="${escapeHTML(label)}">
176        <h3 class="soundtrack-title">${escapeHTML(label)}</h3>
177        <p class="soundtrack-now" aria-live="polite">Choose a track to listen</p>
178        <audio controls preload="none" aria-label="Soundtrack audio player"></audio>
179        <div class="soundtrack-navigation"><button type="button" data-soundtrack-step="-1" disabled aria-label="Previous track">← Previous</button><button type="button" data-soundtrack-step="1" aria-label="Next track">Next →</button></div>
180        <p class="soundtrack-status" role="status"></p>
181        <ol class="soundtrack-tracks">${item.tracks.map((track, i) => `<li><button type="button" data-soundtrack-track="${i}" data-audio-src="${escapeHTML(track.url || '')}" aria-label="Play ${escapeHTML(track.label || `Track ${i+1}`)}"><span class="soundtrack-number">${String(i+1).padStart(2, '0')}</span><span class="soundtrack-track-title">${escapeHTML(track.label || `Track ${i+1}`)}</span><span class="soundtrack-duration">${duration(track.duration)}</span></button></li>`).join('')}</ol>
182        ${url ? `<a class="soundtrack-open" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">Open soundtrack ↗</a>` : ''}
183      </section>`;
184    }
185
186    if(item.localVideo && url){
187      const poster = typeof item.poster === 'string' && /^media\/[a-zA-Z0-9/_%.-]+\.(?:webp|jpe?g|png|avif)$/i.test(item.poster)
188        ? ` poster="${escapeHTML(item.poster)}"` : '';
189      return `<figure class="media-embed video local-video"><video controls playsinline preload="none"${poster}><source src="${escapeHTML(url)}" type="video/mp4"></video><figcaption>${escapeHTML(label)}</figcaption></figure>`;
190    }
191
192    if(mediaField === 'audio' && /^media\/[a-zA-Z0-9/_%.-]+\.mp3$/i.test(url)){
193      return `<figure class="media-embed audio local-audio"><audio controls preload="none" aria-label="${escapeHTML(label)}"><source src="${escapeHTML(url)}" type="audio/mpeg"></audio><figcaption>${escapeHTML(label)}</figcaption></figure>`;
194    }
195
196    if(embedUrl){
197      const kind = mediaField === 'video' ? 'video' : 'audio';
198      return window.PrivacyMedia.player(embedUrl, kind, label, url);
199    }
200
201    if(url){
202      const yt = youtubeEmbed(url);
203      if(yt){
204        return window.PrivacyMedia.player(yt, 'video', label, url);
205      }
206      const vm = vimeoEmbed(url);
207      if(vm){
208        return window.PrivacyMedia.player(vm, 'video', label, url);
209      }
210      if(url.includes('soundcloud.com/')){
211        const player = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(url) + '&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&visual=false';
212        return window.PrivacyMedia.player(player, 'audio', label, url);
213      }
214      return `<a class="media-fallback" href="${escapeHTML(url)}" target="_blank" rel="noreferrer">${escapeHTML(label || 'Open ↗')}</a>`;
215    }
216    return `<span>${escapeHTML(label)}</span>`;
217  }
218
219  if(field === 'videoSnippets' && Array.isArray(value)){
220    return `<div class="media-snippet-row">${value.map(item => mediaItemHTML(item, 'video')).join('')}</div>`;
221  }
222
223  if((field === 'video' || field === 'audio') && Array.isArray(value)){
224    if(field === 'video' && value.some(item => item && typeof item === 'object' && item.snippet)){
225      const regular = value.filter(item => !(item && typeof item === 'object' && item.snippet));
226      const snippets = value.filter(item => item && typeof item === 'object' && item.snippet);
227      const regularHTML = regular.length ? `<div class="media-stack">${regular.map(item => mediaItemHTML(item, field)).join('')}</div>` : '';
228      const snippetHTML = snippets.length ? `<div class="media-snippet-row">${snippets.map(item => mediaItemHTML(item, field)).join('')}</div>` : '';
229      return regularHTML + snippetHTML;
230    }
231    return `<div class="media-stack">${value.map(item => mediaItemHTML(item, field)).join('')}</div>`;
232  }
233
234  if(field === 'video' || field === 'audio'){
235    return mediaItemHTML(value, field);
236  }
237
238  if(Array.isArray(value)){
239    return `<div class="field-list">${value.map(item => {
240      if(item && typeof item === 'object' && item.url) return linkHTML(item);
241      const label = item && typeof item === 'object' ? (item.label || '') : item;
242      return `<span>${escapeHTML(label)}</span>`;
243    }).join('')}</div>`;
244  }
245
246  if(value && typeof value === 'object'){
247    if(value.url) return linkHTML(value);
248    return escapeHTML(value.label || '');
249  }
250
251  return escapeHTML(value);
252}
253
254function latestYearValue(year){
255  const s = String(year || '');
256  const values = [...s.matchAll(/\b(?:19|20)\d{2}\b/g)].map(m => Number(m[0]));
257  const shortRange = s.match(/((?:19|20)\d{2})\s*[\/–-]\s*(\d{2})(?!\d)/);
258  if(shortRange){
259    const first = Number(shortRange[1]);
260    const second = Math.floor(first / 100) * 100 + Number(shortRange[2]);
261    values.push(second < first ? second + 100 : second);
262  }
263  return values.length ? Math.max(...values) : 0;
264}
265
266function sortWorksNewest(a,b){
267  return latestYearValue(b.year) - latestYearValue(a.year) || a.title.localeCompare(b.title);
268}
269
270function renderCategory(category){
271  activeCategory = category;
272  activeWorkMode = 'music';
273  categoryTitle.innerHTML = category === 'Solo, Chamber Music and Ensemble'
274    ? 'SOLO<br>CHAMBER MUSIC<br>ENSEMBLE' : newsEscape(category);
275  const works = [...(MUSIC_WORKS[category] || [])].sort(sortWorksNewest);
276
277  if(!works.length){
278    categoryList.innerHTML = `<div class="category-empty">No works are currently published in this category.</div>`;
279    return;
280  }
281
282  const workButton = (work, index) => `
283    <a class="category-work-row" href="${SiteRoutes.pathFor(`#music/${encodeURIComponent(category)}/${encodeURIComponent(catalogueSlug(work))}`)}" data-work-index="${index}">
284      <span>${work.title}</span>
285      <small>${work.year || ''}</small>
286      <b aria-hidden="true">↗</b>
287    </a>`;
288
289  const sectionOrders = {
290    "Orchestra": ["Solo & Orchestra", "Orchestra"],
291    "String Orchestra": ["Soloists & String Orchestra", "String Orchestra"],
292    "Voices": ["Choir", "Solo voice (ad lib. accompaniment)"],
293    "Solo, Chamber Music and Ensemble": ["Solo", "Chamber Music", "Ensemble"]
294  };
295
296  if(sectionOrders[category]){
297    const indexByWork = new Map(works.map((w,i)=>[w,i]));
298    categoryList.innerHTML = sectionOrders[category].map(section => {
299      const sectionWorks = works.filter(w => w.section === section);
300      if(!sectionWorks.length) return '';
301      return `
302        <section class="work-subsection">
303          <h3 class="${section.split(/\s+/).some(word => word.replace(/[^\p{L}]/gu, '').length >= 12) ? 'is-long-title' : ''}">${section}</h3>
304          <div class="work-subsection-list">
305            ${sectionWorks.map(w => workButton(w, indexByWork.get(w))).join('')}
306          </div>
307        </section>`;
308    }).join('');
309  } else {
310    categoryList.innerHTML = works.map((work, index) => workButton(work, index)).join('');
311  }
312
313  categoryList.querySelectorAll('.category-work-row').forEach(btn => {
314    btn.addEventListener('click', () => openWork(category, works[Number(btn.dataset.workIndex)]));
315  });
316}
317
318function configureWorkLabels(mode, work = {}){
319  const stage = mode === 'film' && !!work.stageCategory;
320  const labels = mode === 'film' ? {
321    year:'Year',
322    duration:'Runtime',
323    format:'Format',
324    director:'Director',
325    commission:'Commission',
326    award:'Awards / nominations',
327    production:'Credits / production',
328    instrumentation:'Instrumentation / setup',
329    performers:stage ? 'Premiere / first performance' : 'Premiere / release',
330    performances:stage ? 'Further performances' : 'Festivals / screenings',
331    recording:stage ? 'Recording / release' : 'Soundtrack / release',
332    source:'Sources'
333  } : {
334    year:'Year of composition',
335    duration:'Duration',
336    format:'Format',
337    director:'Director',
338    commission:'Commission',
339    award:'Awards / prizes',
340    production:'Production',
341    instrumentation:'Instrumentation',
342    performers:'Premiere',
343    performances:'Further performances',
344    recording:'Recording / release',
345    source:'Production / source'
346  };
347  Object.entries(labels).forEach(([field,label])=>{
348    const span = workPage.querySelector(`[data-info-field="${field}"] > span`);
349    if(span) span.textContent = label;
350  });
351}
352
353function populateWorkPage(work, mode='music'){
354  window.EditorialLayout?.resetWork();
355  activeWorkMode = mode;
356  configureWorkLabels(mode, work);
357  workTitle.textContent = work.title;
358
359  const videoItems = Array.isArray(work.video) ? work.video : [];
360  const videoSnippets = videoItems.filter(item => item && typeof item === 'object' && item.snippet);
361  const regularVideo = videoItems.length
362    ? videoItems.filter(item => !(item && typeof item === 'object' && item.snippet))
363    : work.video;
364
365  workPage.classList.toggle('film-mode', mode === 'film');
366  workPage.classList.toggle('has-portrait-snippets', videoSnippets.length > 0);
367
368  const values = {
369    year: work.year,
370    duration: work.duration,
371    format: work.format,
372    director: work.director,
373    instrumentation: work.instrumentation,
374    dedication: work.dedication,
375    commission: work.commission,
376    award: work.award,
377    status: work.status,
378    altTitle: work.altTitle,
379    text: work.text,
380    description: work.description,
381    movements: work.movements,
382    production: work.production,
383    performers: work.performers,
384    recording: work.recording,
385    performances: work.performances,
386    score: work.score,
387    audio: work.audio,
388    video: regularVideo,
389    videoSnippets: videoSnippets.length ? videoSnippets : null,
390    review: work.review,
391    source: work.source,
392    gallery: work.gallery
393  };
394
395  workPage.querySelectorAll('[data-field]').forEach(el => {
396    const field = el.dataset.field;
397    const value = values[field];
398    const cell = workPage.querySelector(`[data-info-field="${field}"]`);
399    const hideScore = mode === 'film' && !value;
400    if((!value && field !== 'score') || hideScore){
401      if(cell) cell.hidden = true;
402      el.innerHTML = '';
403      return;
404    }
405    if(cell) cell.hidden = false;
406    el.innerHTML = fieldHTML(value, field);
407  });
408  window.EditorialLayout?.arrangeWork(work, mode);
409}
410
411const filmCategoryDisplay = {
412  'Feature Film':'FEATURE<br>FILM',
413  'Documentary':'DOCUMENTARY',
414  'Short / Experimental Film':'SHORT<br>+EXPERIMENTAL FILM',
415  'Series & Television':'SERIES<br>+TELEVISION',
416  'Theatre / Dance':'THEATRE<br>+DANCE',
417  'Multimedia':'MULTIMEDIA'
418};
419const stageCategoryOrder = ['Opera', 'Dance', 'Theatre'];
420function stageWorkCategory(work){
421  if(stageCategoryOrder.includes(work.stageCategory)) return work.stageCategory;
422  if(/opera/i.test(work.format || work.subgroup || '')) return 'Opera';
423  if(/dance/i.test(work.format || '')) return 'Dance';
424  return 'Theatre';
425}
426function renderFilmCategory(category){
427  activeFilmCategory = category;
428  activeCategory = null;
429  activeWorkMode = 'film';
430  filmCategoryTitle.innerHTML = filmCategoryDisplay[category] || newsEscape(category);
431  const works = [...(FILM_WORKS[category] || [])].sort(sortWorksNewest);
432
433  if(!works.length){
434    filmCategoryList.innerHTML = `<div class="category-empty">No works are currently published in this category.</div>`;
435    return;
436  }
437
438  const workRow = (work, index) => `
439    <a class="category-work-row" href="${SiteRoutes.pathFor(`#film/${encodeURIComponent(category)}/${encodeURIComponent(filmSlug(work))}`)}" data-film-index="${index}">
440      <span>${work.title}</span>
441      <small>${work.year || ''}</small>
442      <b aria-hidden="true">↗</b>
443    </a>`;
444  filmCategoryList.innerHTML = category === 'Theatre / Dance'
445    ? stageCategoryOrder.map(group => {
446      const groupWorks = works.filter(work => stageWorkCategory(work) === group);
447      return groupWorks.length ? `<section class="work-subsection" data-stage-category="${group}"><h3>${group}</h3><div class="work-subsection-list">${groupWorks.map(work => workRow(work, works.indexOf(work))).join('')}</div></section>` : '';
448    }).join('')
449    : works.map(workRow).join('');
450
451  filmCategoryList.querySelectorAll('[data-film-index]').forEach(btn => {
452    btn.addEventListener('click', () =>
452 openFilmWork(category, works[Number(btn.dataset.filmIndex)]));
453  });
454}
455
456function uniqueCatalogueMeta(values){
457  const seen = new Set();
458  return values.filter(value => {
459    const key = String(value || '').trim().replace(/\s+/g,' ').toLocaleLowerCase('en');
460    if(!key || seen.has(key)) return false;
461    seen.add(key);
462    return true;
463  });
464}
465
466function renderCatalogue(){
467  const query = (catalogueSearch.value || '').trim().toLowerCase();
468  const category = catalogueCategoryFilter.value;
469  const instrument = catalogueInstrumentFilter.value;
470
471  const all = flatCatalogueWorks();
472  const filtered = all.filter(work => {
473    const text = [
474      work.title, work.altTitle, work.year, work.instrumentation,
475      work.section, work.subgroup,
476      ...(work.catalogueTags || []), ...(work.searchTerms || [])
477    ].filter(Boolean).join(' ').toLowerCase();
478
479    const categoryOK = !category || work.__category === category;
480    const instrumentOK = !instrument || (work.catalogueTags || []).includes(instrument);
481    const searchOK = !query || text.includes(query);
482    return categoryOK && instrumentOK && searchOK;
483  });
484
485  catalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'work' : 'works'}`;
486
487  if(!filtered.length){
488    catalogueList.innerHTML = `<div class="catalogue-empty">No works match the current filters.</div>`;
489    return;
490  }
491
492  const categoryOrder = [
493    "Orchestra",
494    "String Orchestra",
495    "Voices",
496    "Solo, Chamber Music and Ensemble",
497    "Opera",
498    "Self-Playing Instruments and Disklavier"
499  ];
500
501  catalogueList.innerHTML = categoryOrder.map(cat => {
502    const works = filtered.filter(w => w.__category === cat);
503    if(!works.length) return '';
504    works.sort(sortWorksNewest);
505    return `
506      <section class="catalogue-group">
507        <h3 class="catalogue-group-title">${cat === 'Solo, Chamber Music and Ensemble' ? 'SOLO<br>CHAMBER MUSIC<br>ENSEMBLE' : newsEscape(cat)}</h3>
508        ${works.map(work => {
509          const meta = uniqueCatalogueMeta([
510            work.section,
511            ...(work.catalogueTags || []).filter(t => ![cat, work.title].some(value => String(t).toLocaleLowerCase('en') === value.toLocaleLowerCase('en')))
512          ]).slice(0,6);
513          return `
514            <a class="catalogue-work-row" href="${SiteRoutes.pathFor(`#music/${encodeURIComponent(work.__category)}/${encodeURIComponent(catalogueSlug(work))}`)}"
515              data-category="${encodeURIComponent(work.__category)}"
516              data-tags="${newsEscape((work.catalogueTags || []).join('|'))}"
517              data-slug="${encodeURIComponent(catalogueSlug(work))}">
518              <span class="catalogue-work-title">${work.title}</span>
519              <span class="catalogue-work-meta">${meta.map(x => `<span>${newsEscape(x)}</span>`).join('')}</span>
520              <span class="catalogue-work-year">${work.year || ''}</span>
521            </a>`;
522        }).join('')}
523      </section>`;
524  }).join('');
525
526  catalogueList.querySelectorAll('.catalogue-work-row').forEach(btn => {
527    btn.addEventListener('click', () => {
528      const cat = decodeURIComponent(btn.dataset.category);
529      const slug = decodeURIComponent(btn.dataset.slug);
530      const work = (MUSIC_WORKS[cat] || []).find(w => catalogueSlug(w) === slug);
531      if(!work) return;
532      cataloguePage.classList.remove('open');
533      cataloguePage.setAttribute('aria-hidden','true');
534      openWork(cat, work);
535    });
536  });
537  window.EditorialLayout?.arrangeCatalogue();
538}
539
540function renderFilmCatalogue(){
541  const query = (filmCatalogueSearch.value || '').trim().toLowerCase();
542  const category = filmCatalogueCategoryFilter.value;
543  const medium = filmCatalogueMediumFilter.value;
544
545  const all = filmUniqueWorks();
546  const filtered = all.filter(work => {
547    const text = [
548      work.title, work.altTitle, work.year, work.format, work.stageCategory, work.director, work.production,
549      work.performers, work.performances, ...(work.catalogueTags || []), ...(work.searchTerms || []),
550      ...(work.__categories || [])
551    ].map(searchValueText).filter(Boolean).join(' ').toLowerCase();
552
553    const categoryOK = !category || (work.__categories || []).includes(category);
554    const mediumOK = !medium || work.stageCategory === medium || (work.catalogueTags || []).some(tag => String(tag).toLowerCase() === medium.toLowerCase());
555    const searchOK = !query || text.includes(query);
556    return categoryOK && mediumOK && searchOK;
557  }).sort(sortWorksNewest);
558
559  filmCatalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'work' : 'works'}`;
560
561  if(!filtered.length){
562    filmCatalogueList.innerHTML = `<div class="catalogue-empty">No works match the current filters.</div>`;
563    return;
564  }
565
566  const categoryOrder = ["Feature Film","Documentary","Short / Experimental Film","Series & Television","Theatre / Dance","Multimedia"];
567  filmCatalogueList.innerHTML = categoryOrder.map(cat => {
568    const works = filtered.filter(w => w.__primaryCategory === cat);
569    if(!works.length) return '';
570    const rows = groupWorks => groupWorks.map(work => {
571      const categories = uniqueCatalogueMeta(work.__categories || []);
572      const meta = uniqueCatalogueMeta([...categories, work.stageCategory,...(work.catalogueTags || []).filter(tag => String(tag).toLocaleLowerCase('en') !== work.title.toLocaleLowerCase('en'))]).slice(0,categories.length + 3);
573      return `
574        <a class="catalogue-work-row" href="${SiteRoutes.pathFor(`#film/${encodeURIComponent(work.__primaryCategory)}/${encodeURIComponent(filmSlug(work))}`)}"
575          data-film-catalogue-category="${encodeURIComponent(work.__primaryCategory)}"
576          data-categories="${newsEscape((work.__categories || []).join('|'))}"
577          data-tags="${newsEscape((work.catalogueTags || []).join('|'))}"
578          data-film-catalogue-slug="${encodeURIComponent(filmSlug(work))}">
579          <span class="catalogue-work-title">${work.title}</span>
580          <span class="catalogue-work-meta">${meta.map(x => `<span>${newsEscape(x)}</span>`).join('')}</span>
581          <span class="catalogue-work-year">${work.year || ''}</span>
582        </a>`;
583    }).join('');
584    return `
585      <section class="catalogue-group film-catalogue-group">
586        <h3 class="catalogue-group-title">${filmCategoryDisplay[cat] || newsEscape(cat)}</h3>
587        ${cat === 'Theatre / Dance' ? stageCategoryOrder.map(group => {
588          const groupWorks = works.filter(work => stageWorkCategory(work) === group);
589          return groupWorks.length ? `<section class="catalogue-subgroup" data-stage-category="${group}"><h4 class="catalogue-subgroup-title">${group}</h4>${rows(groupWorks)}</section>` : '';
590        }).join('') : rows(works)}
591      </section>`;
592  }).join('');
593
594  filmCatalogueList.querySelectorAll('[data-film-catalogue-slug]').forEach(btn => {
595    btn.addEventListener('click', () => {
596      const category = decodeURIComponent(btn.dataset.filmCatalogueCategory);
597      const slug = decodeURIComponent(btn.dataset.filmCatalogueSlug);
598      const work = (FILM_WORKS[category] || []).find(w => filmSlug(w) === slug);
599      if(!work) return;
600      filmCataloguePage.classList.remove('open');
601      filmCataloguePage.setAttribute('aria-hidden','true');
602      openFilmWork(category, work);
603    });
604  });
605  window.EditorialLayout?.arrangeCatalogue(filmCatalogueList);
606}
607
608function newsEscape(value){
609  return String(value ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
610    .replace(/"/g,'&quot;').replace(/'/g,'&#039;');
611}
612
613function newsItemBySlug(slug){ return NEWS_ITEMS.find(item => item.slug === slug); }
614
615function renderNewsHome(){
616  if(!newsGrid) return;
617  newsGrid.innerHTML = NEWS_ITEMS.slice(0,4).map(item => `
618    <a class="date-row news-home-row" href="${SiteRoutes.pathFor(`#news/${item.slug}`)}"
619      data-news-slug="${newsEscape(item.slug)}">
620      <time>${newsEscape(item.date)}</time>
621      <span class="date-work"><strong>${newsEscape(item.title)}</strong><small>${newsEscape(item.panelSubtitle || item.type)}</small></span>
622      <span class="date-venue">${newsEscape(item.preview || item.summary || '')}</span>
623      <span class="date-action">Read ↗</span>
624    </a>`).join('');
625  newsGrid.querySelectorAll('[data-news-slug]').forEach(button => {
626    button.addEventListener('click', () => openNewsItem(button.dataset.newsSlug));
627  });
628}
629
630function renderNewsCatalogue(){
631  const query = (newsCatalogueSearch?.value || '').trim().toLowerCase();
632  const type = newsCatalogueType?.value || '';
633  const year = newsCatalogueYear?.value || '';
634  const filtered = NEWS_ITEMS.filter(item => {
635    const text = [item.title,item.date,item.type,item.language,item.preview,item.summary,...(item.body||[]),...(item.notes||[])]
636      .filter(Boolean).join(' ').toLowerCase();
637    return (!query || text.includes(query)) && (!type || item.type === type) &&
638      (!year || item.date.match(/\b(?:19|20)\d{2}\b/)?.[0] === year);
639  });
640  newsCatalogueSummary.textContent = `${filtered.length} ${filtered.length === 1 ? 'item' : 'items'}`;
641  if(!filtered.length){
642    newsCatalogueList.innerHTML = `<div class="catalogue-empty">No News &amp; Media items match the current filters.</div>`;
643    return;
644  }
645  newsCatalogueList.innerHTML = filtered.map(item => `
646    <a class="news-catalogue-row" href="${SiteRoutes.pathFor(`#news/${item.slug}`)}" data-news-slug="${newsEscape(item.slug)}">
647      <time>${newsEscape(item.date)}</time>
648      <span class="news-row-type">${newsEscape(item.type)}</span>
649      <strong>${newsEscape(item.title)}</strong>
650      <span class="news-row-language">${item.language ? `Language: ${newsEscape(item.language)}` : ''}</span>
651    </a>`).join('');
652  newsCatalogueList.querySelectorAll('[data-news-slug]').forEach(button => {
653    button.addEventListener('click', () => {
654      newsCataloguePage.classList.remove('open');
655      newsCataloguePage.setAttribute('aria-hidden','true');
656      openNewsItem(button.dataset.newsSlug);
657    });
658  });
659  window.EditorialLayout?.arrangeNewsCatalogue(newsCatalogueList);
660}
661
662function populateYearFilter(select, values){
663  if(!select || select.querySelectorAll('option').length > 1) return;
664  const years = [...new Set(values.filter(Boolean))];
665  years.filter(value => /^\d{4}$/.test(value)).sort((a,b) => Number(b) - Number(a))
666    .concat(years.includes('TBA') ? ['TBA'] : []).forEach(value => {
667      const option = document.createElement('option');
668      option.value = value;
669      option.setAttribute('value', value);
670      option.textContent = value === 'TBA' ? 'Date TBA' : value;
671      select.append(option);
672    });
673}
674
675function populateNewsCatalogueYears(select){
676  populateYearFilter(select, NEWS_ITEMS.map(item => item.date.match(/\b(?:19|20)\d{2}\b/)?.[0]));
677}
678
679function populateUpcomingYears(select){
680  populateYearFilter(select, allEventRows.map(item => item.date ? item.date.slice(0,4) : 'TBA'));
681}
682
683function populateNewsDetail(item){
684  window.EditorialLayout?.resetNews();
685  newsDetailTitle.textContent = item.title;
686  newsDetailArticleSubtitle.textContent = item.articleSubtitle || '';
687  newsDetailArticleSubtitle.hidden = !item.articleSubtitle;
688  newsDetailMeta.innerHTML = `<span>${newsEscape(item.panelSubtitle || item.type)}</span><span>${newsEscape(item.date)}</span>${item.language ? `<span>Language: ${newsEscape(item.language)}</span>` : ''}`;
689  newsDetailSummary.textContent = item.summary || item.preview || '';
690  newsDetailBody.innerHTML = (item.body || []).map(p => `<p>${newsEscape(p)}</p>`).join('');
691  newsDetailNotes.innerHTML = (item.notes || []).map((note,index) => {
692    const logos = (item.noteLogos || [])[index] || [];
693    const logoHTML = logos.length ? `<div class="news-supporter-logos">${logos.map(logo => `<a class="news-supporter-logo ${logo.theme === 'dark' ? 'dark' : ''}" href="${newsEscape(logo.url)}" target="_blank" rel="noreferrer" aria-label="${newsEscape(logo.alt)}"><img src="${newsEscape(logo.src)}" alt="${newsEscape(logo.alt)}" loading="lazy"></a>`).join('')}</div>` : '';
694    return `<div class="news-detail-note"><p>${newsEscape(note)}</p>${logoHTML}</div>`;
695  }).join('');
696  const galleryHTML = (item.gallery || []).map(photo => `<figure class="news-gallery-item">${window.PrivacyMedia.galleryItem(photo.src, photo.alt, 'news-gallery-open')}<figcaption>${photo.creditUrl ? `<a href="${newsEscape(photo.creditUrl)}" target="_blank" rel="noreferrer">${newsEscape(photo.credit)}</a>` : newsEscape(photo.credit)}</figcaption></figure>`).join('');
697  const videoHTML = item.heroVideo ? `<figure class="news-feature-video"><video controls autoplay muted loop playsinline preload="metadata" aria-label="Kinetic Etudes — Chopin Waterfall"><source src="${newsEscape(item.heroVideo)}" type="video/mp4"></video><figcaption>Chopin Waterfall · Kinetic Etudes for Disklavier</figcaption></figure>` : '';
698  const embedHTML = item.media ? `<div class="news-article-embed">${fieldHTML(item.media, item.mediaKind === 'audio' ? 'audio' : 'video')}</div>` : '';
699  newsDetailAssets.innerHTML = videoHTML + galleryHTML + embedHTML;
700  newsDetailLinks.innerHTML = (item.links || []).map(link => `<a href="${newsEscape(link.url)}" target="_blank" rel="noreferrer"><span>${newsEscape(link.label)}</span></a>`).join('');
701  window.EditorialLayout?.arrangeNews(item);
702}
703
704function viennaToday(){
705  const parts = new Intl.DateTimeFormat('en-US',{timeZone:'Europe/Vienna',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date());
706  const field = key => parts.find(part => part.type === key)?.value || '';
707  return `${field('year')}-${field('month')}-${field('day')}`;
708}
709
710// Boundaries classify incomplete historical dates without inventing an exact day.
711// A current month/year remains upcoming until that whole period has elapsed.
712function eventDateBounds(date){
713  if(!date)return {start:'',end:''};
714  if(date.length===4)return {start:date+'-01-01',end:date+'-12-31'};
715  if(date.length===7){
716    const last=new Date(Date.UTC(Number(date.slice(0,4)),Number(date.slice(5,7)),0)).getUTCDate();
717    return {start:date+'-01',end:date+'-'+String(last).padStart(2,'0')};
718  }
719  return {start:date.slice(0,10),end:date.slice(0,10)};
720}
721
722function renderUpcomingDates(){
723  const today = viennaToday();
724  const future = allEventRows.filter(item => !item.end || item.end >= today).sort((a,b) => (a.start || '9999').localeCompare(b.start || '9999'));
725  const past = allEventRows.filter(item => item.end && item.end < today).sort((a,b) => b.end.localeCompare(a.end));
726  const query = (upcomingSearch?.value || '').trim().toLowerCase();
727  const period = upcomingPeriodFilter?.value || '';
728  const year = upcomingYearFilter?.value || '';
729  const matches = item => (!year || (item.date ? item.date.slice(0,4) : 'TBA') === year) &&
730    (!query || item.node.textContent.toLowerCase().includes(query));
731  const filteredFuture = future.filter(matches), filteredPast = past.filter(matches);
732  const showFuture = period !== 'past', showPast = period !== 'upcoming';
733  upcomingAllList.hidden = !showFuture;
734  document.getElementById('upcoming-archive').hidden = !showPast;
735  upcomingAllList.replaceChildren(...filteredFuture.map(item => item.node.cloneNode(true)));
736  upcomingPastList.replaceChildren(...filteredPast.map(item => item.node.cloneNode(true)));
737  if(showFuture && !filteredFuture.length) upcomingAllList.innerHTML = `<p class="upcoming-empty">${query || year ? 'No upcoming events match the current filters.' : 'New dates will be announced here.'}</p>`;
738  if(showPast && !filteredPast.length) upcomingPastList.innerHTML = `<p class="upcoming-empty">${query || year ? 'No past events match the current filters.' : 'No past events yet.'}</p>`;
739  const count = (showFuture ? filteredFuture.length : 0) + (showPast ? filteredPast.length : 0);
740  document.getElementById('upcoming-summary').textContent = `${count} ${count === 1 ? 'event' : 'events'}`;
741  upcomingHomeList.replaceChildren(...future.slice(0,4).map(item => item.node.cloneNode(true)));
742  if(!future.length) upcomingHomeList.innerHTML = '<p class="upcoming-empty">New dates will be announced here.</p>';
743  [upcomingHomeList, upcomingAllList, upcomingPastList].forEach(list => window.EditorialLayout?.arrangeDates(list));
744}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.