for UAs without console object
+
+ // allow force of debug mode via URL
+ //
+ if (sm2.debugURLParam.test(wl)) {
+ sm2.setupOptions.debugMode = sm2.debugMode = true;
+ }
+
+ if (id(sm2.debugID)) {
+ return;
+ }
+
+ var oD, oDebug, oTarget, oToggle, tmp;
+
+ if (sm2.debugMode && !id(sm2.debugID) && (!hasConsole || !sm2.useConsole || !sm2.consoleOnly)) {
+
+ oD = doc.createElement('div');
+ oD.id = sm2.debugID + '-toggle';
+
+ oToggle = {
+ position: 'fixed',
+ bottom: '0px',
+ right: '0px',
+ width: '1.2em',
+ height: '1.2em',
+ lineHeight: '1.2em',
+ margin: '2px',
+ textAlign: 'center',
+ border: '1px solid #999',
+ cursor: 'pointer',
+ background: '#fff',
+ color: '#333',
+ zIndex: 10001
+ };
+
+ oD.appendChild(doc.createTextNode('-'));
+ oD.onclick = toggleDebug;
+ oD.title = 'Toggle SM2 debug console';
+
+ if (ua.match(/msie 6/i)) {
+ oD.style.position = 'absolute';
+ oD.style.cursor = 'hand';
+ }
+
+ for (tmp in oToggle) {
+ if (oToggle.hasOwnProperty(tmp)) {
+ oD.style[tmp] = oToggle[tmp];
+ }
+ }
+
+ oDebug = doc.createElement('div');
+ oDebug.id = sm2.debugID;
+ oDebug.style.display = (sm2.debugMode ? 'block' : 'none');
+
+ if (sm2.debugMode && !id(oD.id)) {
+ try {
+ oTarget = getDocument();
+ oTarget.appendChild(oD);
+ } catch(e2) {
+ throw new Error(str('domError') + ' \n' + e2.toString());
+ }
+ oTarget.appendChild(oDebug);
+ }
+
+ }
+
+ oTarget = null;
+ //
+
+ };
+
+ idCheck = this.getSoundById;
+
+ //
+ _wDS = function(o, errorLevel) {
+
+ return (!o ? '' : sm2._wD(str(o), errorLevel));
+
+ };
+
+ toggleDebug = function() {
+
+ var o = id(sm2.debugID),
+ oT = id(sm2.debugID + '-toggle');
+
+ if (!o) {
+ return;
+ }
+
+ if (debugOpen) {
+ // minimize
+ oT.innerHTML = '+';
+ o.style.display = 'none';
+ } else {
+ oT.innerHTML = '-';
+ o.style.display = 'block';
+ }
+
+ debugOpen = !debugOpen;
+
+ };
+
+ debugTS = function(sEventType, bSuccess, sMessage) {
+
+ // troubleshooter debug hooks
+
+ if (window.sm2Debugger !== _undefined) {
+ try {
+ sm2Debugger.handleEvent(sEventType, bSuccess, sMessage);
+ } catch(e) {
+ // oh well
+ return false;
+ }
+ }
+
+ return true;
+
+ };
+ //
+
+ getSWFCSS = function() {
+
+ var css = [];
+
+ if (sm2.debugMode) {
+ css.push(swfCSS.sm2Debug);
+ }
+
+ if (sm2.debugFlash) {
+ css.push(swfCSS.flashDebug);
+ }
+
+ if (sm2.useHighPerformance) {
+ css.push(swfCSS.highPerf);
+ }
+
+ return css.join(' ');
+
+ };
+
+ flashBlockHandler = function() {
+
+ // *possible* flash block situation.
+
+ var name = str('fbHandler'),
+ p = sm2.getMoviePercent(),
+ css = swfCSS,
+ error = {
+ type: 'FLASHBLOCK'
+ };
+
+ if (sm2.html5Only) {
+ // no flash, or unused
+ return;
+ }
+
+ if (!sm2.ok()) {
+
+ if (needsFlash) {
+ // make the movie more visible, so user can fix
+ sm2.oMC.className = getSWFCSS() + ' ' + css.swfDefault + ' ' + (p === null ? css.swfTimedout : css.swfError);
+ sm2._wD(name + ': ' + str('fbTimeout') + (p ? ' (' + str('fbLoaded') + ')' : ''));
+ }
+
+ sm2.didFlashBlock = true;
+
+ // fire onready(), complain lightly
+ processOnEvents({
+ type: 'ontimeout',
+ ignoreInit: true,
+ error: error
+ });
+
+ catchError(error);
+
+ } else {
+
+ // SM2 loaded OK (or recovered)
+
+ //
+ if (sm2.didFlashBlock) {
+ sm2._wD(name + ': Unblocked');
+ }
+ //
+
+ if (sm2.oMC) {
+ sm2.oMC.className = [getSWFCSS(), css.swfDefault, css.swfLoaded + (sm2.didFlashBlock ? ' ' + css.swfUnblocked : '')].join(' ');
+ }
+
+ }
+
+ };
+
+ addOnEvent = function(sType, oMethod, oScope) {
+
+ if (on_queue[sType] === _undefined) {
+ on_queue[sType] = [];
+ }
+
+ on_queue[sType].push({
+ method: oMethod,
+ scope: (oScope || null),
+ fired: false
+ });
+
+ };
+
+ processOnEvents = function(oOptions) {
+
+ // if unspecified, assume OK/error
+
+ if (!oOptions) {
+ oOptions = {
+ type: (sm2.ok() ? 'onready' : 'ontimeout')
+ };
+ }
+
+ // not ready yet.
+ if (!didInit && oOptions && !oOptions.ignoreInit) return false;
+
+ // invalid case
+ if (oOptions.type === 'ontimeout' && (sm2.ok() || (disabled && !oOptions.ignoreInit))) return false;
+
+ var status = {
+ success: (oOptions && oOptions.ignoreInit ? sm2.ok() : !disabled)
+ },
+
+ // queue specified by type, or none
+ srcQueue = (oOptions && oOptions.type ? on_queue[oOptions.type] || [] : []),
+
+ queue = [], i, j,
+ args = [status],
+ canRetry = (needsFlash && !sm2.ok());
+
+ if (oOptions.error) {
+ args[0].error = oOptions.error;
+ }
+
+ for (i = 0, j = srcQueue.length; i < j; i++) {
+ if (srcQueue[i].fired !== true) {
+ queue.push(srcQueue[i]);
+ }
+ }
+
+ if (queue.length) {
+
+ // sm2._wD(sm + ': Firing ' + queue.length + ' ' + oOptions.type + '() item' + (queue.length === 1 ? '' : 's'));
+ for (i = 0, j = queue.length; i < j; i++) {
+
+ if (queue[i].scope) {
+ queue[i].method.apply(queue[i].scope, args);
+ } else {
+ queue[i].method.apply(this, args);
+ }
+
+ if (!canRetry) {
+ // useFlashBlock and SWF timeout case doesn't count here.
+ queue[i].fired = true;
+
+ }
+
+ }
+
+ }
+
+ return true;
+
+ };
+
+ initUserOnload = function() {
+
+ window.setTimeout(function() {
+
+ if (sm2.useFlashBlock) {
+ flashBlockHandler();
+ }
+
+ processOnEvents();
+
+ // call user-defined "onload", scoped to window
+
+ if (typeof sm2.onload === 'function') {
+ _wDS('onload', 1);
+ sm2.onload.apply(window);
+ _wDS('onloadOK', 1);
+ }
+
+ if (sm2.waitForWindowLoad) {
+ event.add(window, 'load', initUserOnload);
+ }
+
+ }, 1);
+
+ };
+
+ detectFlash = function() {
+
+ /**
+ * Hat tip: Flash Detect library (BSD, (C) 2007) by Carl "DocYes" S. Yestrau
+ * http://featureblend.com/javascript-flash-detection-library.html / http://featureblend.com/license.txt
+ */
+
+ // this work has already been done.
+ if (hasFlash !== _undefined) return hasFlash;
+
+ var hasPlugin = false, n = navigator, obj, type, types, AX = window.ActiveXObject;
+
+ // MS Edge 14 throws an "Unspecified Error" because n.plugins is inaccessible due to permissions
+ var nP;
+
+ try {
+ nP = n.plugins;
+ } catch(e) {
+ nP = undefined;
+ }
+
+ if (nP && nP.length) {
+
+ type = 'application/x-shockwave-flash';
+ types = n.mimeTypes;
+
+ if (types && types[type] && types[type].enabledPlugin && types[type].enabledPlugin.description) {
+ hasPlugin = true;
+ }
+
+ } else if (AX !== _undefined && !ua.match(/MSAppHost/i)) {
+
+ // Windows 8 Store Apps (MSAppHost) are weird (compatibility?) and won't complain here, but will barf if Flash/ActiveX object is appended to the DOM.
+ try {
+ obj = new AX('ShockwaveFlash.ShockwaveFlash');
+ } catch(e) {
+ // oh well
+ obj = null;
+ }
+
+ hasPlugin = (!!obj);
+
+ // cleanup, because it is ActiveX after all
+ obj = null;
+
+ }
+
+ hasFlash = hasPlugin;
+
+ return hasPlugin;
+
+ };
+
+ featureCheck = function() {
+
+ var flashNeeded,
+ item,
+ formats = sm2.audioFormats,
+ // iPhone <= 3.1 has broken HTML5 audio(), but firmware 3.2 (original iPad) + iOS4 works.
+ isSpecial = (is_iDevice && !!(ua.match(/os (1|2|3_0|3_1)\s/i)));
+
+ if (isSpecial) {
+
+ // has Audio(), but is broken; let it load links directly.
+ sm2.hasHTML5 = false;
+
+ // ignore flash case, however
+ sm2.html5Only = true;
+
+ // hide the SWF, if present
+ if (sm2.oMC) {
+ sm2.oMC.style.display = 'none';
+ }
+
+ } else if (sm2.useHTML5Audio) {
+
+ if (!sm2.html5 || !sm2.html5.canPlayType) {
+ sm2._wD('SoundManager: No HTML5 Audio() support detected.');
+ sm2.hasHTML5 = false;
+ }
+
+ //
+ if (isBadSafari) {
+ sm2._wD(smc + 'Note: Buggy HTML5 Audio in Safari on this OS X release, see https://bugs.webkit.org/show_bug.cgi?id=32159 - ' + (!hasFlash ? ' would use flash fallback for MP3/MP4, but none detected.' : 'will use flash fallback for MP3/MP4, if available'), 1);
+ }
+ //
+
+ }
+
+ if (sm2.useHTML5Audio && sm2.hasHTML5) {
+
+ // sort out whether flash is optional, required or can be ignored.
+
+ // innocent until proven guilty.
+ canIgnoreFlash = true;
+
+ for (item in formats) {
+
+ if (formats.hasOwnProperty(item)) {
+
+ if (formats[item].required) {
+
+ if (!sm2.html5.canPlayType(formats[item].type)) {
+
+ // 100% HTML5 mode is not possible.
+ canIgnoreFlash = false;
+ flashNeeded = true;
+
+ } else if (sm2.preferFlash && (sm2.flash[item] || sm2.flash[formats[item].type])) {
+
+ // flash may be required, or preferred for this format.
+ flashNeeded = true;
+
+ }
+
+ }
+
+ }
+
+ }
+
+ }
+
+ // sanity check...
+ if (sm2.ignoreFlash) {
+ flashNeeded = false;
+ canIgnoreFlash = true;
+ }
+
+ sm2.html5Only = (sm2.hasHTML5 && sm2.useHTML5Audio && !flashNeeded);
+
+ return (!sm2.html5Only);
+
+ };
+
+ parseURL = function(url) {
+
+ /**
+ * Internal: Finds and returns the first playable URL (or failing that, the first URL.)
+ * @param {string or array} url A single URL string, OR, an array of URL strings or {url:'/path/to/resource', type:'audio/mp3'} objects.
+ */
+
+ var i, j, urlResult = 0, result;
+
+ if (url instanceof Array) {
+
+ // find the first good one
+ for (i = 0, j = url.length; i < j; i++) {
+
+ if (url[i] instanceof Object) {
+
+ // MIME check
+ if (sm2.canPlayMIME(url[i].type)) {
+ urlResult = i;
+ break;
+ }
+
+ } else if (sm2.canPlayURL(url[i])) {
+
+ // URL string check
+ urlResult = i;
+ break;
+
+ }
+
+ }
+
+ // normalize to string
+ if (url[urlResult].url) {
+ url[urlResult] = url[urlResult].url;
+ }
+
+ result = url[urlResult];
+
+ } else {
+
+ // single URL case
+ result = url;
+
+ }
+
+ return result;
+
+ };
+
+
+ startTimer = function(oSound) {
+
+ /**
+ * attach a timer to this sound, and start an interval if needed
+ */
+
+ if (!oSound._hasTimer) {
+
+ oSound._hasTimer = true;
+
+ if (!mobileHTML5 && sm2.html5PollingInterval) {
+
+ if (h5IntervalTimer === null && h5TimerCount === 0) {
+
+ h5IntervalTimer = setInterval(timerExecute, sm2.html5PollingInterval);
+
+ }
+
+ h5TimerCount++;
+
+ }
+
+ }
+
+ };
+
+ stopTimer = function(oSound) {
+
+ /**
+ * detach a timer
+ */
+
+ if (oSound._hasTimer) {
+
+ oSound._hasTimer = false;
+
+ if (!mobileHTML5 && sm2.html5PollingInterval) {
+
+ // interval will stop itself at next execution.
+
+ h5TimerCount--;
+
+ }
+
+ }
+
+ };
+
+ timerExecute = function() {
+
+ /**
+ * manual polling for HTML5 progress events, ie., whileplaying()
+ * (can achieve greater precision than conservative default HTML5 interval)
+ */
+
+ var i;
+
+ if (h5IntervalTimer !== null && !h5TimerCount) {
+
+ // no active timers, stop polling interval.
+
+ clearInterval(h5IntervalTimer);
+
+ h5IntervalTimer = null;
+
+ return;
+
+ }
+
+ // check all HTML5 sounds with timers
+
+ for (i = sm2.soundIDs.length - 1; i >= 0; i--) {
+
+ if (sm2.sounds[sm2.soundIDs[i]].isHTML5 && sm2.sounds[sm2.soundIDs[i]]._hasTimer) {
+ sm2.sounds[sm2.soundIDs[i]]._onTimer();
+ }
+
+ }
+
+ };
+
+ catchError = function(options) {
+
+ options = (options !== _undefined ? options : {});
+
+ if (typeof sm2.onerror === 'function') {
+ sm2.onerror.apply(window, [{
+ type: (options.type !== _undefined ? options.type : null)
+ }]);
+ }
+
+ if (options.fatal !== _undefined && options.fatal) {
+ sm2.disable();
+ }
+
+ };
+
+ badSafariFix = function() {
+
+ // special case: "bad" Safari (OS X 10.3 - 10.7) must fall back to flash for MP3/MP4
+ if (!isBadSafari || !detectFlash()) {
+ // doesn't apply
+ return;
+ }
+
+ var aF = sm2.audioFormats, i, item;
+
+ for (item in aF) {
+
+ if (aF.hasOwnProperty(item)) {
+
+ if (item === 'mp3' || item === 'mp4') {
+
+ sm2._wD(sm + ': Using flash fallback for ' + item + ' format');
+ sm2.html5[item] = false;
+
+ // assign result to related formats, too
+ if (aF[item] && aF[item].related) {
+ for (i = aF[item].related.length - 1; i >= 0; i--) {
+ sm2.html5[aF[item].related[i]] = false;
+ }
+ }
+
+ }
+
+ }
+
+ }
+
+ };
+
+ /**
+ * Pseudo-private flash/ExternalInterface methods
+ * ----------------------------------------------
+ */
+
+ this._setSandboxType = function(sandboxType) {
+
+ //
+ // Security sandbox according to Flash plugin
+ var sb = sm2.sandbox;
+
+ sb.type = sandboxType;
+ sb.description = sb.types[(sb.types[sandboxType] !== _undefined ? sandboxType : 'unknown')];
+
+ if (sb.type === 'localWithFile') {
+
+ sb.noRemote = true;
+ sb.noLocal = false;
+ _wDS('secNote', 2);
+
+ } else if (sb.type === 'localWithNetwork') {
+
+ sb.noRemote = false;
+ sb.noLocal = true;
+
+ } else if (sb.type === 'localTrusted') {
+
+ sb.noRemote = false;
+ sb.noLocal = false;
+
+ }
+ //
+
+ };
+
+ this._externalInterfaceOK = function(swfVersion) {
+
+ // flash callback confirming flash loaded, EI working etc.
+ // swfVersion: SWF build string
+
+ if (sm2.swfLoaded) {
+ return;
+ }
+
+ var e;
+
+ debugTS('swf', true);
+ debugTS('flashtojs', true);
+ sm2.swfLoaded = true;
+ tryInitOnFocus = false;
+
+ if (isBadSafari) {
+ badSafariFix();
+ }
+
+ // complain if JS + SWF build/version strings don't match, excluding +DEV builds
+ //
+ if (!swfVersion || swfVersion.replace(/\+dev/i, '') !== sm2.versionNumber.replace(/\+dev/i, '')) {
+
+ e = sm + ': Fatal: JavaScript file build "' + sm2.versionNumber + '" does not match Flash SWF build "' + swfVersion + '" at ' + sm2.url + '. Ensure both are up-to-date.';
+
+ // escape flash -> JS stack so this error fires in window.
+ setTimeout(function() {
+ throw new Error(e);
+ }, 0);
+
+ // exit, init will fail with timeout
+ return;
+
+ }
+ //
+
+ // IE needs a larger timeout
+ setTimeout(init, isIE ? 100 : 1);
+
+ };
+
+ /**
+ * Private initialization helpers
+ * ------------------------------
+ */
+
+ createMovie = function(movieID, movieURL) {
+
+ // ignore if already connected
+ if (didAppend && appendSuccess) return false;
+
+ function initMsg() {
+
+ //
+
+ var options = [],
+ title,
+ msg = [],
+ delimiter = ' + ';
+
+ title = 'SoundManager ' + sm2.version + (!sm2.html5Only && sm2.useHTML5Audio ? (sm2.hasHTML5 ? ' + HTML5 audio' : ', no HTML5 audio support') : '');
+
+ if (!sm2.html5Only) {
+
+ if (sm2.preferFlash) {
+ options.push('preferFlash');
+ }
+
+ if (sm2.useHighPerformance) {
+ options.push('useHighPerformance');
+ }
+
+ if (sm2.flashPollingInterval) {
+ options.push('flashPollingInterval (' + sm2.flashPollingInterval + 'ms)');
+ }
+
+ if (sm2.html5PollingInterval) {
+ options.push('html5PollingInterval (' + sm2.html5PollingInterval + 'ms)');
+ }
+
+ if (sm2.wmode) {
+ options.push('wmode (' + sm2.wmode + ')');
+ }
+
+ if (sm2.debugFlash) {
+ options.push('debugFlash');
+ }
+
+ if (sm2.useFlashBlock) {
+ options.push('flashBlock');
+ }
+
+ } else if (sm2.html5PollingInterval) {
+ options.push('html5PollingInterval (' + sm2.html5PollingInterval + 'ms)');
+ }
+
+ if (options.length) {
+ msg = msg.concat([options.join(delimiter)]);
+ }
+
+ sm2._wD(title + (msg.length ? delimiter + msg.join(', ') : ''), 1);
+
+ showSupport();
+
+ //
+
+ }
+
+ if (sm2.html5Only) {
+
+ // 100% HTML5 mode
+ setVersionInfo();
+
+ initMsg();
+ sm2.oMC = id(sm2.movieID);
+ init();
+
+ // prevent multiple init attempts
+ didAppend = true;
+
+ appendSuccess = true;
+
+ return false;
+
+ }
+
+ // flash path
+ var remoteURL = (movieURL || sm2.url),
+ localURL = (sm2.altURL || remoteURL),
+ swfTitle = 'JS/Flash audio component (SoundManager 2)',
+ oTarget = getDocument(),
+ extraClass = getSWFCSS(),
+ isRTL = null,
+ html = doc.getElementsByTagName('html')[0],
+ oEmbed, oMovie, tmp, movieHTML, oEl, s, x, sClass;
+
+ isRTL = (html && html.dir && html.dir.match(/rtl/i));
+ movieID = (movieID === _undefined ? sm2.id : movieID);
+
+ function param(name, value) {
+ return '
';
+ }
+
+ // safety check for legacy (change to Flash 9 URL)
+ setVersionInfo();
+ sm2.url = normalizeMovieURL(overHTTP ? remoteURL : localURL);
+ movieURL = sm2.url;
+
+ sm2.wmode = (!sm2.wmode && sm2.useHighPerformance ? 'transparent' : sm2.wmode);
+
+ if (sm2.wmode !== null && (ua.match(/msie 8/i) || (!isIE && !sm2.useHighPerformance)) && navigator.platform.match(/win32|win64/i)) {
+ /**
+ * extra-special case: movie doesn't load until scrolled into view when using wmode = anything but 'window' here
+ * does not apply when using high performance (position:fixed means on-screen), OR infinite flash load timeout
+ * wmode breaks IE 8 on Vista + Win7 too in some cases, as of January 2011 (?)
+ */
+ messages.push(strings.spcWmode);
+ sm2.wmode = null;
+ }
+
+ oEmbed = {
+ name: movieID,
+ id: movieID,
+ src: movieURL,
+ quality: 'high',
+ allowScriptAccess: sm2.allowScriptAccess,
+ bgcolor: sm2.bgColor,
+ pluginspage: http + 'www.macromedia.com/go/getflashplayer',
+ title: swfTitle,
+ type: 'application/x-shockwave-flash',
+ wmode: sm2.wmode,
+ // http://help.adobe.com/en_US/as3/mobile/WS4bebcd66a74275c36cfb8137124318eebc6-7ffd.html
+ hasPriority: 'true'
+ };
+
+ if (sm2.debugFlash) {
+ oEmbed.FlashVars = 'debug=1';
+ }
+
+ if (!sm2.wmode) {
+ // don't write empty attribute
+ delete oEmbed.wmode;
+ }
+
+ if (isIE) {
+
+ // IE is "special".
+ oMovie = doc.createElement('div');
+ movieHTML = [
+ '
'
+ ].join('');
+
+ } else {
+
+ oMovie = doc.createElement('embed');
+ for (tmp in oEmbed) {
+ if (oEmbed.hasOwnProperty(tmp)) {
+ oMovie.setAttribute(tmp, oEmbed[tmp]);
+ }
+ }
+
+ }
+
+ initDebug();
+ extraClass = getSWFCSS();
+ oTarget = getDocument();
+
+ if (oTarget) {
+
+ sm2.oMC = (id(sm2.movieID) || doc.createElement('div'));
+
+ if (!sm2.oMC.id) {
+
+ sm2.oMC.id = sm2.movieID;
+ sm2.oMC.className = swfCSS.swfDefault + ' ' + extraClass;
+ s = null;
+ oEl = null;
+
+ if (!sm2.useFlashBlock) {
+ if (sm2.useHighPerformance) {
+ // on-screen at all times
+ s = {
+ position: 'fixed',
+ width: '8px',
+ height: '8px',
+ // >= 6px for flash to run fast, >= 8px to start up under Firefox/win32 in some cases. odd? yes.
+ bottom: '0px',
+ left: '0px',
+ overflow: 'hidden'
+ };
+ } else {
+ // hide off-screen, lower priority
+ s = {
+ position: 'absolute',
+ width: '6px',
+ height: '6px',
+ top: '-9999px',
+ left: '-9999px'
+ };
+ if (isRTL) {
+ s.left = Math.abs(parseInt(s.left, 10)) + 'px';
+ }
+ }
+ }
+
+ if (isWebkit) {
+ // soundcloud-reported render/crash fix, safari 5
+ sm2.oMC.style.zIndex = 10000;
+ }
+
+ if (!sm2.debugFlash) {
+ for (x in s) {
+ if (s.hasOwnProperty(x)) {
+ sm2.oMC.style[x] = s[x];
+ }
+ }
+ }
+
+ try {
+
+ if (!isIE) {
+ sm2.oMC.appendChild(oMovie);
+ }
+
+ oTarget.appendChild(sm2.oMC);
+
+ if (isIE) {
+ oEl = sm2.oMC.appendChild(doc.createElement('div'));
+ oEl.className = swfCSS.swfBox;
+ oEl.innerHTML = movieHTML;
+ }
+
+ appendSuccess = true;
+
+ } catch(e) {
+
+ throw new Error(str('domError') + ' \n' + e.toString());
+
+ }
+
+ } else {
+
+ // SM2 container is already in the document (eg. flashblock use case)
+ sClass = sm2.oMC.className;
+ sm2.oMC.className = (sClass ? sClass + ' ' : swfCSS.swfDefault) + (extraClass ? ' ' + extraClass : '');
+ sm2.oMC.appendChild(oMovie);
+
+ if (isIE) {
+ oEl = sm2.oMC.appendChild(doc.createElement('div'));
+ oEl.className = swfCSS.swfBox;
+ oEl.innerHTML = movieHTML;
+ }
+
+ appendSuccess = true;
+
+ }
+
+ }
+
+ didAppend = true;
+
+ initMsg();
+
+ // sm2._wD(sm + ': Trying to load ' + movieURL + (!overHTTP && sm2.altURL ? ' (alternate URL)' : ''), 1);
+
+ return true;
+
+ };
+
+ initMovie = function() {
+
+ if (sm2.html5Only) {
+ createMovie();
+ return false;
+ }
+
+ // attempt to get, or create, movie (may already exist)
+ if (flash) return false;
+
+ if (!sm2.url) {
+
+ /**
+ * Something isn't right - we've reached init, but the soundManager url property has not been set.
+ * User has not called setup({url: ...}), or has not set soundManager.url (legacy use case) directly before init time.
+ * Notify and exit. If user calls setup() with a url: property, init will be restarted as in the deferred loading case.
+ */
+
+ _wDS('noURL');
+ return false;
+
+ }
+
+ // inline markup case
+ flash = sm2.getMovie(sm2.id);
+
+ if (!flash) {
+
+ if (!oRemoved) {
+
+ // try to create
+ createMovie(sm2.id, sm2.url);
+
+ } else {
+
+ // try to re-append removed movie after reboot()
+ if (!isIE) {
+ sm2.oMC.appendChild(oRemoved);
+ } else {
+ sm2.oMC.innerHTML = oRemovedHTML;
+ }
+
+ oRemoved = null;
+ didAppend = true;
+
+ }
+
+ flash = sm2.getMovie(sm2.id);
+
+ }
+
+ if (typeof sm2.oninitmovie === 'function') {
+ setTimeout(sm2.oninitmovie, 1);
+ }
+
+ //
+ flushMessages();
+ //
+
+ return true;
+
+ };
+
+ delayWaitForEI = function() {
+
+ setTimeout(waitForEI, 1000);
+
+ };
+
+ rebootIntoHTML5 = function() {
+
+ // special case: try for a reboot with preferFlash: false, if 100% HTML5 mode is possible and useFlashBlock is not enabled.
+
+ window.setTimeout(function() {
+
+ complain(smc + 'useFlashBlock is false, 100% HTML5 mode is possible. Rebooting with preferFlash: false...');
+
+ sm2.setup({
+ preferFlash: false
+ }).reboot();
+
+ // if for some reason you want to detect this case, use an ontimeout() callback and look for html5Only and didFlashBlock == true.
+ sm2.didFlashBlock = true;
+
+ sm2.beginDelayedInit();
+
+ }, 1);
+
+ };
+
+ waitForEI = function() {
+
+ var p,
+ loadIncomplete = false;
+
+ if (!sm2.url) {
+ // No SWF url to load (noURL case) - exit for now. Will be retried when url is set.
+ return;
+ }
+
+ if (waitingForEI) {
+ return;
+ }
+
+ waitingForEI = true;
+ event.remove(window, 'load', delayWaitForEI);
+
+ if (hasFlash && tryInitOnFocus && !isFocused) {
+ // Safari won't load flash in background tabs, only when focused.
+ _wDS('waitFocus');
+ return;
+ }
+
+ if (!didInit) {
+ p = sm2.getMoviePercent();
+ if (p > 0 && p < 100) {
+ loadIncomplete = true;
+ }
+ }
+
+ setTimeout(function() {
+
+ p = sm2.getMoviePercent();
+
+ if (loadIncomplete) {
+ // special case: if movie *partially* loaded, retry until it's 100% before assuming failure.
+ waitingForEI = false;
+ sm2._wD(str('waitSWF'));
+ window.setTimeout(delayWaitForEI, 1);
+ return;
+ }
+
+ //
+ if (!didInit) {
+
+ sm2._wD(sm + ': No Flash response within expected time. Likely causes: ' + (p === 0 ? 'SWF load failed, ' : '') + 'Flash blocked or JS-Flash security error.' + (sm2.debugFlash ? ' ' + str('checkSWF') : ''), 2);
+
+ if (!overHTTP && p) {
+
+ _wDS('localFail', 2);
+
+ if (!sm2.debugFlash) {
+ _wDS('tryDebug', 2);
+ }
+
+ }
+
+ if (p === 0) {
+
+ // if 0 (not null), probably a 404.
+ sm2._wD(str('swf404', sm2.url), 1);
+
+ }
+
+ debugTS('flashtojs', false, ': Timed out' + (overHTTP ? ' (Check flash security or flash blockers)' : ' (No plugin/missing SWF?)'));
+
+ }
+ //
+
+ // give up / time-out, depending
+
+ if (!didInit && okToDisable) {
+
+ if (p === null) {
+
+ // SWF failed to report load progress. Possibly blocked.
+
+ if (sm2.useFlashBlock || sm2.flashLoadTimeout === 0) {
+
+ if (sm2.useFlashBlock) {
+
+ flashBlockHandler();
+
+ }
+
+ _wDS('waitForever');
+
+ } else if (!sm2.useFlashBlock && canIgnoreFlash) {
+
+ // no custom flash block handling, but SWF has timed out. Will recover if user unblocks / allows SWF load.
+ rebootIntoHTML5();
+
+ } else {
+
+ _wDS('waitForever');
+
+ // fire any regular registered ontimeout() listeners.
+ processOnEvents({
+ type: 'ontimeout',
+ ignoreInit: true,
+ error: {
+ type: 'INIT_FLASHBLOCK'
+ }
+ });
+
+ }
+
+ } else if (sm2.flashLoadTimeout === 0) {
+
+ // SWF loaded? Shouldn't be a blocking issue, then.
+
+ _wDS('waitForever');
+
+ } else if (!sm2.useFlashBlock && canIgnoreFlash) {
+
+ rebootIntoHTML5();
+
+ } else {
+
+ failSafely(true);
+
+ }
+
+ }
+
+ }, sm2.flashLoadTimeout);
+
+ };
+
+ handleFocus = function() {
+
+ function cleanup() {
+ event.remove(window, 'focus', handleFocus);
+ }
+
+ if (isFocused || !tryInitOnFocus) {
+ // already focused, or not special Safari background tab case
+ cleanup();
+ return true;
+ }
+
+ okToDisable = true;
+ isFocused = true;
+ _wDS('gotFocus');
+
+ // allow init to restart
+ waitingForEI = false;
+
+ // kick off ExternalInterface timeout, now that the SWF has started
+ delayWaitForEI();
+
+ cleanup();
+ return true;
+
+ };
+
+ flushMessages = function() {
+
+ //
+
+ // SM2 pre-init debug messages
+ if (messages.length) {
+ sm2._wD('SoundManager 2: ' + messages.join(' '), 1);
+ messages = [];
+ }
+
+ //
+
+ };
+
+ showSupport = function() {
+
+ //
+
+ flushMessages();
+
+ var item, tests = [];
+
+ if (sm2.useHTML5Audio && sm2.hasHTML5) {
+ for (item in sm2.audioFormats) {
+ if (sm2.audioFormats.hasOwnProperty(item)) {
+ tests.push(item + ' = ' + sm2.html5[item] + (!sm2.html5[item] && needsFlash && sm2.flash[item] ? ' (using flash)' : (sm2.preferFlash && sm2.flash[item] && needsFlash ? ' (preferring flash)' : (!sm2.html5[item] ? ' (' + (sm2.audioFormats[item].required ? 'required, ' : '') + 'and no flash support)' : ''))));
+ }
+ }
+ sm2._wD('SoundManager 2 HTML5 support: ' + tests.join(', '), 1);
+ }
+
+ //
+
+ };
+
+ initComplete = function(bNoDisable) {
+
+ if (didInit) return false;
+
+ if (sm2.html5Only) {
+ // all good.
+ _wDS('sm2Loaded', 1);
+ didInit = true;
+ initUserOnload();
+ debugTS('onload', true);
+ return true;
+ }
+
+ var wasTimeout = (sm2.useFlashBlock && sm2.flashLoadTimeout && !sm2.getMoviePercent()),
+ result = true,
+ error;
+
+ if (!wasTimeout) {
+ didInit = true;
+ }
+
+ error = {
+ type: (!hasFlash && needsFlash ? 'NO_FLASH' : 'INIT_TIMEOUT')
+ };
+
+ sm2._wD('SoundManager 2 ' + (disabled ? 'failed to load' : 'loaded') + ' (' + (disabled ? 'Flash security/load error' : 'OK') + ') ' + String.fromCharCode(disabled ? 10006 : 10003), disabled ? 2 : 1);
+
+ if (disabled || bNoDisable) {
+
+ if (sm2.useFlashBlock && sm2.oMC) {
+ sm2.oMC.className = getSWFCSS() + ' ' + (sm2.getMoviePercent() === null ? swfCSS.swfTimedout : swfCSS.swfError);
+ }
+
+ processOnEvents({
+ type: 'ontimeout',
+ error: error,
+ ignoreInit: true
+ });
+
+ debugTS('onload', false);
+ catchError(error);
+
+ result = false;
+
+ } else {
+
+ debugTS('onload', true);
+
+ }
+
+ if (!disabled) {
+
+ if (sm2.waitForWindowLoad && !windowLoaded) {
+
+ _wDS('waitOnload');
+ event.add(window, 'load', initUserOnload);
+
+ } else {
+
+ //
+ if (sm2.waitForWindowLoad && windowLoaded) {
+ _wDS('docLoaded');
+ }
+ //
+
+ initUserOnload();
+
+ }
+
+ }
+
+ return result;
+
+ };
+
+ /**
+ * apply top-level setupOptions object as local properties, eg., this.setupOptions.flashVersion -> this.flashVersion (soundManager.flashVersion)
+ * this maintains backward compatibility, and allows properties to be defined separately for use by soundManager.setup().
+ */
+
+ setProperties = function() {
+
+ var i,
+ o = sm2.setupOptions;
+
+ for (i in o) {
+
+ if (o.hasOwnProperty(i)) {
+
+ // assign local property if not already defined
+
+ if (sm2[i] === _undefined) {
+
+ sm2[i] = o[i];
+
+ } else if (sm2[i] !== o[i]) {
+
+ // legacy support: write manually-assigned property (eg., soundManager.url) back to setupOptions to keep things in sync
+ sm2.setupOptions[i] = sm2[i];
+
+ }
+
+ }
+
+ }
+
+ };
+
+
+ init = function() {
+
+ // called after onload()
+
+ if (didInit) {
+ _wDS('didInit');
+ return false;
+ }
+
+ function cleanup() {
+ event.remove(window, 'load', sm2.beginDelayedInit);
+ }
+
+ if (sm2.html5Only) {
+
+ if (!didInit) {
+ // we don't need no steenking flash!
+ cleanup();
+ sm2.enabled = true;
+ initComplete();
+ }
+
+ return true;
+
+ }
+
+ // flash path
+ initMovie();
+
+ try {
+
+ // attempt to talk to Flash
+ flash._externalInterfaceTest(false);
+
+ /**
+ * Apply user-specified polling interval, OR, if "high performance" set, faster vs. default polling
+ * (determines frequency of whileloading/whileplaying callbacks, effectively driving UI framerates)
+ */
+ setPolling(true, (sm2.flashPollingInterval || (sm2.useHighPerformance ? 10 : 50)));
+
+ if (!sm2.debugMode) {
+ // stop the SWF from making debug output calls to JS
+ flash._disableDebug();
+ }
+
+ sm2.enabled = true;
+ debugTS('jstoflash', true);
+
+ if (!sm2.html5Only) {
+ // prevent browser from showing cached page state (or rather, restoring "suspended" page state) via back button, because flash may be dead
+ // http://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/
+ event.add(window, 'unload', doNothing);
+ }
+
+ } catch(e) {
+
+ sm2._wD('js/flash exception: ' + e.toString());
+
+ debugTS('jstoflash', false);
+
+ catchError({
+ type: 'JS_TO_FLASH_EXCEPTION',
+ fatal: true
+ });
+
+ // don't disable, for reboot()
+ failSafely(true);
+
+ initComplete();
+
+ return false;
+
+ }
+
+ initComplete();
+
+ // disconnect events
+ cleanup();
+
+ return true;
+
+ };
+
+ domContentLoaded = function() {
+
+ if (didDCLoaded) return false;
+
+ didDCLoaded = true;
+
+ // assign top-level soundManager properties eg. soundManager.url
+ setProperties();
+
+ initDebug();
+
+ if (!hasFlash && sm2.hasHTML5) {
+
+ sm2._wD('SoundManager 2: No Flash detected' + (!sm2.useHTML5Audio ? ', enabling HTML5.' : '. Trying HTML5-only mode.'), 1);
+
+ sm2.setup({
+ useHTML5Audio: true,
+ // make sure we aren't preferring flash, either
+ // TODO: preferFlash should not matter if flash is not installed. Currently, stuff breaks without the below tweak.
+ preferFlash: false
+ });
+
+ }
+
+ testHTML5();
+
+ if (!hasFlash && needsFlash) {
+
+ messages.push(strings.needFlash);
+
+ // TODO: Fatal here vs. timeout approach, etc.
+ // hack: fail sooner.
+ sm2.setup({
+ flashLoadTimeout: 1
+ });
+
+ }
+
+ if (doc.removeEventListener) {
+ doc.removeEventListener('DOMContentLoaded', domContentLoaded, false);
+ }
+
+ initMovie();
+
+ return true;
+
+ };
+
+ domContentLoadedIE = function() {
+
+ if (doc.readyState === 'complete') {
+ domContentLoaded();
+ doc.detachEvent('onreadystatechange', domContentLoadedIE);
+ }
+
+ return true;
+
+ };
+
+ winOnLoad = function() {
+
+ // catch edge case of initComplete() firing after window.load()
+ windowLoaded = true;
+
+ // catch case where DOMContentLoaded has been sent, but we're still in doc.readyState = 'interactive'
+ domContentLoaded();
+
+ event.remove(window, 'load', winOnLoad);
+
+ };
+
+ // sniff up-front
+ detectFlash();
+
+ // focus and window load, init (primarily flash-driven)
+ event.add(window, 'focus', handleFocus);
+ event.add(window, 'load', delayWaitForEI);
+ event.add(window, 'load', winOnLoad);
+
+ if (doc.addEventListener) {
+
+ doc.addEventListener('DOMContentLoaded', domContentLoaded, false);
+
+ } else if (doc.attachEvent) {
+
+ doc.attachEvent('onreadystatechange', domContentLoadedIE);
+
+ } else {
+
+ // no add/attachevent support - safe to assume no JS -> Flash either
+ debugTS('onload', false);
+ catchError({
+ type: 'NO_DOM2_EVENTS',
+ fatal: true
+ });
+
+ }
+
+} // SoundManager()
+
+// SM2_DEFER details: http://www.schillmania.com/projects/soundmanager2/doc/getstarted/#lazy-loading
+
+if (window.SM2_DEFER === _undefined || !SM2_DEFER) {
+ soundManager = new SoundManager();
+}
+
+/**
+ * SoundManager public interfaces
+ * ------------------------------
+ */
+
+if (typeof module === 'object' && module && typeof module.exports === 'object') {
+
+ /**
+ * commonJS module
+ */
+
+ module.exports.SoundManager = SoundManager;
+ module.exports.soundManager = soundManager;
+
+} else if (typeof define === 'function' && define.amd) {
+
+ /**
+ * AMD - requireJS
+ * basic usage:
+ * require(["/path/to/soundmanager2.js"], function(SoundManager) {
+ * SoundManager.getInstance().setup({
+ * url: '/swf/',
+ * onready: function() { ... }
+ * })
+ * });
+ *
+ * SM2_DEFER usage:
+ * window.SM2_DEFER = true;
+ * require(["/path/to/soundmanager2.js"], function(SoundManager) {
+ * SoundManager.getInstance(function() {
+ * var soundManager = new SoundManager.constructor();
+ * soundManager.setup({
+ * url: '/swf/',
+ * ...
+ * });
+ * ...
+ * soundManager.beginDelayedInit();
+ * return soundManager;
+ * })
+ * });
+ */
+
+ define(function() {
+ /**
+ * Retrieve the global instance of SoundManager.
+ * If a global instance does not exist it can be created using a callback.
+ *
+ * @param {Function} smBuilder Optional: Callback used to create a new SoundManager instance
+ * @return {SoundManager} The global SoundManager instance
+ */
+ function getInstance(smBuilder) {
+ if (!window.soundManager && smBuilder instanceof Function) {
+ var instance = smBuilder(SoundManager);
+ if (instance instanceof SoundManager) {
+ window.soundManager = instance;
+ }
+ }
+ return window.soundManager;
+ }
+ return {
+ constructor: SoundManager,
+ getInstance: getInstance
+ };
+ });
+
+}
+
+// standard browser case
+
+// constructor
+window.SoundManager = SoundManager;
+
+/**
+ * note: SM2 requires a window global due to Flash, which makes calls to window.soundManager.
+ * Flash may not always be needed, but this is not known until async init and SM2 may even "reboot" into Flash mode.
+ */
+
+// public API, flash callbacks etc.
+window.soundManager = soundManager;
+
+}(window));
diff --git a/grails-app/assets/stylesheets/audiotranscribe.css b/grails-app/assets/stylesheets/audiotranscribe.css
new file mode 100644
index 000000000..6dbae610b
--- /dev/null
+++ b/grails-app/assets/stylesheets/audiotranscribe.css
@@ -0,0 +1,596 @@
+#ct-container .itemgrid .griditem {
+ width: 150px;
+ margin: 3px 4px;
+ border-radius: 4px;
+ background-color: #111;
+ background-color: rgba(16,16,16,0.8);
+ /* Allow the item grid to be centered within tab content */
+ display: inline-block;
+ float: none;
+}
+#ct-container .griditem.bvpBadge:active {
+ border: 3px #CAC146 solid;
+ box-sizing: content-box;
+ margin: 0 1px;
+}
+
+#camera-trap-questions {
+ position: relative;
+ /*border: 1px solid rgb(226,226,210);*/
+ /*border-radius: 2px;*/
+}
+
+#ct-landing, #ct-animals-summary {
+ height: 560px;
+}
+
+#ct-animals-list, #ct-unlisted {
+ /*margin: 0 -9px -9px -9px;*/
+ overflow-y: auto;
+ height: 500px;
+}
+
+.ct-item-container, .ct-sub-item-container {
+ position: relative;
+}
+
+.ct-item, .ct-sub-item {
+ opacity: 0.0;
+ transition: opacity .25s ease-in-out;
+ -moz-transition: opacity .25s ease-in-out;
+ -webkit-transition: opacity .25s ease-in-out;
+ /*z-index: 0;*/
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ visibility: hidden;
+ pointer-events: none;
+ /*display: none;*/
+}
+
+.ct-item.fading, .ct-sub-item.fading {
+ /*position: absolute;*/
+ /*left: 0;*/
+ /*top: 0;*/
+ /*right: 0;*/
+ /*bottom: 0;*/
+ visibility: visible;
+ /*display: block;*/
+ /*display: initial;*/
+}
+
+.ct-item.active, .ct-item.active .ct-sub-item.active {
+ opacity: 1.0;
+ /*z-index: 0;*/
+ visibility: visible;
+ position: static;
+ pointer-events: auto;
+ /*display: block;*/
+ /*display: initial;*/
+}
+
+#ct-landing .btn-ct-landing {
+ margin: 0 auto;
+}
+
+#ct-landing .btn-group.btn-group-vertical {
+ display: block;
+}
+
+.thumbnail.ct-thumbnail {
+ position: relative;
+ padding: 0;
+ border: none;
+ margin-bottom: 0;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+ -webkit-box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.5);
+ -moz-box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.5);
+ box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.5);
+}
+
+.thumbnail.ct-thumbnail.ct-certain-selected {
+ -webkit-box-shadow: 0 0 5px 5px green;
+ -moz-box-shadow: 0 0 5px 5px green;
+ box-shadow: 0 0 5px 5px green;
+}
+
+.thumbnail.ct-thumbnail.ct-uncertain-selected {
+ -webkit-box-shadow: 0 0 5px 5px orange;
+ -moz-box-shadow: 0 0 5px 5px orange;
+ box-shadow: 0 0 5px 5px orange;
+}
+
+.ct-badge, .ct-info, .ws-info {
+ position: absolute;
+ padding: 2px 9px;
+ opacity: 0.8;
+ line-height: 20px;
+ font-size: 20px;
+ border-radius: 0;
+ background-color: #111;
+ background-color: rgba(16, 16, 16, 0.4);
+ z-index: 1;
+}
+
+.ct-badge, .ct-info {
+ top: 0;
+}
+
+.ct-info {
+ right: 0;
+ color: #6495ED;
+ vertical-align: baseline;
+ white-space: nowrap;
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ display: inline-block;
+ font-weight: bold;
+ border-bottom-left-radius: 9px;
+}
+
+.ws-info {
+ cursor: zoom-in;
+ right: 0;
+ /*color: #6495ED;*/
+ top: 0;
+ color: white;
+ background-color: black;
+ vertical-align: baseline;
+ white-space: nowrap;
+ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+ display: inline-block;
+ font-weight: bold;
+ border-bottom-left-radius: 9px;
+}
+
+.ws-selected {
+ position: absolute;
+ margin-top: 4px;
+ left: 4px;
+ /* padding: 2px 9px; */
+ font-size: 24px;
+ width: 31px;
+ height: 31px;
+ line-height: 31px;
+ color: #666;
+ background-color: white;
+ display: none;
+ z-index: 1;
+ border-radius: 15px;
+ text-align: center;
+}
+
+.ws-selector.ws-selected {
+ display: initial;
+}
+
+.ws-selected.ws-selected-large {
+ width: 39px;
+ height: 39px;
+ line-height: 39px;
+ border-radius: 20px;
+ top: 5px;
+ left: 25px;
+}
+
+.ws-selected.selected {
+ display: initial;
+ background-color: #CAC146;
+ color: black;
+}
+
+.ws-selector {
+ cursor: pointer;
+}
+
+/*.ct-badge.ct-badge-large.ws-selected {*/
+ /*color: grey;*/
+ /*background-color: unset;*/
+ /*display: initial;*/
+/*}*/
+
+/*.ct-badge.ct-badge-large.ws-selected:hover {*/
+ /*color: grey;*/
+ /*background-color: unset;*/
+/*}*/
+
+/*.ct-badge.ct-badge-large.ws-selected.selected {*/
+ /*color: #CAC146;*/
+/*}*/
+
+/*.ct-badge {*/
+ /*cursor: pointer;*/
+ /*transition: all .25s ease-in-out;*/
+ /*color: #468847;*/
+/*}*/
+
+/*.ct-selected .ct-badge, .ct-badge.selected {*/
+ /*opacity: 1;*/
+/*}*/
+
+/*.ct-certain-selected .ct-badge-sure, .ct-badge-sure.selected {*/
+ /*background-color: #468847;*/
+/*}*/
+
+/*.ct-uncertain-selected .ct-badge-uncertain, .ct-badge-uncertain.selected {*/
+ /*background-color: #f89406;*/
+/*}*/
+
+/*.ct-badge-sure {*/
+ /*left: 0;*/
+ /*border-top-left-radius: 4px;*/
+/*}*/
+
+/*.ct-badge-sure:hover {*/
+ /*background-color: #468847;*/
+/*}*/
+
+/*.ct-badge-uncertain:hover {*/
+ /*background-color: #f89406;*/
+/*}*/
+
+/*.ct-badge-uncertain {*/
+ /*left: 0;*/
+ /*top: 25px;*/
+ /*color: #f89406;*/
+ /*border-bottom-right-radius: 9px;*/
+/*}*/
+
+/*.ct-badge.ct-badge-large, .ct-info.ct-info-large {*/
+ /*border-top-left-radius: 0;*/
+ /*border-top-right-radius: 0;*/
+ /*text-align: center;*/
+ /*width: 2.5em;*/
+ /*height:2.5em;*/
+/*}*/
+
+/*.ct-badge.ct-badge-large i, .ct-info.ct-info-large i {*/
+ /*font-size: 2em;*/
+/*}*/
+
+/*.ct-badge.ct-badge-uncertain.ct-badge-large {*/
+ /*top: 54px;*/
+/*}*/
+
+.bvpBadge {
+ text-align: center;
+}
+
+.bvpBadge img, .bvpBadge .thumbnail.ct-thumbnail img, .bvpBadge .bvpBadgeMain {
+ width: 150px;
+ height: 150px;
+}
+
+.audioBadge img {
+ width: 100px;
+ height: 100px;
+}
+
+.bvpBadge .thumbnail.ct-thumbnail .bvpBadgeMain {
+ border-radius: 4px;
+}
+
+.bvpBadge.ct-selected {
+ box-shadow: 0 0 5px 5px green;
+}
+
+.bvpBadge.ct-selected.ct-uncertain-selected {
+ box-shadow: 0 0 5px 5px orange;
+}
+
+.thumbnail.ct-thumbnail .ct-caption-container {
+ line-height: 0;
+}
+
+.ct-caption-table, .caption-table {
+ display:table;
+ height:40px;
+ line-height:0;
+ width: 100%;
+ margin-top: -40px;
+ position: relative;
+ background-color: #111;
+ background-color: rgba(16,16,16,0.4);
+ color: #ccc;
+ -webkit-border-radius: 0 0 4px 4px;
+ border-radius: 0 0 4px 4px;
+}
+
+.ct-caption-cell, .caption-cell {
+ display:table-cell;
+ vertical-align:middle;
+}
+
+.thumbnail.ct-thumbnail .ct-thumbnail-image {
+ cursor: url('/css/zoom-in.cur'), url('zoom-in.cur'), pointer;
+ cursor: -moz-zoom-in;
+ cursor: -webkit-zoom-in;
+ cursor: zoom-in;
+}
+
+.thumbnail.ct-thumbnail .ct-thumbnail-image.ws-thumbnail-image {
+ cursor: pointer;
+}
+
+.thumbnail.ct-thumbnail img {
+ -webkit-border-radius: inherit;
+ -moz-border-radius: inherit;
+ border-radius: inherit;
+}
+
+#ct-full-image-container.ct-item.active, #ct-full-image-container.ct-item.fading {
+ height: 576px;
+ overflow-x: hidden;
+ /*overflow-y: auto;*/
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+#ct-full-image-container img {
+ /*height: 100%;*/
+ /*width: 100%;*/
+ cursor: url('/css/zoom-out.cur'), url('zoom-out.cur'), pointer;
+ cursor: -moz-zoom-out;
+ cursor: -webkit-zoom-out;
+ cursor: zoom-out;
+ height: 550px;
+}
+
+#ws-dynamic-container.ct-item.active, #ws-dynamic-container.ct-item.fading {
+ overflow-x: hidden;
+ /*overflow-y: auto;*/
+}
+
+.thumbnail.ct-thumbnail .ct-caption, .is-caption {
+ /*display: inline-block;*/
+ /*display: block; /!* Fallback *!/*/
+ /*display: -webkit-box;*/
+ /*display: -moz-box;*/
+ /*display: -ms-flexbox;*/
+ /*display: -webkit-flex;*/
+ /*display: flex;*/
+ /*max-width: 150px;*/
+ line-height: 1.5;
+ max-height: 40px; /*$font-size*$line-height*$lines-to-show; /* Fallback for non-webkit */
+ /*margin: 0 auto;*/
+ /*font-size: $font-size;*/
+ /*line-height: $line-height;*/
+ /*-webkit-line-clamp: 2;*/
+ /*-webkit-box-orient: vertical;*/
+ overflow: hidden;
+ text-align: center;
+ text-overflow: ellipsis;
+}
+
+.pointer {
+ cursor: pointer;
+}
+
+
+.cycler { position:relative; }
+.cycler img {
+ transition: opacity 1.5s ease-in-out;
+ position:absolute;
+ /*z-index:-1;*/
+ opacity: 0;
+ left:0;right:0;top:0;bottom:0;
+}
+.cycler img.active{
+ /*z-index:0;*/
+ opacity: 1;
+}
+
+.cycler .wash {
+ display: none;
+ position: absolute;
+ top: 0; bottom: 0; left: 0; right: 0;
+ opacity: 0.4;
+ background-color: green;
+ pointer-events: none;
+}
+
+.ct-selected .cycler .wash {
+ display: block;
+}
+
+.ct-uncertain-selected .cycler .wash {
+ background-color: orange;
+}
+
+/* Reset horizontal padding on tab content in well */
+#ct-q2-well .nav-tabs, #ct-animals-pill-content {
+ /*margin-left: -10px;*/
+ /*margin-right: -10px;*/
+ /*margin-bottom: 0;*/
+}
+
+.ct-toolbar {
+ /*width: 100%;*/
+ text-align: right;
+ border-bottom: 1px solid #ddd;
+ margin-top: -10px;
+ margin-right: -10px;
+ margin-left: -10px;
+ padding: 2px 10px;
+ /*background-color: #eee;*/
+}
+
+/*.ct-search {*/
+/*position: relative;*/
+/*z-index: 3;*/
+/*right: 0;*/
+/*height: 20px;*/
+/*max-height: 20px;*/
+/*margin-top: 0;*/
+/*background-color: #c8c8aa;*/
+/*border: 1px solid #baba94;*/
+/*border-radius: 4px 0 4px 4px;*/
+/*box-shadow: 1px 1px 5px;*/
+/*}*/
+
+/*.row-fluid .ct-search > input[type="text"] {*/
+/*height: 15px;*/
+/*max-height: 15px;*/
+/*min-height: 15px;*/
+/*outline: none;*/
+/*border: none !important;*/
+/*-webkit-box-shadow: none !important;*/
+/*-moz-box-shadow: none !important;*/
+/*box-shadow: none !important;*/
+/*}*/
+
+.detail-animal .carousel-inner > .item > img, .detail-animal .carousel-inner > .item > a > img {
+ margin: 0 auto;
+}
+
+.ws-full-image-carousel-close {
+ position: absolute;
+ right: 25px;
+ top: 5px;
+ font-size: 35px;
+ font-weight: bolder;
+ width: 39px;
+ height: 39px;
+ display: block;
+ z-index: 5;
+ color: white;
+ background-color: #222;
+ opacity: 0.5;
+ border: solid white 2px;
+ -webkit-border-radius: 18px;
+ -moz-border-radius: 18px;
+ border-radius: 20px;
+ line-height: 35px;
+ text-align: center;
+ cursor: pointer;
+}
+
+.ws-full-image-carousel-close:hover {
+ opacity: 0.9;
+}
+
+#ct-full-image-carousel .carousel-indicators {
+ top: auto;
+ bottom: -12px;
+}
+
+#ct-full-image-carousel .carousel-indicators li {
+ background-color: #444;
+ background-color: rgba(0,0,0,0.25);
+}
+
+#ct-full-image-carousel .carousel-indicators .active {
+ background-color: #000;
+}
+
+.h3-small {
+ margin: 3px 0;
+}
+.ct-well {
+ min-height: 624px;
+}
+
+.ct-well .row-fluid .btn-toolbar input[type="text"] {
+ height: auto;
+ font-size: 13px;
+ line-height: 20px;
+ margin-bottom: 0;
+ padding: 4px 6px;
+ /*-webkit-border-radius: 0;*/
+ /*-moz-border-radius: 0;*/
+ /*border-radius: 0;*/
+ min-height: 0;
+ min-height: initial;
+}
+
+.ct-well .btn-toolbar .input-append, .ct-well .btn-toolbar .input-prepend {
+ margin-bottom: 0;
+}
+
+.ct-well .btn:active, .ct-well .btn.active, .ct-well .btn:focus {
+ outline: none;
+}
+
+ul.filterInfo {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ margin-top: 10px;
+ height: 80px;
+}
+
+ul.filterInfo li {
+ list-style: none;
+ display: inline-block;
+ width: 40%;
+ font-size: 16px;
+ line-height: 18px;
+}
+
+ul.filterInfo li span {
+ text-transform: capitalize;
+}
+
+.filterInfo a.clearall {
+ position: absolute;
+ left: 6px;
+ bottom: 3px;
+ text-decoration: underline;
+ /*color: #dde0b3;*/
+ cursor: pointer;
+}
+
+.scientific-name {
+ font-style: italic;
+}
+
+.status_detail_list {
+ list-style: none;
+}
+
+.status_detail_list .classificationRow .animalName {
+ display: inline-block;
+ line-height: 35px;
+}
+
+.status_detail_list .classificationRow select.form-control {
+ height: auto;
+}
+
+.classificationComments {
+ font-size: 0.9em;
+}
+
+.film-strip {
+ display: flex;
+ width: 100%;
+ justify-content: space-evenly;
+ flex-wrap: wrap;
+ background-color: dimgrey;
+ border-radius: 0 0 5px 5px;
+}
+.film-cell {
+ border: 5px dimgrey solid;
+ width: 14.285714285%;
+ cursor: pointer;
+ opacity: 0.5;
+}
+
+.film-cell.default.active {
+ border-color: #df4a21;
+}
+.film-cell.active {
+ border-color: black;
+}
+.film-cell.default {
+ border-color: #df4a21;
+ opacity: 1.0;
+}
+
+.film-cell img {
+ width: 100%;
+ height: auto;
+}
\ No newline at end of file
diff --git a/grails-app/assets/stylesheets/digivol-custom.css b/grails-app/assets/stylesheets/digivol-custom.css
index d0f10612b..30982ab07 100644
--- a/grails-app/assets/stylesheets/digivol-custom.css
+++ b/grails-app/assets/stylesheets/digivol-custom.css
@@ -173,6 +173,12 @@ table .btn-default {
table .btn-default:hover {
background-color: #e6e6e6;
}
+
+table.task-table {
+ font-size: 12px;
+ vertical-align: middle;
+}
+
/*** End - Styles for tables ***/
/*** Begin - Admin ***/
diff --git a/grails-app/assets/stylesheets/inline-player.css b/grails-app/assets/stylesheets/inline-player.css
new file mode 100644
index 000000000..4e84d78c9
--- /dev/null
+++ b/grails-app/assets/stylesheets/inline-player.css
@@ -0,0 +1,172 @@
+/* two different list types */
+
+ul.flat {
+ list-style-type:none;
+ padding-left:0px;
+}
+
+td.audio-flat {
+ padding-left: 0px;
+}
+
+ul.flat li,
+ul.graphic li{
+ padding-bottom:1px;
+}
+
+ul.flat li a,
+td.audio-flat a {
+ display:inline-block;
+ padding:2px 4px 2px 4px;
+}
+
+ul.graphic {
+ list-style-type:none;
+ padding-left:0px;
+ margin-left:0px;
+}
+
+/* background-image-based CSS3 example */
+
+ul.graphic {
+ list-style-type:none;
+ margin:0px;
+ padding:0px;
+}
+
+ul.graphic li {
+ margin-bottom:2px;
+}
+
+ul.graphic li a,
+ul.graphic li a.sm2_link,
+.thumbnail a,
+.thumbnail a.sm2_link {
+ /* assume all items will be sounds rather than wait for onload etc. in this example.. may differ for your uses. */
+ display: inline-block;
+ /* padding-left:22px; */
+ position: absolute;
+ min-height:51px;
+ top: 51px;
+ left: 51px;
+ vertical-align: middle;
+ /* background-color:#336699; */
+ border-radius:3px;
+ padding:3px 3px 3px 3px;
+ min-width:51px;
+ _width:51px; /* IE 6 */
+ /*text-decoration:none;*/
+ font-weight:normal;
+ color:#f6f9ff;
+ z-index: 999;
+}
+
+ul.graphic li a.sm2_link,
+.thumbnail a.sm2_link {
+ transition: background-color 0.1s linear;
+}
+
+ul.graphic li a, /* use a.sm2_link {} if you want play icons showing only if SM2 is supported */
+.thumbnail a,
+ul.graphic li a.sm2_paused:hover,
+.thumbnail a.sm2_paused:hover,
+ul.graphic li a.sm2_link:hover,
+.thumbnail a.sm2_link:hover {
+ background-image:url(icon_play3.png);
+ /* background-position:3px 10%; */
+ background-repeat:no-repeat;
+ _background-image:url(icon_play3.png); /* IE 6 */
+ z-index: 1;
+}
+
+ul.graphic li a.sm2_link:hover,
+.thumbnail a.sm2_link:hover {
+ /* default hover color, if you'd like.. */
+ /* background-color:#003366; */
+ color:#fff;
+}
+
+ul.graphic li a.sm2_paused,
+.thumbnail a.sm2_paused {
+ /* background-color:#999; */
+}
+
+ul.graphic li a.sm2_paused:hover,
+.thumbnail a.sm2_paused:hover {
+ background:/*#003366*/ url(icon_play3.png);
+ _background-image:url(icon_play3.png);
+ z-index: 1;
+}
+
+ul.graphic li a.sm2_playing,
+ul.graphic li a.sm2_playing:hover,
+.thumbnail a.sm2_playing,
+.thumbnail a.sm2_playing:hover {
+ background:/*#003366*/ url(icon_pause3.png);
+ _background-image:url(icon_pause3.png);
+ text-decoration:none;
+ z-index: 1;
+}
+
+/* hide button while playing?
+ul.graphic li a.sm2_playing {
+ background-image:none;
+}
+*/
+
+body #sm2-container object,
+body #sm2-container embed {
+ /*
+ flashblock handling: hide SWF off-screen by default (until blocked timeout case.)
+ include body prefix to ensure override of flashblock.css.
+ */
+
+ left:-9999em;
+ top:-9999em;
+}
+
+/* flat CSS example */
+
+ul.flat a.sm2_link,
+td.audio-flat a.sm2_link {
+ /* default state: "a playable link" */
+ border-left:6px solid #999;
+ padding-left:4px;
+ padding-right:4px;
+}
+
+ul.flat a.sm2_link:hover,
+td.audio-flat a.sm2_link:hover {
+ /* default (inactive) hover state */
+ border-left-color:#333;
+}
+
+
+ul.flat a.sm2_playing,
+td.audio-flat a.sm2_playing {
+ /* "now playing" */
+ border-left-color:#6666ff;
+ background-color:#000;
+ color:#fff;
+ text-decoration:none;
+}
+
+ul.flat a.sm2_playing:hover,
+td.audio-flat a.sm2_playing:hover {
+ /* "clicking will now pause" */
+ border-left-color:#cc3333;
+}
+
+ul.flat a.sm2_paused,
+td.audio-flat a.sm2_paused {
+ /* "paused state" */
+ background-color:#666;
+ color:#fff;
+ text-decoration:none;
+}
+
+ul.flat a.sm2_paused:hover,
+td.audio-flat a.sm2_paused:hover {
+ /* "clicking will resume" */
+ border-left-color:#33cc33;
+}
\ No newline at end of file
diff --git a/grails-app/conf/logback.groovy b/grails-app/conf/logback.groovy
index 6a4e87179..c28ee4092 100644
--- a/grails-app/conf/logback.groovy
+++ b/grails-app/conf/logback.groovy
@@ -102,7 +102,15 @@ logger('au.org.ala.volunteer.BVPSessionListener', DEBUG, [ACCESS], false)
logger('au.org.ala.cas', DEBUG, [CAS], false)
logger('org.jasig.cas', DEBUG, [CAS], false)
-logger('grails.app.services.au.org.ala.volunteer.TaskService', DEBUG, [DEBUG_LOG], false)
+//logger('grails.app.services.au.org.ala.volunteer.TaskService', DEBUG, [DEBUG_LOG], false)
+final debug_logger = [
+ 'grails.app.domain.au.org.ala.volunteer.Task',
+ 'grails.app.services.au.org.ala.volunteer.TaskService',
+ 'grails.app.services.au.org.ala.volunteer.ValidationService',
+ 'grails.app.controllers.au.org.ala.volunteer.TranscribeController',
+ 'grails.app.controllers.au.org.ala.volunteer.ValidateController'
+]
+for (String name: debug_logger) logger(name, DEBUG, [DEBUG_LOG], false)
logger('org.apache.tomcat.jdbc.pool.interceptor.SlowQueryReport', INFO, [SLOW_QUERIES], false)
diff --git a/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy b/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy
index c39852869..c29ab1382 100644
--- a/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy
@@ -23,6 +23,7 @@ class AdminController {
def projectService
def fullTextIndexService
def eventSourceService
+ def institutionService
def index() {
if (!checkAdminAccess(true)) {
@@ -218,7 +219,7 @@ class AdminController {
// Because this action is run by both Site admins (for IA's) and IA's (for user roles), check that that user
// has permission to delete the selected role
// Also check, in the case of IA's, that they are not deleting another institution's role.
- def institution = (userRole.institution ? userRole.institution : userRole.project.institution)
+ def institution = (userRole.institution ? userRole.institution : userRole.project?.institution)
if (userRole.role.name == BVPRole.INSTITUTION_ADMIN && !userService.isAdmin()) {
log.error("Delete User Role: User ${currentUser.displayName} attempted deletion of Institution Admin role " +
@@ -735,12 +736,19 @@ class AdminController {
}
def projectSummaryReport() {
- if (!checkAdminAccess(false)) {
+ if (!checkAdminAccess(true)) {
render(view: '/notPermitted')
return
}
- def projects = Project.list([sort:'id'])
+ def projects
+ if (userService.isInstitutionAdmin() && !userService.isSiteAdmin()) {
+ def institutionList = userService.getAdminInstitutionList()
+ projects = institutionService.listProjectsForInstititutionList(institutionList)
+ } else {
+ projects = Project.list([sort:'id'])
+ }
+
def dates = taskService.getProjectDates()
def projectSummaries = projectService.getProjectSummaryList(params, true)
def summaryMap = projectSummaries.projectRenderList.collectEntries { [(it.project.id) : it ] }
diff --git a/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy b/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy
index 7d6c32c93..62e06dbf0 100644
--- a/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy
@@ -634,22 +634,29 @@ class AjaxController {
return result
}
+ def resumableUploadImage(ResumableUploadCommand cmd) {
+ def allowedMimeTypes = ['image/jpeg', 'image/gif', 'image/png', 'text/plain']
+ resumableUploadFile(cmd, allowedMimeTypes)
+ }
- def resumableUploadFile(ResumableUploadCommand cmd) {
+ def resumableUploadAudio(ResumableUploadCommand cmd) {
+ def allowedMimeTypes = ['audio/aac', 'audio/wav', 'audio/mpeg', 'audio/x-m4a', 'audio/ogg', 'audio/vnd.dlna.adts']
+ resumableUploadFile(cmd, allowedMimeTypes)
+ }
+ def resumableUploadFile(ResumableUploadCommand cmd, def allowedMimeTypes) {
if (cmd.hasErrors()) {
log.error("Resumable params are not valid {}", cmd)
return render(status: SC_BAD_REQUEST, text: "Params aren't valid")
}
- def allowedMimeTypes = ['image/jpeg', 'image/gif', 'image/png', 'text/plain']
if (!allowedMimeTypes.contains(cmd.type)) {
log.error("Resumable file content-type is not valid {}", cmd)
- return render(status: SC_BAD_REQUEST, text: "The image file must be one of: ${allowedMimeTypes}")
+ return render(status: SC_BAD_REQUEST, text: "The file must be one of: ${allowedMimeTypes}")
}
if (!Project.exists(cmd.projectId)) {
- return render(status: SC_NOT_FOUND, text: "Project doesn't exist")
+ return render(status: SC_NOT_FOUND, text: "Expedition doesn't exist")
}
if (!projectService.isAdminForProject(Project.get(cmd.projectId))) {
diff --git a/grails-app/controllers/au/org/ala/volunteer/FrontPageController.groovy b/grails-app/controllers/au/org/ala/volunteer/FrontPageController.groovy
index 9e8522374..280c0b257 100644
--- a/grails-app/controllers/au/org/ala/volunteer/FrontPageController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/FrontPageController.groovy
@@ -49,7 +49,8 @@ class FrontPageController {
// If Random Project of the Day is selected, and it hasn't been updated today, then update.
if (frontPage.randomProjectOfTheDay &&
projectService.isTimeToUpdateRandomProject(frontPage.randomProjectDateUpdated)) {
- Project potd = projectService.selectRandomProject()
+ def potdId = projectService.selectRandomProject()
+ Project potd = Project.get(potdId)
if (potd) {
frontPage.projectOfTheDay = potd
frontPage.randomProjectDateUpdated = new Date()
@@ -72,7 +73,7 @@ class FrontPageController {
flash.message = message(code: 'default.updated.message',
args: [message(code: 'frontPage.label', default: 'Front Page'), '']) as String
- redirect(action: "edit", params: params)
+ redirect(action: "edit")
} else {
render(view: '/notPermitted')
}
diff --git a/grails-app/controllers/au/org/ala/volunteer/ImageController.groovy b/grails-app/controllers/au/org/ala/volunteer/ImageController.groovy
index 30758bcda..45ada47f0 100644
--- a/grails-app/controllers/au/org/ala/volunteer/ImageController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/ImageController.groovy
@@ -13,6 +13,7 @@ class ImageController {
final static FORMATS = ['png', 'jpg', 'gif']
+ final static FORMAT_AUDIO = ['m4a', 'wav', 'mp3', 'aac']
final static MAX_WIDTH = 1024
final static MAX_HEIGHT = 1024
@@ -70,6 +71,42 @@ class ImageController {
sendImage(result, contentType(format))
}
+ def audioFile(String prefix, String name, String format) {
+ log.debug("Audio File request for $prefix and $name")
+
+ def encodedPrefix = IOUtils.toFileSystemDirectorySafeName(prefix)
+ def encodedName = IOUtils.toFileSystemSafeName(name)
+
+ format = format.toLowerCase()
+ if (!FORMAT_AUDIO.contains(format)) {
+ render([error: "${format} not supported"] as JSON, status: SC_BAD_REQUEST)
+ return
+ }
+
+ def imagesHome = grailsApplication.config.getProperty('images.home')
+ File result = new File("$imagesHome${File.separator}$encodedPrefix", "${encodedName}.${format}")
+
+ if (result.exists()) {
+ sendImage(result, contentType(format))
+ return
+ }
+
+ File original = findImage(encodedPrefix, encodedName)
+ if (!original) {
+ response.sendError(SC_NOT_FOUND)
+ return
+ }
+
+ def originalImage = ImageIO.read(original)
+ if (!originalImage) {
+ log.warn("${original.path} could not be read as an image")
+ render([error: "${original.path} could not be read as an image"] as JSON, status: 500)
+ return
+ }
+ originalImage.flush()
+ sendImage(result, contentType(format))
+ }
+
private def sendImage(File file, String contentType) {
// lastModified(file.lastModified())
def lm = file.lastModified()
diff --git a/grails-app/controllers/au/org/ala/volunteer/IndexController.groovy b/grails-app/controllers/au/org/ala/volunteer/IndexController.groovy
index d683b2cff..62b87e879 100644
--- a/grails-app/controllers/au/org/ala/volunteer/IndexController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/IndexController.groovy
@@ -43,131 +43,27 @@ class IndexController {
def maxContributors = (params.maxContributors as Integer) ?: 5
def disableStats = params.getBoolean('disableStats', false)
def disableHonourBoard = params.getBoolean('disableHonourBoard', false)
- def result = volunteerStatsService.generateStats(institutionId, projectId, projectType, tags, maxContributors, disableStats, disableHonourBoard)
+ log.debug("params: ${params}")
+ def result = volunteerStatsService.generateStats(institutionId, projectType, tags, disableStats, disableHonourBoard)
render result as JSON
}
- /* private generateContributors(Institution institution, Project projectInstance, ProjectType pt, maxContributors) {
-
- def latestTranscribers = LatestTranscribers.withCriteria {
- if (institution) {
- project {
- eq('institution', institution)
- ne('inactive', true)
- }
- } else if (pt) {
- project {
- eq('projectType', pt)
- ne('inactive', true)
- }
- } else if (projectInstance) {
- eq('project', projectInstance)
- } else {
- project {
- ne('inactive', true)
- }
- }
- order('maxDate', 'desc')
- maxResults(maxContributors)
- }
-
- def latestMessages
-
- if (institution) {
- latestMessages = ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxContributors])
- latestMessages += ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.task.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxContributors])
- } else if (pt) {
- latestMessages = ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.project.projectType = :pt ORDER BY date desc', [pt: pt], [max: maxContributors])
- latestMessages += ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.task.project.projectType = :pt ORDER BY date desc', [pt: pt], [max: maxContributors])
- } else if (projectInstance) {
- latestMessages = ForumMessage.withCriteria {
- or {
- 'in'('topic', new DetachedCriteria(ProjectForumTopic).build {
- eq('project', projectInstance)
- projections {
- property('id')
- }
- })
- 'in'('topic', new DetachedCriteria(TaskForumTopic).build {
- task {
- eq('project', projectInstance)
- }
- projections {
- property('id')
- }
- })
- }
- order('date', 'desc')
- maxResults(maxContributors)
- }
- } else {
- latestMessages = ForumMessage.withCriteria {
- order('date', 'desc')
- maxResults(maxContributors)
- }
- }
-
- def messages = latestMessages.collect {
- def topic = it.topic
- def topicId = topic.id
- def details = userService.detailsForUserId(it.user.userId)
- def timestamp = it.date.time / 1000
- def topicUrl = createLink(controller: 'forum', action: 'viewForumTopic', id: topic.id)
-
- def forumName
- def forumUrl
- def thumbnail = null
-
- if (topic instanceof ProjectForumTopic) {
- def project = ((ProjectForumTopic) topic).project
- forumName = project.name
- thumbnail = project.featuredImage
- forumUrl = createLink(controller: 'forum', action: 'projectForum', params: [projectId: project.id])
- } else if (topic instanceof TaskForumTopic) {
- def task = ((TaskForumTopic) topic).task
- forumName = task.project.name
- thumbnail = multimediaService.getImageThumbnailUrl(task.multimedia?.first())
- forumUrl = createLink(controller: 'forum', action: 'projectForum', params: [projectId: task.project.id, selectedTab: 1])
- } else {
- forumName = "General Discussion"
- forumUrl = createLink(controller: 'forum', action: 'index', params: [selectedTab: 1])
- }
-
- [type : 'forum', topicId: topicId, topicUrl: topicUrl, forumName: forumName, forumUrl: forumUrl, userId: it.userId,
- displayName : details?.displayName, email: details?.email?.toLowerCase()?.encodeAsMD5(),
- thumbnailUrl: thumbnail, timestamp: timestamp]
- }
-
- def transcribers = latestTranscribers.collect {
- def proj = it.project
- def userId = it.fullyTranscribedBy
- def details = userService.detailsForUserId(userId)
- def tasks = LatestTranscribersTask.withCriteria() {
- eq('project', proj)
- eq('fullyTranscribedBy', userId)
- order('dateFullyTranscribed', 'desc')
- }
-
- def thumbnailLists = (tasks && (tasks.size() > 0)) ? tasks.subList(0, (tasks.size() < 5)? tasks.size(): 5): []
-
- def thumbnails = thumbnailLists.collect { LatestTranscribersTask t ->
- def taskMultimedia = t.multimedia[0] //Latest.findByTaskId(t.taskId)
- Multimedia multimedia = new Multimedia(
- task: new Task(id: t.id, project: t.project),
- id: taskMultimedia.id,
- filePath: taskMultimedia.filePath,
- filePathToThumbnail: taskMultimedia.filePathToThumbnail,
- mimeType: taskMultimedia.mimeType)
-
- [id: t.id, thumbnailUrl: multimediaService.getImageThumbnailUrl(multimedia)]
- }
- [type : 'task', projectId: proj.id, projectName: proj.name, userId: User.findByUserId(userId)?.id ?: -1, displayName: details?.displayName, email: details?.email?.toLowerCase()?.encodeAsMD5(),
- transcribedThumbs: thumbnails, transcribedItems: tasks.size(), timestamp: it.maxDate.time / 1000]
- }
+ def contributors(long institutionId, long projectId, String projectType) {
+ List
tags = params.list('tags') ?: []
+ def maxContributors = (params.maxContributors as Integer) ?: 5
+ def disableStats = params.getBoolean('disableStats', false)
+ def disableHonourBoard = params.getBoolean('disableHonourBoard', false)
+ def result = volunteerStatsService.generateContributors(institutionId, projectId, projectType, tags, maxContributors)
+ render result as JSON
+ }
- def contributors = (messages + transcribers).sort { -it.timestamp }.take(maxContributors)
- return contributors
- } */
+ def forumActivity(long institutionId, long projectId, String projectType) {
+ List tags = params.list('tags') ?: []
+ def maxPosts = (params.maxPosts as Integer) ?: 5
+ def disablePosts = params.getBoolean('disablePosts', false)
+ def result = volunteerStatsService.generateForumPosts(institutionId, projectId, maxPosts)
+ render result as JSON
+ }
def notPermitted() {
render(view: '/notPermitted')
diff --git a/grails-app/controllers/au/org/ala/volunteer/InstitutionAdminController.groovy b/grails-app/controllers/au/org/ala/volunteer/InstitutionAdminController.groovy
index 462e1cd98..780b1abcc 100644
--- a/grails-app/controllers/au/org/ala/volunteer/InstitutionAdminController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/InstitutionAdminController.groovy
@@ -142,7 +142,7 @@ class InstitutionAdminController {
def title = institutionService.NOTIFICATION_APPLICATION + ": ${institution.name}"
def message = groovyPageRenderer.render(view: '/institutionAdmin/institutionApplicantNotification',
model: model)
- institutionService.emailNotification(message, title)
+ institutionService.emailNotification(message, title, recipient.email)
}
private def sendApplicationNotification(Institution institution) {
diff --git a/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy b/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy
index 9ad4dbff5..56529e789 100644
--- a/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy
@@ -409,10 +409,21 @@ class ProjectController {
}
}
+ if (params.template) {
+ Template newTemplate = Template.get(params.long('template'))
+ ProjectType newProjectType = (params.projectType) ? ProjectType.get(params.long('projectType')) : project.projectType
+ log.debug("Project Type: ${project.projectType}, Template view name ${newTemplate.viewName}")
+ if ((newProjectType.name == ProjectType.PROJECT_TYPE_AUDIO && !newTemplate.viewName.contains("audio")) ||
+ (newProjectType.name != ProjectType.PROJECT_TYPE_AUDIO && newTemplate.viewName.contains("audio"))) {
+ project.errors.rejectValue("template", "project.template.notcompatible",
+ "Template is not compatible with expedition type.")
+ }
+ }
+
if (project.errors.hasErrors()) {
def institutionList = (userService.isSiteAdmin() ? Institution.listApproved([sort: 'name', order: 'asc']) : userService.getAdminInstitutionList())
def projectTypes = ProjectType.listOrderByName()
- render(view: 'create', model: [params: params, institutionList: institutionList, projectTypes: projectTypes])
+ render(view: 'create', model: [projectInstance: project, params: params, institutionList: institutionList, projectTypes: projectTypes])
return
} else {
if (!projectService.createProject(project)) {
@@ -753,6 +764,18 @@ class ProjectController {
message(code: 'project.institution.required', default: 'Institution required') as String)
return false
}
+
+ if (params.template) {
+ Template newTemplate = Template.get(params.long('template'))
+ ProjectType newProjectType = (params.projectType) ? ProjectType.get(params.long('projectType')) : project.projectType
+ log.debug("Project Type: ${project.projectType}, Template view name ${newTemplate.viewName}")
+ if ((newProjectType.name == ProjectType.PROJECT_TYPE_AUDIO && !newTemplate.viewName.contains("audio")) ||
+ (newProjectType.name != ProjectType.PROJECT_TYPE_AUDIO && newTemplate.viewName.contains("audio"))) {
+ project.errors.rejectValue("template", "project.template.notcompatible",
+ "Template is not compatible with expedition type.")
+ return false
+ }
+ }
}
bindData(project, params)
@@ -1284,7 +1307,7 @@ class ProjectController {
}
def institutionFilter = []
- Institution institution = (params.institution ? Institution.get(params.long('institution')) : null)
+ Institution institution = (params.institutionFilter ? Institution.get(params.long('institutionFilter')) : null)
if (institution) institutionFilter.add(institution)
else institutionFilter = institutionList
@@ -1530,6 +1553,11 @@ class ProjectController {
'institutionList' : institutionList])
}
+ /**
+ * @deprecated
+ * @param project
+ * @return
+ */
def projectSize(Project project) {
if (!userService.isInstitutionAdmin()) {
respond status: 403
diff --git a/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy b/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy
index 9e28d231b..316dd8446 100644
--- a/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy
@@ -130,19 +130,33 @@ class TaskController {
views?.values()?.each { List viewList ->
def max = viewList.max { it.lastView }
use (TimeCategory) {
- if (new Date(max.lastView) > 2.hours.ago && !max.skipped) {
- lockedMap[max.task?.id] = max
+ if (new Date(max.lastView as long) > 2.hours.ago && !max.skipped) {
+ // Lock if not fully transcribed
+ // Lock if fully transcribed and opened by a validator
+ if (!max.task?.isFullyTranscribed) {
+ log.debug("Task locked; id: [${max.task?.id}], last view: [${new Date(max.lastView as long)}], skipped: [${max.skipped}]")
+ lockedMap[max.task?.id as long] = max
+ } else {
+ User viewingUser = User.findByUserId(max.userId as String)
+ if (viewingUser) {
+ def lastTranscription = max.task?.transcriptions?.max { it.dateFullyTranscribed }
+
+ log.debug("Checking who the viewing user is: ${viewingUser}")
+ log.debug("Viewing user is a validator: ${userService.userHasValidatorRole(viewingUser, project.id)}")
+ log.debug("View date: ${max.lastView}, date fully transcribed: ${lastTranscription?.dateFullyTranscribed?.getTime()}")
+
+ // If the last view came after the date/time of the last transcription, it was opened by a validator.
+ if (max.lastView > lastTranscription?.dateFullyTranscribed?.getTime() &&
+ (userService.userHasValidatorRole(viewingUser, project.id) && currentUser != max.userId)) {
+ log.debug("Task locked; id: [${max.task?.id}], last view: [${new Date(max.lastView as long)}] by ${max.userId} (current user ${currentUser}), skipped: [${max.skipped}]")
+ lockedMap[max.task?.id as long] = max
+ }
+ }
+ }
}
}
}
- // Get Project size (for Admin view)
- def projectSize = 0
- if (view == VIEW_TASK_LIST_ADMIN) {
- projectSize = projectService.projectSize(project).size
- if (projectSize < 0) projectSize = 0
- }
-
def statusFilterList = [[key: "transcribed", value: "View transcribed tasks"],
[key: "validated", value: "View validated tasks"],
[key: "not-transcribed", value: "View tasks not yet transcribed"]]
@@ -158,7 +172,6 @@ class TaskController {
extraFields : extraFields,
userInstance : userInstance,
lockedMap : lockedMap,
- projectSize : projectSize,
statusFilterList : statusFilterList])
} else {
flash.message = "No project found for ID " + params.long('id')
@@ -268,10 +281,17 @@ class TaskController {
msg = "This task is being viewed/edited by another user, and is currently read-only"
readonly = true
} else if (task.fullyValidatedBy && task.isValid != null) {
- msg = "This task has been validated, and is currently read-only."
+ if (task.isValid) {
+ msg = "This task has been validated, and is currently read-only."
+ } else {
+ msg = "This task has been partially validated and is currently read-only."
+ }
+
if (userService.isValidator(task.project)) {
def link = createLink(controller: 'validate', action: 'task', id: task.id) as String
- msg += ' As a validator you may review/edit this task by clicking here.'
+ //msg += ' As a validator you may review/edit this task by clicking here.'
+ msg += """ As a validator, you may review/${(task.isValid ? "edit" : "continue validating")}
+ this task by clicking here.""".toString()
}
readonly = true
} else if (userTask && userTask != currentUser) {
@@ -357,7 +377,7 @@ class TaskController {
thumbnail: multimediaService.getImageThumbnailUrl(mm, true),
image: multimediaService.getImageUrl(mm),
// TODO: replace these?
- transcriber: userService.detailsForUserId(task.fullyTranscribedBy)?.displayName,
+ transcriber: userService.detailsForUserId(task.fullyTranscribedBy as String)?.displayName,
dateTranscribed: task.dateFullyTranscribed,
validator: userService.detailsForUserId(task.fullyValidatedBy)?.displayName,
dateValidated: task.dateFullyValidated,
@@ -521,11 +541,16 @@ class TaskController {
return
}
+ boolean isAudioProject = (project.projectType.name == ProjectType.PROJECT_TYPE_AUDIO)
+
if (taskLoadService.isProjectLoadingAlready(projectId)) {
flash.message = 'Please wait while existing staged images are loaded'
redirect(controller: 'project', action: 'loadProgress', id: projectId)
} else {
- [projectInstance: project, hasDataFile: stagingService.projectHasDataFile(project), dataFileUrl:stagingService.dataFileUrl(project)]
+ [projectInstance: project,
+ hasDataFile: stagingService.projectHasDataFile(project),
+ dataFileUrl:stagingService.dataFileUrl(project),
+ isAudioProject: isAudioProject]
}
}
diff --git a/grails-app/controllers/au/org/ala/volunteer/TemplateController.groovy b/grails-app/controllers/au/org/ala/volunteer/TemplateController.groovy
index 8d996a779..1b4e0df06 100644
--- a/grails-app/controllers/au/org/ala/volunteer/TemplateController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/TemplateController.groovy
@@ -5,7 +5,7 @@ import com.google.common.hash.HashCode
import grails.converters.JSON
import grails.transaction.Transactional
import org.apache.commons.io.FilenameUtils
-
+import org.h2.util.StringUtils
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.web.multipart.MultipartFile
@@ -453,7 +453,10 @@ class TemplateController {
prevTask: null,
sequenceNumber: 0,
imageMetaData: imageMetaData,
- isPreview: true])
+ isPreview: true,
+ pageController: 'template',
+ mode: params.mode ? params.mode : '',
+ pageAction: 'preview'])
}
def exportFieldsAsCSV() {
@@ -571,6 +574,10 @@ class TemplateController {
* @return
*/
def wildlifeTemplateConfig(long id) {
+ redirect(action: 'spotterTemplateConfig', id: id)
+ }
+
+ def spotterTemplateConfig(long id) {
if (!userService.isInstitutionAdmin()) {
render(view: '/notPermitted')
return
@@ -584,7 +591,9 @@ class TemplateController {
}
def viewParams2 = template.viewParams2 ?: [ categories: [], animals: [] ]
- [id: id, templateInstance: template, viewParams2: viewParams2]
+ def viewName = "wildlifeTemplateConfig"
+ if (template.viewName == "audioTranscribe") viewName = "audioTemplateConfig"
+ render(view: viewName, model: [id: id, templateInstance: template, viewParams2: viewParams2])
}
/**
@@ -615,16 +624,20 @@ class TemplateController {
/**
* Upload image for Wildlife Spotter template picklists
*/
- def uploadWildlifeImage() {
+ def uploadSpotterFile() {
if (!userService.isInstitutionAdmin()) {
respond status: SC_FORBIDDEN
return
}
MultipartFile upload = request.getFile('animal') ?: request.getFile('entry')
+ def fileType = "wildlifespotter"
+ if (!StringUtils.isNullOrEmpty(params.fileType as String) && params.fileType == "audio") {
+ fileType = "audiotranscribe"
+ }
if (upload) {
- def file = fileUploadService.uploadImage('wildlifespotter', upload) { MultipartFile f, HashCode h ->
+ def file = fileUploadService.uploadImage(/* directory */ fileType, upload) { MultipartFile f, HashCode h ->
h.toString() + "." + fileUploadService.extension(f)
}
def hash = FilenameUtils.getBaseName(file.name)
diff --git a/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy b/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy
index 12d5d33d7..2e806b18e 100644
--- a/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy
@@ -1,6 +1,7 @@
package au.org.ala.volunteer
-import com.google.common.base.Stopwatch
+//import com.google.common.base.Stopwatch
+import grails.converters.JSON
import org.apache.commons.lang.StringUtils
class TranscribeController {
@@ -9,6 +10,10 @@ class TranscribeController {
private static final String HEADER_EXPIRES = "Expires"
private static final String HEADER_CACHE_CONTROL = "Cache-Control"
+ private static final int SAVE_TYPE_BACKGROUND = 1
+ private static final int SAVE_TYPE_PARTIAL = 2
+ private static final int SAVE_TYPE_SUBMIT = 3
+
def fieldSyncService
def auditService
def taskService
@@ -21,8 +26,8 @@ class TranscribeController {
def index() {
if (params.id) {
- log.debug("index redirect to showNextFromProject: " + params.id)
- redirect(action: "showNextFromProject", id: params.id)
+ log.debug("index redirect to showNextFromProject: " + params.long('id'))
+ redirect(action: "showNextFromProject", id: params.id, params: [mode: params.mode ?: ''])
} else {
flash.message = "Something unexpected happened. Try pressing the back button to return to the previous task and trying again."
redirect(uri:"/")
@@ -31,43 +36,45 @@ class TranscribeController {
}
def task() {
+// Stopwatch sw = Stopwatch.createStarted()
- Stopwatch sw = Stopwatch.createStarted()
-
- def taskInstance = Task.get(params.int('id'))
+ def task = Task.get(params.int('id'))
def currentUserId = userService.currentUserId
userService.registerCurrentUser()
- if (taskInstance) {
+ if (task) {
- boolean isLockedByOtherUser = auditService.isTaskLockedForTranscription(taskInstance, currentUserId)
+ boolean isLockedByOtherUser = auditService.isTaskLockedForTranscription(task, currentUserId)
+ log.debug("Checking if task ${task.id} is currently locked: [${isLockedByOtherUser}]")
- def isAdmin = (userService.isAdmin() || userService.isInstitutionAdmin(taskInstance.project.institution))
+ def isAdmin = (userService.isAdmin() || userService.isInstitutionAdmin(task.project.institution))
if (isLockedByOtherUser && !isAdmin) {
- def lastView = auditService.getLastViewForTask(taskInstance)
+ def lastView = auditService.getLastViewForTask(task)
// task is already being viewed by another user (with timeout period)
- log.debug("Task ${taskInstance.id} is currently locked by ${lastView.userId}. Another task will be allocated")
- flash.message = "The requested task (id: " + taskInstance.id + ") is being viewed/edited by another user. You have been allocated a new task"
+ log.debug("Task ${task.id} is currently locked by ${lastView.userId}. Another task will be allocated")
+ flash.message = "The requested task (id: " + task.id + ") is being viewed/edited by another user. You have been allocated a new task"
// redirect to another task
- redirect(action: "showNextFromProject", id: taskInstance.project.id, params: [prevId: taskInstance.id, prevUserId: lastView?.userId])
+ redirect(action: "showNextFromProject", id: task.project.id, params: [prevId: task.id, prevUserId: lastView?.userId, mode: params.mode ?: ''])
return
} else {
if (isLockedByOtherUser) {
flash.message = "This task is currently locked by another user. Because you are an admin you are able to work on this task, but only do so if you are confident that no-one else is working on this task as well, as data will be lost if two people save the same task!"
}
// go ahead with this task
- auditService.auditTaskViewing(taskInstance, currentUserId)
+ auditService.auditTaskViewing(task, currentUserId)
}
- def project = Project.findById(taskInstance.project.id)
+ def project = Project.findById(task.project.id)
def isReadonly = false
def isValidator = userService.isValidator(project)
log.debug(currentUserId + " has role: ADMIN = " + isAdmin + " && VALIDATOR = " + isValidator)
- if (taskInstance.isFullyTranscribed && !taskInstance.hasBeenTranscribedByUser(currentUserId) && !isAdmin) {
+ if (task.isFullyTranscribed && !task.hasBeenTranscribedByUser(currentUserId) && !isAdmin) {
isReadonly = "readonly"
}
+ log.debug("Loading task for transcription - project: [${project.id}], task: [${task.id}], user: [${currentUserId}]")
+
// Disable browser caching of this page, to force it to reload from server always
// This, in turn, ensures that there is always an active http session when the page
// is loaded and that all the page JS is run when the back button is clicked.
@@ -80,11 +87,15 @@ class TranscribeController {
response.setHeader(HEADER_CACHE_CONTROL, "no-cache")
response.addHeader(HEADER_CACHE_CONTROL, "no-store")
+ // Background saving of tasks for specimens and fieldnotes.
+ boolean enableBackgroundSave = (task.project.projectType.name == ProjectType.PROJECT_TYPE_FIELDNOTES ||
+ task.project.projectType.name == ProjectType.PROJECT_TYPE_SPECIMEN)
+
//retrieve the existing values
- Map recordValues = fieldSyncService.retrieveFieldsForTask(taskInstance, currentUserId)
- def adjacentTasks = taskService.getAdjacentTasksBySequence(taskInstance)
+ Map recordValues = fieldSyncService.retrieveFieldsForTask(task, currentUserId)
+ def adjacentTasks = taskService.getAdjacentTasksBySequence(task)
def model = [
- taskInstance: taskInstance,
+ taskInstance: task,
recordValues: recordValues,
isReadonly: isReadonly,
template: project.template,
@@ -92,9 +103,13 @@ class TranscribeController {
prevTask: adjacentTasks.prev,
sequenceNumber: adjacentTasks.sequenceNumber,
complete: params.complete,
- thumbnail: multimediaService.getImageThumbnailUrl(taskInstance.multimedia.first(), true)
+ thumbnail: multimediaService.getImageThumbnailUrl(task.multimedia.first(), true),
+ pageController: 'transcribe',
+ pageAction: 'task',
+ mode: params.mode ?: '',
+ enableBackgroundSave: enableBackgroundSave
]
- log.debug('task before render: {}', sw)
+ //log.debug('task before render: {}', sw)
render(view: 'templateViews/' + project.template.viewName, model: model)
} else {
redirect(view: 'list', controller: "task")
@@ -102,9 +117,9 @@ class TranscribeController {
}
def showNextAction() {
- log.debug("rendering view: nextAction")
+ log.debug("Rendering view: nextAction")
def taskInstance = Task.get(params.id)
- render(view: 'nextAction', model: [id: params.id, taskInstance: taskInstance, userId: userService.currentUserId])
+ render(view: 'nextAction', model: [id: params.id, taskInstance: taskInstance, userId: userService.currentUserId, mode: params.mode ?: ''])
}
/**
@@ -148,30 +163,79 @@ class TranscribeController {
* done in the form.
*/
def save() {
- commonSave(params, true)
+ commonSave(params, true, SAVE_TYPE_SUBMIT)
}
/**
* Sync fields
*/
def savePartial() {
- commonSave(params, false)
+ commonSave(params, false, SAVE_TYPE_PARTIAL)
+ }
+
+ /**
+ * Sync fields but return a json response (with no redirect) for AJAX calls.
+ * @return
+ */
+ def backgroundSave() {
+ commonSave(params, false, SAVE_TYPE_BACKGROUND)
+ }
+
+ /**
+ * To be called via AJAX to initialise background saving.
+ * This needs to happen so that we can tell the difference between a new transcription and one that was closed
+ * before saving (and returning to the transcription).
+ * @return JSON response success or failure.
+ */
+ def initBackgroundSave() {
+ def currentUser = userService.currentUserId
+
+ if (!params.id) {
+ log.error("Attempting to save transcription, no task ID was found. Returning error.")
+ render([success: false, message: "Unable to save task with missing ID.", status: 400] as JSON)
+ return
+ }
+
+ if (currentUser != null) {
+ def task = Task.get(params.long('id'))
+
+ Transcription transcription = task.findUserTranscription(currentUser)
+ if (!transcription) {
+ // try to reuse existing transcription which could have been reset
+ if (task.project.requiredNumberOfTranscriptions == 1) {
+ transcription = task.transcriptions[0]
+ }
+ }
+
+ render([success: true, timerInitValue: (transcription?.timeToTranscribe ?: 0)] as JSON)
+ return
+ }
+
+ render([sucess: true, timeToTranscribe: 0] as JSON)
}
/**
* CommonSave (cannot be used for validator's save fields. For multi transcriptions task, validators don't have
* their own transcription record, except for validator's fields
*/
- private def commonSave(params, markTranscribed) {
+ private def commonSave(params, markTranscribed, int saveType) {
def currentUser = userService.currentUserId
+ def currentUserObj = userService.getCurrentUser()
+ //log.debug("Params: ${params}")
if (!params.id && params.failoverTaskId) {
- redirect(action:'task', id: params.failoverTaskId)
+ log.error("Attempting to save transcription, no task ID was found. Returning error.")
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, message: "Unable to save task with missing ID.", status: 400] as JSON)
+ } else {
+ redirect(action:'task', id: params.failoverTaskId, params: [mode: params.mode ?: ''])
+ }
return
}
if (currentUser != null) {
- def taskInstance = Task.get(params.id)
+ log.debug("${(saveType == 1 ? "Auto-saving" : "Saving")} transcription for user: [${currentUser}]")
+ def taskInstance = Task.get(params.long('id'))
// Check if the user has actually viewed this task (remote transcription spam protection)
def currentViews = taskInstance.viewedTasks.findAll {view ->
@@ -179,9 +243,39 @@ class TranscribeController {
}
if (!currentViews) {
def msg = "Task save ${markTranscribed ? '' : 'partial '}failed: "
- log.error(msg + "User ${currentUser} attempted to save a transcription not assigned to them.")
- flash.message = msg + "You attempted to save a transcription for a task not assigned to you."
- redirect(action:'task', id: params.id)
+ log.error(msg + "User ${currentUserObj.displayName} (${currentUserObj.id}) attempted to save a transcription not assigned to them.")
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false,
+ message: "User attempted to save a transcription not assigned to them. " +
+ "User: ${currentUserObj.displayName} (${currentUserObj.id})", status: 400] as JSON)
+ } else {
+ flash.message = msg + "You attempted to save a transcription for a task not assigned to you."
+ redirect(action:'task', id: params.id, params: [mode: params.mode ?: ''])
+ }
+ return
+ } else {
+ // If Background save, update view time
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ def actualView
+ if (currentViews.size() > 1) {
+ def sortedViews = currentViews.sort { a, b ->
+ a.lastView <=> b.lastView
+ }.reverse()
+ actualView = sortedViews?.first()
+ } else {
+ actualView = currentViews.first()
+ }
+
+ try {
+ if (actualView) {
+ actualView.lastView = System.currentTimeMillis()
+ actualView.lastUpdated = new Date()
+ actualView.save(flush: true, failOnError: true)
+ }
+ } catch (Exception e) {
+ log.error("Error updating last view during auto save of transcription. Exception message is: ${e.getMessage()}", e)
+ }
+ }
}
Transcription transcription = taskInstance.findUserTranscription(currentUser)
@@ -195,31 +289,65 @@ class TranscribeController {
}
}
+ // If background or saving partial, only update the transcription record.
+ // If Submitting the transcription for validation, add the timer value to the task.
def seconds = params.getInt('timeTaken', null)
if (seconds) {
- taskInstance.timeToTranscribe = (taskInstance.timeToTranscribe ?: 0) + seconds
- transcription.recordTranscriptionTime(seconds)
+ switch(saveType) {
+ case SAVE_TYPE_BACKGROUND:
+ case SAVE_TYPE_PARTIAL:
+ transcription.timeToTranscribe = seconds
+ break
+ case SAVE_TYPE_SUBMIT:
+ transcription.timeToTranscribe = seconds
+ taskInstance.timeToTranscribe = (taskInstance.timeToTranscribe ?: 0) + seconds
+ break
+ }
}
+
def skipNextAction = params.getBoolean('skipNextAction', false)
- WebUtils.cleanRecordValues(params.recordValues)
- fieldSyncService.syncFields(taskInstance, params.recordValues, currentUser, markTranscribed,
+ WebUtils.cleanRecordValues(params.recordValues as Map)
+
+ fieldSyncService.syncFields(taskInstance, params.recordValues as Map, currentUser, markTranscribed,
false, null, fieldSyncService.truncateFieldsForProject(taskInstance.project),
request.remoteAddr, transcription)
+
if (!taskInstance.hasErrors()) {
updatePicklists(taskInstance)
if (skipNextAction) {
- redirect(action: 'showNextFromProject', id: taskInstance.project.id,
- params: [prevId: taskInstance.id, prevUserId: currentUser, complete: params.id])
- } else redirect(action: 'showNextAction', id: params.id)
- }
- else {
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ // Flow should not reach this...
+ render([success: true] as JSON)
+ } else {
+ log.debug("Save successful, skip to next task.")
+ redirect(action: 'showNextFromProject', id: taskInstance.project.id,
+ params: [prevId: taskInstance.id, prevUserId: currentUser, complete: params.id, mode: params.mode ?: ''])
+ }
+ } else {
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ log.debug("Save successful.")
+ render([success: true] as JSON)
+ } else {
+ log.debug("Save successful. Redirecting to show next action view")
+ redirect(action: 'showNextAction', id: params.id, params: [mode: params.mode ?: ''])
+ }
+ }
+ } else {
def msg = "Task save ${markTranscribed ? '' : 'partial '}failed: " + taskInstance.hasErrors()
log.error(msg)
- flash.message = msg
- redirect(action:'task', id: params.id)
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, message: msg, status: 400] as JSON)
+ } else {
+ flash.message = msg
+ redirect(action:'task', id: params.id, params: [mode: params.mode ?: ''])
+ }
}
} else {
- redirect(view: '../index')
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, status: 401] as JSON)
+ } else {
+ redirect(view: '../index')
+ }
}
}
@@ -228,14 +356,15 @@ class TranscribeController {
*/
def showNextFromProject() {
def currentUser = userService.currentUserId
- def project = Project.get(params.id)
+ def project = Project.get(params.long('id'))
if (project == null) {
- log.error("Project not found for id: " + params.id)
+ log.error("Project not found for id: ${params.id}")
redirect(view: '/index')
+ return
}
- log.debug("project id = " + params.id + " || msg = " + params.msg + " || prevInt = " + params.prevId)
+ log.debug("Finding next task for user [${currentUser}] from project: [${project.id}], previous task ID: [${params.prevId}], msg: [${params.msg}]")
if (params.msg) {
flash.message = params.msg
@@ -309,6 +438,14 @@ class TranscribeController {
[taskInstance: taskInstance, isValidator: validator]
}
+ def taskIdleFragment() {
+ def task = Task.get(params.int('taskId'))
+ def validator = params.boolean('validator')
+ //log.debug("Picking up validator parameter: [${params.validator}] - [${params.boolean('validator')}]")
+ log.debug("Displaying idle warning to user for ${task.toString()}")
+ [taskInstance: task, isValidator: validator]
+ }
+
def discard() {
def taskInstance = Task.get(params.id)
if (!taskInstance) {
diff --git a/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy b/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy
index 5aac5d664..1b476ec11 100644
--- a/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy
+++ b/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy
@@ -1,8 +1,9 @@
package au.org.ala.volunteer
import com.google.common.base.Stopwatch
+import grails.converters.JSON
-import java.util.concurrent.TimeUnit
+//import java.util.concurrent.TimeUnit
class ValidateController {
@@ -12,78 +13,100 @@ class ValidateController {
def userService
def multimediaService
+ private static final int SAVE_TYPE_BACKGROUND = 1
+ private static final int SAVE_TYPE_PROGRESS = 2
+
def task() {
- def taskInstance = Task.get(params.long('id'))
+ def task = Task.get(params.long('id'))
def currentUser = userService.currentUserId
userService.registerCurrentUser()
- if (taskInstance) {
+ if (task) {
- if (auditService.isTaskLockedForValidation(taskInstance, currentUser)) {
- def lastView = auditService.getLastViewForTask(taskInstance)
+ if (auditService.isTaskLockedForValidation(task, currentUser)) {
+ def lastView = auditService.getLastViewForTask(task)
// task is already being viewed by another user (with timeout period)
- log.debug("Task ${taskInstance.id} is currently locked by ${lastView.userId}. Returning to admin list.")
- def msg = "The requested task (id: " + taskInstance.id + ") is being viewed/edited/validated by another user."
+ log.debug("Task ${task.id} is currently locked by ${lastView.userId}. Returning to admin list.")
+ def msg = "The requested task (id: " + task.id + ") is being viewed/edited/validated by another user."
flash.message = msg
// redirect to another task
- redirect(controller: "task", action: "projectAdmin", id: taskInstance.project.id, params: params + [projectId: taskInstance.project.id])
+ redirect(controller: "task", action: "projectAdmin", id: task.project.id, params: params + [projectId: task.project.id])
return
} else {
// go ahead with this task
- auditService.auditTaskViewing(taskInstance, currentUser)
+ auditService.auditTaskViewing(task, currentUser)
}
def isReadonly = false
- def project = Project.findById(taskInstance.project.id)
+ def project = Project.findById(task.project.id)
Template template = Template.findById(project.template.id)
+ log.debug("Loading task for validation - project: [${project?.id}], task: [${task.id}], user: [${currentUser}]")
def isValidator = userService.isValidator(project)
def isAdmin = (userService.isAdmin() || userService.isInstitutionAdmin(project?.institution))
- log.debug(currentUser + " has role: ADMIN = " + isAdmin + " && VALIDATOR = " + isValidator)
+ log.debug("Role check for user: ${currentUser}; Admin: [${isAdmin}], isValidator: [${isValidator}]")
- if (taskInstance.isFullyTranscribed && !taskInstance.hasBeenTranscribedByUser(currentUser) && !(isAdmin || isValidator)) {
+ if (task.isFullyTranscribed && !task.hasBeenTranscribedByUser(currentUser) && !(isAdmin || isValidator)) {
isReadonly = "readonly"
+ log.debug("Transcribed task is being set to read-only.")
} else {
// check that the validator is not the transcriber...Admins can, though!
- if (taskInstance.hasBeenTranscribedByUser(currentUser)) {
+ if (task.hasBeenTranscribedByUser(currentUser)) {
if (isAdmin) {
flash.message = "Normally you cannot validate your own tasks, but you have the ADMIN role, so it is allowed in this case"
} else {
flash.message = "This task is read-only. You cannot validate your own tasks!"
isReadonly = "readonly"
+ log.debug("Transcribed task is being set to read-only.")
}
}
}
Stopwatch sw = Stopwatch.createStarted()
- Map recordValues = fieldSyncService.retrieveValidationFieldsForTask(taskInstance)
+ Map recordValues = fieldSyncService.retrieveValidationFieldsForTask(task)
sw.stop()
- log.debug("retrieveValidationFieldsForTask: ${sw.elapsed(TimeUnit.SECONDS)}")
- def adjacentTasks = taskService.getAdjacentTasksBySequence(taskInstance)
- def imageMetaData = taskService.getImageMetaData(taskInstance)
- def transcribersAnswers = fieldSyncService.retrieveTranscribersFieldsForTask(taskInstance)
+
+ def adjacentTasks = taskService.getAdjacentTasksBySequence(task)
+ def imageMetaData = taskService.getImageMetaData(task)
+ def transcribersAnswers = fieldSyncService.retrieveTranscribersFieldsForTask(task)
/* if (!recordValues && transcribersAnswers && transcribersAnswers.size() > 0) {
recordValues = transcribersAnswers[0].fields
}*/
+ // Background saving of tasks for specimens and fieldnotes.
+ boolean enableBackgroundSave = (task.project.projectType.name == ProjectType.PROJECT_TYPE_FIELDNOTES ||
+ task.project.projectType.name == ProjectType.PROJECT_TYPE_SPECIMEN)
+
render(view: '../transcribe/templateViews/' + template.viewName,
- model: [taskInstance : taskInstance,
- recordValues : recordValues,
- isReadonly : isReadonly,
- nextTask : adjacentTasks.next,
- prevTask : adjacentTasks.prev,
- sequenceNumber : adjacentTasks.sequenceNumber,
- template : template,
- validator : true,
- imageMetaData : imageMetaData,
- transcribersAnswers: transcribersAnswers,
- thumbnail : multimediaService.getImageThumbnailUrl(taskInstance.multimedia.first(), true)])
+ model: [taskInstance : task,
+ recordValues : recordValues,
+ isReadonly : isReadonly,
+ nextTask : adjacentTasks.next,
+ prevTask : adjacentTasks.prev,
+ sequenceNumber : adjacentTasks.sequenceNumber,
+ template : template,
+ validator : true,
+ imageMetaData : imageMetaData,
+ transcribersAnswers : transcribersAnswers,
+ thumbnail : multimediaService.getImageThumbnailUrl(task.multimedia.first(), true),
+ pageController : 'validate',
+ pageAction : 'task',
+ mode : params.mode ?: '',
+ enableBackgroundSave: enableBackgroundSave])
} else {
redirect(view: 'list', controller: "task")
}
}
+ def backgroundSave() {
+ dontValidate(SAVE_TYPE_BACKGROUND)
+ }
+
+ def saveProgress() {
+ dontValidate(SAVE_TYPE_PROGRESS)
+ }
+
/**
* Mark a task as validated, hence removing it from the list of tasks to be validated.
*/
@@ -98,7 +121,7 @@ class ValidateController {
def currentUser = userService.currentUserId
if (!params.id && params.failoverTaskId) {
- redirect(action: 'task', id: params.failoverTaskId)
+ redirect(action: 'task', id: params.failoverTaskId, params: [mode: params.mode ?: ''])
return
}
@@ -106,6 +129,7 @@ class ValidateController {
def seconds = params.getInt('timeTaken', null)
if (seconds) {
taskInstance.timeToValidate = (taskInstance.timeToValidate ?: 0) + seconds
+// taskInstance.timeToValidate = seconds
}
WebUtils.cleanRecordValues(params.recordValues as Map)
@@ -120,34 +144,45 @@ class ValidateController {
if (taskInstance.hasErrors()) {
log.warn("Validation of task ${taskInstance.id} produced errors: " + errors)
}
- redirect(controller: 'task', action: 'projectAdmin', id: taskInstance.project.id, params: [lastTaskId: taskInstance.id])
+ redirect(controller: 'task', action: 'projectAdmin', id: taskInstance.project.id, params: [lastTaskId: taskInstance.id, mode: params.mode ?: ''])
} else {
redirect(view: '../index')
}
}
/**
- * To do determine actions if the validator chooses not to validate
+ * Formerly to determine actions if the validator chooses not to validate. Now used as a background save function.
*/
- def dontValidate() {
+ def dontValidate(int saveType) {
def taskInstance = Task.get(params.long('id'))
if (!userService.isValidator(taskInstance?.project) || !taskInstance) {
- render(view: '/notPermitted')
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, message: "Not permitted to do that action.", status: 403] as JSON)
+ } else {
+ render(view: '/notPermitted')
+ }
+
return
}
def currentUser = userService.currentUserId
if (!params.id && params.failoverTaskId) {
- redirect(action: 'task', id: params.failoverTaskId)
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, message: "Unable to save task with missing ID.", status: 400] as JSON)
+ } else {
+ redirect(action: 'task', id: params.failoverTaskId, params: [mode: params.mode ?: ''])
+ }
return
}
if (currentUser != null) {
+ log.debug("${(saveType == 1 ? "Auto-saving" : "Saving")} validation of task [${taskInstance.id}] for user: [${currentUser}]")
def seconds = params.getInt('timeTaken', null)
if (seconds) {
taskInstance.timeToValidate = (taskInstance.timeToValidate ?: 0) + seconds
+// taskInstance.timeToValidate = seconds
}
WebUtils.cleanRecordValues(params.recordValues as Map)
Transcription transcription = null
@@ -157,16 +192,27 @@ class ValidateController {
fieldSyncService.syncFields(taskInstance, params.recordValues as Map, currentUser, false,
true, false, fieldSyncService.truncateFieldsForProject(taskInstance.project),
request.remoteAddr, transcription)
- redirect(controller: 'task', action: 'projectAdmin', id: taskInstance.project.id, params: [lastTaskId: taskInstance.id])
+
+ log.debug("Save successful.")
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: true] as JSON)
+ } else {
+ redirect(controller: 'task', action: 'projectAdmin', id: taskInstance.project.id, params: [lastTaskId: taskInstance.id, mode: params.mode ?: ''])
+ }
+
} else {
- redirect(view: '../index')
+ if (saveType == SAVE_TYPE_BACKGROUND) {
+ render([success: false, status: 401] as JSON)
+ } else {
+ redirect(view: '../index')
+ }
}
}
def skip() {
def taskInstance = Task.get(params.long('id'))
if (taskInstance != null) {
- redirect(action: 'showNextFromProject', id: taskInstance.project.id)
+ redirect(action: 'showNextFromProject', id: taskInstance.project.id, params: [mode: params.mode ?: ''])
} else {
flash.message = "No task id supplied!"
render(view: '/notPermitted')
@@ -183,8 +229,7 @@ class ValidateController {
return
}
- log.debug("project id = " + params.long('id') + " || msg = " + params.msg?.toString() +
- " || prevInt = " + params.long('prevId'))
+ log.debug("Finding next task for user [${currentUser}] from project: [${project.id}], previous task ID: [${params.prevId}], msg: [${params.msg}]")
flash.message = params.msg
def previousId = params.long('prevId',-1)
@@ -207,7 +252,7 @@ class ValidateController {
render(view: 'noTasks')
} else if (taskInstance && project) {
log.debug "2."
- redirect(action: 'task', id: taskInstance.id)
+ redirect(action: 'task', id: taskInstance.id, params: [mode: params.mode ?: ''])
} else if (!project) {
log.error("Project not found for id: " + params.long('id'))
redirect(view: '/index')
diff --git a/grails-app/domain/au/org/ala/volunteer/Field.groovy b/grails-app/domain/au/org/ala/volunteer/Field.groovy
index 42468e954..fbb4d3cb5 100644
--- a/grails-app/domain/au/org/ala/volunteer/Field.groovy
+++ b/grails-app/domain/au/org/ala/volunteer/Field.groovy
@@ -52,4 +52,22 @@ class Field implements Serializable {
}
// Fields are only deleted when a task is deleted so there is no
// need to push an task update event on delete here
+
+
+ @Override
+ public String toString() {
+ return "Field{" +
+ "id: " + id +
+ ", task: " + task?.id +
+ ", transcription: " + transcription?.id +
+ ", name: '" + name + '\'' +
+ ", value: '" + value + '\'' +
+ ", recordIdx: " + recordIdx +
+ ", transcribedByUserId: '" + transcribedByUserId + '\'' +
+ ", validatedByUserId: '" + validatedByUserId + '\'' +
+ ", superceded: " + superceded +
+ ", created: " + created +
+ ", updated: " + updated +
+ '}';
+ }
}
diff --git a/grails-app/domain/au/org/ala/volunteer/Project.groovy b/grails-app/domain/au/org/ala/volunteer/Project.groovy
index 772a51dfa..6066cf4c2 100644
--- a/grails-app/domain/au/org/ala/volunteer/Project.groovy
+++ b/grails-app/domain/au/org/ala/volunteer/Project.groovy
@@ -40,6 +40,7 @@ class Project implements Serializable {
Date lastUpdated
// Project of the Day Last Selected Date
Date potdLastSelected
+ Long sizeInBytes = 0L
Integer version
@@ -108,6 +109,7 @@ class Project implements Serializable {
transcriptionsPerTask nullable: true
thresholdMatchingTranscriptions nullable: true
potdLastSelected nullable: true
+ sizeInBytes nullable: false
}
/**
@@ -235,4 +237,13 @@ class Project implements Serializable {
]
}
+ String getProjectSizeFormatted() {
+ String size
+ if (!archived) {
+ if (sizeInBytes > 0) size = PrettySize.toPrettySize(BigInteger.valueOf(sizeInBytes))
+ else size = PrettySize.toPrettySize(BigInteger.valueOf(0))
+ } else {
+ size = PrettySize.toPrettySize(BigInteger.valueOf(0))
+ }
+ }
}
diff --git a/grails-app/domain/au/org/ala/volunteer/ProjectType.groovy b/grails-app/domain/au/org/ala/volunteer/ProjectType.groovy
index bf89e9958..1ecd222c0 100644
--- a/grails-app/domain/au/org/ala/volunteer/ProjectType.groovy
+++ b/grails-app/domain/au/org/ala/volunteer/ProjectType.groovy
@@ -9,6 +9,7 @@ class ProjectType implements Serializable {
static final String PROJECT_TYPE_CAMERATRAP = 'cameratraps'
static final String PROJECT_TYPE_FIELDNOTES = 'fieldnotes'
static final String PROJECT_TYPE_SPECIMEN = 'specimens'
+ static final String PROJECT_TYPE_AUDIO = 'audio'
static hasMany = [projects: Project, landingPages: LandingPage]
diff --git a/grails-app/domain/au/org/ala/volunteer/Task.groovy b/grails-app/domain/au/org/ala/volunteer/Task.groovy
index 665a84dad..de3a88ca9 100644
--- a/grails-app/domain/au/org/ala/volunteer/Task.groovy
+++ b/grails-app/domain/au/org/ala/volunteer/Task.groovy
@@ -34,7 +34,7 @@ class Task implements Serializable {
multimedia cascade: 'all,delete-orphan'
viewedTasks cascade: 'all,delete-orphan'
fields cascade: 'all,delete-orphan'
- comments cascade: 'all,delete-orphan'
+ //comments cascade: 'all,delete-orphan'
transcriptions cascade: 'all,delete-orphan'
//transcribedUUID type: 'pg-uuid'
validatedUUID type: 'pg-uuid'
@@ -72,7 +72,7 @@ class Task implements Serializable {
return true
}
int requiredTranscriptionCount = project.requiredNumberOfTranscriptions
- int transcriptionCount = transcriptions?.count{it.fullyTranscribedBy} ?: 0
+ int transcriptionCount = (int) (transcriptions?.count { it.fullyTranscribedBy } ?: 0)
return transcriptionCount >= requiredTranscriptionCount
}
@@ -132,13 +132,9 @@ class Task implements Serializable {
* valid fields that have been approved (or transcribed) by a validator.
*/
Set getTaskFields() {
-
Set taskFields = fields.findAll{it.transcription == null}
- log.debug('taskfields {}', taskFields)
taskFields
-
-
}
/**
@@ -155,12 +151,19 @@ class Task implements Serializable {
long timeoutWindow = System.currentTimeMillis() - timeoutInSeconds
Set usersWhoCompletedTheirTranscriptions = transcriptions.findAll{it.fullyTranscribedBy}
.collect{it.fullyTranscribedBy}.toSet()
- log.debug("Task; Users with transcriptions: ${usersWhoCompletedTheirTranscriptions}")
+ log.debug("[isLockedForTranscription] Task: ${id}; Users with transcriptions: ${usersWhoCompletedTheirTranscriptions}")
boolean locked = false
if (!usersWhoCompletedTheirTranscriptions.contains(userId)) {
// Only views made by users that have not completed their transcription are relevant.
Set currentViews = viewedTasks.findAll { view ->
+ // If this view's user is not in the list of completed transcriptions
+ // AND the view was less than 2hours ago
+ // and the view's user is not the requesting user
+ // and the view wasn't skipped
+ // Then the task is locked.
+ log.debug("View on this task by user: [${view.userId}], date: [${new Date(view.lastView)}]")
+ log.debug("Does view count towards locking: ${!(view.userId in usersWhoCompletedTheirTranscriptions) && (view.lastView > timeoutWindow && userId != view.userId && !view.skipped)}")
return !(view.userId in usersWhoCompletedTheirTranscriptions) && (view.lastView > timeoutWindow && userId != view.userId && !view.skipped)
}.collect{it.userId}.toSet()
diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties
index 6fa68a322..14bb0090b 100644
--- a/grails-app/i18n/messages.properties
+++ b/grails-app/i18n/messages.properties
@@ -49,6 +49,10 @@ default.button.update.label=Update
default.button.approve.label=Approve
default.button.delete.label=Delete
default.button.delete.confirm.message=Are you sure?
+default.button.validate.label=Submit validation
+default.button.dont.validate.label=Save partial validation
+
+default.leaderboard.describeBadges.label=Badges
# Data binding errors. Use "typeMismatch.$className.$propertyName to customize (eg typeMismatch.Book.author)
typeMismatch.java.net.URL=Property {0} must be a valid URL
@@ -137,9 +141,11 @@ leaderboard.viewTop20.label=View Top 20
monthly.leader.label=Monthly Maestro
alltime.leader.label=Legend
latest.contributions.label=Latest Contributions
+latest.forum.activity.label=Latest Forum Activity
items.from.the=items from the
join.expedition.label=Join expedition
join.discussion.label=Join discussion
+start.discussion.label=Start a discussion topic!
view.all.contributors.label=View all contributors
default.getInvolved.label=How can I volunteer?
project.label=Expedition
@@ -162,6 +168,7 @@ field.validatorNotes.label=Validator Notes
status.archived=Archived
status.inactive=Inactive
project.archived.description=Archived expeditions have all their task images removed.
+project.template.notcompatible=The selected template is not compatible with the expedition's type.
project.template.notavailable=The template {0} is no longer available.
project.institution.required=An institution is required.
image.attribution.prefix=Image
@@ -184,7 +191,7 @@ layout.whyinvolved.accessible.heading=Make data accessible
layout.whyinvolved.accessible.body=Unlock collections and extend the reach of information around the world.
task.details.button.label=Show task details
status.saved=Saved
-status.invalidated=Invalidated
+status.invalidated=In progress
status.transcribed=Transcribed
status.validated=Validated
user.notebook.title={0}'s Notebook
@@ -210,6 +217,8 @@ harvest.license.version=3.0
wildlifespotter.widget.badge.title=Select this to indicate there is a {0} in the image
wildlifespotter.sequenceImages.helpText=**Only classify** the image highlighted with a **red** border. Images on either side are used to help you classify this image.
+audiotranscribe.widget.badge.title=Click the play button to listen to the sample of this animal. Select this to indicate there is a {0} in the audio sample
+
landingPage.title.nullable=Landing Page Title cannot be null
landingPage.title.label=Title
landingPage.enabled.label=Is this landing page enabled?
diff --git a/grails-app/init/au/org/ala/volunteer/BootStrap.groovy b/grails-app/init/au/org/ala/volunteer/BootStrap.groovy
index f70f69623..e834a01c7 100644
--- a/grails-app/init/au/org/ala/volunteer/BootStrap.groovy
+++ b/grails-app/init/au/org/ala/volunteer/BootStrap.groovy
@@ -17,6 +17,7 @@ import org.springframework.web.context.support.ServletContextResource
class BootStrap {
def projectTypeService
+ def projectService
GrailsApplication grailsApplication
def auditService
def sessionFactory
@@ -57,6 +58,29 @@ class BootStrap {
fullTextIndexService.ping()
+ initProjectSize()
+ }
+
+ /**
+ * This is to initialise the project sizes for release 6.1.0.
+ * Disable this in next release.
+ */
+ private void initProjectSize() {
+ log.info("Initialising project sizes...")
+
+ def projectList = Project.findAllByArchived(false)
+ int count = 0
+
+ projectList.each { project ->
+ if (project.sizeInBytes == 0L) {
+ def size = projectService.projectSize(project).size as long
+ if (size > 0) {
+ log.info("Project [${project.id}] ${project.name} calculated to be ${size} bytes.")
+ }
+ }
+ }
+
+ log.info("Completed Project Size initialisation for ${projectList.size()} projects.")
}
private void fixTaskLastViews() {
@@ -102,15 +126,19 @@ class BootStrap {
private void prepareProjectTypes() {
log.info("Checking project types...")
- def builtIns = [[name: ProjectType.PROJECT_TYPE_SPECIMEN,
- label: 'Specimens',
- icon: '/public/images/2.0/iconLabels.png'],
- [name: ProjectType.PROJECT_TYPE_FIELDNOTES,
- label: 'Field notes',
- icon: '/public/images/2.0/iconNotes.png'],
- [name: ProjectType.PROJECT_TYPE_CAMERATRAP,
- label: 'Camera Traps',
- icon: '/public/images/2.0/iconWild.png']]
+ def builtIns = [
+ [name: ProjectType.PROJECT_TYPE_SPECIMEN,
+ label: 'Specimens',
+ icon: '/public/images/2.0/iconLabels.png'],
+ [name: ProjectType.PROJECT_TYPE_FIELDNOTES,
+ label: 'Field notes',
+ icon: '/public/images/2.0/iconNotes.png'],
+ [name: ProjectType.PROJECT_TYPE_CAMERATRAP,
+ label: 'Camera Traps',
+ icon: '/public/images/2.0/iconWild.png'],
+ [name: ProjectType.PROJECT_TYPE_AUDIO,
+ label: 'Audio',
+ icon: '/public/images/2.0/iconWild.png']]
builtIns.each {
def projectType = ProjectType.findByName(it.name)
if (!projectType) {
diff --git a/grails-app/services/au/org/ala/volunteer/AuditService.groovy b/grails-app/services/au/org/ala/volunteer/AuditService.groovy
index 4b5eb32b2..90b121aaf 100644
--- a/grails-app/services/au/org/ala/volunteer/AuditService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/AuditService.groovy
@@ -19,19 +19,25 @@ class AuditService {
return viewedTasks ? viewedTasks[0] : null
}
- boolean isTaskLockedForTranscription(Task taskInstance, String userId) {
+ boolean isTaskLockedForTranscription(Task task, String userId) {
long timeout = grailsApplication.config.viewedTask.timeout as long
- return taskInstance.isLockedForTranscription(userId, timeout)
+ return task.isLockedForTranscription(userId, timeout)
}
- boolean isTaskLockedForValidation(Task taskInstance, String userId) {
- ViewedTask lastView = getLastViewForTask(taskInstance)
+ boolean isTaskLockedForValidation(Task task, String userId) {
+ ViewedTask lastView = getLastViewForTask(task)
String currentUser = userService.currentUserId
- if (lastView) {
- log.debug "userId = " + currentUser + " || prevUserId = " + lastView.userId + " || prevLastView = " + lastView.lastView
- def millisecondsSinceLastView = System.currentTimeMillis() - lastView.lastView
- if (lastView.userId != currentUser && millisecondsSinceLastView < (grailsApplication.config.viewedTask.timeout as long)) {
- return true
+
+ // If task has been fully transcribed, allow it to be validated straight away:
+ if (task.isFullyTranscribed) {
+ return false
+ } else {
+ if (lastView) {
+ log.debug "userId = " + currentUser + " || prevUserId = " + lastView.userId + " || prevLastView = " + lastView.lastView
+ def millisecondsSinceLastView = System.currentTimeMillis() - lastView.lastView
+ if (lastView.userId != currentUser && millisecondsSinceLastView < (grailsApplication.config.viewedTask.timeout as long)) {
+ return true
+ }
}
}
return false
diff --git a/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy b/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy
index 09873674f..4101d8d8c 100644
--- a/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy
@@ -123,13 +123,13 @@ class DomainUpdateService {
break
case UpdateProjectTask:
def tasks = Task.withCriteria {
- project {
- eq 'id', jobDescriptor.projectId
- }
- projections {
- property 'id'
- }
- }
+ project {
+ eq 'id', jobDescriptor.projectId
+ }
+ projections {
+ property 'id'
+ }
+ }
updates.addAll(tasks)
indexes.addAll(tasks)
taskCount+= tasks.size()
@@ -148,7 +148,7 @@ class DomainUpdateService {
sw.reset().start()
if (deletes) fullTextIndexService.deleteTasks(deletes)
if (indexes) fullTextIndexService.indexTasks(indexes) { currentlyProcessing.decrementAndGet() }
- if (updates) postIndexTaskActions(updates)
+ if (updates) postIndexTaskActions(updates)
if (validations) validationService.autoValidate(validations)
if (deletes || indexes || updates) log.debug("Took ${sw.stop().elapsed(TimeUnit.MILLISECONDS)}ms to process ${deletes.size()} deletes, ${indexes.size()} indexes, ${updates.size()} post-index updates, ${validations.size()} task validations")
}
diff --git a/grails-app/services/au/org/ala/volunteer/EmailService.groovy b/grails-app/services/au/org/ala/volunteer/EmailService.groovy
index 902d493ac..b8a0bd392 100644
--- a/grails-app/services/au/org/ala/volunteer/EmailService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/EmailService.groovy
@@ -74,7 +74,14 @@ class EmailService {
}
if (Environment.current != Environment.PRODUCTION || !Strings.isNullOrEmpty(subjPrefix)) {
+ String originalRecipient = queuedEmailMessage.emailAddress
queuedEmailMessage.emailAddress = grailsApplication.config.getProperty('notifications.default.address', "digivol@austmus.gov.au")
+ if (QueuedEmailMessage.FORMAT_TEXT.equals(formatType)) {
+ queuedEmailMessage.message = "This message is addressed to: [${originalRecipient}]\n\n" + queuedEmailMessage.message
+ } else if (QueuedEmailMessage.FORMAT_HTML.equals(formatType)) {
+ queuedEmailMessage.message = "This message is addressed to: [${originalRecipient}
]
" + queuedEmailMessage.message
+ }
+
log.debug("Test/Dev environment, sending to notification email instead: ${queuedEmailMessage.emailAddress}")
}
diff --git a/grails-app/services/au/org/ala/volunteer/ExportService.groovy b/grails-app/services/au/org/ala/volunteer/ExportService.groovy
index eda31840d..456cbd458 100644
--- a/grails-app/services/au/org/ala/volunteer/ExportService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/ExportService.groovy
@@ -497,11 +497,12 @@ class ExportService {
}
private def taskValidationStatus(Task task) {
+ // Updated to update the definition of validation status
switch (task.isValid) {
case true:
- return "Valid"
+ return "Validated"
case false:
- return "Invalid"
+ return "In progress"
default:
return ""
}
diff --git a/grails-app/services/au/org/ala/volunteer/FieldService.groovy b/grails-app/services/au/org/ala/volunteer/FieldService.groovy
index 419031211..38a888be4 100644
--- a/grails-app/services/au/org/ala/volunteer/FieldService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/FieldService.groovy
@@ -243,4 +243,38 @@ class FieldService {
databaseFieldNames
}
+ List findAllTasksByFieldAndFieldValue(Project project, String fieldName, String fieldValue, String sortFieldName = null) {
+ def select = """
+ SELECT distinct task_id
+ FROM field
+ JOIN task ON (task.id = field.task_id)
+ WHERE task.project_id = :projectId
+ AND field.name = :fieldName
+ AND field.value = :fieldValue
+ """
+
+ def params = [projectId: project.id, fieldName: fieldName, fieldValue: fieldValue]
+
+ if (sortFieldName) {
+ select = """
+ ${select}
+ ORDER BY ${sortFieldName} ASC
+ """
+ }
+
+ log.debug("Query: ${select}")
+
+ def sql = new Sql(dataSource)
+ def taskList = []
+ sql.eachRow(select, params) { row ->
+ Task task = Task.get(row.task_id as long)
+ if (task) {
+ taskList.add(task)
+ }
+ }
+ sql.close()
+
+ taskList
+ }
+
}
diff --git a/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy b/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy
index 407e7de56..7e1b2f804 100644
--- a/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy
@@ -3,12 +3,19 @@ package au.org.ala.volunteer
import grails.gorm.DetachedCriteria
import grails.transaction.Transactional
import org.apache.commons.lang3.StringUtils
+import org.jooq.DSLContext
+import org.springframework.beans.factory.annotation.Autowired
+
+import static au.org.ala.volunteer.jooq.tables.VpUser.VP_USER
@Transactional
class FieldSyncService {
ValidationService validationService
+ @Autowired
+ Closure jooqContextFactory
+
Map retrieveFieldsForTask(Task taskInstance, String currentUserId) {
Transcription transcription = taskInstance.findUserTranscription(currentUserId)
@@ -276,8 +283,9 @@ class FieldSyncService {
transcription.fullyTranscribedBy = transcriberUserId
transcription.fullyTranscribedIpAddress = userIp
def user = User.findByUserId(transcriberUserId)
- user?.transcribedCount++
- user?.save(flush: true)
+// user?.transcribedCount++
+// user?.save(flush: true)
+ incrementTranscriptionCount(user.id)
}
if (!transcription.dateFullyTranscribed) {
transcription.dateFullyTranscribed = now
@@ -296,8 +304,9 @@ class FieldSyncService {
if (!task.fullyValidatedBy) {
def user = User.findByUserId(transcriberUserId)
- user?.validatedCount++
- user?.save(flush: true)
+// user?.validatedCount++
+// user?.save(flush: true)
+ incrementValidationCount(user.id)
}
task.validate(transcriberUserId, isValid, now)
}
@@ -349,4 +358,59 @@ class FieldSyncService {
}.updateAll(superceded: true)
}
+ /**
+ * Increments the transcription count for a user by 1. Done with JOOQ so that issues with cached values aren't
+ * encountered.
+ * @param userId the user's ID.
+ */
+ def incrementTranscriptionCount(long userId) {
+ DSLContext context = jooqContextFactory()
+
+ context.update(VP_USER)
+ .set(VP_USER.TRANSCRIBED_COUNT, VP_USER.TRANSCRIBED_COUNT.plus(1))
+ .where(VP_USER.ID.eq(userId))
+ .execute()
+ }
+
+ /**
+ * Decrements the transcription count for a user by 1. Done with JOOQ so that issues with cached values aren't
+ * encountered.
+ * @param userId the user's ID.
+ */
+ def decrementTranscriptionCount(long userId) {
+ DSLContext context = jooqContextFactory()
+
+ context.update(VP_USER)
+ .set(VP_USER.TRANSCRIBED_COUNT, VP_USER.TRANSCRIBED_COUNT.minus(1))
+ .where(VP_USER.ID.eq(userId))
+ .execute()
+ }
+
+ /**
+ * Increments the validated count for a user by 1. Done with JOOQ so that issues with cached values aren't
+ * encountered.
+ * @param userId the user's ID.
+ */
+ def incrementValidationCount(long userId) {
+ DSLContext context = jooqContextFactory()
+
+ context.update(VP_USER)
+ .set(VP_USER.VALIDATED_COUNT, VP_USER.VALIDATED_COUNT.plus(1))
+ .where(VP_USER.ID.eq(userId))
+ .execute()
+ }
+
+ /**
+ * Decrements the validated count for a user by 1. Done with JOOQ so that issues with cached values aren't
+ * encountered.
+ * @param userId the user's ID.
+ */
+ def decrementValidationCount(long userId) {
+ DSLContext context = jooqContextFactory()
+
+ context.update(VP_USER)
+ .set(VP_USER.VALIDATED_COUNT, VP_USER.VALIDATED_COUNT.minus(1))
+ .where(VP_USER.ID.eq(userId))
+ .execute()
+ }
}
diff --git a/grails-app/services/au/org/ala/volunteer/FileUploadService.groovy b/grails-app/services/au/org/ala/volunteer/FileUploadService.groovy
index 72a44c508..bba5839ee 100644
--- a/grails-app/services/au/org/ala/volunteer/FileUploadService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/FileUploadService.groovy
@@ -44,7 +44,7 @@ class FileUploadService {
File uploadImage(String directory, MultipartFile mpf, Closure renameFile) {
if (!directory) throw new IllegalArgumentException('directory can not be empty')
- def imagesHome = grailsApplication.config.images.home
+ def imagesHome = grailsApplication.config.images.home as String
if (!imagesHome) throw new IllegalStateException('images.home not set')
def imagesDir = new File(imagesHome, directory)
if (!imagesDir.exists() && !imagesDir.mkdirs()) throw new IOException("Couldn't create $imagesHome/$directory")
diff --git a/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy b/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy
index 4df340df6..adb3cc3d9 100644
--- a/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy
@@ -16,19 +16,28 @@ class ForumNotifierService {
def messageSource
List getModeratorsForTopic(ForumTopic topic) {
+ log.debug("Getting moderators for forum topic")
List results = []
- Project projectInstance = null
+ Project project = null
if (topic?.instanceOf(ProjectForumTopic)) {
- projectInstance = (topic as ProjectForumTopic).project
+ project = (topic as ProjectForumTopic).project
} else if (topic?.instanceOf(TaskForumTopic)) {
- projectInstance = (topic as TaskForumTopic).task?.project
+ project = (topic as TaskForumTopic).task?.project
}
- results = userService.getUsersWithRole("forum_moderator", projectInstance)
+ results = userService.getUsersWithRole("forum_moderator", project)
+
+ if (project) {
+ // Include institution admins for the project's institution
+ def institutionAdmins = userService.getInstitutionAdminsForProject(project)
+ institutionAdmins.each {
+ log.debug("Adding institution admin: ${it.displayName}")
+ results << it
+ }
- if (projectInstance) {
- def watchList = getUsersInterestedInProject(projectInstance)
+ // And people watching the forum
+ def watchList = getUsersInterestedInProject(project)
watchList?.each { user ->
if (!results.contains(user)) {
results << user
@@ -39,9 +48,9 @@ class ForumNotifierService {
return results
}
- List getUsersInterestedInProject(Project projectInstance) {
+ List getUsersInterestedInProject(Project project) {
def list = new ArrayList()
- ProjectForumWatchList watchList = ProjectForumWatchList.findByProject(projectInstance)
+ ProjectForumWatchList watchList = ProjectForumWatchList.findByProject(project)
if (watchList) {
watchList.users?.each { user ->
list << user
diff --git a/grails-app/services/au/org/ala/volunteer/InstitutionService.groovy b/grails-app/services/au/org/ala/volunteer/InstitutionService.groovy
index a9e84716d..2d09313ae 100644
--- a/grails-app/services/au/org/ala/volunteer/InstitutionService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/InstitutionService.groovy
@@ -194,6 +194,24 @@ class InstitutionService {
return retVal
}
+ /**
+ * Returns a list of all projects for a given list of institutions.
+ * @param institutionList the list of institutions for the user
+ * @return the list of projects for the provided institutions
+ */
+ def listProjectsForInstititutionList(def institutionList) {
+ if (!institutionList || institutionList?.size() == 0) {
+ return []
+ }
+
+ def result = Project.createCriteria().list {
+ 'in'("institution", institutionList)
+ order('id', 'asc')
+ }
+
+ result
+ }
+
/**
* Returns a simple count of projects within a given institution.
* @param institution the institution to query.
diff --git a/grails-app/services/au/org/ala/volunteer/LeaderBoardService.groovy b/grails-app/services/au/org/ala/volunteer/LeaderBoardService.groovy
index 375851f29..a9733bfd9 100644
--- a/grails-app/services/au/org/ala/volunteer/LeaderBoardService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/LeaderBoardService.groovy
@@ -1,19 +1,23 @@
package au.org.ala.volunteer
-import grails.transaction.NotTransactional
+
+import groovy.sql.Sql
+
+import javax.sql.DataSource
class LeaderBoardService {
static transactional = false
final static EMPTY_LEADERBOARD_WINNER = [userId: 0, name:'', email:'', score:0]
-
+
private static Date getTodaysDate() {
// def today = Date.parse("yyyy-MM-dd", "2014-03-01") // for testing
def today = new Date().clearTime()
return today.clearTime()
}
+ DataSource dataSource
def settingsService
def userService
@@ -51,7 +55,7 @@ class LeaderBoardService {
} else {
result = EMPTY_LEADERBOARD_WINNER
}
- } else if (pt?.size() >= 0) {
+ } else if (pt) { //(pt?.size() >= 0) {
def tmp = getTopNForProjectType(1, pt, ineligibleUsers)
if (tmp) {
result = tmp[0]
@@ -139,14 +143,14 @@ class LeaderBoardService {
def scoreMap = getUserCountsForInstitution(institution, ActivityType.Transcribed, ineligibleUsers)
def validatedMap = getUserCountsForInstitution(institution, ActivityType.Validated, ineligibleUsers)
- return mergeScores(validatedMap, scoreMap, count)
+ return mergeScores(validatedMap, scoreMap, count, ineligibleUsers)
}
List getTopNForProjectType(int count, def projectsInLabels = null, List ineligibleUsers = []) {
def scoreMap = (projectsInLabels?.size() > 0)? getUserCountsForProjectType(projectsInLabels, ActivityType.Transcribed, ineligibleUsers) : [:]
def validatedMap = (projectsInLabels?.size() > 0)? getUserCountsForProjectType(projectsInLabels, ActivityType.Validated, ineligibleUsers) : [:]
- return mergeScores(validatedMap, scoreMap, count)
+ return mergeScores(validatedMap, scoreMap, count, ineligibleUsers)
}
List getTopNForPeriod(Date startDate, Date endDate, int count, Institution institution, List ineligibleUsers = [], def pt = null) {
@@ -159,18 +163,24 @@ class LeaderBoardService {
// Get a map of user who validated tasks during this periodn, along with the count
def validatedMap = getUserMapForPeriod(startDate, endDate, ActivityType.Validated, institutionList, ineligibleUsers, pt)
- return mergeScores(validatedMap, scoreMap, count)
+ return mergeScores(validatedMap, scoreMap, count, ineligibleUsers)
}
- private List mergeScores(LinkedHashMap validatedMap, LinkedHashMap scoreMap, int count) {
+ private List mergeScores(LinkedHashMap validatedMap, LinkedHashMap scoreMap, int count, def ineligibleUsers) {
// merge the validated map into the transcribed map, forming a total activity score for the superset of users
validatedMap.each { kvp ->
- // if there exists a validator who is not a transcriber, set the transcription count to 0
- if (!scoreMap[kvp.key]) {
- scoreMap[kvp.key] = 0
+ // If the user is excluded, set their score to -1.
+ if (ineligibleUsers?.size() > 0 && ineligibleUsers?.contains(kvp.key)) {
+ scoreMap[kvp.key] = -1
+ } else {
+ // if there exists a validator who is not a transcriber, set the transcription count to 0
+ if (!scoreMap[kvp.key]) {
+ scoreMap[kvp.key] = 0
+ }
+
+ // combine the transcribed count with the validated count for that user.
+ scoreMap[kvp.key] += kvp.value
}
- // combine the transcribed count with the validated count for that user.
- scoreMap[kvp.key] += kvp.value
}
scoreMap = scoreMap.sort { a, b -> b.value <=> a.value }
@@ -196,9 +206,45 @@ class LeaderBoardService {
Map getUserMapForPeriod(Date startDate, Date endDate, ActivityType activityType, List institutionList,
List ineligibleUserIds, def projectsInLabels = null) {
- def results
+ def map = [:]
+ def sql = new Sql(dataSource)
+
+ String select = "select fully_${activityType}_by, count(fully_${activityType}_by) as count "
+ String groupByClause = " group by fully_${activityType}_by "
+ def filter = " date_fully_${activityType} >= :startDate and date_fully_${activityType} < :endDate "
+
+ def ineligibleUserClause = ""
+ if (ineligibleUserIds) {
+ ineligibleUserClause = " and fully_${activityType}_by not in (${ineligibleUserIds.join(",").tr(/"/, /'/)})"
+ }
+
if (ActivityType.Transcribed == activityType) {
- results = Transcription.withCriteria {
+
+ def institutionJoin = ""
+ if (institutionList) {
+ String institutionIdList = institutionList.collect{ it.id }.join(',').tr(/"/, /'/)
+ institutionJoin = " join project on (project.id = transcription.project_id " +
+ " and project.institution_id in (${institutionIdList})) "
+ }
+
+ def projectJoin = ""
+ if (projectsInLabels) {
+ projectJoin = " join ${projectsInLabels} on (${projectsInLabels}.project_id = transcription.project_id)"
+ }
+
+ def query = """\
+ ${select}
+ from transcription
+ ${institutionJoin}
+ ${projectJoin}
+ where ${filter}
+ ${groupByClause} """.stripIndent()
+//${ineligibleUserClause}
+ sql.eachRow(query, [startDate: startDate.toTimestamp(), endDate: (endDate + 1).toTimestamp()]) { row ->
+ map[row[0]] = row[1]
+ }
+
+ /*results = Transcription.withCriteria {
ge("dateFully${activityType}", startDate)
lt("dateFully${activityType}", endDate + 1)
@@ -223,9 +269,34 @@ class LeaderBoardService {
groupProperty("fully${activityType}By")
count("fully${activityType}By", 'count')
}
-
}
+ */
+
} else {
+ def institutionJoin = ""
+ if (institutionList) {
+ institutionJoin = " join project on (project.id = task.project_id " +
+ " and project.institution_id in (${institutionList.collect{ it.id }.join(',')})) "
+ }
+
+ def projectJoin = ""
+ if (projectsInLabels) {
+ projectJoin = " join ${projectsInLabels} on (${projectsInLabels}.project_id = task.project_id)"
+ }
+
+ def query = """\
+ ${select}
+ from task
+ ${institutionJoin}
+ ${projectJoin}
+ where ${filter}
+ ${groupByClause} """.stripIndent()
+//${ineligibleUserClause}
+ sql.eachRow(query, [startDate: startDate.toTimestamp(), endDate: (endDate + 1).toTimestamp()]) { row ->
+ map[row[0]] = row[1]
+ }
+
+ /*
results = Task.withCriteria {
ge("dateFully${activityType}", startDate)
lt("dateFully${activityType}", endDate + 1)
@@ -253,11 +324,12 @@ class LeaderBoardService {
count("fully${activityType}By", 'count')
}
}
+ */
}
- def map = [:]
- results.each { row ->
- map[row[0]] = row[1]
- }
+ //def map = [:]
+// results.each { row ->
+// map[row[0]] = row[1]
+// }
return map
}
@@ -313,8 +385,35 @@ class LeaderBoardService {
}
private getUserCountsForProjectType(def projectsInLabels = null, ActivityType activityType, List exceptUsers = []) {
- def results
+ def map = [:]
+ def sql = new Sql(dataSource)
+
+ String select = "select fully_${activityType}_by, count(fully_${activityType}_by) as count "
+ String groupByClause = " group by fully_${activityType}_by "
+
+ def ineligibleUserClause = ""
+ if (exceptUsers) {
+ ineligibleUserClause = " where fully_${activityType}_by not in (${exceptUsers.join(",").tr(/"/, /'/)})"
+ }
+
+ // def results
if (ActivityType.Transcribed == activityType) {
+ def projectJoin = ""
+ if (projectsInLabels) {
+ projectJoin = " join ${projectsInLabels} on (${projectsInLabels}.project_id = transcription.project_id)"
+ }
+
+ def query = """\
+ ${select}
+ from transcription
+ ${projectJoin}
+ ${groupByClause} """.stripIndent()
+//${ineligibleUserClause}
+ sql.eachRow(query) { row ->
+ map[row[0]] = row[1]
+ }
+
+ /*
results = Transcription.withCriteria {
if (projectsInLabels) {
project {
@@ -332,7 +431,24 @@ class LeaderBoardService {
count("fully${activityType}By", 'count')
}
}
+ */
} else {
+ def projectJoin = ""
+ if (projectsInLabels) {
+ projectJoin = " join ${projectsInLabels} on (${projectsInLabels}.project_id = task.project_id)"
+ }
+
+ def query = """\
+ ${select}
+ from task
+ ${projectJoin}
+ ${groupByClause} """.stripIndent()
+//${ineligibleUserClause}
+ sql.eachRow(query) { row ->
+ map[row[0]] = row[1]
+ }
+
+ /*
results = Task.withCriteria {
if (projectsInLabels) {
project {
@@ -350,12 +466,13 @@ class LeaderBoardService {
count("fully${activityType}By", 'count')
}
}
+ */
}
- def map = [:]
- results.each { row ->
- map[row[0]] = row[1]
- }
+// def map = [:]
+// results.each { row ->
+// map[row[0]] = row[1]
+// }
return map
}
diff --git a/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy b/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy
index 831a32bd4..e01fa6137 100644
--- a/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy
@@ -38,23 +38,33 @@ class MultimediaService {
media.filePath ? getImageUrl(media.filePath) : ''
}
+ String getSampleAudioUrl(String prefix, String name, String format) {
+ def encodedPrefix = IOUtils.toFileSystemDirectorySafeName(prefix)
+ def encodedName = IOUtils.toFileSystemSafeName(name)
+
+ def imagesHome = grailsApplication.config.getProperty('images.home')
+ def filePath = imagesHome + File.separator + encodedPrefix + File.separator + encodedName + format
+ return filePath
+ }
+
public String getImageUrl(String filePath) {
return filePath ? "${grailsApplication.config.server.url}${filePath}" : ''
}
public String getImageThumbnailUrl(Multimedia media, boolean absolute = false) {
if (media == null) {
- log.warn("getImageThumbnailUrl called for null media object")
+ log.error("getImageThumbnailUrl called for null media object")
return grailsLinkGenerator.resource(file:'/sample-task-thumbnail.jpg')
}
String filePath = filePathFor(media) ?: ''
String filename = filenameFromFilePath(media.filePathToThumbnail) ?: ''
File file = new File(filePath, filename)
- log.debug("getImageThumbnailUrl media: $media, filePath: $filePath, filename: $filename, file: $file, exists: ${file.exists()}")
+ // log.debug("getImageThumbnailUrl media: $media, filePath: $filePath, filename: $filename, file: $file, exists: ${file.exists()}")
if (file.exists()) {
return media.filePathToThumbnail ? "${grailsApplication.config.server.url}${media.filePathToThumbnail}" : ''
} else {
- log.warn("Thumbnail requested for $media but $file doesn't exist")
+ // Log the warning from the Taglib, if the image isn't available.
+ // log.warn("Thumbnail requested for $media but $file doesn't exist")
return grailsLinkGenerator.resource(file:'/sample-task-thumbnail.jpg', absolute: absolute)
}
}
diff --git a/grails-app/services/au/org/ala/volunteer/ProjectService.groovy b/grails-app/services/au/org/ala/volunteer/ProjectService.groovy
index b311a3b72..fe33e61e3 100644
--- a/grails-app/services/au/org/ala/volunteer/ProjectService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/ProjectService.groovy
@@ -108,7 +108,12 @@ class ProjectService {
}
tasks = Task.findAllByProjectAndIdGreaterThan(p, lastId, [sort: 'id', order: 'asc', max: 100])
}
+
+ // Reset disk usage to zero, as all tasks have been deleted.
+ p.sizeInBytes = 0
+
log.info("Completed deleting all tasks for ${msg.projectId}")
+
session.flush()
notify(EventSourceService.NEW_MESSAGE, new Message.EventSourceMessage(to: msg.userId, event: 'deleteTasks', data: [projectId: msg.projectId, count: count, complete: true]))
}
@@ -668,9 +673,12 @@ class ProjectService {
def projectSize(Project project) {
final projectPath = new File(grailsApplication.config.images.home, project.id.toString())
try {
- [size: projectPath.directorySize(), error: null]
+ long sizeInBytes = projectPath.directorySize()
+ project.sizeInBytes = sizeInBytes
+ project.save(flush: true, failOnError: true)
+ [size: sizeInBytes, error: null]
} catch (e) {
- log.debug("ProjectService was unable to calculate project path directory size (possibly already archived?): ${e.message}")
+ log.warn("ProjectService was unable to calculate project path directory size (possibly already archived?): ${e.message}")
[error: e, size: -1]
}
}
@@ -948,14 +956,22 @@ class ProjectService {
log.debug("Projects list: ${projectList.size()}")
}
- // Randomly select a project from the list.
- int randomIndex = ThreadLocalRandom.current().nextInt(0, projectList.size() + 1);
- Project projectToDisplay = projectList.get(randomIndex) as Project
- log.debug("Project selected: ${projectToDisplay}")
- projectToDisplay.potdLastSelected = new Date()
- projectToDisplay.save(failOnError: true, flush: true)
+ Project projectToDisplay = null
+ if (projectList.size() > 1) {
+ // Randomly select a project from the list.
+ int randomIndex = ThreadLocalRandom.current().nextInt(0, ((projectList.size() - 1) > 0 ? projectList.size() - 1 : 1))
+ projectToDisplay = projectList.get(randomIndex) as Project
+ } else if (projectList.size() == 1) {
+ projectToDisplay = projectList.first() as Project
+ }
+
+ if (projectToDisplay) {
+ log.debug("Project selected: ${projectToDisplay}")
+ projectToDisplay.potdLastSelected = new Date()
+ projectToDisplay.save(failOnError: true, flush: true)
+ }
- return projectToDisplay
+ return projectToDisplay.id
}
/**
@@ -983,7 +999,8 @@ class ProjectService {
if (isTimeToUpdateRandomProject(frontPage.randomProjectDateUpdated) ||
isProjectComplete(frontPage.projectOfTheDay)) {
log.debug("Yes, updating PotD...")
- def project = selectRandomProject()
+ def projectId = selectRandomProject()
+ def project = Project.get(projectId)
if (project) {
log.debug("New PotD: ${project}")
frontPage.projectOfTheDay = project
diff --git a/grails-app/services/au/org/ala/volunteer/StagingService.groovy b/grails-app/services/au/org/ala/volunteer/StagingService.groovy
index 756e1ed8a..02288a2b5 100644
--- a/grails-app/services/au/org/ala/volunteer/StagingService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/StagingService.groovy
@@ -9,6 +9,7 @@ import com.google.common.io.Files
import groovy.transform.Canonical
import org.apache.commons.io.ByteOrderMark
import org.apache.commons.io.FileUtils
+import org.apache.commons.io.FilenameUtils
import org.apache.commons.io.input.BOMInputStream
import org.springframework.web.multipart.MultipartFile
@@ -107,6 +108,20 @@ class StagingService {
if (!it.isDirectory()) {
def url = grailsApplication.config.server.url + '/' + grailsApplication.config.images.urlPrefix + "${project.id}/staging/" + URLEncoder.encode(it.name, "UTF-8").replaceAll("\\+", "%20")
Map image = [file: it, name: it.name, url: url]
+
+ // If Audio project, check if it's an AAC file and rename to mp3.
+ def extension = FilenameUtils.getExtension(it.getPath())
+ log.debug("Extn: ${extension}")
+ if (project.projectType.name == ProjectType.PROJECT_TYPE_AUDIO && 'aac' == extension.toLowerCase()) {
+ log.debug("Renaming aac file to mp3")
+ def newFile = changeFileExtension(it, 'mp3')
+ log.debug("Path: ${newFile.path}")
+ image.file = newFile
+ image.name = newFile.name
+ image.url = grailsApplication.config.server.url + '/' + grailsApplication.config.images.urlPrefix + "${project.id}/staging/" + URLEncoder.encode(newFile.name, "UTF-8").replaceAll("\\+", "%20")
+ }
+ log.debug("File details: ${image}")
+
if (sortByDateTaken) {
Date dateTaken = ImageUtils.getDateTaken(it)
image.dateTaken = dateTaken ? dateTaken.getTime() : 0
@@ -116,9 +131,34 @@ class StagingService {
}
def sort = sortByDateTaken ? {it.dateTaken} : {it.name}
+ log.debug("Staged files: ${images}")
+
images.sort(sort)
}
+ /**
+ * To cater for requirements for the WaveSurfer JS library, AAC files need to be renamed.
+ * This method renames a file's extension to the provided extension.
+ * @param oldFile
+ * @param newExtension
+ * @return
+ */
+ def changeFileExtension(File oldFile, String newExtension) {
+ def filePath = oldFile.getPath() //createFilePath(oldname)
+// def file = new File(filePath)
+ if (oldFile.exists()) {
+ def extension = FilenameUtils.getExtension(filePath)
+ def newFilePath = filePath.replace(extension, newExtension)
+ def newFile = new File(newFilePath)
+ if (!newFile.exists()) {
+ oldFile.renameTo(newFile)
+ return newFile
+ }
+ } else {
+ return oldFile
+ }
+ }
+
def unstageImage(long projectId, String imageName) {
def file = new File(createStagedPath(projectId, imageName))
if (file.exists()) {
@@ -315,15 +355,20 @@ class StagingService {
// First pass - computed defined field values (either literals, name captures etc...)
stagedFiles.each { stagedFile ->
+ log.debug("Staged file: ${stagedFile}")
- def m = shadowFilePattern.matcher(stagedFile.name)
+ def m = shadowFilePattern.matcher(stagedFile.name as CharSequence)
if (m.matches()) {
-
+ log.debug("File is a shadow file")
def parentFile = m.group(1)
- def shadowFile = [stagedFile: stagedFile, fieldName: m.group(2), recordIndex: Integer.parseInt(m.group(3) ?: '0'), parentFile: parentFile]
+ def shadowFile = [stagedFile: stagedFile,
+ fieldName: m.group(2),
+ recordIndex: Integer.parseInt(m.group(3) ?: '0'),
+ parentFile: parentFile]
if (!shadowFiles[parentFile]) {
shadowFiles[parentFile] = []
}
+ log.debug("ShadowFile: ${shadowFile}")
shadowFiles[parentFile] << shadowFile
return
@@ -380,9 +425,11 @@ class StagingService {
// stage 2, process shadow files...
images.each { imageFile ->
- imageFile.shadowFiles = shadowFiles[imageFile.name]
+ //imageFile.shadowFiles = shadowFiles[imageFile.name]
+ imageFile.shadowFiles = shadowFiles[FilenameUtils.removeExtension(imageFile.name as String)]
}
-
+ log.debug("Shadow Files: ${shadowFiles}")
+ log.debug("Images: ${images}")
return images
}
diff --git a/grails-app/services/au/org/ala/volunteer/StatsService.groovy b/grails-app/services/au/org/ala/volunteer/StatsService.groovy
index cb0b955ee..85a511b30 100644
--- a/grails-app/services/au/org/ala/volunteer/StatsService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/StatsService.groovy
@@ -2,6 +2,7 @@ package au.org.ala.volunteer
import com.google.common.base.Stopwatch
import grails.transaction.Transactional
+import grails.plugin.cache.Cacheable
import groovy.sql.Sql
import javax.sql.DataSource
@@ -532,6 +533,7 @@ class StatsService {
return results
}
+ @Cacheable(value = 'StatsHourlyContribution', key = "(#institution?.id?.toString()?:'-1') + (#startDate?.getTime()?:'-1') + (#endDate?.getTime()?:'-1')")
def getHourlyContributions(Date startDate, Date endDate, Institution institution) {
def taskView = ""
def taskJoin = ""
@@ -601,6 +603,7 @@ class StatsService {
return results
}
+ @Cacheable(value = 'StatsTranscriptionTime', key = "(#institution?.id?.toString()?:'-1') + (#startDate?.getTime()?:'-1') + (#endDate?.getTime()?:'-1')")
def getTranscriptionTimeByProjectType(Date startDate, Date endDate, Institution institution) {
def institutionClause = ""
def params = [:]
diff --git a/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy b/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy
index 20e5b9843..99238daa3 100644
--- a/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy
@@ -51,6 +51,8 @@ class TaskLoadService {
def taskService
def stagingService
Closure jooqContext
+ def assetResourceLocator
+ def projectService
@Value('${digivol.ingest.queue.size:200}')
Integer batchSize = 100
@@ -143,10 +145,10 @@ class TaskLoadService {
create.settings().updatablePrimaryKeys = true
create.transaction({ cfg ->
- def ctx = DSL.using(cfg)
+ def txContext = DSL.using(cfg)
imageData.each { imgData ->
- def taskDesc = ctx.newRecord(TASK_DESCRIPTOR).with {
+ def taskDesc = txContext.newRecord(TASK_DESCRIPTOR).with {
projectId = project.id
projectName = project.name
imageUrl = imgData.url
@@ -159,7 +161,7 @@ class TaskLoadService {
def fieldName = kvp.key
def recordIndex = 0
- def matcher = nameIndexRegex.matcher(fieldName)
+ def matcher = nameIndexRegex.matcher(fieldName as CharSequence)
if (matcher.matches()) {
fieldName = matcher.group(1)
recordIndex = Integer.parseInt(matcher.group(2))
@@ -175,7 +177,7 @@ class TaskLoadService {
def shadowFileRecords = imgData.shadowFiles?.collect { shadowFile ->
log.info("Adding shadow files pre task import ${taskDesc.id}: ${shadowFile.stagedFile.file}")
def filePath = shadowFile.stagedFile.file as String
- ctx.newRecord(SHADOW_FILE_DESCRIPTOR).with {
+ txContext.newRecord(SHADOW_FILE_DESCRIPTOR).with {
taskDescriptorId = taskDesc.id
name = shadowFile.fieldName as String
recordIdx = shadowFile.recordIndex as Integer
@@ -185,7 +187,7 @@ class TaskLoadService {
}
if (shadowFileRecords) {
- create.batchInsert(shadowFileRecords).execute()
+ txContext.batchInsert(shadowFileRecords).execute()
}
}
} as TransactionalRunnable)
@@ -408,6 +410,13 @@ class TaskLoadService {
def doTaskLoad(Long projectId = null) {
int dequeuedTasks
while ((dequeuedTasks = doTaskLoadIteration(projectId)) != 0) {
+ // Calculate project directory disk usage after completion
+ def project = Project.get(projectId)
+ if (project) {
+ def projectSize = projectService.projectSize(project).size as long
+ log.info("Project size: ${projectSize}")
+ }
+
log.info("Completed loading {} tasks for project {}", dequeuedTasks, projectId)
}
}
@@ -926,13 +935,20 @@ class TaskLoadService {
}
private TaskService.FileMap completeMultimediaRecord(MultimediaRecord multimedia, long projectId) {
- def filePath = taskService.copyImageToStore(multimedia.filePath, projectId, multimedia.taskId, multimedia.id)
- if (!filePath) throw new IOException("Unable to complete copyImageToStore for ${multimedia.filePath}, ${projectId}, ${multimedia.taskId}, ${multimedia.id}")
+ Project project = Project.get(projectId)
+
+ def filePath = taskService.copyImageToStore(multimedia.filePath, projectId, multimedia.taskId, multimedia.id)
+ if (!filePath) throw new IOException("Unable to complete copyImageToStore for ${multimedia.filePath}, ${projectId}, ${multimedia.taskId}, ${multimedia.id}")
+
+ if (project.projectType.name == ProjectType.PROJECT_TYPE_AUDIO) {
+ multimedia.filePathToThumbnail = null
+ } else {
filePath = taskService.createImageThumbs(filePath) // creates thumbnail versions of images
- multimedia.filePath = filePath.localUrlPrefix + filePath.raw // This contains the url to the image without the server component
multimedia.filePathToThumbnail = filePath.localUrlPrefix + filePath.thumb // Ditto for the thumbnail
- multimedia.mimeType = filePath.contentType
- return filePath
+ }
+ multimedia.filePath = filePath.localUrlPrefix + filePath.raw // This contains the url to the image without the server component
+ multimedia.mimeType = filePath.contentType
+ return filePath
}
diff --git a/grails-app/services/au/org/ala/volunteer/TaskService.groovy b/grails-app/services/au/org/ala/volunteer/TaskService.groovy
index 43a6c1c6e..0324377b2 100644
--- a/grails-app/services/au/org/ala/volunteer/TaskService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/TaskService.groovy
@@ -31,6 +31,7 @@ class TaskService {
def multimediaService
def grailsLinkGenerator
def fieldService
+ def fieldSyncService
def i18nService
def userService
Closure jooqContext
@@ -222,7 +223,7 @@ class TaskService {
def sql = new Sql(dataSource)
def taskList = []
- def results = sql.eachRow(selectQuery, queryParams) { row->
+ sql.eachRow(selectQuery, queryParams) { row->
Task task = Task.get(row.id as long)
if (task) taskList.add(task)
}
@@ -280,11 +281,10 @@ class TaskService {
private Task processResults(List results, long timeoutWindow, String userId = null, int jump = 1, long lastId = -1) {
Task transcribableTask = null
int matches = 0
- log.debug("Processing results for user ${userId}, jump ${jump}, and lastId ${lastId}")
+ log.debug("Processing results for user_id: [${userId}], jump: [${jump}], lastId: [${lastId}]")
results?.find { result ->
Task task = Task.get(result[0])
- log.debug("Checking task ${result[0]}")
- log.debug("Task: ${task}")
+ log.debug("Checking ${task}")
// If locked or skipped, move onto next task.
if (!task.isLockedForTranscription(userId, timeoutWindow) && !task.wasSkippedByUser(userId)) {
@@ -295,7 +295,7 @@ class TaskService {
transcribableTask = task
matches++
if (matches >= jump) {
- log.debug("Allocating task ${task.id}.")
+ log.debug("Allocating task [${task.id}] to user_id [${userId}].")
return true
}
}
@@ -336,15 +336,16 @@ class TaskService {
order by $orderBy
""".stripIndent()
- log.debug("Unviewed task Query: ${query}")
+ //log.debug("Unviewed task Query: ${query}")
List results = Task.executeQuery(query,queryParams)
- log.debug("Results: ${results}")
+ log.debug("Searching for unviewed tasks resulted in [${results.size()}] tasks.")
if (results) {
def taskResult = results.last()
task = Task.get(taskResult[0])
}
+
task
}
@@ -376,7 +377,8 @@ class TaskService {
"order by count(transcriptions) desc, task.id",
params
)
- log.debug("Unfinished task not viewed results: ${results}")
+ //log.debug("Unfinished task not viewed results: ${results}")
+ log.debug("Searching for unfinished tasks for user_id [${userId}] returned ${results.size()} tasks.")
// The query above could likely be improved to make this unnecessary, but the result set shouldn't be
// too large.
@@ -394,7 +396,8 @@ class TaskService {
"having count(transcriptions) < :transcriptionsPerTask "+
"order by count(transcriptions) desc, task.id",
[userId: userId, project:project, transcriptionsPerTask:(long)transcriptionsPerTask])
- log.debug("Viewed but not transcribed results: ${results}")
+ //log.debug("Viewed but not transcribed results: ${results}")
+ log.debug("Searching for viewed but not transcribed tasks for user_id [${userId}] resulted in ${results.size()} tasks.")
// The query above could likely be improved to make this unnecessary, but the result set shouldn't be
// too large.
@@ -408,16 +411,14 @@ class TaskService {
* @return
*/
Task getNextTask(String userId, Project project, Long lastId = -1) {
-
+ log.debug("Get next task for user_id: [${userId}], project: [${project.id}], lastId: [${lastId}]")
if (!project || !userId) {
- return null;
+ return null
}
-// Task task = null
int jump = (project?.template?.viewParams?.jumpNTasks ?: 1) as int
- log.debug("Task jump set to: [${jump}]")
int transcriptionsPerTask = project.transcriptionsPerTask ?: 1 //(project?.template?.viewParams?.transcriptionsPerTask ?: 1) as int
- log.debug("Transcriptions per task: [${transcriptionsPerTask}]")
+ log.debug("Transcriptions per task: [${transcriptionsPerTask}], task jump: [${jump}]")
// This is the length of time for which a Task remains locked after a user views it
long timeout = grailsApplication.config.viewedTask.timeout as long
@@ -428,7 +429,7 @@ class TaskService {
// required to transcribe the Task.
Task task = findUnviewedTask(userId, project, transcriptionsPerTask, lastId, jump)
if (task) {
- log.debug("getNextTask(project ${project.id}, lastId $lastId) found an unviewed task to jump to: ${task.id}")
+ log.debug("Unviewed task selected to jump to: [${task.id}]")
return task
}
@@ -438,19 +439,22 @@ class TaskService {
// At this point, either the remaining transcriptions are in progress or some transcriptions have been abandoned.
task = findUnfinishedTaskNotViewedByUser(userId, project, transcriptionsPerTask, timeout, lastId, jump)
if (task) {
- log.debug("getNextTask(project ${project.id}, lastId $lastId) found an unfinished task not viewed by user to jump to: ${task.id}")
+ //log.debug("getNextTask(project ${project.id}, lastId $lastId) found an unfinished task not viewed by user to jump to: ${task.id}")
+ log.debug("Unfinished task assigned to user: [${task.id}]")
return task
}
task = findViewedButNotTranscribedTask(userId, project, transcriptionsPerTask, timeout, lastId)
if (task) {
- log.debug("getNextTask(project ${project.id}, lastId $lastId) found a viewed but not transcribed task to jump to: ${task.id}")
+ //log.debug("getNextTask(project ${project.id}, lastId $lastId) found a viewed but not transcribed task to jump to: ${task.id}")
+ log.debug("Viewed non-transcribed task assigned to user: [${task.id}]")
return task
}
// If we have been unable to find a Task while jumping over Tasks, see if we can get any Task.
- if (lastId >=0 && jump > 1) {
+ if (lastId >= 0 && jump > 1) {
// Try it all again, but without the jump
+ log.debug("Unable to find a task with jump [${jump}] specified. Re-searching with no jump.")
task = getNextTask(userId, project)
}
@@ -467,7 +471,7 @@ class TaskService {
Task getNextTaskForValidationForProject(String userId, Project project) {
if (!project || !userId) {
- return null;
+ return null
}
// We have to look for tasks whose last view was before the lock period AND hasn't already been viewed by this user
@@ -524,7 +528,7 @@ class TaskService {
c.list(params) {
eq("fullyTranscribedBy", userId)
isNotNull("dateFullyTranscribed")
- }
+ } as List
}
/**
@@ -711,8 +715,8 @@ SELECT COUNT(*) FROM (SELECT * FROM updated_task_ids UNION SELECT * FROM validat
log.debug("Getting recently validated tasks. ")
}
- def SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
- def recentDate = sdf.parse(sdf.format(new Date() - NUMBER_OF_RECENT_DAYS));
+ def SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd")
+ def recentDate = sdf.parse(sdf.format(new Date() - NUMBER_OF_RECENT_DAYS))
def tasks = Task.createCriteria().list() {
eq("fullyTranscribedBy", transcriber)
@@ -728,7 +732,7 @@ SELECT COUNT(*) FROM (SELECT * FROM updated_task_ids UNION SELECT * FROM validat
log.debug("Returning validated tasks: " + sw.toString())
}
- return tasks
+ return tasks as List
}
/**
@@ -830,7 +834,7 @@ ORDER BY record_idx, name;
def url = new URL(imageUrl)
def filename = url.path.replaceAll(/\/.*\//, "") // get the filename portion of url
if (!filename.trim()) {
- filename = "image_" + taskId;
+ filename = "image_" + taskId
}
filename = URLDecoder.decode(filename, "utf-8")
def conn = url.openConnection()
@@ -873,7 +877,7 @@ ORDER BY record_idx, name;
def sizes = ['thumb': 300, 'small': 600, 'medium': 1280, 'large': 2000]
sizes.each{
fileMap[it.key] = fileMap.raw.replaceFirst(/\.(.{3,4})$/,'_' + it.key +'.$1') // add _small to filename
- BufferedImage scaledImage = srcImage;
+ BufferedImage scaledImage = srcImage
if (srcImage.width > it.value /* || srcImage.height > it.value */) {
scaledImage = Scalr.resize(srcImage, it.value)
}
@@ -885,14 +889,14 @@ ORDER BY record_idx, name;
static BufferedImage ensureOpaque(BufferedImage bi) {
if (bi.getTransparency() == BufferedImage.OPAQUE)
- return bi;
- int w = bi.getWidth();
- int h = bi.getHeight();
- int[] pixels = new int[w * h];
- bi.getRGB(0, 0, w, h, pixels, 0, w);
- BufferedImage bi2 = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
- bi2.setRGB(0, 0, w, h, pixels, 0, w);
- return bi2;
+ return bi
+ int w = bi.getWidth()
+ int h = bi.getHeight()
+ int[] pixels = new int[w * h]
+ bi.getRGB(0, 0, w, h, pixels, 0, w)
+ BufferedImage bi2 = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
+ bi2.setRGB(0, 0, w, h, pixels, 0, w)
+ return bi2
}
/** Attempt to rollback any changes made during @link copyImageToStore or @link createImageThumbs */
@@ -935,7 +939,7 @@ ORDER BY record_idx, name;
results.add(taskRow)
}
- return results;
+ return results
}
@@ -980,11 +984,11 @@ ORDER BY record_idx, name;
Task findByProjectAndFieldValue(Project project, String fieldName, String fieldValue) {
def select = """
WITH task_ids AS (SELECT id from task where project_id = :projectId)
- SELECT f.task_id as id from field f WHERE f.task_id in (SELECT id FROM task_ids) and f.superceded = false and f.name = :fieldName and value = :fieldValue;
+ SELECT f.task_id as id from field f WHERE f.task_id in (SELECT id FROM task_ids) and f.superceded = false and f.name = :fieldName and value = :fieldValue
"""
def sql = new Sql(dataSource: dataSource)
- int taskId = -1;
+ int taskId = -1
def row = sql.firstRow(select, [projectId: project.id, fieldName: fieldName, fieldValue: fieldValue])
if (row) {
taskId = row[0]
@@ -1003,6 +1007,16 @@ ORDER BY record_idx, name;
return imageMetaData
}
+ @Cacheable(value='getAudioMetaData', key="(#multimedia?.id?:0)")
+ String getAudioMetaData(Multimedia multimedia) {
+ def path = multimedia?.filePath
+ if (path) {
+ return multimediaService.getImageUrl(multimedia)
+ } else {
+ return null
+ }
+ }
+
@Cacheable(value='getImageMetaData', key="(#multimedia?.id?:0) + '-' + (#rotate?:0)")
ImageMetaData getImageMetaData(Multimedia multimedia, int rotate = 0) {
def path = multimedia?.filePath
@@ -1030,7 +1044,7 @@ ORDER BY record_idx, name;
try {
image = ImageIO.read(resource.inputStream)
} catch (Exception ex) {
- log.error("Exception trying to read image path: {}, {}", resource, ex.message) // don't print whoel stack trace
+ log.error("Exception trying to read image path: ${resource}, ${ex.message}") // don't print whole stack trace
}
if (image) {
@@ -1078,7 +1092,8 @@ ORDER BY record_idx, name;
tr.each {
def transcriber = User.findByUserId(it.fullyTranscribedBy)
if (transcriber) {
- transcriber.transcribedCount--
+ //transcriber.transcribedCount--
+ fieldSyncService.decrementTranscriptionCount(transcriber.id)
}
it.fullyTranscribedBy = null
@@ -1104,7 +1119,8 @@ ORDER BY record_idx, name;
def validator = User.findByUserId(task.fullyValidatedBy)
if (validator) {
- validator.validatedCount--
+ //validator.validatedCount--
+ fieldSyncService.decrementValidationCount(validator.id)
}
task.isValid = null
task.fullyValidatedBy = null
@@ -1140,7 +1156,7 @@ ORDER BY record_idx, name;
def field = fieldService.getFieldForTask(task, "sequenceNumber")
if (field?.value && field.value.isInteger()) {
- def sequenceNumber = Integer.parseInt(field.value);
+ def sequenceNumber = Integer.parseInt(field.value)
def padSize = 0
if (field.value.startsWith("0")) {
// remember to left pad the resulting sequence numbers with 0
@@ -1212,6 +1228,7 @@ ORDER BY record_idx, name;
*/
Map getTaskViewList(int selectedTab, User user, Project project, String query, Integer offset, Integer max, String sort, String order) {
Stopwatch sw = Stopwatch.createStarted()
+ log.debug("Generating task view list for project [${project?.id}]")
// DEFAULTS FOR MAX, OFFSET
if (!offset) offset = 0
max = Math.max(Math.min(max ?: 0, 100), 1)
@@ -1222,15 +1239,10 @@ ORDER BY record_idx, name;
String additionalJoins = ''
String dateTranscribed = 'tr.date_fully_transcribed'
List withClauses = []
- /*
- """catalog_numbers AS (
- SELECT f.task_id, ARRAY_AGG(f.value) as catalog_number
- FROM field f
- WHERE f.name = 'catalogNumber'
- AND superceded = false
- GROUP BY f.task_id
- )""".stripIndent()
- ]*/
+
+ // Query for transcribed and saved tasks needs to select distinct tasks
+ boolean distinctTasks = false
+
switch (selectedTab) {
case 0:
// transcribed tasks that are recently validated
@@ -1240,26 +1252,21 @@ ORDER BY record_idx, name;
break
case 1:
filter = 'tr.fully_transcribed_by = :userId'
+ distinctTasks = true
break
case 2:
- /*withClauses += """saved_tasks AS (
- SELECT f.task_id, MAX(f.updated) as date_last_updated
- FROM field f
- WHERE f.superceded = false
- AND f.transcribed_by_user_id = :userId
- GROUP BY f.task_id
- )""".stripIndent()*/
filter = 't.is_fully_transcribed = false'
- //additionalJoins = 'JOIN saved_tasks s ON s.task_id = t.id'
additionalJoins = """JOIN (SELECT f.task_id, MAX(f.updated) as date_last_updated
FROM field f
WHERE f.superceded = false
AND f.transcribed_by_user_id = :userId
GROUP BY f.task_id) as s ON s.task_id = t.id """.stripIndent()
dateTranscribed = "COALESCE($dateTranscribed, s.date_last_updated)"
+ distinctTasks = true
break
case 3:
filter = 't.fully_validated_by = :userId'
+ distinctTasks = true
break
default:
throw new IllegalArgumentException("selectedTab must be between 0 and 3")
@@ -1270,12 +1277,12 @@ ORDER BY record_idx, name;
// SORTING
final validSorts = [
- 'id': 't.id',
- 'externalIdentfier': 't.external_identfier',
+ 'id': 'id',
+ 'externalIdentfier': 'external_identfier',
//'catalogNumber': 'catalog_number',
'projectName': 'project_name',
'dateTranscribed': 'date_transcribed',
- 'dateValidated': 't.date_fully_validated',
+ 'dateValidated': 'date_fully_validated',
//'validator': 'validator',
'status': 'status'
].withDefault { 'date_transcribed' }
@@ -1283,10 +1290,12 @@ ORDER BY record_idx, name;
if (!'asc'.equalsIgnoreCase(order)) order = 'desc'
final validatedStatus = i18nService.message(code: 'status.validated', default: 'Validated')
- final invalidatedStatus = i18nService.message(code: 'status.invalidated', default: 'Invalidated')
+ final invalidatedStatus = i18nService.message(code: 'status.invalidated', default: 'In progress')
final transcribedStatus = i18nService.message(code: 'status.transcribed', default: 'Transcribed')
final savedStatus = i18nService.message(code: 'status.saved', default: 'Saved')
+ log.debug("invalidated status: ${invalidatedStatus}")
+
final statusSnippet = """
CASE WHEN t.is_valid = true THEN '$validatedStatus'
WHEN t.is_valid = false THEN '$invalidatedStatus'
@@ -1309,8 +1318,13 @@ ORDER BY record_idx, name;
querySnippet = ''
}
+ def distinctTaskClause = ""
+ if (distinctTasks) {
+ distinctTaskClause = " DISTINCT ON (t.id) "
+ }
+
def withClause = "WITH \n${withClauses.join(',\n')}"
- def selectClause = """SELECT
+ def selectClause = """SELECT $distinctTaskClause
t.id,
t.created,
t.external_identifier,
@@ -1342,19 +1356,7 @@ ORDER BY record_idx, name;
// (vu.first_name || ' ' || vu.last_name) AS "validator_display_name",
def countClause = "SELECT count(DISTINCT t.id)"
- /*
- def queryClause = """FROM task t
- JOIN project p ON t.project_id = p.id
- LEFT OUTER JOIN (select DISTINCT ON (tr.task_id) * from transcription tr where tr.fully_transcribed_by = :userId ORDER BY tr.task_id) as tr on (t.id = tr.task_id)
- LEFT OUTER JOIN catalog_numbers c on c.task_id = t.id
- LEFT OUTER JOIN vp_user tu ON tr.fully_transcribed_by = tu.user_id
- LEFT OUTER JOIN vp_user vu on t.fully_validated_by = vu.user_id
- $additionalJoins
- WHERE
- $filter
- $querySnippet
- """.stripIndent()
- */
+
def queryClause = """FROM transcription tr
JOIN task t ON (t.id = tr.task_id)
JOIN project p ON t.project_id = p.id
@@ -1364,9 +1366,15 @@ ORDER BY record_idx, name;
$querySnippet
""".stripIndent()
- def pagingClause = """
- ORDER BY $sortColumn $order;
- """.stripIndent()
+// def pagingClause = """
+// ORDER BY $sortColumn $order;
+// """.stripIndent()
+ def pagingClause = "ORDER BY "
+ if (distinctTasks) {
+ pagingClause += " t.id "
+ } else {
+ pagingClause += " ${sortColumn} ${order} "
+ }
def results = [:]
final params = [userId: user.userId, project: project?.id, query: query]
@@ -1378,15 +1386,25 @@ ORDER BY record_idx, name;
log.debug("Count query:\n$countQuery")
- final rowsQuery = """
+ def rowsQuery = """
$selectClause
$queryClause
$pagingClause
""".stripIndent()
// removed $withClause
- log.debug("View list query:\n$rowsQuery")
- log.debug("Params: $params")
+ if (distinctTasks) {
+ rowsQuery = """
+ SELECT *
+ FROM (
+ $rowsQuery
+ ) pv
+ ORDER BY $sortColumn $order
+ """.stripIndent()
+ }
+
+ //log.debug("View list query:\n$rowsQuery")
+ //log.debug("Params: $params")
log.debug("Took ${sw.stop()} to generate queries")
sw.reset().start()
diff --git a/grails-app/services/au/org/ala/volunteer/UserService.groovy b/grails-app/services/au/org/ala/volunteer/UserService.groovy
index 956beaa71..b2427dfb2 100644
--- a/grails-app/services/au/org/ala/volunteer/UserService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/UserService.groovy
@@ -191,6 +191,30 @@ class UserService {
return false
}
+ /**
+ * Returns a list of users who hold the institution admin role for a given project's institution.
+ * @param project
+ * @return
+ */
+ List getInstitutionAdminsForProject(Project project) {
+ if (!project) return []
+
+ // log.debug("Getting institution admins for project: [${project.id}]")
+
+ def role = Role.findByNameIlike(BVPRole.INSTITUTION_ADMIN)
+ // log.debug("role: ${role}")
+ // log.debug("institution: ${project.institution}")
+
+ def userRoles = UserRole.findAllByRoleAndInstitution(role, project.institution)
+ // log.debug("User roles: ${userRoles}")
+
+ def users = userRoles.collect {
+ it.user
+ }
+
+ users
+ }
+
/**
* Determines if the current user holds the institution admin role for any institution.
* To find if a user holds the institution admin role for a specific institution, call
@@ -257,10 +281,6 @@ class UserService {
return false
}
- // If the user has been granted the ALA-AUTH ROLE_BVP_ADMIN....
-// if (authService.userInRole(CASRoles.ROLE_ADMIN)) {
-// return true
-// }
return authService.userInRole(CASRoles.ROLE_ADMIN)
}
@@ -270,7 +290,7 @@ class UserService {
* @return
*/
boolean isValidator(Project project) {
- isValidatorForProjectId(project?.id, project?.institution?.id)
+ return isValidatorForProjectId(project?.id, project?.institution?.id)
}
/**
@@ -280,7 +300,6 @@ class UserService {
* @return true if user has validator access, false if user does not.
*/
boolean isValidatorForProjectId(Long projectId, Long projectInstitutionId = null) {
-
def userId = currentUserId
if (!userId) {
return false
@@ -304,17 +323,44 @@ class UserService {
// - If the provided project matches the role's project, this is a project-level role - return true.
log.debug("Checking if user has validator role")
def user = User.findByUserId(userId)
+// if (user) {
+// def validatorRole = Role.findByNameIlike(BVPRole.VALIDATOR)
+// def role = user.userRoles.find {
+// it.role.id == validatorRole.id && ((it.institution == null && it.project == null) ||
+// projectId == null ||
+// (it.institution != null && it.institution?.id == projectInstitutionId) ||
+// it.project?.id == projectId)
+// }
+// if (role) {
+// // a role exists for the current user and the specified project/institution (or the user has a role with a null project and null institution
+// // indicating that they can validate tasks from any project and or institution)
+// return true
+// }
+// }
+
+// return false
+ return userHasValidatorRole(user, projectId, projectInstitutionId)
+ }
+
+ boolean userHasValidatorRole(User user, Long projectId, Long projectInstitutionId = null) {
if (user) {
+
+ if (hasCasRole(user, CASRoles.ROLE_VALIDATOR) || hasCasRole(user, CASRoles.ROLE_ADMIN)) {
+ log.debug("[userHasValidatorRole]: User has CAS Validator role/CAS Site Admin, granting validator.")
+ return true
+ }
+
def validatorRole = Role.findByNameIlike(BVPRole.VALIDATOR)
def role = user.userRoles.find {
it.role.id == validatorRole.id && ((it.institution == null && it.project == null) ||
- projectId == null ||
- (it.institution != null && it.institution?.id == projectInstitutionId) ||
- it.project?.id == projectId)
+ projectId == null ||
+ (it.institution != null && it.institution?.id == projectInstitutionId) ||
+ it.project?.id == projectId)
}
if (role) {
// a role exists for the current user and the specified project/institution (or the user has a role with a null project and null institution
// indicating that they can validate tasks from any project and or institution)
+ log.debug("[userHasValidatorRole]: User has the validator role, returning true.")
return true
}
}
@@ -322,6 +368,31 @@ class UserService {
return false
}
+ /**
+ * Checks with the ALA user service if a given user has a given role.
+ * @param user The user to query
+ * @param role the role to query
+ * @return true of the user has the role, false if not.
+ */
+ boolean hasCasRole(User user, String role) {
+ if (!user) return false
+ def serviceResults = [:]
+ try {
+ log.debug("[hasCasRole]: User: ${user}, Role: ${role}")
+ serviceResults = authService.getUserDetailsById([user.userId], true)
+ def userFromService = serviceResults?.users?.get(user.userId)
+ def userRoles = user.userRoles
+ def roleObjs = userRoles*.role
+ def currentRoles = (roleObjs*.name + userFromService?.roles).toSet()
+ log.debug("[hasCasRole]: ALA service roles: ${currentRoles}")
+ //log.debug("${currentRoles?.intersect([role])?.isEmpty()}")
+ log.debug("[hasCasRole]: role check: [${!currentRoles?.intersect([role])?.isEmpty()}]")
+ return !currentRoles?.intersect([role])?.isEmpty()
+ } catch (Exception e) {
+ log.warn("[hasCasRole]: Couldn't get user details from web service", e)
+ }
+ }
+
String getCurrentUserId() {
return authService.userId
}
diff --git a/grails-app/services/au/org/ala/volunteer/ValidationService.groovy b/grails-app/services/au/org/ala/volunteer/ValidationService.groovy
index 45db5e677..1564d74c1 100644
--- a/grails-app/services/au/org/ala/volunteer/ValidationService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/ValidationService.groovy
@@ -136,7 +136,7 @@ class ValidationService {
private boolean fieldsMatch(Transcription t1, Transcription t2, Set excludedFields) {
Set t1Fields = (t1.fields ?: new HashSet()).findAll{it.superceded == false && !excludedFields.contains(it.name)}
Set t2Fields = (t2.fields ?: new HashSet()).findAll{it.superceded == false && !excludedFields.contains(it.name)}
- log.debug("Fields selected: ${t1Fields}")
+ // log.debug("Fields selected: ${t1Fields}")
if (t1Fields?.size() != t2Fields.size()) {
return false
diff --git a/grails-app/services/au/org/ala/volunteer/VolunteerStatsService.groovy b/grails-app/services/au/org/ala/volunteer/VolunteerStatsService.groovy
index fe465a7cf..192aa3dfc 100644
--- a/grails-app/services/au/org/ala/volunteer/VolunteerStatsService.groovy
+++ b/grails-app/services/au/org/ala/volunteer/VolunteerStatsService.groovy
@@ -4,65 +4,69 @@ import com.google.common.base.Stopwatch
import grails.gorm.DetachedCriteria
import grails.transaction.Transactional
import grails.plugin.cache.Cacheable
+import groovy.sql.Sql
+
import javax.annotation.PostConstruct
+import javax.sql.DataSource
+
import static java.util.concurrent.TimeUnit.MILLISECONDS
import grails.web.mapping.LinkGenerator
@Transactional
class VolunteerStatsService {
+ DataSource dataSource
def multimediaService
def userService
def leaderBoardService
def institutionService
- def projectService
-
LinkGenerator grailsLinkGenerator
- @Cacheable(value = 'MainVolunteerContribution', key = "(#institutionId?.toString()?:'-1') + (#projectId?.toString()?:'-1') + (#projectTypeName?:'') + (#tags?.toString()?:'[]') + (#maxContributors.toString()) + (#disableStats.toString()) + (#disableHonourBoard.toString())")
- def generateStats(long institutionId, long projectId, String projectTypeName, List tags, int maxContributors, boolean disableStats, boolean disableHonourBoard) {
+ /**
+ * Generates a map of stats info, including total transcribers and leaderboard (daily/weekly/monthly)
+ *
+ * @param institutionId optional institution
+ * @param projectId optional project
+ * @param projectTypeName optional project type
+ * @param tags optional tags to filter on
+ * @param maxContributors the maximum number of transcribers to
+ * @param disableStats flag whether stats has been disabled
+ * @param disableHonourBoard flag whether honour board has been disabled
+ * @return transcriber stats on the given parameters
+ */
+ @Cacheable(value = 'MainVolunteerStats', key = "(#institutionId?.toString()?:'-1') + (#projectId?.toString()?:'-1') + (#projectTypeName?:'') + (#tags?.toString()?:'[]') + (#disableStats?.toString()) + (#disableHonourBoard?.toString())")
+ def generateStats(long institutionId, String projectTypeName, List tags, boolean disableStats, boolean disableHonourBoard) {
Institution institution = (institutionId == -1l) ? null : Institution.get(institutionId)
- Project projectInstance = (projectId == -1l) ? null : Project.get(projectId)
- List projectsInLabels = null
- if (tags || projectTypeName) {
- projectsInLabels = Project.withCriteria {
- if (tags) {
- labels {
- 'in'('value', tags)
- }
- }
- if (projectTypeName) {
- projectType {
- eq('name', projectTypeName)
- }
- }
- projections {
- property('id')
- }
- }
- }
-
- log.debug("Generating stats for inst id $institutionId, proj id: $projectId, maxContrib: $maxContributors, disableStats: $disableStats, disableHB: $disableHonourBoard, projectType: $projectTypeName, projectsInLabels: $projectsInLabels")
+ String projectTempTable = null
def sw = Stopwatch.createStarted()
+ log.debug("Generating stats for inst id $institutionId, disableStats: $disableStats, disableHB: $disableHonourBoard, projectType: $projectTypeName")
def totalTasks
def completedTasks
def transcriberCount
+
if (disableStats) {
+ log.debug("Stats are disabled")
totalTasks = 0
completedTasks = 0
transcriberCount = 0
} else if (institution) {
+ log.debug("Getting institution stats for [${institution.name}]")
totalTasks = institutionService.countTasksForInstitution(institution)
completedTasks = institutionService.countTranscribedTasksForInstitution(institution)
transcriberCount = institutionService.getTranscriberCount(institution)
- } else if (projectsInLabels?.size() >= 0) {
- totalTasks = projectService.countTasksForTag(projectsInLabels)
- completedTasks = projectService.countTranscribedTasksForTag(projectsInLabels)
- transcriberCount = getTranscriberCountForTag(projectsInLabels)
+ } else if (tags || projectTypeName) {
+ log.debug("Getting tag/project type stats for [tags: ${tags}, projectTypeName: ${projectTypeName}]")
+ def stats = getStatsForProjects(tags, projectTypeName)
+ totalTasks = stats.tasks
+ completedTasks = stats.transcriptions
+ transcriberCount = stats.transcribers
+ projectTempTable = stats.projectTempTable
+
} else { // TODO Project stats, not needed for v2.3
+ log.debug("Getting full site stats")
totalTasks = Task.count()
completedTasks = Task.countByIsFullyTranscribed(true) //Transcription.countByFullyTranscribedByIsNotNull()
transcriberCount = User.countByTranscribedCountGreaterThan(0)
@@ -70,7 +74,7 @@ class VolunteerStatsService {
log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to generate volunteer-stats section")
- sw.start()
+ sw.reset().start()
def daily
def weekly
@@ -80,10 +84,10 @@ class VolunteerStatsService {
if (disableHonourBoard) {
daily = weekly = monthly = alltime = LeaderBoardService.EMPTY_LEADERBOARD_WINNER
} else { // TODO Project honour board, not needed for v2.3
- daily = leaderBoardService.winner(LeaderBoardCategory.daily, institution, projectsInLabels)
- weekly = leaderBoardService.winner(LeaderBoardCategory.weekly, institution, projectsInLabels)
- monthly = leaderBoardService.winner(LeaderBoardCategory.monthly, institution, projectsInLabels)
- alltime = leaderBoardService.winner(LeaderBoardCategory.alltime, institution, projectsInLabels)
+ daily = leaderBoardService.winner(LeaderBoardCategory.daily, institution, /*projectsInLabels*/ projectTempTable)
+ weekly = leaderBoardService.winner(LeaderBoardCategory.weekly, institution, projectTempTable)
+ monthly = leaderBoardService.winner(LeaderBoardCategory.monthly, institution, projectTempTable)
+ alltime = leaderBoardService.winner(LeaderBoardCategory.alltime, institution, projectTempTable)
}
// Encode the email addresses for gravatar before sending to the client to prevent
@@ -92,96 +96,55 @@ class VolunteerStatsService {
log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to generate honour board section")
- sw.start()
-
- List> contributors = generateContributors(institution, projectInstance, projectsInLabels, maxContributors)
- //generateContributors(institution, projectInstance, pt, maxContributors)
-
- log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to generate contributors section")
-
- ['totalTasks':totalTasks, 'completedTasks':completedTasks, 'transcriberCount':transcriberCount,
- daily: daily, weekly: weekly, monthly: monthly, alltime: alltime, contributors: contributors]
-
+ cleanUpTables(projectTempTable)
+ ['totalTasks': totalTasks, 'completedTasks': completedTasks, 'transcriberCount': transcriberCount,
+ daily: daily, weekly: weekly, monthly: monthly, alltime: alltime]
}
- def generateContributors(Institution institution, Project projectInstance, List projectIds, Integer maxContributors) {
+ /**
+ * Collects forum post information for a given project or institution
+ *
+ * @param institutionId the optional institution
+ * @param projectId the optional project
+ * @param maxPosts the maximum number of posts to collect
+ * @return a list of contributors and their forum posts
+ */
+ def generateForumPosts(long institutionId, long projectId, int maxPosts) {
+ def messages = getForumActivity(institutionId, projectId, maxPosts)
+ def contributors = messages.sort { -it.timestamp }.take(maxPosts)
+ return [contributors: contributors]
+ }
- def latestTranscribers = LatestTranscribers.withCriteria {
- if (institution) {
- project {
- eq('institution', institution)
- ne('inactive', true)
- }
- } else if (projectIds) {
- project {
- 'in' 'id', projectIds
- ne('inactive', true)
- }
- } else if (projectInstance) {
- eq('project', projectInstance)
- } else {
- project {
- ne('inactive', true)
- }
- }
- order('maxDate', 'desc')
- maxResults(maxContributors)
- }
+ /**
+ * Retrieves and collates forum activity for a given institution, project or all forums.
+ *
+ * @param institutionId optional institution
+ * @param projectId optional project
+ * @param maxPosts maximum number of posts to collect
+ * @return a map containing forum posts
+ */
+ def getForumActivity(Long institutionId, Long projectId, Integer maxPosts) {
+ Institution institution = (institutionId == -1l) ? null : Institution.get(institutionId)
+ Project project = (projectId == -1l) ? null : Project.get(projectId)
def latestMessages
if (institution) {
- latestMessages = ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxContributors])
- latestMessages += ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.task.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxContributors])
- } else if (projectIds) {
- // latestMessages = ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.project.projectType = :pt ORDER BY date desc', [pt: pt], [max: maxContributors])
- // latestMessages += ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.task.project.projectType = :pt ORDER BY date desc', [pt: pt], [max: maxContributors])
- def projects = Project.findAllByIdInList(projectIds)
- /*Project.createCriteria().list {
- and {
- if (pt) {
- eq('projectType', pt)
- }
- if (pt) {
- 'in'('id', pt)
- }
- }
- }*/
-
- latestMessages = ForumMessage.withCriteria{
- or {
- 'in'('topic', new DetachedCriteria(ProjectForumTopic).build {
- 'in'('project', projects)
- projections {
- property('id')
- }
- })
- 'in'('topic', new DetachedCriteria(TaskForumTopic).build {
- task {
- 'in'('project', projects)
- }
- projections {
- property('id')
- }
- })
- }
- order('date', 'desc')
- maxResults(maxContributors)
- }
-
- } else if (projectInstance) {
+ latestMessages = ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxPosts])
+ latestMessages += ForumMessage.findAll('FROM ForumMessage fm WHERE fm.topic.task.project.institution = :institution ORDER BY date desc', [institution: institution], [max: maxPosts])
+ } else if (project) {
latestMessages = ForumMessage.withCriteria {
or {
'in'('topic', new DetachedCriteria(ProjectForumTopic).build {
- eq('project', projectInstance)
+ eq('project', project)
projections {
property('id')
}
})
'in'('topic', new DetachedCriteria(TaskForumTopic).build {
task {
- eq('project', projectInstance)
+ eq('project', project)
}
projections {
property('id')
@@ -189,12 +152,12 @@ class VolunteerStatsService {
})
}
order('date', 'desc')
- maxResults(maxContributors)
+ maxResults(maxPosts)
}
} else {
latestMessages = ForumMessage.withCriteria {
order('date', 'desc')
- maxResults(maxContributors)
+ maxResults(maxPosts)
}
}
@@ -211,10 +174,10 @@ class VolunteerStatsService {
def thumbnail = null
if (topic instanceof ProjectForumTopic) {
- def project = ((ProjectForumTopic) topic).project
- forumName = project.name
- thumbnail = project.featuredImage
- forumUrl = grailsLinkGenerator.link(controller: 'forum', action: 'projectForum', params: [projectId: project.id])
+ def forumProject = ((ProjectForumTopic) topic).project
+ forumName = forumProject.name
+ thumbnail = forumProject.featuredImage
+ forumUrl = grailsLinkGenerator.link(controller: 'forum', action: 'projectForum', params: [projectId: forumProject.id])
} else if (topic instanceof TaskForumTopic) {
def task = ((TaskForumTopic) topic).task
forumName = task.project.name
@@ -225,35 +188,152 @@ class VolunteerStatsService {
forumUrl = grailsLinkGenerator.link(controller: 'forum', action: 'index', params: [selectedTab: 1])
}
- [type : 'forum', topicId: topicId, topicUrl: topicUrl, forumName: forumName, forumUrl: forumUrl, userId: it.userId,
- displayName : details?.displayName, email: details?.email?.toLowerCase()?.encodeAsMD5(),
- thumbnailUrl: thumbnail, timestamp: timestamp]
+ [type : 'forum',
+ topicId : topicId,
+ topicUrl : topicUrl,
+ forumName : forumName,
+ forumUrl : forumUrl,
+ userId : it.userId,
+ displayName : details?.displayName,
+ email : details?.email?.toLowerCase()?.encodeAsMD5(),
+ thumbnailUrl: thumbnail,
+ timestamp : timestamp]
+ }
+
+ return messages
+ }
+
+ /**
+ * Retrieves and collects information from {@link #getContributors(Institution, Project, List, Integer, String)}
+ *
+ * @param institutionId optional institution filter
+ * @param projectId optional project filter
+ * @param projectTypeName optional project type filter
+ * @param tags tag filter
+ * @param maxContributors maximum number of contributors to collect
+ * @return a map containing the contribution information.
+ */
+ @Cacheable(value = 'MainVolunteerContribution', key = "(#institutionId?.toString()?:'-1') + (#projectId?.toString()?:'-1') + (#projectTypeName?:'') + (#tags?.toString()?:'[]') + (#maxContributors?.toString())")
+ def generateContributors(long institutionId, long projectId, String projectTypeName, List tags, int maxContributors) {
+ Institution institution = (institutionId == -1l) ? null : Institution.get(institutionId)
+ Project projectInstance = (projectId == -1l) ? null : Project.get(projectId)
+
+ String tempTableName = ""
+ def projectList = []
+
+ if (projectTypeName || tags) {
+ tempTableName = 'stats_projects_for_contributors'
+ projectList = getProjectsForLabels(tempTableName, tags, projectTypeName)
+ }
+
+ List> contributors = getContributors(institution, projectInstance, projectList, maxContributors, tempTableName)
+ cleanUpTables(tempTableName)
+
+ [contributors: contributors]
+ }
+
+ /**
+ * Collects a list of contributions from transcribers, including transcriptions AND forum posts. Can be filtered
+ * on institution, project, project type or tag.
+ *
+ * @param institution optional institution filter
+ * @param projectInstance optional project filter
+ * @param projectIds optional project ID list
+ * @param maxContributors maximum number of contributors to collect
+ * @param projectTempTable collation table containing data
+ * @return a map of contributors containing information on their activity
+ */
+ def getContributors(Institution institution, Project projectInstance, List projectIds, Integer maxContributors, String projectTempTable) {
+ def sw = Stopwatch.createStarted()
+
+ def latestTranscribers = []
+ if (projectTempTable) {
+ def projectJoin = ""
+ if (projectIds) {
+ projectJoin = " join ${projectTempTable} on (${projectTempTable}.project_id = latest_transcribers.project_id) "
+ }
+ def query = """\
+ select latest_transcribers.project_id,
+ fully_transcribed_by,
+ max_date
+ from latest_transcribers
+ ${projectJoin}
+ order by max_date desc
+ limit ${maxContributors} """.stripIndent()
+
+ def sql = new Sql(dataSource)
+ sql.eachRow(query) { row ->
+ latestTranscribers.add(LatestTranscribers.findByFullyTranscribedByAndMaxDate(row.fully_transcribed_by as String, (row.max_date as Date).toTimestamp()))
+ }
+ } else {
+ latestTranscribers = LatestTranscribers.withCriteria {
+ if (institution) {
+ project {
+ eq('institution', institution)
+ ne('inactive', true)
+ }
+ } else if (projectInstance) {
+ eq('project', projectInstance)
+ } else {
+ project {
+ ne('inactive', true)
+ }
+ }
+ order('maxDate', 'desc')
+ maxResults(maxContributors)
+ }
}
- userDetails = userService.detailsForUserIds(latestTranscribers*.fullyTranscribedBy).collectEntries { [(it.userId): it] }
+ log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to collect latest transcribers")
+ sw.reset().start()
+
+ def messages = getForumActivity(institution?.id, projectInstance?.id, maxContributors)
+ log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to compile message details")
+ sw.reset().start()
+
+ def userDetails = userService.detailsForUserIds(latestTranscribers*.fullyTranscribedBy).collectEntries { [(it.userId): it] }
def transcribers = latestTranscribers.collect {
+
def proj = it.project
def userId = it.fullyTranscribedBy
def details = userDetails[userId]
-
+
+
def tasks = LatestTranscribersTask.withCriteria() {
eq('project', proj)
eq('fullyTranscribedBy', userId)
order('dateFullyTranscribed', 'desc')
}
+ def sw2 = Stopwatch.createStarted()
def thumbnailLists = (tasks && (tasks.size() > 0)) ? tasks.subList(0, (tasks.size() < 5)? tasks.size(): 5): []
+ log.debug("Took ${sw2.stop().elapsed(MILLISECONDS)}ms to get transcriber thumbnailLists for user ${userId}")
+ sw2.reset().start()
def thumbnails = thumbnailLists.collect { LatestTranscribersTask t ->
def task = Task.findById (t.taskId)
[id: t.id, thumbnailUrl: multimediaService.getImageThumbnailUrl(task.multimedia?.first())]
}
- [type : 'task', projectId: proj.id, projectName: proj.name, userId: User.findByUserId(userId)?.id ?: -1, displayName: details?.displayName, email: details?.email?.toLowerCase()?.encodeAsMD5(),
- transcribedThumbs: thumbnails, transcribedItems: tasks.size(), timestamp: it.maxDate.time / 1000]
-
+ log.debug("Took ${sw2.stop().elapsed(MILLISECONDS)}ms to compile thumbnail info for user ${userId}")
+
+ [type: 'task',
+ projectId: proj.id,
+ projectName: proj.name,
+ projectType: proj.projectType.name,
+ userId: User.findByUserId(userId)?.id ?: -1,
+ displayName: details?.displayName,
+ email: details?.email?.toLowerCase()?.encodeAsMD5(),
+ transcribedThumbs: thumbnails,
+ transcribedItems: tasks.size(),
+ timestamp: it.maxDate.time / 1000]
}
+ log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to collect transcriber details and thumbnails")
+ sw.reset().start()
+
def contributors = (messages + transcribers).sort { -it.timestamp }.take(maxContributors)
+ log.debug("Took ${sw.stop().elapsed(MILLISECONDS)}ms to sort final list")
+
return contributors
}
@@ -282,4 +362,141 @@ class VolunteerStatsService {
}
}
+ def createTempTableForProjectStats(String tempTableName) {
+ log.debug("Executing temp table creation")
+ String query = """\
+ create temp table ${tempTableName} (
+ project_id bigint primary key
+ )
+ """.stripIndent()
+ def sql = new Sql(dataSource)
+ sql.execute(query)
+ }
+
+ def cleanUpTables(String tempTableName) {
+ if (!tempTableName) {
+ return
+ }
+
+ def query = "drop table if exists " + tempTableName
+ def sql = new Sql(dataSource)
+ sql.execute(query)
+ sql.close()
+ }
+
+ def getProjectsForLabels(String tempTableName, List tags, String projectType) {
+
+ if (!tempTableName) {
+ tempTableName = "stats_temp_table"
+ }
+ createTempTableForProjectStats(tempTableName)
+
+ def labelJoin = ""
+ def projectTypeJoin = ""
+ def parameters = [:]
+
+ if (tags?.size() > 0) {
+ def tagList = tags.join("','")
+ labelJoin = """\
+ join project_labels on (project_labels.project_id = project.id)
+ join label on (label.id = project_labels.label_id and label.value in ('${tagList}')) """
+ log.debug("tagList: ${tagList}")
+ log.debug("labelJoin: ${labelJoin}")
+ }
+
+ if (projectType) {
+ projectTypeJoin = """\
+ join project_type on (project_type.id = project.project_type_id and project_type.name = :projectType)
+ """
+ parameters.projectType = projectType
+ }
+
+ def query = """\
+ insert into {tempTableName}
+ select distinct project.id
+ from project
+ ${labelJoin}
+ ${projectTypeJoin}
+ """
+ // This is SAFE - the variable is not modifiable/input from parameters.
+ query = query.replace("{tempTableName}", tempTableName)
+
+ log.debug("Filling temp project table (${tempTableName}): ")
+ log.debug(query)
+ log.debug("Params: tags: ${tags}")
+ log.debug("Params: projectType: ${projectType}")
+
+ def sql = new Sql(dataSource)
+ if (parameters.size() > 0) {
+ sql.executeInsert(query, parameters)
+ } else {
+ sql.executeInsert(query)
+ }
+
+ query = """\
+ select project_id
+ from """ + tempTableName
+
+ def projectList = []
+ sql.eachRow(query) { row ->
+ projectList.add(row.project_id)
+ }
+
+ projectList
+ }
+
+ private def getTempJoin(String tempTableName, String joinTable) {
+ return "join ${tempTableName} on (${tempTableName}.project_id = ${joinTable}.project_id)"
+ }
+
+ def getStatsForProjects(List tags, String projectType) {
+ // Tasks
+ // Transcribed Tasks
+ // Transcribers
+ def sw = Stopwatch.createStarted()
+ String tempTableName = 'stats_projects_for_labels'
+ def projectList = getProjectsForLabels(tempTableName, tags, projectType)
+ def tempJoin = getTempJoin(tempTableName, "task")
+ log.debug("Got projects list in ${sw.stop().elapsed(MILLISECONDS)}ms")
+ sw.reset().start()
+
+ String query = """\
+ select count(*) as task_count
+ from task
+ ${tempJoin} """
+
+ def sql = new Sql(dataSource)
+
+ def result = sql.firstRow(query)
+ def taskCount = result.task_count
+
+ log.debug("Got task count in ${sw.stop().elapsed(MILLISECONDS)}ms")
+ sw.reset().start()
+
+ query = """\
+ select count(is_fully_transcribed) filter (where is_fully_transcribed = true) as transcribed_count
+ from task
+ ${tempJoin} """
+
+ result = sql.firstRow(query)
+ def transcribedTaskCount = result.transcribed_count
+
+ log.debug("Got transcribed task count in ${sw.stop().elapsed(MILLISECONDS)}ms")
+ sw.reset().start()
+
+ tempJoin = getTempJoin(tempTableName, "transcription")
+ query = """\
+ select distinct fully_transcribed_by
+ from transcription
+ ${tempJoin}
+ where fully_transcribed_by is not null """
+
+ result = sql.rows(query)
+ def transcriberCount = result.size()
+
+ log.debug("Got transcriber count in ${sw.stop().elapsed(MILLISECONDS)}ms")
+
+ [tasks: taskCount, transcriptions: transcribedTaskCount, transcribers: transcriberCount, projectsInLabels: projectList, projectTempTable: tempTableName]
+ }
+
}
diff --git a/grails-app/taglib/au/org/ala/volunteer/TranscribeTagLib.groovy b/grails-app/taglib/au/org/ala/volunteer/TranscribeTagLib.groovy
index 1c41becb5..ef638352e 100644
--- a/grails-app/taglib/au/org/ala/volunteer/TranscribeTagLib.groovy
+++ b/grails-app/taglib/au/org/ala/volunteer/TranscribeTagLib.groovy
@@ -349,6 +349,102 @@ class TranscribeTagLib {
return w
}
+ def audioSampleWave = { attrs, body ->
+ def prefix = attrs.remove('prefix') as String
+ def name = attrs.remove('name') as String
+ def format = (attrs.remove('format') ?: 'wav') as String
+ def template = attrs.remove('template')?.toBoolean()
+ if (prefix && name) {
+ def audioFileUrl = multimediaService.getSampleAudioUrl(prefix, name, format)
+
+ }
+ }
+
+ def audioWaveViewer = { attrs, body ->
+ def multimedia = attrs.multimedia as Multimedia
+ def waveColour = attrs.waveColour ?: '#d5502a'
+
+ if (multimedia) {
+ def mb = new MarkupBuilder(out)
+ def audioFileUrl = taskService.getAudioMetaData(multimedia)
+
+ if (!audioFileUrl) {
+ def sampleFile = grailsApplication.mainContext.getResource("classpath:/public/audio/klankbeeld_suburb-sunday-713am.wav")
+ audioFileUrl = resource(file:'/klankbeeld_suburb-sunday-713am.wav')
+
+ mb.div(class:'alert alert-danger') {
+ button(type: 'button', class: 'close', ('data-dismiss'): 'alert') {
+ mkp.yieldUnescaped('×')
+ }
+ span() {
+ mkp.yield("An error occurred getting the meta data for task image ${multimedia.id}!")
+ }
+ }
+ }
+
+ mb.div(id:'audio-parent-container') {
+ mb.div(id:'audio-container') {
+ mb.div(id:'waveform', style:'padding: 1px; border-radius: 5px; border: 1px solid #ddd;') {
+ mkp.yieldUnescaped('')
+ }
+
+ mb.div(id:'wave-controls') {
+ mb.div(id:'row') {
+ mb.div(class:'col-sm-12', style:'padding-top:10px; padding-left:0px;') {
+ mkp.yieldUnescaped("""
+
+
+ Play /
+
+ Pause
+
+ """)
+ }
+ }
+ }
+ }
+ }
+
+ asset.script([type: 'text/javascript', 'asset-defer': ''],
+ """
+ // Create an instance
+ let wavesurfer = {};
+
+ // Init & load audio file
+ document.addEventListener('DOMContentLoaded', function() {
+ wavesurfer = WaveSurfer.create({
+ container: document.querySelector('#waveform'),
+ backgroundColor: 'white',
+ waveColor: '#a1a1a1',
+ progressColor: '${waveColour}',
+ cursorColor: 'black',
+ cursorWidth: 1,
+ height: 300,
+ hideScrollbar: true,
+ scrollParent: false,
+ fillParent: true,
+ barMinHeight: 50,
+ barHeight: 70,
+ normalize: true
+ });
+
+ wavesurfer.on('error', function(e) {
+ console.warn(e);
+ });
+
+ // Load audio from URL
+ wavesurfer.load('${audioFileUrl}');
+
+ // Play button
+ var button = document.querySelector('[data-action="play"]');
+ button.addEventListener('click', wavesurfer.playPause.bind(wavesurfer));
+
+ });
+ """.toString()
+ )
+ }
+ }
+
/**
* @attr multimedia
* @attr elementId
@@ -359,24 +455,21 @@ class TranscribeTagLib {
*/
def imageViewer = { attrs, body ->
def multimedia = attrs.multimedia as Multimedia
- if (multimedia) {
+ if (multimedia) {
int rotate = 0
if (attrs.rotate) {
rotate = attrs.rotate
}
def mb = new MarkupBuilder(out)
-
def imageMetaData = taskService.getImageMetaData(multimedia, rotate)
if (!imageMetaData) {
-
def sampleFile = grailsApplication.mainContext.getResource("classpath:/public/images/sample-task.jpg")
-
-
def sampleUrl = resource(file:'/sample-task.jpg')
imageMetaData = taskService.getImageMetaDataFromFile(sampleFile, sampleUrl, 0)
+
mb.div(class:'alert alert-danger') {
button(type: 'button', class: 'close', ('data-dismiss'): 'alert') {
mkp.yieldUnescaped('×')
@@ -407,17 +500,18 @@ class TranscribeTagLib {
}
}
}
+
if (!attrs.hideShowInOtherWindow) {
div(class:'show-image-control') {
a(id:'showImageWindow', href:'#', title:'Show image in a separate window', ('data-container'): 'body') {
mkp.yield('Show image in a separate window')
}
}
-
}
}
}
}
+
if (attrs.height) {
asset.script([type: 'text/javascript', 'asset-defer': ''], " \$(document).ready(function() { if (setImageViewerHeight) { setImageViewerHeight(${attrs.height}); } } );" )
// mb.script(type:"text/javascript") {
@@ -686,10 +780,12 @@ class TranscribeTagLib {
def taskSequence = { attrs, body ->
Stopwatch sw = Stopwatch.createStarted()
- Task taskInstance = attrs.task
+ Task taskInstance = attrs.task as Task
boolean isPreview = attrs.isPreview
Project project = taskInstance.project
+ log.debug("Loading task sequence for task: ${taskInstance.id}")
+
Field field = null
// The query for the field will throw an Exception if the task hasn't been saved to the database, as is the case
@@ -698,6 +794,8 @@ class TranscribeTagLib {
field = fieldService.getFieldForTask(taskInstance, "sequenceGroupId")
}
+ log.debug("Field: ${field}")
+
Map tasks = [previous:[], current: [:], next:[]]
if (!field) {
@@ -709,43 +807,47 @@ class TranscribeTagLib {
def seqToTaskId = taskService.findByProjectAndFieldValues(project.id, 'sequenceNumber', allSeqNos)
def taskIds = seqToTaskId.values() + taskInstance.id
Map taskIdToMM = multimediaService.findImagesForTasks(taskIds)
+
sequenceNumbers.previous.each { seqNo ->
def seq = seqNo as String
tasks.previous << [sequenceNumber:seqNo, multimedia: taskIdToMM[seqToTaskId[seq]]]
}
+
tasks.current = [sequenceNumber: sequenceNumber, multimedia: taskIdToMM[taskInstance.id]]
sequenceNumbers.next.each { seqNo ->
def seq = seqNo as String
tasks.next << [sequenceNumber:seqNo, multimedia: taskIdToMM[seqToTaskId[seq]]]
}
- }
- else {
-
+ } else {
// Get other tasks with the same sequenceGroupId
String sequenceGroupId = field.value
- QueryResults results = fullTextIndexService.findProjectTasksByFieldValue(project, "sequenceGroupId",sequenceGroupId, "sequenceNumber")
+ //QueryResults results = fullTextIndexService.findProjectTasksByFieldValue(project, "sequenceGroupId", sequenceGroupId, "sequenceNumber")
+ List allTasks = fieldService.findAllTasksByFieldAndFieldValue(project, "sequenceGroupId", sequenceGroupId, "task_id")
+
+ log.debug("Sequence Group: ${sequenceGroupId}")
// The results are sorted by sequence number
- List allTasks = results.list
+ //List allTasks = results.list
Map taskIdToMM = multimediaService.findImagesForTasks(allTasks*.id)
+ log.debug("allTasks size: ${allTasks?.size()}")
+ log.debug("taskIdToMM size: ${taskIdToMM?.size()}")
int taskIndex = allTasks.indexOf(taskInstance)
- int minIndex = Math.max(0, taskIndex-attrs.count)
- int maxIndex = Math.min(allTasks.size()-1, taskIndex+attrs.count)
+ int minIndex = Math.max(0, taskIndex - attrs.count as int)
+ int maxIndex = Math.min(allTasks.size() - 1, taskIndex + attrs.count as int)
- for (int i=minIndex; i
diff --git a/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy b/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy
index d158fbc04..4401385d7 100644
--- a/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy
+++ b/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy
@@ -10,8 +10,10 @@ import groovy.time.TimeCategory
import groovy.xml.MarkupBuilder
import org.apache.commons.io.FileUtils
import org.apache.http.client.utils.URIBuilder
+import org.grails.web.util.GrailsApplicationAttributes
import org.springframework.beans.factory.annotation.Value
-
+import org.springframework.web.context.request.RequestAttributes
+import org.springframework.web.context.request.RequestContextHolder
import java.text.SimpleDateFormat
@@ -417,11 +419,11 @@ class VolunteerTagLib {
if (validator) {
def status = "Not yet validated"
def badgeClass = "label"
- if (taskInstance.isValid == false) {
- status = "Marked as invalid by ${validator.displayName} on ${taskInstance?.dateFullyValidated?.format("yyyy-MM-dd HH:mm:ss")}"
- badgeClass = "label label-danger"
+ if (!taskInstance.isValid) {
+ status = "Partially validated by ${validator.displayName} on ${taskInstance?.dateFullyValidated?.format("yyyy-MM-dd HH:mm:ss")}"
+ badgeClass = "label label-warning"
} else if (taskInstance.isValid) {
- status = "Marked as Valid by ${validator.displayName} on ${taskInstance?.dateFullyValidated?.format("yyyy-MM-dd HH:mm:ss")}"
+ status = "Validated by ${validator.displayName} on ${taskInstance?.dateFullyValidated?.format("yyyy-MM-dd HH:mm:ss")}"
badgeClass = "label label-success"
}
mb.span(class:badgeClass) {
@@ -620,6 +622,10 @@ class VolunteerTagLib {
fullUrl = multimediaService.getImageUrl(mm)
}
+ if (task.project.projectType.name == ProjectType.PROJECT_TYPE_AUDIO) {
+ url = resource(file:'/icons-audio-52.png')
+ }
+
if (!url) {
// sample
url = resource(file:'/sample-task-thumbnail.jpg')
@@ -628,9 +634,12 @@ class VolunteerTagLib {
fullUrl = resource(file: '/sample-task.jpg')
}
- if (url) {
+ if (url && task.project.projectType.name != ProjectType.PROJECT_TYPE_AUDIO) {
out << ""
if (withHidden) out << ""
+ } else {
+ out << ""
+ if (withHidden) out << ""
}
}
@@ -704,7 +713,7 @@ class VolunteerTagLib {
def alt = attrs.remove('alt')
def cssClass = attrs.remove('class')
out << ""
}
+ def audioSample = { attrs, body ->
+ def linkText = attrs.remove('linkText')
+ out << ""
+ if (linkText) {
+ out << linkText
+ }
+ out << ""
+ }
+
def sizedImageUrl = { attrs, body ->
def prefix = attrs.remove('prefix')
def name = attrs.remove('name')
@@ -728,6 +748,14 @@ class VolunteerTagLib {
out << (template ? url.replace('%7B', '{').replace('%7D','}') : url)
}
+ def audioUrl = { attrs, body ->
+ def prefix = attrs.remove('prefix')
+ def name = attrs.remove('name')
+ def format = attrs.remove('format') ?: 'wav'
+ def template = attrs.remove('template')?.toBoolean()
+ String url = g.createLink(controller: 'image', action: 'audioFile', params: [prefix: prefix, name: name, format: format])
+ out << (template ? url.replace('%7B', '{').replace('%7D','}') : url)
+ }
/**
* @id The id of the institution
diff --git a/grails-app/views/about.gsp b/grails-app/views/about.gsp
index d1ce16dca..c43fe10ec 100644
--- a/grails-app/views/about.gsp
+++ b/grails-app/views/about.gsp
@@ -138,7 +138,7 @@