Merge branch 'eclipse-main' into translation

This commit is contained in:
Axel Uhl committed 2026-10-18 09:00:49 +02:00
commit 102cf7d5ef
39 files changed
+2067 -483

No files matched your search

+447 -21
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eclipse Azimuth Sailing Analytics — Developer Hub</title>
<title>Eclipse Azimuth Sailing Analytics - Developer Hub</title>
<meta name="description" content="Open-source platform for live visualization, analysis, and broadcasting of sailing regattas. Proven at the 2020 and 2024 Olympics.">
<link rel="icon" href="source/favicon-light.png" type="image/png" media="(prefers-color-scheme: light)">
<link rel="icon" href="source/favicon-dark.png" type="image/png" media="(prefers-color-scheme: dark)">
@@ -156,10 +156,10 @@
/* ════════════ SOLUTIONS CAROUSEL ════════════ */
.solutions__carousel-wrap {
position: relative; margin-top: 48px;
position: relative; margin-top: 40px;
}
.solutions__track-outer {
overflow: hidden; border-radius: var(--radius-lg);
overflow: hidden; border-radius: var(--radius-lg); padding-top: 8px;
}
.solutions__track {
display: flex; gap: 20px;
@@ -430,7 +430,7 @@
}
.getting-started .tech-item span { color: rgba(255,255,255,.9); }
.getting-started .tech-item small { color: rgba(255,255,255,.5); }
/* Java and Jetty use dark #333 — invert to white on dark bg */
/* Java and Jetty use dark #333 - invert to white on dark bg */
.getting-started .tech-item--invert img { filter: brightness(0) invert(1) opacity(.85); }
/* ════════════ DOCS GRAPH ════════════ */
@@ -503,7 +503,7 @@
}
/* Floating close button for open accordions */
.accordion-close-fab { display: none; }
/* Sticky "Close all" bar — fixed, only visible while an accordion in #whatsnew is open */
/* Sticky "Close all" bar - fixed, only visible while an accordion in #whatsnew is open */
.whatsnew__header {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px; margin-bottom: 0;
@@ -527,7 +527,7 @@
.whatsnew__close-btn:hover { background: var(--blue-dark); }
/* ════════════ GETTING STARTED ════════════ */
.getting-started { background: var(--dark); color: var(--white); }
.getting-started { background: #192533; color: var(--white); }
.getting-started .eyebrow { color: var(--yellow); }
.getting-started h2 { color: var(--white); }
.getting-started .lead { color: rgba(255,255,255,.7); }
@@ -567,6 +567,193 @@
.ck { color: #4caf50; }
.cv { color: var(--yellow); }
/* ════════════ REVIEWS ════════════ */
.reviews {
background: #e8ecf0;
position: relative; overflow: hidden;
}
.reviews::before {
content: ''; position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 700px 400px at 0% 60%, rgba(0,143,204,.07) 0%, transparent 70%),
radial-gradient(ellipse 500px 400px at 100% 20%, rgba(239,170,0,.05) 0%, transparent 70%);
}
.reviews .eyebrow { color: var(--blue); }
.reviews h2 { color: var(--text); }
.reviews .lead { color: var(--muted); }
.reviews__ticker-wrap {
position: relative; margin-top: 44px;
overflow-x: auto; overflow-y: visible;
padding-top: 8px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
cursor: grab;
}
.reviews__ticker-wrap:active { cursor: grabbing; }
/* hide scrollbar */
.reviews__ticker-wrap::-webkit-scrollbar { display: none; }
.reviews__ticker-wrap { -ms-overflow-style: none; scrollbar-width: none; }
.reviews__ticker {
display: flex; gap: 24px;
width: max-content;
}
.rev-card {
flex-shrink: 0; width: 340px;
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 26px 26px 22px;
position: relative;
display: flex; flex-direction: column;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: border-color .2s, transform .2s, box-shadow .2s;
}
.rev-card:hover {
border-color: rgba(0,143,204,.4);
box-shadow: var(--shadow-md);
transform: translateY(-3px);
}
.rev-card__quote-mark {
font-size: 4rem; line-height: 1; color: var(--blue);
opacity: .12; position: absolute; top: 14px; right: 16px;
font-family: Georgia, serif; user-select: none;
}
.rev-card__body-wrap {
height: 120px; overflow: hidden; position: relative; margin-bottom: 18px;
}
.rev-card__body-wrap.overflows::after {
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 56px;
background: linear-gradient(to bottom, transparent, var(--white));
pointer-events: none;
}
.rev-card__body-wrap.overflows .rev-card__chevron {
display: flex;
}
.rev-card__chevron {
display: none;
position: absolute; bottom: 4px; left: 0; right: 0;
justify-content: center;
color: var(--blue); font-size: 1.5rem; line-height: 1;
pointer-events: none;
z-index: 4;
opacity: .6;
transition: opacity .2s, bottom .2s;
}
.rev-card:hover .rev-card__chevron { opacity: 1; bottom: 7px; }
.rev-card__body {
font-size: .875rem; color: var(--text);
line-height: 1.7; padding-right: 20px;
}
.rev-card__footer {
display: flex; align-items: center; gap: 12px;
border-top: 1px solid var(--border);
padding-top: 14px; margin-top: auto;
}
/* Ticker nav arrows */
.reviews__nav {
display: flex; align-items: center; justify-content: center;
gap: 28px; margin-top: 40px;
}
.reviews__nav-btn {
background: none; border: none; cursor: pointer;
color: rgba(0,0,0,.3);
font-size: 1.6rem; font-weight: 400; line-height: 1;
padding: 4px 10px;
letter-spacing: -1px;
transition: color .2s;
}
.reviews__nav-btn:hover { color: var(--blue); }
.rev-card__avatar {
width: 48px; height: 48px; border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(0,143,204,.3);
flex-shrink: 0;
}
.rev-card__avatar--top { object-position: center top; }
.rev-card__avatar--face { object-position: center 20%; }
.rev-card__avatar-placeholder {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, var(--blue-dark), var(--blue));
display: flex; align-items: center; justify-content: center;
font-size: 1rem; color: var(--white); font-weight: 700;
flex-shrink: 0; border: 2px solid rgba(0,143,204,.3); line-height: 1;
}
.rev-card__name { font-size: .85rem; font-weight: 700; color: var(--dark); line-height: 1.3; }
.rev-card__role { font-size: .73rem; color: var(--muted); margin-top: 2px; }
.rev-card__org { font-size: .68rem; color: var(--blue); font-weight: 700; text-transform: uppercase; letter-spacing: .07em; margin-top: 3px; }
/* Review modal */
.rev-modal-overlay {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.5); backdrop-filter: blur(4px);
align-items: center; justify-content: center; padding: 24px;
}
.rev-modal-overlay.open { display: flex; }
.rev-modal {
background: var(--white); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
width: 100%; max-width: 600px;
position: relative; animation: modalIn .2s ease;
display: flex; overflow: hidden; max-height: 90vh;
}
.rev-modal__photo {
width: 180px; flex-shrink: 0;
background: var(--surface);
overflow: hidden;
}
.rev-modal__photo img {
width: 100%; height: 100%; object-fit: cover;
}
.rev-modal__photo-placeholder {
width: 100%; height: 100%;
background: linear-gradient(135deg, var(--blue-dark), var(--blue));
display: flex; align-items: center; justify-content: center;
font-size: 3rem; color: var(--white); font-weight: 700;
}
.rev-modal__content {
flex: 1; padding: 44px 28px 24px; min-width: 0;
overflow-y: auto; position: relative;
}
.rev-modal__quote-mark {
font-size: 5rem; line-height: 1; color: var(--blue); opacity: .08;
position: absolute; top: 8px; right: 16px;
font-family: Georgia, serif; user-select: none;
}
.rev-modal__body {
font-size: .9rem; color: var(--text);
line-height: 1.75; margin-bottom: 20px;
}
.rev-modal__footer {
display: flex; align-items: center; gap: 14px;
border-top: 1px solid var(--border); padding-top: 16px;
}
.rev-modal__close {
position: absolute; top: 10px; right: 10px;
background: rgba(0,0,0,.06); border: 1px solid rgba(0,0,0,.1);
color: var(--muted); width: 28px; height: 28px; border-radius: 50%;
font-size: 1rem; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .15s; z-index: 2; flex-shrink: 0;
}
.rev-modal__close:hover { background: rgba(0,0,0,.12); color: var(--text); }
/* Review maintenance: one adaptive layout for short and long feedback. */
.rev-card { width: min(340px, calc(100vw - 48px)); }
.rev-card:focus-visible { outline: 3px solid var(--blue); outline-offset: 3px; }
.rev-modal { max-width: min(760px, calc(100vw - 32px)); min-height: 240px; }
.rev-modal__photo { width: clamp(150px, 30%, 230px); align-self: stretch; }
.rev-modal__photo img { object-position: center 20%; }
.rev-modal__content { padding: 36px 30px 26px; }
@media (max-width: 620px) {
.rev-modal { flex-direction: column; overflow-y: auto; max-height: 90dvh; }
.rev-modal__photo { width: 100%; height: 190px; flex: 0 0 190px; }
.rev-modal__photo img { object-fit: cover; }
.rev-modal__content { padding: 24px 22px; overflow: visible; }
}
/* ════════════ FOOTER ════════════ */
.footer { background: var(--dark); color: rgba(255,255,255,.6); padding: 56px 0 28px; }
.footer__grid {
@@ -629,6 +816,7 @@
</a>
<div class="nav__links">
<a href="#solutions">Solutions</a>
<a href="#reviews">Reviews</a>
<a href="#getting-started">Quick Start</a>
<a href="#docs">Documentation</a>
<a href="#tech">Technology</a>
@@ -788,6 +976,30 @@
</div>
</section>
<!-- ═══════════════════════════════════ REVIEWS ══════════════════════════════ -->
<section class="section reviews" id="reviews">
<div class="container">
<p class="eyebrow">User Feedback 2026</p>
<h2>Trusted by the sailing world</h2>
<p class="lead" style="max-width:560px;margin-top:10px;">
From club regattas to international championships - what organizers, coaches, and sailors say.
</p>
</div>
<div class="reviews__ticker-wrap">
<div class="reviews__ticker" id="rev-ticker">
<!-- Cards are generated from REVIEWS below. Add future feedback there only. -->
</div>
</div>
<div class="container">
<div class="reviews__nav">
<button class="reviews__nav-btn" onclick="revTickerStep(-1)">&#8249;</button>
<button class="reviews__nav-btn" onclick="revTickerStep(1)">&#8250;</button>
</div>
</div>
</section>
<!-- ═══════════════════════════════════ GETTING STARTED ═══════════════════════ -->
<section class="section getting-started" id="getting-started">
<div class="container">
@@ -833,7 +1045,7 @@
<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:center;margin-top:28px;">
<a class="btn btn-yellow" href="https://wiki.sapsailing.com/wiki/howto/onboarding.md" target="_blank" rel="noopener">Full Onboarding Guide</a>
<a class="btn btn-outline-white" href="https://github.com/eclipse-sailing-analytics/sailing-analytics/releases" target="_blank" rel="noopener">Latest Release</a>
<a class="btn btn-outline-white" href="https://github.com/eclipse-sailing-analytics/sailing-analytics/pkgs/container/sailing-analytics" target="_blank" rel="noopener">Docker Image</a>
<a class="btn btn-outline-white" href="https://github.com/eclipse-sailing-analytics/sailing-analytics/releases?q=docker&expanded=true" target="_blank" rel="noopener">Docker Image</a>
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:8px;padding:9px 16px;background:rgba(239,170,0,.12);border:1px solid rgba(239,170,0,.35);border-radius:var(--radius);border-left:3px solid var(--yellow);">
<span style="font-size:.9rem;font-weight:900;color:var(--yellow);line-height:1;">!</span>
<span style="font-size:.82rem;color:rgba(255,255,255,.8);">Found a bug or have a feature request? Use the <a href="https://github.com/eclipse-sailing-analytics/sailing-analytics/issues/" target="_blank" rel="noopener" style="color:var(--yellow);font-weight:700;">issue tracker</a> on GitHub.</span>
@@ -859,7 +1071,7 @@
<p class="eyebrow">Technology</p>
<h2>Built on proven open-source foundations</h2>
<p class="lead" style="max-width:560px;margin-top:10px;">
A polyglot stack - Java backend, GWT frontend, mobile apps - all tied together
A polyglot stack — Java backend, GWT frontend, mobile apps — all tied together
by battle-tested middleware and cloud infrastructure.
</p>
<div class="tech__grid">
@@ -871,7 +1083,7 @@
<div class="tech-item">
<img src="https://cdn.simpleicons.org/eclipseide/2C2255" alt="OSGi / Eclipse">
<span>OSGi / Equinox</span>
<small>Modular plugin architecture — 225+ bundles</small>
<small>Modular plugin architecture - 225+ bundles</small>
</div>
<div class="tech-item">
<img src="source/logo-gwt.svg" alt="GWT">
@@ -901,7 +1113,7 @@
<div class="tech-item">
<img src="source/logo-aws.svg" alt="AWS">
<span>AWS</span>
<small>Cloud hosting — EC2, S3, CloudFront</small>
<small>Cloud hosting - EC2, S3, CloudFront</small>
</div>
<div class="tech-item">
<img src="https://cdn.simpleicons.org/android/34A853" alt="Android">
@@ -1024,6 +1236,23 @@
</div>
</section>
<!-- Review dialog is populated automatically from REVIEWS. -->
<div class="rev-modal-overlay" id="review-dialog" role="presentation">
<div class="rev-modal" role="dialog" aria-modal="true" aria-labelledby="review-dialog-name">
<button type="button" class="rev-modal__close" aria-label="Close review">&times;</button>
<div class="rev-modal__photo" id="review-dialog-photo"></div>
<div class="rev-modal__content">
<div class="rev-modal__quote-mark" aria-hidden="true">&ldquo;</div>
<p class="rev-modal__body" id="review-dialog-quote"></p>
<div class="rev-modal__footer"><div>
<div class="rev-card__name" id="review-dialog-name"></div>
<div class="rev-card__role" id="review-dialog-role"></div>
<div class="rev-card__org" id="review-dialog-org"></div>
</div></div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════ FOOTER ════════════════════════════════ -->
<footer class="footer">
<div class="container">
@@ -1047,6 +1276,7 @@
<li><a href="#solutions">Solutions</a></li>
<li><a href="#getting-started">Quick Start</a></li>
<li><a href="#whatsnew">What's New</a></li>
<li><a href="#reviews">Reviews</a></li>
<li><a href="https://www.sapsailing.com/gwt/Home.html" target="_blank" rel="noopener">Application</a></li>
<li><a href="https://github.com/eclipse-sailing-analytics/sailing-analytics/releases" target="_blank" rel="noopener">Releases</a></li>
</ul>
@@ -1107,7 +1337,7 @@
<div class="modal__card-icon modal__card-icon--blue">🚀</div>
<div class="modal__card-text">
<div class="modal__card-title">Open Application</div>
<div class="modal__card-desc">sapsailing.com — real events, real data</div>
<div class="modal__card-desc">sapsailing.com - real events, real data</div>
</div>
<span class="modal__card-arrow">→</span>
</a>
@@ -1394,6 +1624,7 @@
</div>
</div>
<script src="reviews.js"></script>
<script>
// ── Documentation categories (shared data) ────────────────────────────────
var CATS = [
@@ -1451,7 +1682,106 @@
]},
];
// ── Solutions carousel ────────────────────────────────────────────────────
// REVIEWS are loaded from reviews.js (separate file so downstream can merge cleanly)
(function initReviews() {
const ticker = document.getElementById('rev-ticker');
const dialog = document.getElementById('review-dialog');
const photoBox = document.getElementById('review-dialog-photo');
const close = dialog.querySelector('.rev-modal__close');
let previousFocus = null;
let selected = null;
function avatar(review, large) {
if (review.photo) {
const img = document.createElement('img');
img.src = review.photo;
img.alt = review.name;
img.loading = large ? 'eager' : 'lazy';
img.style.objectPosition = review.photoPosition || 'center 20%';
if (!large) img.className = 'rev-card__avatar';
return img;
}
const placeholder = document.createElement('div');
placeholder.className = large ? 'rev-modal__photo-placeholder' : 'rev-card__avatar-placeholder';
placeholder.textContent = review.name.charAt(0).toUpperCase();
return placeholder;
}
function openReview(review) {
selected = review;
previousFocus = document.activeElement;
document.getElementById('review-dialog-quote').textContent = review.quote;
document.getElementById('review-dialog-name').textContent = review.name;
document.getElementById('review-dialog-role').textContent = review.role || '';
document.getElementById('review-dialog-org').textContent = review.org || '';
photoBox.replaceChildren(avatar(review, true));
dialog.classList.add('open');
document.body.style.overflow = 'hidden';
close.focus();
}
function closeReview() {
dialog.classList.remove('open');
document.body.style.overflow = '';
if (previousFocus) previousFocus.focus();
selected = null;
}
close.addEventListener('click', closeReview);
dialog.addEventListener('click', e => { if (e.target === dialog) closeReview(); });
document.addEventListener('keydown', e => {
if (!selected) return;
if (e.key === 'Escape') { e.stopPropagation(); closeReview(); }
if (e.key === 'Tab') {
// Only one dialog control: do not let keyboard focus escape the dialog.
e.preventDefault(); close.focus();
}
});
REVIEWS.forEach(review => {
const card = document.createElement('div');
card.className = 'rev-card';
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.setAttribute('aria-label', 'Read full feedback from ' + review.name);
const mark = document.createElement('div');
mark.className = 'rev-card__quote-mark';
mark.setAttribute('aria-hidden','true');
mark.textContent = '“';
const wrap = document.createElement('div');
wrap.className = 'rev-card__body-wrap';
const body = document.createElement('p');
body.className = 'rev-card__body';
body.textContent = review.quote;
const chevron = document.createElement('span');
chevron.className = 'rev-card__chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = '⌄';
wrap.append(body, chevron);
const footer = document.createElement('div');
footer.className = 'rev-card__footer';
const details = document.createElement('div');
[['rev-card__name',review.name],['rev-card__role',review.role],['rev-card__org',review.org]].forEach(([cls,val])=>{
const field=document.createElement('div');field.className=cls;field.textContent=val||'';details.appendChild(field);
});
footer.append(avatar(review,false),details);
card.append(mark,wrap,footer);
card.addEventListener('click',()=>openReview(review));
card.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openReview(review);}});
ticker.appendChild(card);
});
function updateTruncation() {
ticker.querySelectorAll('.rev-card').forEach(card => {
const wrap = card.querySelector('.rev-card__body-wrap');
const body = card.querySelector('.rev-card__body');
wrap.classList.toggle('overflows', body.scrollHeight > wrap.clientHeight + 4);
});
}
requestAnimationFrame(updateTruncation);
window.addEventListener('load',updateTruncation);
window.addEventListener('resize',updateTruncation);
})();
// ── Solutions carousel ┈───────────────────────────────────────────────────
(function() {
const track = document.getElementById('sol-track');
const dotsEl = document.getElementById('sol-dots');
@@ -1591,16 +1921,17 @@
body.appendChild(div);
})
.catch(function() {
var div = document.createElement('div');
div.className = 'accordion-loading';
div.appendChild(document.createTextNode('Could not load changelog — '));
var a = document.createElement('a');
a.href = body.dataset.src;
a.target = '_blank';
a.textContent = 'view source file';
div.appendChild(a);
body.innerHTML = '';
body.appendChild(div);
const loading = document.createElement('div');
loading.className = 'accordion-loading';
loading.appendChild(document.createTextNode('Could not load changelog - '));
const link = document.createElement('a');
link.href = body.dataset.src || '#';
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'view source file';
loading.appendChild(link);
body.appendChild(loading);
});
}
}
@@ -1643,6 +1974,101 @@
}
}
(function() {
var wrap = document.querySelector('.reviews__ticker-wrap');
if (!wrap) return;
var isDragging = false, startX, scrollLeft;
wrap.addEventListener('mousedown', function(e) {
isDragging = true;
startX = e.pageX - wrap.offsetLeft;
scrollLeft = wrap.scrollLeft;
wrap.style.userSelect = 'none';
});
document.addEventListener('mouseup', function() {
isDragging = false;
wrap.style.userSelect = '';
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
e.preventDefault();
var x = e.pageX - wrap.offsetLeft;
wrap.scrollLeft = scrollLeft - (x - startX);
});
})();
// Infinite review carousel
(function () {
const wrap = document.querySelector('.reviews__ticker-wrap');
const ticker = document.getElementById('rev-ticker');
if (!wrap || !ticker) return;
const originals = Array.from(ticker.querySelectorAll('.rev-card'));
const count = originals.length;
if (!count) return;
// Duplicate cards before and after the originals.
// This allows scrolling in either direction indefinitely.
originals.forEach(card => ticker.appendChild(card.cloneNode(true)));
originals.slice().reverse().forEach(card => {ticker.insertBefore(card.cloneNode(true), ticker.firstChild);});
const allCards = Array.from(ticker.querySelectorAll('.rev-card'));
allCards.forEach((card, index) => {
if (index >= count && index < count * 2) return;
const original = originals[index % count];
card.addEventListener('click', () => original.click());
card.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
original.click();
}
});
});
const cardWidth = () => originals[0].offsetWidth + 24;
const sectionWidth = () =>
ticker.children[count * 2].offsetLeft -
ticker.children[count].offsetLeft;
// Start at the original set of cards.
wrap.style.scrollBehavior = 'auto';
wrap.scrollLeft = sectionWidth();
// Keep the scroll position within the middle copy.
// All three copies have the same card order.
let resetting = false;
wrap.addEventListener('scroll', () => {
if (resetting) return;
const width = sectionWidth();
const position = wrap.scrollLeft;
// Reset only when leaving the middle copy.
if (position < width || position >= width * 2) {
resetting = true;
const previousBehavior = wrap.style.scrollBehavior;
wrap.style.scrollBehavior = 'auto';
wrap.scrollLeft = position < width
? position + width
: position - width;
requestAnimationFrame(() => {
wrap.style.scrollBehavior = previousBehavior;
resetting = false;
});
}
});
window.revTickerStep = function (dir) {
wrap.scrollBy({
left: dir * cardWidth(),
behavior: 'smooth'
});
};
})();
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.open').forEach(function(m) {
+13
View File
@@ -0,0 +1,13 @@
// ══════════ EDIT ONLY THIS FILE TO ADD/UPDATE REVIEWS ══════════
// Add another { id, name, role, org, quote, photo?, photoPosition? } entry.
// Photos live in source/. No manual HTML cards, modal sizes or handlers needed.
// photoPosition (optional) is CSS object-position, e.g. 'center 20%'.
const REVIEWS = [
{"id": "frank", "name": "Frank Schärffe", "role": "Managing Director", "org": "Travemünder Woche gGmbH", "photo": "source/frank_scharffe.png", "photoPosition": "center 12%", "quote": "With SAP's return to processing tracked data at Travemünder Woche 2026, 42 races across 9 boat classes could be processed in a graphically appealing way and presented attractively for the interested public. An excellent instrument for our participants to analyse their own performance - a genuine added value! For the sailing-enthusiast public, the data was incorporated into the live broadcast on an LED large screen and provided excellent background information for the commentary - overall, we were able to make the regatta sport of Travemünder Woche more viewer-friendly!"},
{"id": "sven-christensen", "name": "Sven Christensen", "role": "CEO, Point of Sailing - Sailing promotion & staging", "org": "Kieler Woche", "photo": "source/sven_christensen.png", "photoPosition": "center 15%", "quote": "SAP Sailing Analytics is an extremely helpful tool for Kieler Woche in many respects. Not only the analysis itself, but also the presentation and the depth of information behind it help us convey sailing in near real-time in a way that is broadly understandable and exciting. Beyond that, the data provides participants and coaches with valuable opportunities to analyse performance and decisions."},
{"id": "stanislaw", "name": "Stanisław Konarzewski", "role": "Marketing & Communication Manager", "org": "Polish Sailing League", "photo": "source/stanislav_konarzewski.jpg", "photoPosition": "45% 22%", "quote": "As the organizers of the Polish Sailing League, integrating SAP Sailing Analytics has been a complete game-changer for our fast-paced regattas. The system's live tracking, complete with real-time data, effortlessly translates complex tactical decisions into an engaging, easily digestible experience for our fans and commentators. In a format featuring back-to-back flights, the automated, real-time scoring is invaluable; leaderboards update instantly, eliminating manual calculation delays and ensuring absolute accuracy. From an operational standpoint, the platform is intuitive and easy to use. Deploying the GPS units across our one-design fleet adds no friction to our morning routines, while the Race Committee backend makes virtual course setting and safety monitoring seamless. Combined with the in-depth post-race analytics, SAP Sailing is much more than just a tracking tool - it is a robust, indispensable platform that has fundamentally elevated the professionalism, operational efficiency, and broadcast quality of our entire league."},
{"id": "jack", "name": "Jack V.", "role": "Pacific Northwest Sailor", "org": "Sidney North Saanich Yacht Club", "quote": "SAP Sailing Analytics strikes a unique balance of being accessible to any average club racer on a budget, and powerful enough to satisfy the most complex regatta or performance analytics far better than its competitors. We use it for all sorts of events and are always discovering new and interesting features to take advantage of."},
{"id": "sven-jurgensen", "name": "Sven Jürgensen", "role": "Chairman, Wir sind Wir Inclusion in Sailing e.V.", "org": "Helga Cup", "photo": "source/sven_jurgensen.png", "photoPosition": "center 22%", "quote": "More than 20 J/70 boats, well over 60 crews, three racing groups: results, tracking, live broadcasts, commentary and analyses for the crews are almost unimaginable at the Helga Cup without SAP Sailing Analytics. The teams even use data from previous years to prepare specifically for the regatta."},
{"id": "joachim-hellmich", "name": "Joachim Hellmich", "role": "Heinz Nixdorf Verein zur Förderung des Segelsports e.V.", "org": "Heinz Nixdorf Verein", "photo": "source/heinz_nixdorf_verein.jpg", "quote": "For me, SAP Sailing Analytics is an extremely valuable tool when working with guests at the Heinz Nixdorf Verein Academy, allowing us to study the patterns of both the winners and the defeated. Furthermore, I wouldn't know how else I could have followed the Paris/Marseille 2024 Olympic Games; no other tool visualizes the entire racecourse and live leaderboards with such precision and clarity. I am happy to see SAP Sailing Analytics return to the German Sailing League in 2027, following a painful hiatus that lasted until 2026. Thank you, SAP!"},
{"id": "roland-regnemer", "name": "Roland Regnemer", "role": "Liga Manager", "org": "Segelbundesliga", "photo": "source/roland_regnemer.jpg", "quote": "SAP Sailing Analytics is inseparably linked to the Segelbundesliga in Austria. It is a shared success story that has been written together for over a decade. Excitement, visibility, identification, and engagement with the league all work thanks to SAP Sailing Analytics."}
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

@@ -170,11 +170,11 @@ public enum FieldNames {
// image fields
IMAGE_URL, IMAGE_LOCALE, IMAGE_TITLE, IMAGE_SUBTITLE, IMAGE_MIMETYPE, IMAGE_COPYRIGHT, IMAGE_CREATEDATDATE,
IMAGE_WIDTH_IN_PX, IMAGE_HEIGHT_IN_PX, IMAGE_TAGS,
IMAGE_WIDTH_IN_PX, IMAGE_HEIGHT_IN_PX, IMAGE_TAGS, IMAGE_MISSING, IMAGE_MISSING_MAIL_NOTIFICATION_SENT,
// video fields
VIDEO_URL, VIDEO_LOCALE, VIDEO_TITLE, VIDEO_SUBTITLE, VIDEO_MIMETYPE, VIDEO_COPYRIGHT, VIDEO_CREATEDATDATE,
VIDEO_TAGS, VIDEO_THUMBNAIL_URL, VIDEO_LENGTH_IN_SECONDS,
VIDEO_TAGS, VIDEO_THUMBNAIL_URL, VIDEO_LENGTH_IN_SECONDS, VIDEO_MISSING, VIDEO_MISSING_MAIL_NOTIFICATION_SENT,
// sailors info fileds
SAILORS_INFO_URL, SAILORS_INFO_LOCALE,
@@ -2952,6 +2952,9 @@ public class DomainObjectFactoryImpl implements DomainObjectFactory {
image.setSubtitle(subtitle);
image.setLocale(locale);
image.setTags(imageTags);
image.setMissing(Boolean.TRUE.equals(dbObject.get(FieldNames.IMAGE_MISSING.name())));
image.setMissingMailNotificationSent(
Boolean.TRUE.equals(dbObject.get(FieldNames.IMAGE_MISSING_MAIL_NOTIFICATION_SENT.name())));
if (imageWidth != null && imageHeight != null) {
image.setSize(imageWidth.intValue(), imageHeight.intValue());
}
@@ -2988,6 +2991,9 @@ public class DomainObjectFactoryImpl implements DomainObjectFactory {
video.setTags(videoTags);
video.setLengthInSeconds(lengthInSeconds == null ? null : lengthInSeconds.intValue());
video.setThumbnailURL(thumbnailURL);
video.setMissing(Boolean.TRUE.equals(dbObject.get(FieldNames.VIDEO_MISSING.name())));
video.setMissingMailNotificationSent(
Boolean.TRUE.equals(dbObject.get(FieldNames.VIDEO_MISSING_MAIL_NOTIFICATION_SENT.name())));
}
return video;
}
@@ -1865,6 +1865,8 @@ public class MongoObjectFactoryImpl implements MongoObjectFactory {
result.put(FieldNames.IMAGE_COPYRIGHT.name(), image.getCopyright());
result.put(FieldNames.IMAGE_WIDTH_IN_PX.name(), image.getWidthInPx());
result.put(FieldNames.IMAGE_HEIGHT_IN_PX.name(), image.getHeightInPx());
result.put(FieldNames.IMAGE_MISSING.name(), image.isMissing());
result.put(FieldNames.IMAGE_MISSING_MAIL_NOTIFICATION_SENT.name(), image.isMissingMailNotificationSent());
storeTimePoint(image.getCreatedAtDate(), result, FieldNames.IMAGE_CREATEDATDATE);
BasicDBList tags = new BasicDBList();
for (String tag : image.getTags()) {
@@ -1884,6 +1886,8 @@ public class MongoObjectFactoryImpl implements MongoObjectFactory {
result.put(FieldNames.VIDEO_MIMETYPE.name(), video.getMimeType() != null ? video.getMimeType().name() : null);
result.put(FieldNames.VIDEO_COPYRIGHT.name(), video.getCopyright());
result.put(FieldNames.VIDEO_LENGTH_IN_SECONDS.name(), video.getLengthInSeconds());
result.put(FieldNames.VIDEO_MISSING.name(), video.isMissing());
result.put(FieldNames.VIDEO_MISSING_MAIL_NOTIFICATION_SENT.name(), video.isMissingMailNotificationSent());
storeTimePoint(video.getCreatedAtDate(), result, FieldNames.VIDEO_CREATEDATDATE);
BasicDBList tags = new BasicDBList();
for (String tag : video.getTags()) {
@@ -8,7 +8,6 @@ import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.StreamSupport;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
@@ -17,8 +16,9 @@ import org.junit.jupiter.api.extension.ExtensionContext;
import com.sap.sailing.domain.common.orc.ORCCertificate;
import com.sap.sailing.domain.orc.ORCPublicCertificateDatabase.CertificateFamily;
import com.sap.sailing.domain.orc.ORCPublicCertificateDatabase.CertificateHandle;
import com.sap.sailing.domain.orc.ORCPublicCertificateDatabase.CountryOverview;
import com.sap.sailing.domain.orc.impl.ORCPublicCertificateDatabaseImpl;
import com.sap.sse.common.CountryCode;
import com.sap.sse.common.CountryCodeFactory;
import com.sap.sse.common.Util;
/***
@@ -40,6 +40,7 @@ import com.sap.sse.common.Util;
public class FailIfNoValidOrcCertificateRule implements BeforeTestExecutionCallback {
private static final Logger logger = Logger.getLogger(FailIfNoValidOrcCertificateRule.class.getName());
private static final int NUMBER_OF_CERTIFICATES_TO_PROBE = 3;
private static final String PROBE_COUNTRY_IOC_CODE = "GER";
private static List<ORCCertificate> availableCerts;
private static boolean certificateExists;
@@ -48,24 +49,26 @@ public class FailIfNoValidOrcCertificateRule implements BeforeTestExecutionCallb
certificateExists = false;
availableCerts = new ArrayList<>();
final ORCPublicCertificateDatabase db = new ORCPublicCertificateDatabaseImpl();
CountryOverview countryWithMostValidCertificates;
try {
countryWithMostValidCertificates = StreamSupport
.stream(db.getCountriesWithValidCertificates().spliterator(), /* parallel */ false)
.max((c1, c2) -> c1.getCertCount() - c2.getCertCount()).get();
Iterable<CertificateHandle> certificateHandles = Util.filter(db.search(countryWithMostValidCertificates.getIssuingCountry(),
countryWithMostValidCertificates.getVPPYear(), null, null, null, null, /* includeInvalid */ false),
certHandle->certHandle.getFamily() != CertificateFamily.ORC_LIGHT); // exclude LITE certificates
final CountryCode probeCountry = CountryCodeFactory.INSTANCE
.getFromThreeLetterIOCName(PROBE_COUNTRY_IOC_CODE);
final Iterable<CertificateHandle> certificateHandles = Util.filter(
db.search(probeCountry, /* yearOfIssuance */ null, /* referenceNumber */ null,
/* yachtName */ null, /* sailNumber */ null, /* boatClassName */ null,
/* includeInvalid */ false),
certHandle -> certHandle.getFamily() != CertificateFamily.ORC_LIGHT); // exclude LITE certificates
final List<CertificateHandle> randomSubset = new ArrayList<>();
Util.addAll(certificateHandles, randomSubset);
Collections.shuffle(randomSubset);
Iterable<ORCCertificate> orcCertificates = db.getCertificates(randomSubset.subList(0, NUMBER_OF_CERTIFICATES_TO_PROBE));
final List<CertificateHandle> probeHandles = randomSubset.subList(0,
Math.min(NUMBER_OF_CERTIFICATES_TO_PROBE, randomSubset.size()));
final Iterable<ORCCertificate> orcCertificates = db.getCertificates(probeHandles);
orcCertificates.forEach(availableCerts::add);
if (orcCertificates.iterator().hasNext()) {
certificateExists = true;
}
} catch (Exception ex) {
logger.log(Level.WARNING, "Problem trying to fetch certificates tests");
} catch (final Exception ex) {
logger.log(Level.WARNING, "Problem trying to fetch certificates tests", ex);
}
}
@@ -89,4 +92,4 @@ public class FailIfNoValidOrcCertificateRule implements BeforeTestExecutionCallb
Assumptions.assumeTrue(false, "Skipping test: no valid ORC certificates found.");
}
}
}
}
@@ -8,14 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.StreamSupport;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.commons.math.ArgumentOutsideDomainException;
import org.apache.commons.math.FunctionEvaluationException;
@@ -25,8 +20,6 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.function.Executable;
import org.w3c.dom.DOMException;
import org.xml.sax.SAXException;
import com.sap.sailing.domain.common.orc.ORCCertificate;
import com.sap.sailing.domain.common.orc.ORCPerformanceCurveCourse;
@@ -39,6 +32,7 @@ import com.sap.sailing.domain.orc.impl.ORCPerformanceCurveImpl;
import com.sap.sailing.domain.orc.impl.ORCPublicCertificateDatabaseImpl;
import com.sap.sse.common.Bearing;
import com.sap.sse.common.CountryCode;
import com.sap.sse.common.CountryCodeFactory;
import com.sap.sse.common.Distance;
import com.sap.sse.common.Duration;
import com.sap.sse.common.Speed;
@@ -54,8 +48,6 @@ import com.sap.sse.common.impl.NauticalMileDistance;
*/
@ExtendWith(FailIfNoValidOrcCertificateRule.class)
public class TestORCPerformanceCurve {
private static final Logger logger = Logger.getLogger(TestORCPerformanceCurve.class.getName());
private static ORCPerformanceCurveCourse alturaCourse;
private static ORCCertificatesCollection importerLocal;
private static ORCCertificatesCollection importerWithSpecificBins;
@@ -64,8 +56,7 @@ public class TestORCPerformanceCurve {
private static final String RESOURCES = "resources/orc/";
@BeforeAll
public static void initialize() throws IOException, ParseException, DOMException, SAXException,
ParserConfigurationException, java.text.ParseException {
public static void initialize() throws IOException, ParseException {
List<ORCPerformanceCurveLeg> legs = new ArrayList<>();
legs.add(new ORCPerformanceCurveLegImpl(new NauticalMileDistance(2.23), new DegreeBearingImpl(10)));
legs.add(new ORCPerformanceCurveLegImpl(new NauticalMileDistance(2.00), new DegreeBearingImpl(170)));
@@ -81,13 +72,10 @@ public class TestORCPerformanceCurve {
File fileWithSpecificBins = new File(RESOURCES + "newFormatCertificate.json");
importerWithSpecificBins = new ORCCertificatesJsonImporter().read(new FileInputStream(fileWithSpecificBins));
// Online File:
final CountryCode countryWithMostValidCertificates = StreamSupport
.stream(ORCPublicCertificateDatabaseImpl.INSTANCE.getCountriesWithValidCertificates().spliterator(),
/* parallel */ false)
.max((c1, c2) -> c1.getCertCount() - c2.getCertCount()).get().getIssuingCountry();
logger.info("Trying to read country certificates for "+countryWithMostValidCertificates);
importerOnline = new ORCCertificatesJsonImporter().read(new URL("https://data.orc.org/public/WPub.dll?action=DownBoatRMS&CountryId="+
countryWithMostValidCertificates.getThreeLetterIOCCode()+"&ext=json").openStream());
final CountryCode ger = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName("GER");
importerOnline = new ORCCertificatesJsonImporter().read(
((ORCPublicCertificateDatabaseImpl) ORCPublicCertificateDatabase.INSTANCE)
.openBulkCertificatesJsonStream(ger));
}
@Test
@@ -23,7 +23,6 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.logging.Logger;
@@ -35,13 +34,17 @@ import org.junit.jupiter.api.extension.ExtendWith;
import com.sap.sailing.domain.base.impl.BoatClassImpl;
import com.sap.sailing.domain.common.orc.ORCCertificate;
import com.sap.sailing.domain.orc.ORCPublicCertificateDatabase.CertificateFamily;
import com.sap.sailing.domain.orc.ORCPublicCertificateDatabase.CertificateHandle;
import com.sap.sailing.domain.orc.impl.ORCPublicCertificateDatabaseImpl;
import com.sap.sse.common.CountryCode;
import com.sap.sse.common.CountryCodeFactory;
import com.sap.sse.common.Util;
@ExtendWith(FailIfNoValidOrcCertificateRule.class)
public class TestORCPublicCertificateDatabase {
private static final Logger logger = Logger.getLogger(TestORCPublicCertificateDatabase.class.getName());
private static final String PROBE_COUNTRY_IOC_CODE = "GER";
private ORCPublicCertificateDatabase db;
private Map<String, Date> dateComparisonMap = new LinkedHashMap<String, Date>();
@@ -91,57 +94,101 @@ public class TestORCPublicCertificateDatabase {
);
}
/**
* Picks a live seed {@link CertificateHandle} from the {@value #PROBE_COUNTRY_IOC_CODE} bulk download, excluding
* {@link CertificateFamily#ORC_LIGHT LITE} certificates and those without a GPH value so that the handle can be
* resolved into an {@link ORCCertificate} and used for GPH-based assertions. Returns {@code null} when the live
* service yields no usable handle; callers gated by {@link FailIfNoValidOrcCertificates} will have been skipped in
* that case.
*/
private CertificateHandle pickSeedHandle() throws Exception {
final CountryCode probeCountry = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(PROBE_COUNTRY_IOC_CODE);
final Iterable<CertificateHandle> handles = db.search(probeCountry, /* yearOfIssuance */ null,
/* referenceNumber */ null, /* yachtName */ null, /* sailNumber */ null, /* boatClassName */ null,
/* includeInvalid */ false);
CertificateHandle seed = null;
for (final CertificateHandle handle : handles) {
if (seed == null && handle.getFamily() != CertificateFamily.ORC_LIGHT && handle.getGPH() != null) {
seed = handle;
}
}
return seed;
}
@FailIfNoValidOrcCertificates
@Test
public void testSimpleSearchByRefNo() throws Exception {
final String referenceNumber = "FRA00013881";
final CertificateHandle seed = pickSeedHandle();
assertNotNull(seed, "Expected at least one usable "+PROBE_COUNTRY_IOC_CODE+" certificate handle from the live service");
final String referenceNumber = seed.getReferenceNumber();
final Iterable<CertificateHandle> result = db.search(/* country */ null, /* yearOfIssuance */ null,
/* referenceNumber */ referenceNumber, /* yachtName */ null, /* sailNumber */ null,
/* boatClassName */ null, /* includeInvalid */ true);
assertEquals(1, Util.size(result));
assertSoulmate(referenceNumber, result.iterator().next());
final CertificateHandle found = result.iterator().next();
assertEquals(referenceNumber, found.getReferenceNumber());
assertEquals(seed.getGPH(), found.getGPH(), 0.000001);
}
@FailIfNoValidOrcCertificates
@Test
public void testSearchByYachtNameAndSailNumberAndYear() throws Exception {
final String referenceNumber = "FRA00013881";
final Iterable<CertificateHandle> result = db.search(/* country */ null, /* yearOfIssuance */ 2019,
/* referenceNumber */ null, /* yachtName */ "Soulmate", /* sailNumber */ "DEN- 13",
final CertificateHandle seed = pickSeedHandle();
assertNotNull(seed, "Expected at least one usable "+PROBE_COUNTRY_IOC_CODE+" certificate handle from the live service");
final Iterable<CertificateHandle> result = db.search(/* country */ CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(PROBE_COUNTRY_IOC_CODE),
/* yearOfIssuance */ LocalDate.now().getYear(), /* referenceNumber */ null,
/* yachtName */ seed.getYachtName(), /* sailNumber */ seed.getSailNumber(),
/* boatClassName */ null, /* includeInvalid */ true);
assertEquals(1, Util.size(result));
assertSoulmate(referenceNumber, result.iterator().next());
}
@Test
public void testSearchByYachtNameNotUnique() throws Exception {
final String referenceNumber = "FRA00013881";
final Iterable<CertificateHandle> result = db.search(/* country */ null, /* yearOfIssuance */ null,
/* referenceNumber */ null, /* yachtName */ "Soulmate", /* sailNumber */ null,
/* boatClassName */ null, /* includeInvalid */ true);
assertTrue(Util.size(result)>1);
boolean found = false;
for (final CertificateHandle handle : result) {
if (handle.getReferenceNumber().equals(referenceNumber)) {
assertSoulmate(referenceNumber, handle);
if (handle.getReferenceNumber().equals(seed.getReferenceNumber())) {
found = true;
}
}
assertTrue(found, "Expected search by yacht name and sail number to contain the seed certificate "+seed.getReferenceNumber());
}
@FailIfNoValidOrcCertificates
@Test
public void testSearchByYachtNameNotUnique() throws Exception {
final CertificateHandle seed = pickSeedHandle();
assertNotNull(seed, "Expected at least one usable "+PROBE_COUNTRY_IOC_CODE+" certificate handle from the live service");
final Iterable<CertificateHandle> result = db.search(/* country */ CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(PROBE_COUNTRY_IOC_CODE),
/* yearOfIssuance */ null, /* referenceNumber */ null, /* yachtName */ seed.getYachtName(),
/* sailNumber */ null, /* boatClassName */ null, /* includeInvalid */ true);
assertTrue(Util.size(result) >= 1);
boolean found = false;
for (final CertificateHandle handle : result) {
if (handle.getReferenceNumber().equals(seed.getReferenceNumber())) {
found = true;
}
}
assertTrue(found);
}
@FailIfNoValidOrcCertificates
@Test
public void testCertificateHandleAPI() throws Exception {
final String referenceNumber = "FRA00013881";
final CertificateHandle seed = pickSeedHandle();
assertNotNull(seed, "Expected at least one usable "+PROBE_COUNTRY_IOC_CODE+" certificate handle from the live service");
final String referenceNumber = seed.getReferenceNumber();
final CertificateHandle result = db.getCertificateHandle(referenceNumber);
assertSoulmate(referenceNumber, result);
assertEquals(referenceNumber, result.getReferenceNumber());
assertNotNull(result.getIssueDate());
}
@FailIfNoValidOrcCertificates
@Test
public void testCertificateUpdate() throws Exception {
final String referenceNumber = "FRA00013881";
final CertificateHandle seed = pickSeedHandle();
assertNotNull(seed, "Expected at least one usable "+PROBE_COUNTRY_IOC_CODE+" certificate handle from the live service");
final String referenceNumber = seed.getReferenceNumber();
final CertificateHandle oldHandle = db.getCertificateHandle(referenceNumber);
final ORCCertificate result = db.searchForUpdate(oldHandle);
if (result != null) {
assertEquals(oldHandle.getFileId(), result.getFileId());
if (oldHandle.getFileId() != null && result.getFileId() != null) {
assertEquals(oldHandle.getFileId(), result.getFileId());
}
assertEquals(oldHandle.getIssuingCountry(), result.getIssuingCountry());
assertFalse(oldHandle.getIssueDate().after(result.getIssueDate()));
} else {
@@ -153,39 +200,16 @@ public class TestORCPublicCertificateDatabase {
@FailIfNoValidOrcCertificates
@Test
public void testGetCertificate() throws Exception {
final CountryCode probeCountry = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(PROBE_COUNTRY_IOC_CODE);
Collection<ORCCertificate> certificates = FailIfNoValidOrcCertificateRule.getAvailableCerts();
// some certificates seem to be lacking a GPH value; can't use those for this test; we're explicitly excluding LITE certificates already...
for (final ORCCertificate cert : certificates) {
if (cert.getGPH() != null) {
Iterable<CertificateHandle> certHandles = db.search(/* country */ null, LocalDate.now().getYear(), /* referenceNumber */ null, cert.getBoatName(),
final Iterable<CertificateHandle> certHandles = db.search(/* country */ probeCountry, LocalDate.now().getYear(), /* referenceNumber */ null, cert.getBoatName(),
cert.getSailNumber(), /*
* boat class name; could be set to cert.getBoatClassName() but there are
* deviations in ORC DBs and query API, so leaving null:
*/ null, /* includeInvalid */ false);
if (Util.isEmpty(certHandles)) {
// there were certs; get one from the previous year
certHandles = db.search(null, LocalDate.now().getYear()-1, null, cert.getBoatName(),
cert.getSailNumber(), /*
* boat class name; could be set to cert.getBoatClassName() but there are
* deviations in ORC DBs and query API, so leaving null:
*/ null, /* includeInvalid */ false);
}
if (Util.isEmpty(certHandles)) {
// there were certs; try searching by reference number
certHandles = db.search(null, LocalDate.now().getYear(), cert.getReferenceNumber(), /* boat name may have deviated due to special characters */ null,
cert.getSailNumber(), /*
* boat class name; could be set to cert.getBoatClassName() but there are
* deviations in ORC DBs and query API, so leaving null:
*/ null, /* includeInvalid */ false);
}
if (Util.isEmpty(certHandles)) {
// still nothing? Then try by reference number in previous year:
certHandles = db.search(null, LocalDate.now().getYear()-1, cert.getReferenceNumber(), /* boat name may have deviated due to special characters */ null,
cert.getSailNumber(), /*
* boat class name; could be set to cert.getBoatClassName() but there are
* deviations in ORC DBs and query API, so leaving null:
*/ null, /* includeInvalid */ false);
}
Optional<CertificateHandle> certificateHandle = Optional.ofNullable(certHandles.iterator().hasNext() ? certHandles.iterator().next() : null);
assertTrue(certificateHandle.isPresent(), "No certificate found for handle "+certificateHandle+
" extracted from certificates "+certificates);
@@ -220,7 +244,7 @@ public class TestORCPublicCertificateDatabase {
new BoatClassImpl(orcCertificate.getBoatClassName(), true)));
}
for (Future<Set<ORCCertificate>> futureResult : futures) {
isYearFound = isYearFound || assertFoundYear(futureResult.get(), year) || assertFoundYear(futureResult.get(), year-1);
isYearFound = isYearFound || assertFoundYear(futureResult.get(), year);
}
assertTrue(isYearFound);
}
@@ -250,14 +274,4 @@ public class TestORCPublicCertificateDatabase {
return certDate.getYear();
}).anyMatch(cy -> cy == year);
}
private void assertSoulmate(final String referenceNumber, CertificateHandle handle) {
assertEquals("SOULMATE", handle.getYachtName());
assertEquals("MOSQUITO 85", handle.getBoatClassName());
assertEquals(662.4, handle.getGPH(), 0.000001);
assertEquals(referenceNumber, handle.getReferenceNumber());
assertEquals(1985, (int) handle.getYearBuilt());
assertFalse(handle.isProvisional());
assertEquals(UUID.fromString("1890A35C-C71E-4F15-A13D-2A0AC464B699"), handle.getDatInGID());
}
}
@@ -24,7 +24,7 @@ Export-Package: com.sap.sailing.domain.base,
com.sap.sailing.domain.markpassinghash.impl;x-friends:="com.sap.sailing.domain.persistence",
com.sap.sailing.domain.masterdataimport,
com.sap.sailing.domain.orc,
com.sap.sailing.domain.orc.impl;x-friends:="com.sap.sailing.domain.test,com.sap.sailing.domain.test,com.sap.sailing.mongodb.test",
com.sap.sailing.domain.orc.impl;x-friends:="com.sap.sailing.domain.test,com.sap.sailing.mongodb.test",
com.sap.sailing.domain.polars,
com.sap.sailing.domain.racelog,
com.sap.sailing.domain.racelog.impl,
@@ -1,7 +1,6 @@
package com.sap.sailing.domain.orc;
import java.io.IOException;
import java.text.ParseException;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.Date;
@@ -11,11 +10,6 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Future;
import javax.xml.parsers.ParserConfigurationException;
import org.w3c.dom.DOMException;
import org.xml.sax.SAXException;
import com.sap.sailing.domain.base.BoatClass;
import com.sap.sailing.domain.common.orc.ORCCertificate;
import com.sap.sailing.domain.orc.impl.ORCPublicCertificateDatabaseImpl;
@@ -24,79 +18,17 @@ import com.sap.sse.common.TimePoint;
import com.sap.sse.common.Util;
/**
* An interface to {@code data.orc.org}, mostly the search functionality at
* {@code https://data.orc.org/public/WPub.dll?action=SrchCert&xslp=scert.php}. It can search for certificates by a
* number of criteria, among them the issuing country, sail number, ORC reference number, or boat name. Those search
* results are represented as {@link CertificateHandle} objects. Such handles can also be obtained for certificates that
* are not currently valid. See the {@code includeInvalid} parameter of the
* {@link #search(CountryCode, Integer, String, String, String, String, boolean)} method. However, handles for
* {@link CertificateHandle#isValid() invalid} certificates cannot be resolved into {@link ORCCertificate} objects
* through {@link #getCertificates(Iterable)} or {@link #getCertificates(CertificateHandle...)} because the database
* returns full certificates only if they are valid.
* <p>
*
* As the web site emits XML documents as search results, those can be parsed and turned into handles to certificates.
* An example output (excerpt):
*
* <pre>
&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;?xml-stylesheet type="text/xsl" href="public.php?content=ListCert.php" ?&gt;
&lt;ROOT&gt;
&lt;DATA&gt;
&lt;ROW RowNum="1"&gt;
&lt;CountryId&gt;GRE&lt;/CountryId&gt;
&lt;SSSID&gt;61576&lt;/SSSID&gt;
&lt;DatInGID&gt;{8C8F3E75-4926-4A30-B0C8-08D36774B1F4}&lt;/DatInGID&gt;
&lt;dxtID&gt;4793&lt;/dxtID&gt;
&lt;RefNo&gt;GRE00000701&lt;/RefNo&gt;
&lt;dxtName&gt;GR1329.dxt&lt;/dxtName&gt;
&lt;YachtName&gt;*AXION ESTI&lt;/YachtName&gt;
&lt;SailNo&gt;GRE-1329&lt;/SailNo&gt;
&lt;Class&gt;SUN ODYSSEY 32&lt;/Class&gt;
&lt;Designer&gt;&lt;/Designer&gt;
&lt;Builder&gt;JEANNEAU&lt;/Builder&gt;
&lt;dxtDate&gt;2009-05-12T23:09:58.000&lt;/dxtDate&gt;
&lt;CertType&gt;3&lt;/CertType&gt;
&lt;VPPVer&gt;&lt;/VPPVer&gt;
&lt;VPPYear&gt;2009&lt;/VPPYear&gt;
&lt;IsOd&gt;False&lt;/IsOd&gt;
&lt;GPH&gt;786.1&lt;/GPH&gt;
&lt;Age&gt;1993&lt;/Age&gt;
&lt;Override&gt;&lt;/Override&gt;
&lt;Provisional&gt;False&lt;/Provisional&gt;
&lt;Selected&gt;False&lt;/Selected&gt;
&lt;CanSelect&gt;False&lt;/CanSelect&gt;
&lt;/ROW&gt;
&lt;ROW RowNum="2"&gt;
&lt;CountryId&gt;GRE&lt;/CountryId&gt;
&lt;SSSID&gt;15709&lt;/SSSID&gt;
&lt;DatInGID&gt;{C790AAFA-9861-435B-BEBE-0606BAFE2721}&lt;/DatInGID&gt;
&lt;dxtID&gt;&lt;/dxtID&gt;
&lt;RefNo&gt;&lt;/RefNo&gt;
&lt;dxtName&gt;GR1329.dat&lt;/dxtName&gt;
&lt;YachtName&gt;*AXION ESTI&lt;/YachtName&gt;
&lt;SailNo&gt;GRE-1329&lt;/SailNo&gt;
&lt;Class&gt;SUN ODYSSEY 32&lt;/Class&gt;
&lt;Designer&gt;&lt;/Designer&gt;
&lt;Builder&gt;JEANNEAU&lt;/Builder&gt;
&lt;dxtDate&gt;2008-09-18T15:29:36.000&lt;/dxtDate&gt;
&lt;CertType&gt;0&lt;/CertType&gt;
&lt;VPPVer&gt;&lt;/VPPVer&gt;
&lt;VPPYear&gt;2008&lt;/VPPYear&gt;
&lt;IsOd&gt;False&lt;/IsOd&gt;
&lt;GPH&gt;&lt;/GPH&gt;
&lt;Age&gt;1998&lt;/Age&gt;
&lt;Override&gt;&lt;/Override&gt;
&lt;Provisional&gt;False&lt;/Provisional&gt;
&lt;Selected&gt;False&lt;/Selected&gt;
&lt;CanSelect&gt;False&lt;/CanSelect&gt;
&lt;/ROW&gt;
&lt;/ROOT&gt;
&lt;/DATA&gt;
* </pre>
*
* This also shows that some query results do not provide a valid link to a downloadable copy of the certificate. Those
* are automatically removed from the results here.
* An interface to {@code data.orc.org}, backed by the surviving {@code WPub.dll} JSON actions at
* {@code https://data.orc.org/public/WPub.dll}. It can search for certificates by a number of criteria, among them the
* issuing country, sail number, ORC reference number, or boat name. Those search results are represented as
* {@link CertificateHandle} objects, obtained either from the single-certificate action
* {@code ?action=DownBoatRMS&RefNo=<ref>&ext=json} or from the per-country bulk action
* {@code ?action=DownRMS&CountryId=<CC>&ext=json}. Such handles can be resolved into {@link ORCCertificate} objects
* through {@link #getCertificates(Iterable)} or {@link #getCertificates(CertificateHandle...)}. The JSON actions only
* ever return currently-valid certificates, so every handle returned is
* {@link CertificateHandle#isValid() valid}; the historic {@code includeInvalid} parameter of
* {@link #search(CountryCode, Integer, String, String, String, String, boolean)} is retained for API compatibility
* only and no longer has an effect.
*
* @author Axel Uhl (D043530)
*
@@ -137,20 +69,11 @@ public interface ORCPublicCertificateDatabase {
}
/**
* Data about valid certificates in a country, as obtained, e.g., from http://data.orc.org/public/WPub.dll/RMS. Such
* a record, in its original XML representation, looks like this:
*
* <pre>
&lt;CountryId&gt;AUS&lt;/CountryId&gt;
&lt;Family&gt;&lt;/Family&gt;
&lt;CertType&gt;3&lt;/CertType&gt;
&lt;VPPYear&gt;2020&lt;/VPPYear&gt;
&lt;CertCount&gt;161&lt;/CertCount&gt;
&lt;CountryName&gt;Australia&lt;/CountryName&gt;
&lt;LastUpdate&gt;2021-01-08T08:12:57.000Z&lt;/LastUpdate&gt;
&lt;CertName&gt;Club&lt;/CertName&gt;
&lt;RMSCode&gt;CLUB&lt;/RMSCode&gt;
* </pre>
* Data about valid certificates in a country. The country list is read from the top-level {@code Countries} array
* of the {@code https://data.orc.org/public/WPub.dll?action=DownRMS&ext=json} response, so only
* {@link #getIssuingCountry()}, {@link #getCertName()} and {@link #getRMSCode()} are populated;
* {@link #getVPPYear()} is {@code null}, {@link #getCertCount()} is {@code 0} and {@link #getLastUpdate()} is
* {@code null}, as the JSON country list does not carry those.
*
* Mappings of family and type work as follows:
*
@@ -348,6 +271,5 @@ public interface ORCPublicCertificateDatabase {
Date parseDate(final String dateString) throws DateTimeParseException;
Iterable<CountryOverview> getCountriesWithValidCertificates()
throws SAXException, IOException, ParserConfigurationException, DOMException, ParseException;
Iterable<CountryOverview> getCountriesWithValidCertificates() throws IOException;
}
@@ -1,16 +1,16 @@
package com.sap.sailing.domain.orc.impl;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.io.InputStreamReader;
import java.io.Reader;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
@@ -30,24 +30,16 @@ import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.ParserConfigurationException;
import org.apache.commons.io.ByteOrderMark;
import org.apache.commons.io.input.BOMInputStream;
import org.apache.http.HttpMessage;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import com.sap.sailing.domain.base.BoatClass;
import com.sap.sailing.domain.common.BoatClassMasterdata;
@@ -59,35 +51,36 @@ import com.sap.sse.common.TimePoint;
import com.sap.sse.common.Util;
import com.sap.sse.common.impl.MillisecondsTimePoint;
import com.sap.sse.util.LaxRedirectStrategyForAllRedirectResponseCodes;
import com.sap.sse.util.XmlUtil;
public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDatabase {
private static final Logger logger = Logger.getLogger(ORCPublicCertificateDatabaseImpl.class.getName());
private static final String USER_NAME = "Sailing_Analytics@sap.com";
private static final String CREDENTIALS = "QUdIVUJU";
private static final String ACTION_PARAM_NAME = "action";
private static final String ACTION_PARAM_VALUE_LIST_CERT = "ListCert";
private static final String XSLP_PARAM_NAME = "xslp";
private static final String XSLP_PARAM_VALUE_LIST_CERT = "ListCert.php";
private static final String COUNTRY_PARAM_NAME = "CountryId";
private static final String VPP_YEAR_PARAM_NAME = "VPPYear";
private static final String REF_NO_PARAM_NAME = "RefNo";
private static final String YACHT_NAME_PARAM_NAME = "YachtName";
private static final String SAIL_NUMBER_PARAM_NAME = "SailNo";
private static final String BOAT_CLASS_PARAM_NAME = "Class";
private static final NameValuePair ACTION_PARAM = new BasicNameValuePair(ACTION_PARAM_NAME, ACTION_PARAM_VALUE_LIST_CERT);
private static final NameValuePair XSLP_PARAM = new BasicNameValuePair(XSLP_PARAM_NAME, XSLP_PARAM_VALUE_LIST_CERT);
private static final String SEARCH_URL = "http://data.orc.org/public/WPub.dll";
private static final String COUNTRY_OVERVIEW_URL = SEARCH_URL+"/RMS";
private static final String SEARCH_URL = "https://data.orc.org/public/WPub.dll";
private static final String SINGLE_CERTIFICATE_DOWNLOAD_URL = SEARCH_URL+"?action=DownBoatRMS";
// For possible future use: to download all certificates of, e.g., a country in one go, use the following action:
// private static final String MANY_CERTIFICATES_DOWNLOAD_URL = SEARCH_URL+"?action=DownRMS";
private static final String ROOT_ELEMENT = "ROOT";
private static final String DATA_ELEMENT = "DATA";
private static final String ROW_ELEMENT = "ROW";
private static final String DXT_SUFFIX = ".dxt";
private static final String BULK_CERTIFICATES_DOWNLOAD_URL = SEARCH_URL+"?action=DownRMS";
private static final String EXT_JSON = "ext=json";
// The legacy XML country-overview action ({@code SEARCH_URL+"/RMS"}) still responds, but the country list is now
// read from the JSON {@code DownRMS} action's top-level {@code Countries} array to keep a single JSON transport.
private static final String RMS_ARRAY_KEY = "rms";
private static final String COUNTRIES_ARRAY_KEY = "Countries";
private static final String COUNTRY_ID_KEY = "CountryId";
private static final String COUNTRY_NAME_KEY = "Name";
private static final String ORC_PSEUDO_COUNTRY_ID = "ORC";
private static final String KEY_REF_NO = "RefNo";
private static final String KEY_NAT_AUTH = "NatAuth";
private static final String KEY_BIN = "BIN";
private static final String KEY_GPH = "GPH";
private static final String KEY_YACHT_NAME = "YachtName";
private static final String KEY_SAIL_NO = "SailNo";
private static final String KEY_CLASS = "Class";
private static final String KEY_DESIGNER = "Designer";
private static final String KEY_BUILDER = "Builder";
private static final String KEY_AGE_YEAR = "Age_Year";
private static final String KEY_ISSUE_DATE = "IssueDate";
private static final String KEY_FAMILY = "Family";
private static final DateTimeFormatter[] DATE_FORMATTERS =
new DateTimeFormatter[] {
@@ -335,225 +328,258 @@ public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDat
", isValid=" + isValid + "]";
}
}
@Override
public Iterable<CertificateHandle> search(CountryCode issuingCountry, Integer yearOfIssuance, String referenceNumber,
String yachtName, String sailNumber, String boatClassName, boolean includeInvalid) throws Exception {
final Set<ORCPublicCertificateDatabase.CertificateHandle> result = new HashSet<>();
/**
* Performs an authenticated HTTP GET against {@code url} and parses the response body as a JSON object. The ORC
* {@code WPub.dll} JSON responses are prefixed with a byte-order mark, so the stream is wrapped in a
* {@link BOMInputStream} and decoded with the detected charset (UTF-8 otherwise), mirroring the BOM handling in
* {@link AbstractORCCertificatesImporter} which this class cannot reuse directly. Any
* {@link org.json.simple.parser.ParseException} is wrapped in an {@link IOException} so that no {@code org.json}
* type leaks into the signatures of the calling methods.
*/
private JSONObject httpGetJson(final String url) throws IOException {
final HttpClient client = HttpClientBuilder.create().setRedirectStrategy(new LaxRedirectStrategyForAllRedirectResponseCodes()).build();
final List<NameValuePair> params = new ArrayList<>();
params.add(ACTION_PARAM);
params.add(XSLP_PARAM);
params.add(new BasicNameValuePair(COUNTRY_PARAM_NAME, issuingCountry==null?"*":issuingCountry.getThreeLetterIOCCode()));
params.add(new BasicNameValuePair(VPP_YEAR_PARAM_NAME, yearOfIssuance==null?"0":yearOfIssuance.toString()));
if (referenceNumber != null) {
params.add(new BasicNameValuePair(REF_NO_PARAM_NAME, referenceNumber));
final HttpGet getRequest = new HttpGet(url);
addAuthorizationHeader(getRequest);
logger.fine(()->"Fetching ORC JSON from "+url);
final InputStream content = client.execute(getRequest).getEntity().getContent();
final BOMInputStream bomInputStream = new BOMInputStream(content);
final ByteOrderMark bom = bomInputStream.getBOM();
final String charsetName = bom == null ? "UTF-8" : bom.getCharsetName();
final Reader reader = new InputStreamReader(new BufferedInputStream(bomInputStream), charsetName);
final JSONObject root;
try {
root = (JSONObject) new JSONParser().parse(reader);
} catch (final org.json.simple.parser.ParseException e) {
throw new IOException("Could not parse ORC JSON response from "+url, e);
}
if (yachtName != null) {
params.add(new BasicNameValuePair(YACHT_NAME_PARAM_NAME, yachtName));
return root;
}
/**
* Opens an authenticated HTTP GET stream to the per-country bulk certificate JSON action
* {@code ?action=DownRMS&CountryId=<CC>&ext=json}, built from this class's own endpoint constants so that no caller
* has to restate the ORC {@code WPub.dll} host, path or query shape. The returned raw stream still carries the
* byte-order mark the ORC responses are prefixed with; callers are expected to feed it to
* {@link ORCCertificatesJsonImporter#read(InputStream)}, which strips the BOM via
* {@link AbstractORCCertificatesImporter#getReaderForInputStream(InputStream)}. The {@code .impl} package is
* exported with an {@code x-friends} restriction, so this method is reachable only from the declared friend bundles
* such as {@code com.sap.sailing.domain.test}.
*/
public InputStream openBulkCertificatesJsonStream(final CountryCode country) throws IOException {
final String url = BULK_CERTIFICATES_DOWNLOAD_URL + "&" + COUNTRY_ID_KEY + "=" + country.getThreeLetterIOCCode()
+ "&" + EXT_JSON;
final HttpClient client = HttpClientBuilder.create().setRedirectStrategy(new LaxRedirectStrategyForAllRedirectResponseCodes()).build();
final HttpGet getRequest = new HttpGet(url);
addAuthorizationHeader(getRequest);
logger.fine(()->"Opening ORC bulk certificate JSON stream from "+url);
final InputStream content = client.execute(getRequest).getEntity().getContent();
return content;
}
/**
* Maps one {@code rms} row of the ORC {@code WPub.dll} JSON response to a {@link CertificateHandle}. The JSON keys
* differ from the historic XML element names: {@code NatAuth} carries the IOC country code (formerly
* {@code CountryId}), {@code BIN} serves as the {@link CertificateHandle#getFileId() file id} (formerly
* {@code dxtName}), {@code IssueDate} carries the issue instant (formerly {@code dxtDate}) and {@code Age_Year}
* carries the build year (formerly {@code Age}). The JSON action only ever returns finalized, valid certificates,
* so {@link CertificateHandle#isValid()} is always {@link Boolean#TRUE} and
* {@link CertificateHandle#isProvisional()} is {@link Boolean#FALSE}. There is no JSON equivalent for the former
* {@code SSSID}, {@code DatInGID}, {@code CertType} and {@code IsOd} fields, so those are left {@code null}.
*/
private CertificateHandle parseHandleFromJson(final JSONObject row) {
final String natAuth = asTrimmedString(row.get(KEY_NAT_AUTH));
final CountryCode issuingCountry = natAuth == null ? null
: CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(natAuth);
final String fileId = asTrimmedString(row.get(KEY_BIN));
final Double gph = asDouble(row.get(KEY_GPH));
final String referenceNumber = asTrimmedString(row.get(KEY_REF_NO));
final CertificateFamily family = familyFromString(asTrimmedString(row.get(KEY_FAMILY)));
final String yachtName = asString(row.get(KEY_YACHT_NAME));
final String sailNumber = asString(row.get(KEY_SAIL_NO));
final String boatClassName = asString(row.get(KEY_CLASS));
final String designer = asString(row.get(KEY_DESIGNER));
final String builder = asString(row.get(KEY_BUILDER));
final Integer yearBuilt = asInteger(row.get(KEY_AGE_YEAR));
final String issueDateString = asTrimmedString(row.get(KEY_ISSUE_DATE));
final TimePoint issueDate = issueDateString == null ? null
: new MillisecondsTimePoint(parseDate(issueDateString));
return new CertificateHandleImpl(issuingCountry, fileId, gph, /* sssid */ null, /* datInGID */ null,
referenceNumber, family, yachtName, sailNumber, boatClassName, designer, builder, yearBuilt, issueDate,
/* certType */ null, /* oneDesign */ null, /* isProvisional */ Boolean.FALSE, /* isValid */ Boolean.TRUE);
}
private static String asString(final Object value) {
return value == null ? null : value.toString();
}
private static String asTrimmedString(final Object value) {
final String asString = asString(value);
final String trimmed = asString == null ? null : asString.trim();
return trimmed == null || trimmed.isEmpty() ? null : trimmed;
}
private static Double asDouble(final Object value) {
final Double result;
if (value == null) {
result = null;
} else if (value instanceof Number) {
result = ((Number) value).doubleValue();
} else {
Double parsed;
try {
parsed = Double.valueOf(value.toString().trim());
} catch (final NumberFormatException e) {
parsed = null;
}
result = parsed;
}
if (sailNumber != null) {
params.add(new BasicNameValuePair(SAIL_NUMBER_PARAM_NAME, sailNumber));
return result;
}
private static Integer asInteger(final Object value) {
final Integer result;
if (value == null) {
result = null;
} else if (value instanceof Number) {
result = ((Number) value).intValue();
} else {
Integer parsed;
try {
parsed = Integer.valueOf(value.toString().trim());
} catch (final NumberFormatException e) {
parsed = null;
}
result = parsed;
}
if (boatClassName != null) {
params.add(new BasicNameValuePair(BOAT_CLASS_PARAM_NAME, boatClassName));
}
final UrlEncodedFormEntity parametersEntity = new UrlEncodedFormEntity(params);
final HttpPost postRequest = new HttpPost(SEARCH_URL);
postRequest.setEntity(parametersEntity);
addAuthorizationHeader(postRequest);
logger.fine(()->"Searching for "+params+"...");
final HttpResponse processorResponse = client.execute(postRequest);
final InputStream content = processorResponse.getEntity().getContent();
final DocumentBuilder builder = XmlUtil.getSecureDocumentBuilderFactory().newDocumentBuilder();
final Document document = builder.parse(content);
final NodeList dataNodes = document.getElementsByTagName(ROOT_ELEMENT).item(0).getChildNodes();
for (int dataNodeIndex=0; dataNodeIndex<dataNodes.getLength(); dataNodeIndex++) {
final Node dataNode = dataNodes.item(dataNodeIndex);
if (dataNode.getNodeName().equals(DATA_ELEMENT)) {
final NodeList rowNodes = dataNode.getChildNodes();
for (int rowNodeIndex=0; rowNodeIndex<rowNodes.getLength(); rowNodeIndex++) {
final Node rowNode = rowNodes.item(rowNodeIndex);
if (rowNode.getNodeName().equals(ROW_ELEMENT)) {
final CertificateHandle certificateHandle = parseHandle(rowNode);
// certificates can only be obtained if they are currently valid, so ignore expired ones
if (includeInvalid || certificateHandle.isValid()) {
result.add(certificateHandle);
}
}
return result;
}
/**
* Resolves the JSON {@code Family} string (e.g. {@code "ORC"}) to a {@link CertificateFamily} by matching it
* case-insensitively against each family's {@link CertificateFamily#getFamilyQueryParamValue()}. Defaults to
* {@link CertificateFamily#ORC} when {@code familyString} is {@code null} or unrecognized, because the JSON actions
* only return standard ORC certificates today.
*/
private static CertificateFamily familyFromString(final String familyString) {
CertificateFamily result = CertificateFamily.ORC;
if (familyString != null) {
for (final CertificateFamily candidate : CertificateFamily.values()) {
if (candidate.getFamilyQueryParamValue().equalsIgnoreCase(familyString)) {
result = candidate;
}
}
}
logger.fine(()->"Search for "+params+" returned "+result.size()+" results");
return result;
}
@Override
public Iterable<CountryOverview> getCountriesWithValidCertificates()
throws SAXException, IOException, ParserConfigurationException, DOMException, ParseException {
final Set<ORCPublicCertificateDatabase.CountryOverview> result = new HashSet<>();
final HttpClient client = HttpClientBuilder.create().setRedirectStrategy(new LaxRedirectStrategyForAllRedirectResponseCodes()).build();
final List<NameValuePair> params = new ArrayList<>();
final HttpGet getRequest = new HttpGet(COUNTRY_OVERVIEW_URL);
addAuthorizationHeader(getRequest);
logger.fine(()->"Searching for "+params+"...");
final HttpResponse processorResponse = client.execute(getRequest);
final InputStream content = processorResponse.getEntity().getContent();
final DocumentBuilder builder = XmlUtil.getSecureDocumentBuilderFactory().newDocumentBuilder();
final Document document = builder.parse(content);
final NodeList dataNodes = document.getElementsByTagName(ROOT_ELEMENT).item(0).getChildNodes();
for (int dataNodeIndex=0; dataNodeIndex<dataNodes.getLength(); dataNodeIndex++) {
final Node dataNode = dataNodes.item(dataNodeIndex);
if (dataNode.getNodeName().equals(DATA_ELEMENT)) {
final NodeList rowNodes = dataNode.getChildNodes();
for (int rowNodeIndex=0; rowNodeIndex<rowNodes.getLength(); rowNodeIndex++) {
final Node rowNode = rowNodes.item(rowNodeIndex);
if (rowNode.getNodeName().equals(ROW_ELEMENT)) {
final CountryOverview countryOverview = parseCountryOverview(rowNode);
if (countryOverview.getVPPYear() != null) {
result.add(countryOverview);
}
}
public Iterable<CertificateHandle> search(final CountryCode issuingCountry, final Integer yearOfIssuance,
final String referenceNumber, final String yachtName, final String sailNumber, final String boatClassName,
final boolean includeInvalid) throws Exception {
// includeInvalid is retained for API compatibility; the JSON actions only ever return valid certificates, so
// every handle produced here is valid and the flag has no effect.
final Set<ORCPublicCertificateDatabase.CertificateHandle> result = new HashSet<>();
final String trimmedReferenceNumber = referenceNumber == null ? null : referenceNumber.trim();
if (trimmedReferenceNumber != null && !trimmedReferenceNumber.isEmpty()) {
final JSONObject root = httpGetJson(SINGLE_CERTIFICATE_DOWNLOAD_URL+"&"+EXT_JSON+"&"+KEY_REF_NO+"="+trimmedReferenceNumber);
addMatchingHandles(root, result, yachtName, sailNumber, boatClassName, yearOfIssuance);
} else if (issuingCountry != null) {
final JSONObject root = httpGetJson(BULK_CERTIFICATES_DOWNLOAD_URL+"&"+COUNTRY_ID_KEY+"="+issuingCountry.getThreeLetterIOCCode()+"&"+EXT_JSON);
addMatchingHandles(root, result, yachtName, sailNumber, boatClassName, yearOfIssuance);
} else {
logger.warning("ORC JSON search requires either a reference number or an issuing country; neither was "+
"given, returning no results (yachtName="+yachtName+", sailNumber="+sailNumber+", boatClassName="+
boatClassName+")");
}
logger.fine(()->"ORC search returned "+result.size()+" results");
return result;
}
private void addMatchingHandles(final JSONObject root, final Set<CertificateHandle> result, final String yachtName,
final String sailNumber, final String boatClassName, final Integer yearOfIssuance) {
final JSONArray rows = (JSONArray) root.get(RMS_ARRAY_KEY);
if (rows != null) {
for (final Object rowObject : rows) {
final CertificateHandle handle = parseHandleFromJson((JSONObject) rowObject);
if (matchesClientFilters(handle, yachtName, sailNumber, boatClassName, yearOfIssuance)) {
result.add(handle);
}
}
}
logger.fine(()->"Search for "+params+" returned "+result.size()+" results");
return result;
}
private CountryOverview parseCountryOverview(Node rowNode) throws DOMException, ParseException {
final DateFormat isoDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.mmmX");
CountryCode issuingCountry = null;
CertificateFamily family = null;
Integer certType = null;
Integer vppYear = null;
int certCount = 0;
TimePoint lastUpdate = null;
String certName = null;
String rmsCode = null;
for (int i=0; i<rowNode.getChildNodes().getLength(); i++) {
final Node child = rowNode.getChildNodes().item(i);
switch (child.getNodeName()) {
case "CountryId":
issuingCountry = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(child.getTextContent().trim());
break;
case "Family":
family = Util.hasLength(child.getTextContent().trim()) ? CertificateFamily.fromId(Integer.valueOf(child.getTextContent().trim())) : null;
break;
case "CertType":
certType = child.getTextContent().trim().isEmpty() ? null : Integer.valueOf(child.getTextContent().trim());
break;
case "VPPYear":
vppYear = child.getTextContent().trim().isEmpty() ? null : Integer.valueOf(child.getTextContent().trim());
break;
case "CertCount":
certCount = Integer.valueOf(child.getTextContent().trim()).intValue();
break;
case "LastUpdate":
lastUpdate = TimePoint.of(isoDateFormat.parse(child.getTextContent().trim()));
break;
case "CertName":
certName = child.getTextContent().trim();
break;
case "RMSCode":
rmsCode = child.getTextContent().trim();
break;
/**
* Applies the optional client-side restrictions to a {@link CertificateHandle} obtained from the JSON actions. A
* {@code null} criterion imposes no restriction. The {@code sailNumber} comparison normalizes both sides to their
* alphanumeric core (uppercased) so that {@code "DEN 13"}, {@code "DEN-13"}, {@code "DEN13"} and the
* {@code %}-bearing fuzzy variants all collapse to a comparable form. The {@code yearOfIssuance} is compared
* against the year of the handle's {@link CertificateHandle#getIssueDate() issue date}; note that the JSON actions
* only return currently valid certificates, so a historical year effectively matches nothing and year search is
* limited to the current issuance year.
*/
private static boolean matchesClientFilters(final CertificateHandle handle, final String yachtName,
final String sailNumber, final String boatClassName, final Integer yearOfIssuance) {
boolean match = true;
if (yachtName != null) {
final String normalizedFilter = normalizeContains(yachtName);
match = match && handle.getYachtName() != null
&& handle.getYachtName().toLowerCase().contains(normalizedFilter);
}
if (boatClassName != null) {
final String normalizedFilter = normalizeContains(boatClassName);
match = match && handle.getBoatClassName() != null
&& handle.getBoatClassName().toLowerCase().contains(normalizedFilter);
}
if (sailNumber != null) {
final String normalizedFilter = normalizeSailNumber(sailNumber);
match = match && handle.getSailNumber() != null
&& normalizeSailNumber(handle.getSailNumber()).contains(normalizedFilter);
}
if (yearOfIssuance != null) {
match = match && handle.getIssueDate() != null
&& Instant.ofEpochMilli(handle.getIssueDate().asMillis()).atOffset(ZoneOffset.UTC)
.getYear() == yearOfIssuance.intValue();
}
return match;
}
private static String normalizeContains(final String value) {
return value.replace("%", "").toLowerCase().trim();
}
private static String normalizeSailNumber(final String sailNumber) {
return sailNumber.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
}
@Override
public Iterable<CountryOverview> getCountriesWithValidCertificates() throws IOException {
// Read the country list from the JSON DownRMS action's top-level "Countries" array (the legacy /RMS XML action
// remains available as a fallback). The JSON does not carry a VPP year or certificate counts, so those are left
// unset; the first pseudo-entry "ORC" ("Offshore Racing Congress") is skipped.
final Set<ORCPublicCertificateDatabase.CountryOverview> result = new HashSet<>();
final JSONObject root = httpGetJson(BULK_CERTIFICATES_DOWNLOAD_URL+"&"+EXT_JSON);
final JSONArray countries = (JSONArray) root.get(COUNTRIES_ARRAY_KEY);
if (countries != null) {
for (final Object countryObject : countries) {
final JSONObject country = (JSONObject) countryObject;
final String countryId = asTrimmedString(country.get(COUNTRY_ID_KEY));
final String countryName = asString(country.get(COUNTRY_NAME_KEY));
if (countryId != null && !ORC_PSEUDO_COUNTRY_ID.equalsIgnoreCase(countryId)) {
final CountryCode issuingCountry = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(countryId);
result.add(new CountryOverviewImpl(issuingCountry, CertificateFamily.ORC, /* certType */ null,
/* vppYear */ null, /* certCount */ 0, /* lastUpdate */ null, countryName, countryId));
}
}
}
return new CountryOverviewImpl(issuingCountry, family, certType, vppYear, certCount, lastUpdate, certName, rmsCode);
logger.fine(()->"ORC country overview returned "+result.size()+" countries");
return result;
}
private void addAuthorizationHeader(final HttpMessage httpMessage) {
httpMessage.addHeader("Authorization", "Basic "+new String(Base64.getEncoder().encode((USER_NAME+":"+getDecodedCredentials()).getBytes())));
}
private CertificateHandle parseHandle(Node rowNode) throws DOMException, ParseException {
assert rowNode.getNodeName().equals(ROW_ELEMENT);
CountryCode issuingCountry = null;
String fileId = null;
String sssid = null;
Double gph = null;
UUID datInGID = null;
String referenceNumber = null;
CertificateFamily family = CertificateFamily.UNKNOWN;
String yachtName = null;
String sailNumber = null;
String boatClassName = null;
String designer = null;
String builder = null;
Integer yearBuilt = null;
Integer certType = null;
TimePoint issueDate = null;
Boolean isOneDesign = null;
Boolean isProvisional = null;
Boolean isValid = null;
for (int i=0; i<rowNode.getChildNodes().getLength(); i++) {
final Node child = rowNode.getChildNodes().item(i);
switch (child.getNodeName()) {
case "CountryId":
issuingCountry = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(child.getTextContent().trim());
break;
case "SSSID":
sssid = child.getTextContent();
break;
case "GPH":
gph = child.getTextContent().trim().isEmpty() ? null : Double.valueOf(child.getTextContent().trim());
break;
case "DatInGID":
datInGID = UUID.fromString(child.getTextContent().trim().replaceAll("[{}]", ""));
break;
case "RefNo":
referenceNumber = child.getTextContent().trim();
break;
case "Family":
family = child.getTextContent().trim().isEmpty() ? CertificateFamily.UNKNOWN : CertificateFamily.fromId(Integer.valueOf(child.getTextContent().trim()));
break;
case "YachtName":
yachtName = child.getTextContent();
break;
case "SailNo":
sailNumber = child.getTextContent();
break;
case "Class":
boatClassName = child.getTextContent();
break;
case "Designer":
designer = child.getTextContent();
break;
case "Builder":
builder = child.getTextContent();
break;
case "dxtDate":
issueDate = new MillisecondsTimePoint(parseDate(child.getTextContent())); // assume UTC
break;
case "dxtName":
final String fileIdWithOptionalDxtSuffix = child.getTextContent();
fileId = fileIdWithOptionalDxtSuffix.toLowerCase().endsWith(DXT_SUFFIX) ?
fileIdWithOptionalDxtSuffix.substring(0, fileIdWithOptionalDxtSuffix.length()-DXT_SUFFIX.length()) :
fileIdWithOptionalDxtSuffix;
break;
case "CertType":
certType = Integer.valueOf(child.getTextContent().trim());
break;
case "Age":
yearBuilt = child.getTextContent().trim().isEmpty() ? null : Integer.valueOf(child.getTextContent().trim());
break;
case "IsOd":
isOneDesign = Boolean.valueOf(child.getTextContent());
break;
case "Provisional":
isProvisional = Boolean.valueOf(child.getTextContent());
break;
case "CanSelect":
isValid = Boolean.valueOf(child.getTextContent());
break;
}
}
return new CertificateHandleImpl(issuingCountry, fileId, gph, sssid, datInGID, referenceNumber, family==null?CertificateFamily.UNKNOWN:family,
yachtName, sailNumber, boatClassName, designer, builder, yearBuilt, issueDate, certType, isOneDesign,
isProvisional, isValid);
}
@Override
public Date parseDate(final String dateString) throws DateTimeParseException {
Optional<LocalDateTime> parsedDateWithoutZone = Arrays.asList(DATE_FORMATTERS).stream()
@@ -591,21 +617,19 @@ public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDat
@Override
public ORCCertificate searchForUpdate(CertificateHandle certificateHandle)
throws ClientProtocolException, IOException, org.json.simple.parser.ParseException {
return searchForUpdate(certificateHandle.getIssuingCountry(), certificateHandle.getFileId(), certificateHandle.getReferenceNumber());
return searchForUpdate(certificateHandle.getReferenceNumber());
}
@Override
public ORCCertificate searchForUpdate(ORCCertificate certificate)
throws ClientProtocolException, IOException, org.json.simple.parser.ParseException {
return searchForUpdate(certificate.getIssuingCountry(), certificate.getFileId(), certificate.getReferenceNumber());
return searchForUpdate(certificate.getReferenceNumber());
}
private ORCCertificate searchForUpdate(final CountryCode issuingCountry, final String fileId,
final String existingCertificateRefNo)
private ORCCertificate searchForUpdate(final String existingCertificateRefNo)
throws IOException, org.json.simple.parser.ParseException, ClientProtocolException {
logger.fine("Looking for update of certificate with reference number "+existingCertificateRefNo);
final String queryParameters = "ext=json&CountryId=" + issuingCountry.getThreeLetterIOCCode()+
"&dxtName="+fileId+".dxt";
final String queryParameters = EXT_JSON+"&"+KEY_REF_NO+"="+existingCertificateRefNo;
final ORCCertificate result = getSingleCertificate(queryParameters);
if (result == null) {
logger.info("Couldn't find any valid certificate for that boat");
@@ -733,7 +757,10 @@ public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDat
/**
* Varies the boat class name by first using the true boat class name, then, if nothing is found, stepping through
* the alternative names, and finally removing the boat class name constraint altogether.
* the alternative names, and finally removing the boat class name constraint altogether. Because the JSON bulk
* action needs an issuing country to return rows, the nationality is derived from the {@code sailNumber} prefix via
* {@link #countryFromSailNumber(String)}; a sail number without a resolvable three-letter IOC prefix will therefore
* yield no results.
*
* @param yachtName
* may be {@code null}
@@ -746,14 +773,15 @@ public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDat
* sequence of handles; may be {@code null}
*/
private Iterable<CertificateHandle> fuzzySearchVaryingBoatClassName(final String yachtName, final String sailNumber, final BoatClass boatClass) throws Exception {
final CountryCode issuingCountry = countryFromSailNumber(sailNumber);
String successfulBoatClassName = boatClass==null?null:boatClass.getName();
Iterable<CertificateHandle> certificateHandles = search(/* issuingCountry */ null, /* yearOfIssuance */ null, /* referenceNumber */ null, yachtName, sailNumber, successfulBoatClassName, /* includeInvalid */ false);
Iterable<CertificateHandle> certificateHandles = search(issuingCountry, /* yearOfIssuance */ null, /* referenceNumber */ null, yachtName, sailNumber, successfulBoatClassName, /* includeInvalid */ false);
if (!containsValidHandle(certificateHandles) && successfulBoatClassName != null) {
if (yachtName != null || sailNumber != null) {
logger.fine(()->"Nothing found; removing boat class name restriction "+boatClass.getName());
// try without boat class restriction
successfulBoatClassName = null;
certificateHandles = search(/* issuingCountry */ null, /* yearOfIssuance */ null, /* referenceNumber */ null, yachtName, sailNumber, successfulBoatClassName, /* includeInvalid */ false);
certificateHandles = search(issuingCountry, /* yearOfIssuance */ null, /* referenceNumber */ null, yachtName, sailNumber, successfulBoatClassName, /* includeInvalid */ false);
} else {
logger.fine(()->"No current certificates found for boat class "+boatClass.getName()+
" but yacht name and sail number are not specified either; giving up.");
@@ -775,6 +803,26 @@ public class ORCPublicCertificateDatabaseImpl implements ORCPublicCertificateDat
return certificateHandles;
}
/**
* Extracts the leading three-letter nationality prefix from a sail number (e.g. {@code "GER"} from
* {@code "GER 5711"}) and resolves it to a {@link CountryCode} via its IOC code, mirroring the prefix extraction in
* {@link #getSailNumberVariants(String)}. Returns {@code null} when {@code sailNumber} is {@code null} or has no
* resolvable three-letter prefix.
*/
private static CountryCode countryFromSailNumber(final String sailNumber) {
CountryCode result = null;
if (sailNumber != null) {
final Matcher matcher = Pattern.compile("[^A-Za-z]*([A-Za-z]*).*([0-9]*).*").matcher(sailNumber);
if (matcher.find()) {
final String country = matcher.group(1);
if (country != null && country.length() == 3) {
result = CountryCodeFactory.INSTANCE.getFromThreeLetterIOCName(country.toUpperCase());
}
}
}
return result;
}
/**
* Produces a sequence of sail number variations of which {@code sailNumber} is the first.
* It then adds separations of a recognized three-letter IOC nationality code at the beginning
@@ -68,7 +68,7 @@ public class GetEventViewAction implements SailingAction<EventViewDTO>, IsClient
final EventViewDTO dto = new EventViewDTO();
HomeServiceUtil.mapToMetadataDTO(event, dto);
ImageDescriptor logoImage = event.findImageWithTag(MediaTagConstants.LOGO.getName());
dto.setLogoImage(logoImage != null ? HomeServiceUtil.convertToImageDTO(logoImage) : null);
dto.setLogoImage(logoImage != null && !logoImage.isMissing() ? HomeServiceUtil.convertToImageDTO(logoImage) : null);
dto.setOfficialWebsiteURL(event.getOfficialWebsiteURL() == null ? null : event.getOfficialWebsiteURL().toString());
URL sailorsInfoWebsiteURL = event.getSailorsInfoWebsiteURLOrFallback(context.getClientLocale());
dto.setSailorsInfoWebsiteURL(sailorsInfoWebsiteURL == null ? null : sailorsInfoWebsiteURL.toString());
@@ -88,7 +88,7 @@ public class GetEventSeriesViewAction implements SailingAction<EventSeriesViewDT
EventSeriesViewDTO dto = new EventSeriesViewDTO();
dto.setLeaderboardGroupUUID(leaderBoardGroup.getId());
ImageDescriptor logoImage = o.findImageWithTag(MediaTagConstants.LOGO.getName());
dto.setLogoImage(logoImage != null ? HomeServiceUtil.convertToImageDTO(logoImage) : null);
dto.setLogoImage(logoImage != null && !logoImage.isMissing() ? HomeServiceUtil.convertToImageDTO(logoImage) : null);
// TODO implement correctly. We currently do not show media for series.
dto.setHasMedia(false);
@@ -39,7 +39,7 @@ public abstract class StageTeaser extends Composite implements LazyLoadable {
@Override
public void doInitializeLazyComponents() {
final String stageImageUrl = event.getThumbnailImageURL() != null ? event.getThumbnailImageURL() :
final String stageImageUrl = event.getThumbnailImageURL() != null ? event.getThumbnailImageURL() :
SharedHomeResources.INSTANCE.defaultStageEventTeaserImage().getSafeUri().asString();
final String backgroundImage = "url(" + stageImageUrl + ")";
teaserImage.getStyle().setBackgroundImage(backgroundImage);
@@ -7,7 +7,6 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map.Entry;
@@ -69,8 +68,8 @@ public final class HomeServiceUtil {
private static final int MINIMUM_IMAGE_HEIGHT_FOR_SAILING_PHOTOGRAPHY_IN_PIXELS = 500;
public static String findEventThumbnailImageUrlAsString(EventBase event) {
ImageDescriptor url = findEventThumbnailImage(event);
return url == null ? null : url.getURL().toString();
ImageDescriptor image = findEventThumbnailImage(event);
return image == null || image.isMissing() ? null : image.getURL().toString();
}
public static boolean isSingleRegatta(Event event) {
@@ -134,41 +133,31 @@ public final class HomeServiceUtil {
public static String getFeaturedImageUrlAsString(EventBase event) {
ImageDescriptor image = getFeaturedImage(event);
return image == null ? null : image.getURL().toString();
return image == null || image.isMissing() ? null : image.getURL().toString();
}
public static String getStageImageURLAsString(final EventBase event) {
ImageDescriptor image = getStageImage(event);
return image == null ? null : image.getURL().toString();
return image == null || image.isMissing() ? null : image.getURL().toString();
}
public static ImageDescriptor getStageImage(final EventBase event) {
return event.findImageWithTag(MediaTagConstants.STAGE.getName());
}
public static List<String> getPhotoGalleryImageURLsAsString(EventBase event) {
List<ImageDescriptor> urls = getPhotoGalleryImages(event);
List<String> result = new ArrayList<String>(urls.size());
for (ImageDescriptor url : urls) {
result.add(url.getURL().toString());
}
return result;
public static Iterable<String> getPhotoGalleryImageURLsAsString(EventBase event) {
return Util.map(getPhotoGalleryImages(event), url->url.getURL().toString());
}
public static List<ImageDescriptor> getPhotoGalleryImages(EventBase event) {
return event.findImagesWithTag(MediaTagConstants.GALLERY.getName());
public static Iterable<ImageDescriptor> getPhotoGalleryImages(EventBase event) {
return Util.filter(event.findImagesWithTag(MediaTagConstants.GALLERY.getName()), image->!image.isMissing());
}
public static List<ImageDescriptor> getSailingLovesPhotographyImages(EventBase event) {
final List<ImageDescriptor> acceptedImages = new LinkedList<>();
for (ImageDescriptor candidateImageUrl : event.getImages()) {
if (candidateImageUrl.hasSize() && candidateImageUrl.getHeightInPx() > MINIMUM_IMAGE_HEIGHT_FOR_SAILING_PHOTOGRAPHY_IN_PIXELS) {
if (candidateImageUrl.hasTag(MediaTagConstants.STAGE.getName()) || candidateImageUrl.hasTag(MediaTagConstants.GALLERY.getName())) {
acceptedImages.add(candidateImageUrl);
}
}
}
return acceptedImages;
public static Iterable<ImageDescriptor> getSailingLovesPhotographyImages(EventBase event) {
return Util.filter(event.getImages(), candidateImageUrl->
!candidateImageUrl.isMissing() && candidateImageUrl.hasSize()
&& candidateImageUrl.getHeightInPx() > MINIMUM_IMAGE_HEIGHT_FOR_SAILING_PHOTOGRAPHY_IN_PIXELS
&& (candidateImageUrl.hasTag(MediaTagConstants.STAGE.getName()) || candidateImageUrl.hasTag(MediaTagConstants.GALLERY.getName())));
}
public static int calculateCompetitorsCount(Leaderboard sl) {
@@ -203,7 +192,7 @@ public final class HomeServiceUtil {
}
public static boolean hasPhotos(Event event) {
return event.hasImageWithTag(MediaTagConstants.GALLERY.getName());
return !Util.isEmpty(getPhotoGalleryImages(event));
}
public static boolean hasVideos(Event event) {
@@ -5521,7 +5521,7 @@ public class SailingServiceImpl extends ResultCachingProxiedRemoteServiceServlet
regattaRegistrationLinkSecret);
if (skipSecuritychecks || getService().getSecurityService().hasCurrentUserReadPermission(event)) {
ImageDescriptor logoImage = event.findImageWithTag(MediaTagConstants.LOGO.getName());
ImageDTO logo = logoImage != null ? HomeServiceUtil.convertToImageDTO(logoImage) : null;
ImageDTO logo = logoImage != null && !logoImage.isMissing() ? HomeServiceUtil.convertToImageDTO(logoImage) : null;
String name = HomeServiceUtil.getEventDisplayName(event);
String location = HomeServiceUtil.getLocation(event);
if ((location == null || location.isEmpty()) && event.getVenue() != null) {
@@ -288,6 +288,8 @@ public class TestStoringAndLoadingEventsAndRegattas extends AbstractMongoDBTest
image1.addTag("Tag1");
image1.addTag("Tag2");
image1.addTag("Tag3");
image1.setMissing(true);
image1.setMissingMailNotificationSent(true);
event.addImage(image1);
ImageDescriptor image2 = new ImageDescriptorImpl(new URL("http://some.host/with/some/file2.jpg"), MillisecondsTimePoint.now());
@@ -310,6 +312,8 @@ public class TestStoringAndLoadingEventsAndRegattas extends AbstractMongoDBTest
assertEquals(imageWidth, loadedImage1.getWidthInPx());
assertEquals(imageHeight, loadedImage1.getHeightInPx());
assertEquals(3, Util.size(loadedImage1.getTags()));
assertTrue(loadedImage1.isMissing());
assertTrue(loadedImage1.isMissingMailNotificationSent());
}
@Test
@@ -343,6 +347,8 @@ public class TestStoringAndLoadingEventsAndRegattas extends AbstractMongoDBTest
video1.addTag("Tag1");
video1.addTag("Tag2");
video1.addTag("Tag3");
video1.setMissing(true);
video1.setMissingMailNotificationSent(true);
event.addVideo(video1);
VideoDescriptor video2 = new VideoDescriptorImpl(new URL("http://some.host/with/some/file2.ogg"), MimeType.ogg, MillisecondsTimePoint.now());
@@ -365,6 +371,8 @@ public class TestStoringAndLoadingEventsAndRegattas extends AbstractMongoDBTest
assertEquals(videoSubtitle, loadedVideo1.getSubtitle());
assertEquals(createdAt, loadedVideo1.getCreatedAtDate());
assertEquals(3, Util.size(loadedVideo1.getTags()));
assertTrue(loadedVideo1.isMissing());
assertTrue(loadedVideo1.isMissingMailNotificationSent());
}
@Test
@@ -491,6 +491,18 @@ public interface RacingEventService extends TrackedRegattaRegistry, RegattaFetch
Map<Locale, URL> sailorsInfoWebsiteURLs, Iterable<ImageDescriptor> images, Iterable<VideoDescriptor> videos,
Iterable<String> windFinderReviewedSpotCollectionIds);
/**
* Updates the persisted health state for all images of the event whose URL matches <code>imageUrl</code>. Does not
* replicate automatically; see {@link com.sap.sailing.server.operationaltransformation.UpdateEventImageHealth}.
*/
void updateEventImageHealth(UUID eventId, String imageUrl, boolean missing, boolean missingMailNotificationSent);
/**
* Updates the persisted health state for all videos of the event whose URL matches <code>videoUrl</code>. Does not
* replicate automatically; see {@link com.sap.sailing.server.operationaltransformation.UpdateEventVideoHealth}.
*/
void updateEventVideoHealth(UUID eventId, String videoUrl, boolean missing, boolean missingMailNotificationSent);
/**
* Renames a sailing event. If a sailing event by the name <code>oldName</code> does not exist in {@link #getEvents()},
* or if a event with the name <code>newName</code> already exists, an {@link IllegalArgumentException} is thrown.
@@ -0,0 +1,22 @@
package com.sap.sailing.server.operationaltransformation;
import java.util.UUID;
import com.sap.sailing.server.interfaces.RacingEventService;
/**
* Updates the persisted health state for an image URL attached to an event.
*/
public class UpdateEventImageHealth extends UpdateEventMediaHealth {
private static final long serialVersionUID = 6923230063351853605L;
public UpdateEventImageHealth(UUID eventId, String imageUrl, boolean missing, boolean missingMailNotificationSent) {
super(eventId, imageUrl, missing, missingMailNotificationSent);
}
@Override
public Void internalApplyTo(RacingEventService toState) {
toState.updateEventImageHealth(getId(), mediaUrl, missing, missingMailNotificationSent);
return null;
}
}
@@ -0,0 +1,33 @@
package com.sap.sailing.server.operationaltransformation;
import java.util.UUID;
import com.sap.sailing.server.interfaces.RacingEventServiceOperation;
/**
* Updates the persisted health state for a media URL attached to an event.
*/
public abstract class UpdateEventMediaHealth extends AbstractEventOperation<Void> {
private static final long serialVersionUID = 2814759036548190723L;
protected final String mediaUrl;
protected final boolean missing;
protected final boolean missingMailNotificationSent;
public UpdateEventMediaHealth(UUID eventId, String mediaUrl, boolean missing, boolean missingMailNotificationSent) {
super(eventId);
this.mediaUrl = mediaUrl;
this.missing = missing;
this.missingMailNotificationSent = missingMailNotificationSent;
}
@Override
public RacingEventServiceOperation<?> transformClientOp(RacingEventServiceOperation<?> serverOp) {
return null;
}
@Override
public RacingEventServiceOperation<?> transformServerOp(RacingEventServiceOperation<?> clientOp) {
return null;
}
}
@@ -0,0 +1,22 @@
package com.sap.sailing.server.operationaltransformation;
import java.util.UUID;
import com.sap.sailing.server.interfaces.RacingEventService;
/**
* Updates the persisted health state for a video URL attached to an event.
*/
public class UpdateEventVideoHealth extends UpdateEventMediaHealth {
private static final long serialVersionUID = 4187320561438792034L;
public UpdateEventVideoHealth(UUID eventId, String videoUrl, boolean missing, boolean missingMailNotificationSent) {
super(eventId, videoUrl, missing, missingMailNotificationSent);
}
@Override
public Void internalApplyTo(RacingEventService toState) {
toState.updateEventVideoHealth(getId(), mediaUrl, missing, missingMailNotificationSent);
return null;
}
}
@@ -3,7 +3,9 @@ package com.sap.sailing.server.replication.test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.Is.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
@@ -11,6 +13,7 @@ import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.URL;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -31,6 +34,7 @@ import org.mockito.Mockito;
import com.mongodb.ConnectionString;
import com.sap.sailing.domain.base.CompetitorAndBoatStore;
import com.sap.sailing.domain.base.DomainFactory;
import com.sap.sailing.domain.base.Event;
import com.sap.sailing.domain.base.Regatta;
import com.sap.sailing.domain.base.impl.DomainFactoryImpl;
import com.sap.sailing.domain.common.RaceIdentifier;
@@ -51,6 +55,9 @@ import com.sap.sailing.domain.tracking.impl.EmptyWindStore;
import com.sap.sailing.server.impl.RacingEventServiceImpl;
import com.sap.sailing.server.interfaces.RacingEventService;
import com.sap.sailing.server.masterdata.MasterDataImporter;
import com.sap.sailing.server.operationaltransformation.CreateEvent;
import com.sap.sailing.server.operationaltransformation.UpdateEventImageHealth;
import com.sap.sailing.server.operationaltransformation.UpdateEventVideoHealth;
import com.sap.sailing.shared.server.gateway.jaxrs.AbstractSailingServerResource;
import com.sap.sse.common.Duration;
import com.sap.sse.common.TimePoint;
@@ -71,6 +78,11 @@ import com.sap.sse.security.shared.impl.User;
import com.sap.sse.security.shared.impl.UserGroupImpl;
import com.sap.sse.shared.util.Wait;
import com.sap.sse.shared.media.ImageDescriptor;
import com.sap.sse.shared.media.VideoDescriptor;
import com.sap.sse.shared.media.impl.ImageDescriptorImpl;
import com.sap.sse.shared.media.impl.VideoDescriptorImpl;
public class MediaReplicationTest extends AbstractServerReplicationTest {
@SuppressWarnings("unchecked")
public MediaReplicationTest() {
@@ -343,6 +355,57 @@ public class MediaReplicationTest extends AbstractServerReplicationTest {
final Iterable<MediaTrack> targetTracksReplica = replica.getAllMediaTracks();
compareTracks(trackOnSource, targetTracksReplica);
}
@Test
public void testEventImageHealthStateReplication() throws Exception {
final ImageDescriptor image = new ImageDescriptorImpl(new URL("http://example.com/event.jpg"), TimePoint.now());
final Event event = master.apply(new CreateEvent("Event", "Description", TimePoint.now(),
TimePoint.now().plus(Duration.ONE_DAY), "Venue", /* isPublic */ true, UUID.randomUUID(),
/* officialWebsiteURL */ null, /* baseURL */ null, Collections.emptyMap(),
Collections.singleton(image), Collections.emptyList(), Collections.emptyList()));
waitSomeTime();
ImageDescriptor replicatedImage = replica.getEvent(event.getId()).getImages().iterator().next();
assertFalse(replicatedImage.isMissing());
assertFalse(replicatedImage.isMissingMailNotificationSent());
master.apply(new UpdateEventImageHealth(event.getId(), image.getURL().toString(),
/* missing */ true, /* missingMailNotificationSent */ true));
waitSomeTime();
replicatedImage = replica.getEvent(event.getId()).getImages().iterator().next();
assertTrue(replicatedImage.isMissing());
assertTrue(replicatedImage.isMissingMailNotificationSent());
master.apply(new UpdateEventImageHealth(event.getId(), image.getURL().toString(),
/* missing */ false, /* missingMailNotificationSent */ false));
waitSomeTime();
replicatedImage = replica.getEvent(event.getId()).getImages().iterator().next();
assertFalse(replicatedImage.isMissing());
assertFalse(replicatedImage.isMissingMailNotificationSent());
}
@Test
public void testEventVideoHealthStateReplication() throws Exception {
final VideoDescriptor video = new VideoDescriptorImpl(new URL("http://example.com/event.mp4"),
MimeType.mp4, TimePoint.now());
final Event event = master.apply(new CreateEvent("Event", "Description", TimePoint.now(),
TimePoint.now().plus(Duration.ONE_DAY), "Venue", /* isPublic */ true, UUID.randomUUID(),
/* officialWebsiteURL */ null, /* baseURL */ null, Collections.emptyMap(),
Collections.emptyList(), Collections.singleton(video), Collections.emptyList()));
waitSomeTime();
VideoDescriptor replicatedVideo = replica.getEvent(event.getId()).getVideos().iterator().next();
assertFalse(replicatedVideo.isMissing());
assertFalse(replicatedVideo.isMissingMailNotificationSent());
master.apply(new UpdateEventVideoHealth(event.getId(), video.getURL().toString(),
/* missing */ true, /* missingMailNotificationSent */ true));
waitSomeTime();
replicatedVideo = replica.getEvent(event.getId()).getVideos().iterator().next();
assertTrue(replicatedVideo.isMissing());
assertTrue(replicatedVideo.isMissingMailNotificationSent());
master.apply(new UpdateEventVideoHealth(event.getId(), video.getURL().toString(),
/* missing */ false, /* missingMailNotificationSent */ false));
waitSomeTime();
replicatedVideo = replica.getEvent(event.getId()).getVideos().iterator().next();
assertFalse(replicatedVideo.isMissing());
assertFalse(replicatedVideo.isMissingMailNotificationSent());
}
private void compareTracks(MediaTrack trackOnSource, Iterable<MediaTrack> targetTracksMaster) {
Assertions.assertEquals(1, Util.size(targetTracksMaster));
@@ -0,0 +1,539 @@
package com.sap.sailing.server.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.UUID;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import com.sap.sailing.domain.base.Event;
import com.sap.sailing.server.interfaces.RacingEventService;
import com.sap.sailing.server.operationaltransformation.UpdateEventImageHealth;
import com.sap.sailing.server.operationaltransformation.UpdateEventVideoHealth;
import com.sap.sse.common.Duration;
import com.sap.sse.common.mail.MailException;
import com.sap.sse.i18n.ResourceBundleStringMessages;
import com.sap.sse.replication.ReplicationMasterDescriptor;
import com.sap.sse.security.SecurityService;
import com.sap.sse.security.shared.OwnershipAnnotation;
import com.sap.sse.security.shared.QualifiedObjectIdentifier;
import com.sap.sse.security.shared.impl.Ownership;
import com.sap.sse.security.shared.impl.User;
import com.sap.sse.shared.media.ImageDescriptor;
import com.sap.sse.shared.media.VideoDescriptor;
public class MediaHealthCheckerTest {
private static final String VALID_IMAGE_PATH = "/image";
private static final String VALID_VIDEO_PATH = "/video";
private static final String REDIRECT_PATH = "/redirect";
private static final String HTTP_ERROR_PATH = "/not-found";
private static final String HTML_PATH = "/html";
private static final String SLOW_IMAGE_PATH = "/slow-image";
private TestHttpServer server;
private byte[] imageBytes;
@BeforeEach
public void setUp() throws Exception {
imageBytes = createImageBytes();
server = new TestHttpServer();
server.start();
}
@AfterEach
public void tearDown() throws Exception {
server.stop();
}
@Test
public void testValidImageIsAvailable() throws Exception {
assertTrue(new ImageUrlHealthChecker().isImageAvailable(getUrl(VALID_IMAGE_PATH)));
}
@Test
public void testRedirectToValidImageIsAvailable() throws Exception {
assertTrue(new ImageUrlHealthChecker().isImageAvailable(getUrl(REDIRECT_PATH)));
}
@Test
public void testHttpErrorIsNotAvailable() throws Exception {
assertFalse(new ImageUrlHealthChecker().isImageAvailable(getUrl(HTTP_ERROR_PATH)));
}
@Test
public void testHtmlResponseIsNotAvailable() throws Exception {
assertFalse(new ImageUrlHealthChecker().isImageAvailable(getUrl(HTML_PATH)));
}
@Test
public void testReadTimeoutIsNotAvailable() throws Exception {
final ImageUrlHealthChecker checker = new ImageUrlHealthChecker(Duration.ONE_SECOND.divide(10));
assertFalse(checker.isImageAvailable(getUrl(SLOW_IMAGE_PATH)));
}
@Test
public void testConnectionFailureIsNotAvailable() throws Exception {
final URL url = getUrl(VALID_IMAGE_PATH);
server.stop();
assertFalse(new ImageUrlHealthChecker(Duration.ONE_SECOND).isImageAvailable(url));
}
@Test
public void testBrokenEventImageNotifiesOwnerAndPersistsState() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, times(1)).sendMail(eq("owner"), anyString(), anyString());
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ true, /* notificationSent */ true);
}
@Test
public void testAlreadyMissingAndNotifiedImageDoesNotNotifyAgain() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ true, /* notificationSent */ true);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verify(eventService, never()).apply(any(UpdateEventImageHealth.class));
}
@Test
public void testMissingImageWithoutNotificationRetriesNotification() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ true, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, times(1)).sendMail(eq("owner"), anyString(), anyString());
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ true, /* notificationSent */ true);
}
@Test
public void testRecoveredEventImageClearsPersistedState() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ true, /* notificationSent */ true);
final SecurityService securityService = mock(SecurityService.class);
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(true);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ false, /* notificationSent */ false);
}
@Test
public void testHealthyEventImageWithCleanStateIsNotUpdated() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(true);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(eventService, never()).apply(any(UpdateEventImageHealth.class));
}
@Test
public void testSameImageUrlIsCheckedOncePerRunButNotifiesBothOwners() throws Exception {
final URL imageUrl = new URL("http://example.com/shared.jpg");
final Event firstEvent = mockEvent("First Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final Event secondEvent = mockEvent("Second Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, firstEvent, "first-owner");
mockOwnership(securityService, secondEvent, "second-owner");
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Arrays.asList(firstEvent, secondEvent),
securityService, imageUrlHealthChecker, eventService);
assertEquals(1, imageUrlHealthChecker.getNumberOfChecks());
verify(securityService, times(1)).sendMail(eq("first-owner"), anyString(), anyString());
verify(securityService, times(1)).sendMail(eq("second-owner"), anyString(), anyString());
verify(eventService, times(2)).apply(any(UpdateEventImageHealth.class));
}
@Test
public void testFailedMailLeavesNotificationUnsentForRetry() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
doThrow(new MailException("test failure")).when(securityService).sendMail(anyString(), anyString(), anyString());
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ true, /* notificationSent */ false);
}
@Test
public void testBrokenEventImageDoesNotNotifyOwnerWhenOwnerNotificationsAreDisabled() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(false).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ true, /* notificationSent */ false);
}
@Test
public void testEventMediaAreNotCheckedWhenRacingEventServiceIsReplica() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
when(eventService.getMasterDescriptor()).thenReturn(mock(ReplicationMasterDescriptor.class));
new Activator(ResourceBundleStringMessages.NULL).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
assertEquals(0, imageUrlHealthChecker.getNumberOfChecks());
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verify(eventService, never()).apply(any(UpdateEventImageHealth.class));
}
@Test
public void testEventMediaAreCheckedWhenOnlySecurityServiceIsReplica() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
when(securityService.getMasterDescriptor()).thenReturn(mock(ReplicationMasterDescriptor.class));
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(true);
final RacingEventService eventService = mock(RacingEventService.class);
new Activator(ResourceBundleStringMessages.NULL).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
assertEquals(1, imageUrlHealthChecker.getNumberOfChecks());
verify(eventService, never()).apply(any(UpdateEventImageHealth.class));
}
@Test
public void testBrokenEventImageWithoutOwnerDoesNotSendMail() throws Exception {
final URL imageUrl = new URL("http://example.com/image.jpg");
final Event event = mockEvent("Event", imageUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
final TestImageUrlHealthChecker imageUrlHealthChecker = new TestImageUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
new Activator(ResourceBundleStringMessages.NULL).checkMedia(Collections.singleton(event),
securityService, imageUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verifyImageHealthUpdate(eventService, event, imageUrl, /* missing */ true, /* notificationSent */ false);
}
@Test
public void testValidVideoUrlIsReachable() throws Exception {
assertTrue(new ImageUrlHealthChecker().isUrlReachable(getUrl(VALID_VIDEO_PATH)));
}
@Test
public void testHttpErrorVideoUrlIsNotReachable() throws Exception {
assertFalse(new ImageUrlHealthChecker().isUrlReachable(getUrl(HTTP_ERROR_PATH)));
}
@Test
public void testBrokenEventVideoNotifiesOwnerAndPersistsState() throws Exception {
final URL videoUrl = new URL("http://example.com/race.mp4");
final Event event = mockEventWithVideo("Event", videoUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestVideoUrlHealthChecker videoUrlHealthChecker = new TestVideoUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, videoUrlHealthChecker, eventService);
verify(securityService, times(1)).sendMail(eq("owner"), anyString(), anyString());
verifyVideoHealthUpdate(eventService, event, videoUrl, /* missing */ true, /* notificationSent */ true);
}
@Test
public void testAlreadyMissingAndNotifiedVideoDoesNotNotifyAgain() throws Exception {
final URL videoUrl = new URL("http://example.com/race.mp4");
final Event event = mockEventWithVideo("Event", videoUrl, /* missing */ true, /* notificationSent */ true);
final SecurityService securityService = mock(SecurityService.class);
mockOwnership(securityService, event, "owner");
final TestVideoUrlHealthChecker videoUrlHealthChecker = new TestVideoUrlHealthChecker(false);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, videoUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verify(eventService, never()).apply(any(UpdateEventVideoHealth.class));
}
@Test
public void testRecoveredEventVideoClearsPersistedState() throws Exception {
final URL videoUrl = new URL("http://example.com/race.mp4");
final Event event = mockEventWithVideo("Event", videoUrl, /* missing */ true, /* notificationSent */ true);
final SecurityService securityService = mock(SecurityService.class);
final TestVideoUrlHealthChecker videoUrlHealthChecker = new TestVideoUrlHealthChecker(true);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, videoUrlHealthChecker, eventService);
verify(securityService, never()).sendMail(anyString(), anyString(), anyString());
verifyVideoHealthUpdate(eventService, event, videoUrl, /* missing */ false, /* notificationSent */ false);
}
@Test
public void testHealthyEventVideoWithCleanStateIsNotUpdated() throws Exception {
final URL videoUrl = new URL("http://example.com/race.mp4");
final Event event = mockEventWithVideo("Event", videoUrl, /* missing */ false, /* notificationSent */ false);
final SecurityService securityService = mock(SecurityService.class);
final TestVideoUrlHealthChecker videoUrlHealthChecker = new TestVideoUrlHealthChecker(true);
final RacingEventService eventService = mock(RacingEventService.class);
createActivatorWithOwnerNotificationEnabled(true).checkMedia(Collections.singleton(event),
securityService, videoUrlHealthChecker, eventService);
verify(eventService, never()).apply(any(UpdateEventVideoHealth.class));
}
private Event mockEvent(String name, URL imageUrl, boolean missing, boolean notificationSent) {
final Event event = mock(Event.class);
final ImageDescriptor image = mock(ImageDescriptor.class);
when(event.getId()).thenReturn(UUID.randomUUID());
when(event.getIdentifier()).thenReturn(mock(QualifiedObjectIdentifier.class));
when(event.getName()).thenReturn(name);
when(event.getImages()).thenReturn(Collections.singleton(image));
when(event.getVideos()).thenReturn(Collections.emptyList());
when(image.getURL()).thenReturn(imageUrl);
when(image.getTags()).thenReturn(Arrays.asList("Stage", "Teaser"));
when(image.isMissing()).thenReturn(missing);
when(image.isMissingMailNotificationSent()).thenReturn(notificationSent);
return event;
}
private Event mockEventWithVideo(String name, URL videoUrl, boolean missing, boolean notificationSent) {
final Event event = mock(Event.class);
final VideoDescriptor video = mock(VideoDescriptor.class);
when(event.getId()).thenReturn(UUID.randomUUID());
when(event.getIdentifier()).thenReturn(mock(QualifiedObjectIdentifier.class));
when(event.getName()).thenReturn(name);
when(event.getImages()).thenReturn(Collections.emptyList());
when(event.getVideos()).thenReturn(Collections.singleton(video));
when(video.getURL()).thenReturn(videoUrl);
when(video.getTags()).thenReturn(Arrays.asList("Stage"));
when(video.isMissing()).thenReturn(missing);
when(video.isMissingMailNotificationSent()).thenReturn(notificationSent);
return event;
}
private void verifyImageHealthUpdate(RacingEventService eventService, Event event, URL imageUrl,
boolean missing, boolean notificationSent) {
final ArgumentCaptor<UpdateEventImageHealth> operationCaptor =
ArgumentCaptor.forClass(UpdateEventImageHealth.class);
verify(eventService, times(1)).apply(operationCaptor.capture());
final RacingEventService target = mock(RacingEventService.class);
operationCaptor.getValue().internalApplyTo(target);
verify(target, times(1)).updateEventImageHealth(event.getId(), imageUrl.toString(), missing, notificationSent);
}
private void verifyVideoHealthUpdate(RacingEventService eventService, Event event, URL videoUrl, boolean missing, boolean notificationSent) {
final ArgumentCaptor<UpdateEventVideoHealth> operationCaptor = ArgumentCaptor.forClass(UpdateEventVideoHealth.class);
verify(eventService, times(1)).apply(operationCaptor.capture());
final RacingEventService target = mock(RacingEventService.class);
operationCaptor.getValue().internalApplyTo(target);
verify(target, times(1)).updateEventVideoHealth(event.getId(), videoUrl.toString(), missing, notificationSent);
}
private void mockOwnership(SecurityService securityService, Event event, String username) {
final OwnershipAnnotation ownership = mock(OwnershipAnnotation.class);
final Ownership ownershipValue = mock(Ownership.class);
final User owner = mock(User.class);
when(securityService.getOwnership(event.getIdentifier())).thenReturn(ownership);
when(ownership.getAnnotation()).thenReturn(ownershipValue);
when(ownershipValue.getUserOwner()).thenReturn(owner);
when(owner.getName()).thenReturn(username);
when(owner.getLocaleOrDefault()).thenReturn(Locale.ROOT);
}
private URL getUrl(String path) throws Exception {
return new URL("http://127.0.0.1:" + server.getPort() + path);
}
private byte[] createImageBytes() throws IOException {
final BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", outputStream);
return outputStream.toByteArray();
}
private static class TestImageUrlHealthChecker extends ImageUrlHealthChecker {
private final boolean imageAvailable;
private int numberOfChecks;
private TestImageUrlHealthChecker(boolean imageAvailable) {
this.imageAvailable = imageAvailable;
}
@Override
boolean isImageAvailable(URL imageUrl) {
numberOfChecks++;
return imageAvailable;
}
private int getNumberOfChecks() {
return numberOfChecks;
}
}
private static class TestVideoUrlHealthChecker extends ImageUrlHealthChecker {
private final boolean videoReachable;
private TestVideoUrlHealthChecker(boolean videoReachable) {
this.videoReachable = videoReachable;
}
@Override
boolean isUrlReachable(URL url) {
return videoReachable;
}
}
private class TestHttpServer implements Runnable {
private final ServerSocket serverSocket;
private final Thread serverThread;
private TestHttpServer() throws IOException {
serverSocket = new ServerSocket(0);
serverThread = new Thread(this, MediaHealthCheckerTest.class.getSimpleName() + " HTTP server");
serverThread.setDaemon(true);
}
private void start() {
serverThread.start();
}
private int getPort() {
return serverSocket.getLocalPort();
}
private void stop() throws InterruptedException, IOException {
if (!serverSocket.isClosed()) {
serverSocket.close();
}
serverThread.join();
}
@Override
public void run() {
while (!serverSocket.isClosed()) {
try (Socket socket = serverSocket.accept()) {
handle(socket);
} catch (SocketException e) {
if (!serverSocket.isClosed()) {
throw new RuntimeException(e);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private void handle(Socket socket) throws IOException {
final BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
final String requestLine = reader.readLine();
if (requestLine == null) {
return;
}
final String[] requestLineParts = requestLine.split(" ");
final String path = requestLineParts[1];
final OutputStream outputStream = socket.getOutputStream();
switch (path) {
case VALID_IMAGE_PATH:
writeImage(outputStream);
break;
case VALID_VIDEO_PATH:
writeResponse(outputStream, "200 OK", "Content-Type: video/mp4\r\n", new byte[0]);
break;
case REDIRECT_PATH:
writeResponse(outputStream, "302 Found", "Location: " + VALID_IMAGE_PATH + "\r\n", new byte[0]);
break;
case HTTP_ERROR_PATH:
writeResponse(outputStream, "404 Not Found", "", new byte[0]);
break;
case HTML_PATH:
writeResponse(outputStream, "200 OK", "Content-Type: text/html\r\n",
"<html><body>Not an image</body></html>".getBytes(StandardCharsets.US_ASCII));
break;
case SLOW_IMAGE_PATH:
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
writeImage(outputStream);
break;
default:
writeResponse(outputStream, "404 Not Found", "", new byte[0]);
break;
}
}
private void writeImage(OutputStream outputStream) throws IOException {
writeResponse(outputStream, "200 OK", "Content-Type: image/jpeg\r\n", imageBytes);
}
private void writeResponse(OutputStream outputStream, String status, String headers, byte[] body)
throws IOException {
outputStream.write(("HTTP/1.1 " + status + "\r\n" + headers + "Content-Length: " + body.length
+ "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
outputStream.write(body);
outputStream.flush();
}
}
private Activator createActivatorWithOwnerNotificationEnabled(boolean enabled) {
final String propertyName = Activator.EVENT_MEDIA_OWNER_NOTIFICATION_ENABLED_PROPERTY_NAME;
final String previousValue = System.getProperty(propertyName);
System.setProperty(propertyName, Boolean.toString(enabled));
try {
return new Activator(ResourceBundleStringMessages.NULL);
} finally {
if (previousValue == null) {
System.clearProperty(propertyName);
} else {
System.setProperty(propertyName, previousValue);
}
}
}
}
@@ -0,0 +1,119 @@
package com.sap.sailing.server.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.sap.sailing.domain.base.Event;
import com.sap.sailing.server.impl.RacingEventServiceImpl;
import com.sap.sailing.server.interfaces.RacingEventService;
import com.sap.sailing.server.operationaltransformation.CreateEvent;
import com.sap.sse.common.Duration;
import com.sap.sse.common.TimePoint;
import com.sap.sse.common.media.MimeType;
import com.sap.sse.mongodb.MongoDBService;
import com.sap.sse.shared.media.ImageDescriptor;
import com.sap.sse.shared.media.VideoDescriptor;
import com.sap.sse.shared.media.impl.ImageDescriptorImpl;
import com.sap.sse.shared.media.impl.VideoDescriptorImpl;
public class RacingEventServiceMediaHealthUpdateTest {
private RacingEventService service;
private Event event;
private ImageDescriptor imageDescriptor;
private VideoDescriptor videoDescriptor;
@BeforeEach
public void setUp() throws MalformedURLException {
MongoDBService.INSTANCE.getDB().drop();
service = new RacingEventServiceImpl();
final UUID eventId = UUID.randomUUID();
imageDescriptor = new ImageDescriptorImpl(new URL("https://example.com/image.png"), TimePoint.now());
videoDescriptor = new VideoDescriptorImpl(new URL("https://example.com/video.mp4"), MimeType.mp4, TimePoint.now());
final CreateEvent createEventOperation = new CreateEvent("Event Name", "Event Description",
/* startDate */ TimePoint.now(), /* endDate */ TimePoint.now().plus(Duration.ONE_WEEK),
"The Venue", /* isPublic */ true, eventId, /* officialWebsiteURL */ null, /* baseURL */ null,
/* sailorsInfoWebsiteURLs */ null, Collections.singleton(imageDescriptor), Collections.singleton(videoDescriptor),
/* leaderboardGroupIds */ Collections.emptySet());
event = service.apply(createEventOperation);
}
@Test
public void testSettingImageHealthy() {
service.updateEventImageHealth(event.getId(), imageDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final ImageDescriptor newImageDescriptor = newEvent.getImages().iterator().next();
assertEquals(imageDescriptor.isMissing(), newImageDescriptor.isMissing());
assertEquals(imageDescriptor.isMissingMailNotificationSent(), newImageDescriptor.isMissingMailNotificationSent());
}
@Test
public void testTogglingImageHealthyTrueFalse() {
service.updateEventImageHealth(event.getId(), imageDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
service.updateEventImageHealth(event.getId(), imageDescriptor.getURL().toString(), /* missing */ false, /* missingMailNotificationSent */ false);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final ImageDescriptor newImageDescriptor = newEvent.getImages().iterator().next();
assertFalse(newImageDescriptor.isMissing());
assertFalse(newImageDescriptor.isMissingMailNotificationSent());
}
@Test
public void testTogglingImageHealthyFalseTrue() {
service.updateEventImageHealth(event.getId(), imageDescriptor.getURL().toString(), /* missing */ false, /* missingMailNotificationSent */ false);
service.updateEventImageHealth(event.getId(), imageDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final ImageDescriptor newImageDescriptor = newEvent.getImages().iterator().next();
assertTrue(newImageDescriptor.isMissing());
assertTrue(newImageDescriptor.isMissingMailNotificationSent());
}
@Test
public void testSettingVideoHealthy() {
service.updateEventVideoHealth(event.getId(), videoDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final VideoDescriptor newVideoDescriptor = newEvent.getVideos().iterator().next();
assertEquals(videoDescriptor.isMissing(), newVideoDescriptor.isMissing());
assertEquals(videoDescriptor.isMissingMailNotificationSent(), newVideoDescriptor.isMissingMailNotificationSent());
}
@Test
public void testTogglingVideoHealthyTrueFalse() {
service.updateEventVideoHealth(event.getId(), videoDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
service.updateEventVideoHealth(event.getId(), videoDescriptor.getURL().toString(), /* missing */ false, /* missingMailNotificationSent */ false);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final VideoDescriptor newVideoDescriptor = newEvent.getVideos().iterator().next();
assertFalse(newVideoDescriptor.isMissing());
assertFalse(newVideoDescriptor.isMissingMailNotificationSent());
}
@Test
public void testTogglingVideoHealthyFalseTrue() {
service.updateEventVideoHealth(event.getId(), videoDescriptor.getURL().toString(), /* missing */ false, /* missingMailNotificationSent */ false);
service.updateEventVideoHealth(event.getId(), videoDescriptor.getURL().toString(), /* missing */ true, /* missingMailNotificationSent */ true);
final RacingEventService newService = new RacingEventServiceImpl(); // assuming it reads from the default (test) DB
final Event newEvent = newService.getEvent(event.getId());
assertEquals(event.getName(), newEvent.getName());
final VideoDescriptor newVideoDescriptor = newEvent.getVideos().iterator().next();
assertTrue(newVideoDescriptor.isMissing());
assertTrue(newVideoDescriptor.isMissingMailNotificationSent());
}
}
@@ -27,6 +27,13 @@ boatClassUpcomingRaceSubject=A race for boat class {0} will start soon
# 0: boatClassName, 1: raceDescription, 2: time
boatClassUpcomingRaceBody=For boat class {0} {1} will start approximately at {2}.
brokenImageMediaSubject=Broken image for event {0}
# 0: eventName, 1: mediaUrl, 2: mediaTags
brokenImageMediaBody=The image {1} configured for event "{0}" is no longer available. Tags: {2}. Please update or replace the image.
brokenVideoMediaSubject=Broken video for event {0}
# 0: eventName, 1: mediaUrl, 2: mediaTags
brokenVideoMediaBody=The video {1} configured for event "{0}" is no longer available. Tags: {2}. Please update or replace the videos.
subscriptionInformation=You are receiving notifications from SAP Sailing Analytics. To turn off notifications, please visit your user profile at the link below.
userProfile=User Profile
imprint=Imprint
@@ -27,6 +27,13 @@ boatClassUpcomingRaceSubject=Wettfahrt in Bootsklasse {0} startet bald
# 0: boatClassName, 1: raceDescription, 2: time
boatClassUpcomingRaceBody=In Bootsklasse {0} startet {1} um etwa {2}.
brokenImageMediaSubject=Bild fehlt bei Veranstaltung {0}
# 0: eventName, 1: mediaUrl, 2: mediaTags
brokenImageMediaBody=Das Bild {1} bei Veranstaltung "{0}" ist nicht mehr verfügbar. Tags: {2}. Bitte aktualisieren oder ersetzen Sie das Bild.
brokenVideoMediaSubject=Video fehlt bei Veranstaltung {0}
# 0: eventName, 1: mediaUrl, 2: mediaTags
brokenVideoMediaBody=Das Video {1} bei Veranstaltung "{0}" ist nicht mehr verfügbar. Tags: {2}. Bitte aktualisieren oder ersetzen Sie das Video.
subscriptionInformation=Bei dieser Mail handelt es sich um eine automatisierte Benachrichtigung von SAP Sailing Analytics. Die Benachrichtigungen können im Benutzerprofil (s.u.) abgeschaltet werden.
userProfile=Benutzerprofil
imprint=Impressum
@@ -2,11 +2,20 @@ package com.sap.sailing.server.impl;
import java.lang.management.ManagementFactory;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -26,6 +35,7 @@ import org.osgi.util.tracker.ServiceTrackerCustomizer;
import com.sap.sailing.competitorimport.CompetitorProvider;
import com.sap.sailing.domain.abstractlog.race.analyzing.impl.RaceLogResolver;
import com.sap.sailing.domain.base.Event;
import com.sap.sailing.domain.base.MasterDataImportClassLoaderService;
import com.sap.sailing.domain.base.RaceDefinition;
import com.sap.sailing.domain.base.Regatta;
@@ -55,7 +65,10 @@ import com.sap.sailing.server.impl.preferences.model.StoredDataMiningQueryPrefer
import com.sap.sailing.server.impl.preferences.model.StoredDataMiningReportPreferences;
import com.sap.sailing.server.impl.preferences.model.TrackedEventPreferences;
import com.sap.sailing.server.interfaces.RacingEventService;
import com.sap.sailing.server.interfaces.RacingEventServiceOperation;
import com.sap.sailing.server.notification.impl.SailingNotificationServiceImpl;
import com.sap.sailing.server.operationaltransformation.UpdateEventImageHealth;
import com.sap.sailing.server.operationaltransformation.UpdateEventVideoHealth;
import com.sap.sailing.server.preferences.SailorProfilePreferences;
import com.sap.sailing.server.security.EventManagerRole;
import com.sap.sailing.server.security.SailingViewerRole;
@@ -65,8 +78,11 @@ import com.sap.sailing.server.gateway.interfaces.SailingServerFactory;
import com.sap.sailing.shared.server.SharedSailingData;
import com.sap.sse.branding.BrandingConfigurationService;
import com.sap.sse.classloading.ServiceTrackerCustomizerForClassLoaderSupplierRegistrations;
import com.sap.sse.common.Duration;
import com.sap.sse.common.TypeBasedServiceFinder;
import com.sap.sse.common.Util;
import com.sap.sse.common.mail.MailException;
import com.sap.sse.i18n.ResourceBundleStringMessages;
import com.sap.sse.mail.MailService;
import com.sap.sse.mail.queue.MailQueue;
import com.sap.sse.mail.queue.impl.ExecutorMailQueue;
@@ -79,18 +95,30 @@ import com.sap.sse.security.SecurityService;
import com.sap.sse.security.SecurityUrlPathProvider;
import com.sap.sse.security.interfaces.PreferenceConverter;
import com.sap.sse.security.shared.HasPermissionsProvider;
import com.sap.sse.security.shared.OwnershipAnnotation;
import com.sap.sse.security.shared.RoleDefinition;
import com.sap.sse.security.shared.ServerAdminRole;
import com.sap.sse.security.shared.SubscriptionPlanProvider;
import com.sap.sse.security.shared.impl.User;
import com.sap.sse.security.shared.subscription.AllDataMiningRole;
import com.sap.sse.security.shared.subscription.ArchiveDataMiningRole;
import com.sap.sse.security.util.GenericJSONPreferenceConverter;
import com.sap.sse.shared.media.ImageDescriptor;
import com.sap.sse.shared.media.MediaDescriptor;
import com.sap.sse.shared.media.VideoDescriptor;
import com.sap.sse.util.ClearStateTestSupport;
import com.sap.sse.util.ServiceTrackerFactory;
import com.sap.sse.util.ThreadPoolUtil;
public class Activator implements BundleActivator {
private static final Logger logger = Logger.getLogger(Activator.class.getName());
private static final Duration EVENT_MEDIA_CHECK_INITIAL_DELAY = Duration.ONE_MINUTE;
private static final Duration EVENT_MEDIA_CHECK_INTERVAL = Duration.ONE_DAY;
static final String EVENT_MEDIA_OWNER_NOTIFICATION_ENABLED_PROPERTY_NAME = "eventMediaHealthCheck.ownerNotification.enabled";
private static final String CLEAR_PERSISTENT_COMPETITORS_PROPERTY_NAME = "persistentcompetitors.clear";
@@ -107,9 +135,15 @@ public class Activator implements BundleActivator {
private final boolean clearPersistentCompetitors;
private final boolean restoreTrackedRaces;
private final boolean eventMediaOwnerNotificationEnabled;
private final ResourceBundleStringMessages messages;
private Set<ServiceRegistration<?>> registrations = new HashSet<>();
private ScheduledFuture<?> eventMediaHealthCheckTask;
private ObjectName mBeanName;
private ServiceTracker<MasterDataImportClassLoaderService, MasterDataImportClassLoaderService> masterDataImportClassLoaderServiceTracker;
@@ -135,12 +169,22 @@ public class Activator implements BundleActivator {
private ServiceTracker<SailingServerFactory, SailingServerFactory> sailingServerFactoryTracker;
public Activator() {
this(ResourceBundleStringMessages.create(SailingNotificationServiceImpl.STRING_MESSAGES_BASE_NAME,
Activator.class.getClassLoader(), StandardCharsets.UTF_8.name()));
}
public Activator(ResourceBundleStringMessages messages) {
clearPersistentCompetitors = Boolean
.valueOf(System.getProperty(CLEAR_PERSISTENT_COMPETITORS_PROPERTY_NAME, "" + false));
restoreTrackedRaces = Boolean
.valueOf(System.getProperty(RESTORE_TRACKED_RACES_PROPERTY_NAME, "" + false));
eventMediaOwnerNotificationEnabled = Boolean.valueOf(
System.getProperty(EVENT_MEDIA_OWNER_NOTIFICATION_ENABLED_PROPERTY_NAME, "" + false));
this.messages = messages;
logger.log(Level.INFO,
"setting " + CLEAR_PERSISTENT_COMPETITORS_PROPERTY_NAME + " to " + clearPersistentCompetitors);
logger.log(Level.INFO, "setting " + EVENT_MEDIA_OWNER_NOTIFICATION_ENABLED_PROPERTY_NAME + " to "
+ eventMediaOwnerNotificationEnabled);
// there is exactly one instance of the racingEventService in the whole server
}
@@ -201,8 +245,134 @@ public class Activator implements BundleActivator {
public static BundleContext getContext() {
return context;
}
void checkMedia(Iterable<Event> events, SecurityService securityService,
ImageUrlHealthChecker imageUrlHealthChecker, RacingEventService eventService) {
// only run this on a RacingEventService primary node, not on replicas
if (eventService.getMasterDescriptor() == null) {
final Map<String, Boolean> availabilityByUrl = new HashMap<>();
for (final Event event : events) {
for (final ImageDescriptor image : event.getImages()) {
checkMediaDescriptor(event, image, "Image", availabilityByUrl,
url -> imageUrlHealthChecker.isImageAvailable(url),
(id, url, missing, sent) -> new UpdateEventImageHealth(id, url, missing, sent),
securityService, eventService);
}
for (final VideoDescriptor video : event.getVideos()) {
checkMediaDescriptor(event, video, "Video", availabilityByUrl,
url -> imageUrlHealthChecker.isUrlReachable(url),
(id, url, missing, sent) -> new UpdateEventVideoHealth(id, url, missing, sent),
securityService, eventService);
}
}
}
}
private void checkMediaDescriptor(Event event, MediaDescriptor media, String mediaType,
Map<String, Boolean> availabilityByUrl,
Function<URL, Boolean> checker,
MediaHealthOperationFactory operationFactory,
SecurityService securityService, RacingEventService eventService) {
final URL mediaUrl = media.getURL();
if (mediaUrl != null) {
final String mediaUrlAsString = mediaUrl.toString();
Boolean available = availabilityByUrl.get(mediaUrlAsString);
if (available == null) {
available = checker.apply(mediaUrl);
availabilityByUrl.put(mediaUrlAsString, available);
}
if (available) {
if (media.isMissing() || media.isMissingMailNotificationSent()) {
eventService.apply(operationFactory.create(event.getId(), mediaUrlAsString,
/* missing */ false, /* missingMailNotificationSent */ false));
}
} else {
boolean notificationSent = media.isMissingMailNotificationSent();
if (!notificationSent) {
notificationSent = notifyEventOwnerAboutBrokenMedia(event, media, mediaType, securityService);
}
if (!media.isMissing() || media.isMissingMailNotificationSent() != notificationSent) {
eventService.apply(operationFactory.create(event.getId(), mediaUrlAsString,
/* missing */ true, notificationSent));
}
}
}
}
interface MediaHealthOperationFactory {
RacingEventServiceOperation<?> create(UUID eventId, String mediaUrl, boolean missing, boolean missingMailNotificationSent);
}
private void checkEventMedia() {
try {
final SecurityService securityService = securityServiceTracker.getInitializedService(0);
if (securityService != null) {
checkMedia(racingEventService.getAllEvents(), securityService, new ImageUrlHealthChecker(),
racingEventService);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.log(Level.WARNING, "Interrupted while checking event media", e);
} catch (RuntimeException e) {
logger.log(Level.SEVERE, "Could not check event media", e);
}
}
private void scheduleEventMediaHealthChecks() {
eventMediaHealthCheckTask = ThreadPoolUtil.INSTANCE.getDefaultBackgroundTaskThreadPoolExecutor()
.scheduleAtFixedRate(this::checkEventMedia, EVENT_MEDIA_CHECK_INITIAL_DELAY.asMillis(),
EVENT_MEDIA_CHECK_INTERVAL.asMillis(), TimeUnit.MILLISECONDS);
}
private boolean notifyEventOwnerAboutBrokenMedia(Event event, MediaDescriptor media, String mediaType,
SecurityService securityService) {
boolean result;
final String mediaUrl = media.getURL().toString();
final String mediaTags = getMediaTagsAsString(media);
final OwnershipAnnotation ownership = securityService.getOwnership(event.getIdentifier());
final User owner = ownership == null ? null : ownership.getAnnotation().getUserOwner();
if (owner == null) {
logger.warning("Cannot notify owner about broken " + mediaType + " " + mediaUrl + " with tags " + mediaTags
+ " for event " + event.getName() + " because the event has no user owner");
result = false;
} else {
final Locale locale = owner.getLocaleOrDefault();
final String subject = messages.get(locale, "broken"+mediaType+"MediaSubject", event.getName());
final String body = messages.get(locale, "broken"+mediaType+"MediaBody", event.getName(), mediaUrl, mediaTags);
if (!eventMediaOwnerNotificationEnabled) {
logger.warning("Would notify owner " + owner.getName() + " about broken " + mediaType + " " + mediaUrl
+ " with tags " + mediaTags + " for event " + event.getName() + "; enable with -D"
+ EVENT_MEDIA_OWNER_NOTIFICATION_ENABLED_PROPERTY_NAME + "=true");
result = false;
} else {
try {
securityService.sendMail(owner.getName(), subject, body);
result = true;
} catch (MailException e) {
logger.log(Level.SEVERE, "Could not notify owner " + owner.getName() + " about broken " + mediaType
+ " " + mediaUrl + " with tags " + mediaTags + " for event " + event.getName(), e);
result = false;
}
}
}
return result;
}
private String getMediaTagsAsString(MediaDescriptor media) {
final StringBuilder result = new StringBuilder();
for (String tag : media.getTags()) {
if (result.length() > 0) {
result.append(", ");
}
result.append(tag);
}
return result.length() == 0 ? "<none>" : result.toString();
}
public void stop(BundleContext context) throws Exception {
if (eventMediaHealthCheckTask != null) {
eventMediaHealthCheckTask.cancel(/* mayInterruptIfRunning */ true);
}
masterDataImportClassLoaderServiceTracker.close();
if (extenderBundleTracker != null) {
extenderBundleTracker.close();
@@ -368,6 +538,7 @@ public class Activator implements BundleActivator {
// load has been finished in case this is a replica with auto-replication.
racingEventService.ensureOwnerships();
racingEventService.migrateCompetitorNotificationPreferencesWithCompetitorNames();
scheduleEventMediaHealthChecks();
}
private class PolarDataServiceTrackerCustomizer
@@ -0,0 +1,93 @@
package com.sap.sailing.server.impl;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.Iterator;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import com.sap.sse.common.Duration;
import com.sap.sse.util.HttpUrlConnectionHelper;
class ImageUrlHealthChecker {
private static final Duration DEFAULT_TIMEOUT = Duration.ONE_SECOND.times(10);
private final Duration timeout;
ImageUrlHealthChecker() {
this(DEFAULT_TIMEOUT);
}
ImageUrlHealthChecker(Duration timeout) {
this.timeout = timeout;
}
boolean isUrlReachable(URL url) {
URLConnection connection = null;
boolean result;
try {
connection = HttpUrlConnectionHelper.redirectConnection(url, timeout, urlConnection -> urlConnection.setConnectTimeout((int) timeout.asMillis()));
if (connection instanceof HttpURLConnection) {
final int responseCode = ((HttpURLConnection) connection).getResponseCode();
result = responseCode >= HttpURLConnection.HTTP_OK && responseCode < HttpURLConnection.HTTP_MULT_CHOICE;
} else {
result = true;
}
} catch (IOException e) {
result = false;
} finally {
if (connection instanceof HttpURLConnection) {
((HttpURLConnection) connection).disconnect();
}
}
return result;
}
boolean isImageAvailable(URL imageUrl) {
URLConnection connection = null;
boolean result;
try {
connection = HttpUrlConnectionHelper.redirectConnection(imageUrl, timeout,
urlConnection -> urlConnection.setConnectTimeout((int) timeout.asMillis()));
if (connection instanceof HttpURLConnection) {
final int responseCode = ((HttpURLConnection) connection).getResponseCode();
if (responseCode < HttpURLConnection.HTTP_OK || responseCode >= HttpURLConnection.HTTP_MULT_CHOICE) {
result = false;
}
}
try (InputStream inputStream = connection.getInputStream();
ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream)) {
if (imageInputStream == null) {
result = false;
} else {
final Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
if (!readers.hasNext()) {
result = false;
} else {
final ImageReader reader = readers.next();
try {
reader.setInput(imageInputStream);
reader.getWidth(0);
reader.getHeight(0);
result = true;
} finally {
reader.dispose();
}
}
}
}
} catch (IOException e) {
result = false;
} finally {
if (connection instanceof HttpURLConnection) {
((HttpURLConnection) connection).disconnect();
}
}
return result;
}
}
@@ -376,6 +376,7 @@ import com.sap.sse.security.shared.impl.UserGroup;
import com.sap.sse.security.util.RemoteServerUtil;
import com.sap.sse.shared.classloading.ClassLoaderRegistry;
import com.sap.sse.shared.media.ImageDescriptor;
import com.sap.sse.shared.media.MediaDescriptor;
import com.sap.sse.shared.media.VideoDescriptor;
import com.sap.sse.util.ClearStateTestSupport;
import com.sap.sse.util.HttpUrlConnectionHelper;
@@ -4086,6 +4087,37 @@ Replicator {
// to keep changes minimial
mongoObjectFactory.storeEvent(event);
}
private <MD extends MediaDescriptor >void updateEventMediaHealth(UUID eventId, String mediaUrl, boolean missing, boolean missingMailNotificationSent, Function<Event, Iterable<MD>> mediaProvider) {
final Event event = eventsById.get(eventId);
if (event == null) {
throw new IllegalArgumentException("Sailing event with ID " + eventId + " does not exist.");
}
boolean mediumFound = false;
for (final MediaDescriptor medium : mediaProvider.apply(event)) {
if (medium.getURL() != null && mediaUrl.equals(medium.getURL().toString())) {
medium.setMissing(missing);
medium.setMissingMailNotificationSent(missingMailNotificationSent);
mediumFound = true;
}
}
if (mediumFound) {
mongoObjectFactory.storeEvent(event);
} else {
logger.warning("Could not update media health for URL " + mediaUrl + " because it is not attached to event "
+ event.getName());
}
}
@Override
public void updateEventImageHealth(UUID eventId, String imageUrl, boolean missing, boolean missingMailNotificationSent) {
updateEventMediaHealth(eventId, imageUrl, missing, missingMailNotificationSent, Event::getImages);
}
@Override
public void updateEventVideoHealth(UUID eventId, String videoUrl, boolean missing, boolean missingMailNotificationSent) {
updateEventMediaHealth(eventId, videoUrl, missing, missingMailNotificationSent, Event::getVideos);
}
@Override
public void renameEvent(UUID id, String newName) {
@@ -31,6 +31,12 @@
deadlocking a <tt>ConcurrentHashMap</tt> update on itself. Replica startup and operator-initiated
replication from a non-empty primary are affected. This is now fixed; catch-up notifications are
dispatched after the listener registration completes.</li>
<li>Image and video links are now checked for availability on a regular basis (currently once per day).
Media owners are informed by e-mail now about broken links. Broken links will be flagged and will
not be shown anymore. Those media will continue to be checked, and in case they re-appear under their
original URL, the will again be displayed. See
<a href="https://github.com/eclipse-sailing-analytics/sailing-analytics/issues/6202">issue #6202</a>.
</li>
</ul>
<h2 class="articleSubheadline">September 2026</h2>
<ul class="bulletList">
@@ -14,6 +14,19 @@ public interface MediaDescriptor extends Serializable {
MimeType getMimeType();
URL getURL();
/**
* Whether the media URL is currently known to be unavailable.
*/
boolean isMissing();
void setMissing(boolean missing);
/**
* Whether the owner has already been notified for the current missing state. This is reset when the media becomes
* available again.
*/
boolean isMissingMailNotificationSent();
void setMissingMailNotificationSent(boolean missingMailNotificationSent);
String getTitle();
void setTitle(String title);
@@ -33,6 +33,10 @@ public abstract class AbstractMediaDescriptor implements MediaDescriptor, Serial
protected Set<String> tags = new LinkedHashSet<String>();
protected URL url;
protected boolean missing;
protected boolean missingMailNotificationSent;
protected Locale locale;
@@ -57,6 +61,26 @@ public abstract class AbstractMediaDescriptor implements MediaDescriptor, Serial
return url;
}
@Override
public boolean isMissing() {
return missing;
}
@Override
public void setMissing(boolean missing) {
this.missing = missing;
}
@Override
public boolean isMissingMailNotificationSent() {
return missingMailNotificationSent;
}
@Override
public void setMissingMailNotificationSent(boolean missingMailNotificationSent) {
this.missingMailNotificationSent = missingMailNotificationSent;
}
@Override
public String getTitle() {
return title;