diff --git a/vspace/pom.xml b/vspace/pom.xml index c453493ef..7e677b9e8 100644 --- a/vspace/pom.xml +++ b/vspace/pom.xml @@ -34,6 +34,10 @@ + + + + vspace TomcatServer @@ -219,6 +223,13 @@ 0.15.0 + + + com.squareup.okhttp3 + okhttp + 4.9.3 + + org.thymeleaf diff --git a/vspace/src/main/java/edu/asu/diging/vspace/config/SecurityContext.java b/vspace/src/main/java/edu/asu/diging/vspace/config/SecurityContext.java index 494ce166d..8b8cccfed 100644 --- a/vspace/src/main/java/edu/asu/diging/vspace/config/SecurityContext.java +++ b/vspace/src/main/java/edu/asu/diging/vspace/config/SecurityContext.java @@ -51,7 +51,8 @@ protected void configure(HttpSecurity http) throws Exception { .authorizeRequests() // Anyone can access the urls .antMatchers("/", "/exhibit/**", "/api/**", "/resources/**", "/login", - "/logout", "/register", "/reset/**", "/setup/admin", "/404","/preview/**").permitAll() + "/logout", "/register", "/reset/**", "/setup/admin", "/404","/preview/**", + "/staff/citesphere/oauth/**").permitAll() // The rest of the our application is protected. .antMatchers("/users/**", "/admin/**", "/staff/user/**").hasRole("ADMIN") .antMatchers("/staff/**").hasAnyRole("STAFF", "ADMIN") diff --git a/vspace/src/main/java/edu/asu/diging/vspace/core/exception/CitesphereTokenException.java b/vspace/src/main/java/edu/asu/diging/vspace/core/exception/CitesphereTokenException.java new file mode 100644 index 000000000..9f9d69060 --- /dev/null +++ b/vspace/src/main/java/edu/asu/diging/vspace/core/exception/CitesphereTokenException.java @@ -0,0 +1,43 @@ +package edu.asu.diging.vspace.core.exception; + +/** + * Exception thrown when there are issues with Citesphere authentication tokens + */ +public class CitesphereTokenException extends Exception { + + private static final long serialVersionUID = 1L; + private final int statusCode; + private final boolean isTokenExpired; + + public CitesphereTokenException(String message) { + super(message); + this.statusCode = 0; + this.isTokenExpired = false; + } + + public CitesphereTokenException(String message, int statusCode) { + super(message); + this.statusCode = statusCode; + this.isTokenExpired = (statusCode == 401 || statusCode == 403); + } + + public CitesphereTokenException(String message, Throwable cause) { + super(message, cause); + this.statusCode = 0; + this.isTokenExpired = false; + } + + public CitesphereTokenException(String message, int statusCode, boolean isTokenExpired) { + super(message); + this.statusCode = statusCode; + this.isTokenExpired = isTokenExpired; + } + + public int getStatusCode() { + return statusCode; + } + + public boolean isTokenExpired() { + return isTokenExpired; + } +} diff --git a/vspace/src/main/java/edu/asu/diging/vspace/core/services/CitesphereAuthToken.java b/vspace/src/main/java/edu/asu/diging/vspace/core/services/CitesphereAuthToken.java new file mode 100644 index 000000000..cf0a18818 --- /dev/null +++ b/vspace/src/main/java/edu/asu/diging/vspace/core/services/CitesphereAuthToken.java @@ -0,0 +1,115 @@ +package edu.asu.diging.vspace.core.services; + +import java.util.Map; + +/** + * Authentication token object for Citesphere API + */ +public class CitesphereAuthToken { + + private String authType; + private Map headers; + private String accessToken; + private String refreshToken; + private String username; + private String password; + private long tokenExpiryTime; + + /** + * Constructor for OAuth authentication + * @param accessToken OAuth access token + */ + public CitesphereAuthToken(String accessToken) { + this.authType = "oauth"; + this.accessToken = accessToken; + } + + /** + * Constructor for OAuth authentication with refresh token + * @param accessToken OAuth access token + * @param refreshToken OAuth refresh token + * @param expiryTime Token expiry time in milliseconds + */ + public CitesphereAuthToken(String accessToken, String refreshToken, long expiryTime) { + this.authType = "oauth"; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenExpiryTime = expiryTime; + } + + /** + * Constructor for Basic authentication + * @param username Username + * @param password Password + */ + public CitesphereAuthToken(String username, String password) { + this.authType = "basic"; + this.username = username; + this.password = password; + } + + // Getters and setters + public String getAuthType() { + return authType; + } + + public void setAuthType(String authType) { + this.authType = authType; + } + + public Map getHeaders() { + return headers; + } + + public void setHeaders(Map headers) { + this.headers = headers; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public long getTokenExpiryTime() { + return tokenExpiryTime; + } + + public void setTokenExpiryTime(long tokenExpiryTime) { + this.tokenExpiryTime = tokenExpiryTime; + } + + /** + * Check if the access token is expired + * @return true if token is expired, false otherwise + */ + public boolean isTokenExpired() { + return tokenExpiryTime > 0 && System.currentTimeMillis() > tokenExpiryTime; + } +} \ No newline at end of file diff --git a/vspace/src/main/java/edu/asu/diging/vspace/core/services/ICitesphereManager.java b/vspace/src/main/java/edu/asu/diging/vspace/core/services/ICitesphereManager.java new file mode 100644 index 000000000..a81444a33 --- /dev/null +++ b/vspace/src/main/java/edu/asu/diging/vspace/core/services/ICitesphereManager.java @@ -0,0 +1,118 @@ +package edu.asu.diging.vspace.core.services; + +import java.util.Map; +import edu.asu.diging.vspace.core.exception.CitesphereTokenException; + +/** + * Service interface for Citesphere API operations + */ +public interface ICitesphereManager { + + /** + * Get user information + * @return User data as Map + */ + Map getUser(); + + /** + * Check test endpoint + * @return Test response as Map + */ + Map checkTest(); + + /** + * Check access for a document + * @param documentId Document ID to check access for + * @return Access check response as Map + */ + Map checkAccess(String documentId); + + /** + * Get data by endpoint + * @param endpoint API endpoint path + * @return Data response as Map + */ + Map getDataByEndpoint(String endpoint); + + /** + * Get all groups + * @return Groups data as Map + */ + Map getGroups(); + + /** + * Get group information + * @param groupId Group ID + * @return Group information as Map + */ + Map getGroupInfo(String groupId); + + /** + * Get group items + * @param zoteroGroupId Zotero group ID + * @return Group items as Map + */ + Map getGroupItems(String zoteroGroupId); + + /** + * Get collections + * @param zoteroGroupId Zotero group ID + * @return Collections as Map + */ + Map getCollections(String zoteroGroupId); + + /** + * Get collection items + * @param zoteroGroupId Zotero group ID + * @param collectionId Collection ID + * @param pageNumber Page number (optional, defaults to 0) + * @return Collection items as Map + */ + Map getCollectionItems(String zoteroGroupId, String collectionId, int pageNumber); + + /** + * Get collection items (overloaded method without page number) + * @param zoteroGroupId Zotero group ID + * @param collectionId Collection ID + * @return Collection items as Map + */ + Map getCollectionItems(String zoteroGroupId, String collectionId); + + /** + * Get item information + * @param zoteroGroupId Zotero group ID + * @param itemId Item ID + * @return Item information as Map + */ + Map getItemInfo(String zoteroGroupId, String itemId); + + /** + * Get collections by collection ID + * @param zoteroGroupId Zotero group ID + * @param collectionId Collection ID + * @return Collections as Map + */ + Map getCollectionsByCollectionId(String zoteroGroupId, String collectionId); + + /** + * Add item to group + * @param groupId Group ID + * @param data Item data + * @param filePath Path to file to upload + * @return Response from API + */ + Object addItem(String groupId, Map data, String filePath); + + /** + * Refresh the access token using refresh token + * @return New CitesphereAuthToken with refreshed access token + * @throws CitesphereTokenException if refresh fails + */ + CitesphereAuthToken refreshToken() throws CitesphereTokenException; + + /** + * Validate if current token is still valid + * @return true if token is valid, false otherwise + */ + boolean isTokenValid(); +} diff --git a/vspace/src/main/java/edu/asu/diging/vspace/core/services/impl/CitesphereManager.java b/vspace/src/main/java/edu/asu/diging/vspace/core/services/impl/CitesphereManager.java new file mode 100644 index 000000000..a7e6c1cc7 --- /dev/null +++ b/vspace/src/main/java/edu/asu/diging/vspace/core/services/impl/CitesphereManager.java @@ -0,0 +1,450 @@ +package edu.asu.diging.vspace.core.services.impl; + + +//import com.citesphere.api.CitesphereService; +//import com.citesphere.api.auth.CitesphereAuthToken; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; + +import edu.asu.diging.vspace.core.services.CitesphereAuthToken; +import edu.asu.diging.vspace.core.services.ICitesphereManager; +import edu.asu.diging.vspace.core.exception.CitesphereTokenException; + +import java.io.File; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import okhttp3.*; + +/** + * Implementation of CitesphereService for API operations + */ +public class CitesphereManager implements ICitesphereManager { + + private final String api; + private final CitesphereAuthToken authTokenObject; + private final OkHttpClient client; + private final ObjectMapper objectMapper; + + /** + * Constructor + * @param api API base URL + * @param authTokenObject Authentication token object + * @return + */ + public CitesphereManager(String api, CitesphereAuthToken authTokenObject) { + this.api = api; + this.authTokenObject = authTokenObject; + this.client = new OkHttpClient(); + this.objectMapper = new ObjectMapper(); + + validate(); + handleApiParams(); + } + + + /** + * Validate authentication token object + */ + private void validate() { + if (authTokenObject.getAuthType() == null) { + throw new IllegalArgumentException("Missing authType attribute"); + } + + if (authTokenObject.getAccessToken() == null) { + if (authTokenObject.getUsername() == null || authTokenObject.getPassword() == null) { + throw new IllegalArgumentException( + "Either username and password or access_token should be present"); + } + } + + if (!"oauth".equals(authTokenObject.getAuthType()) && + !"basic".equals(authTokenObject.getAuthType())) { + throw new IllegalArgumentException("authType should be either oauth or basic"); + } + } + + /** + * Handle API parameters and set headers + */ + private void handleApiParams() { + Map headers = new HashMap<>(); + + if ("oauth".equals(authTokenObject.getAuthType())) { + headers.put("Authorization", "Bearer " + authTokenObject.getAccessToken()); + } else if ("basic".equals(authTokenObject.getAuthType())) { + String authStr = authTokenObject.getUsername() + ":" + authTokenObject.getPassword(); + String authB64 = Base64.getEncoder().encodeToString(authStr.getBytes()); + headers.put("Authorization", "Basic " + authB64); + } + + authTokenObject.setHeaders(headers); + } + + /** + * Execute GET command + * @param url Request URL + * @return Response data as Map + * @throws CitesphereTokenException if token is invalid or expired + */ + private Map executeCommand(String url) throws CitesphereTokenException { + try { + Request.Builder requestBuilder = new Request.Builder().url(url); + + // Add headers + if (authTokenObject.getHeaders() != null) { + for (Map.Entry header : authTokenObject.getHeaders().entrySet()) { + requestBuilder.addHeader(header.getKey(), header.getValue()); + } + } + + Request request = requestBuilder.build(); + + try (Response response = client.newCall(request).execute()) { + // Check for unauthorized or forbidden responses + if (response.code() == 401 || response.code() == 403) { + throw new CitesphereTokenException( + "Invalid or expired access token", + response.code(), + true + ); + } + + if (response.body() != null) { + String responseBody = response.body().string(); + + // Check if response is an array or object + if (responseBody.trim().startsWith("[")) { + // Response is an array, wrap it in a data object + List responseList = objectMapper.readValue(responseBody, new TypeReference>(){}); + Map responseMap = new HashMap<>(); + responseMap.put("data", responseList); + return responseMap; + } else { + // Response is an object + @SuppressWarnings("unchecked") + Map responseMap = objectMapper.readValue(responseBody, Map.class); + return responseMap; + } + } + } + } catch (CitesphereTokenException e) { + throw e; // Re-throw token exceptions + } catch (Exception e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + return errorMap; + } + + return new HashMap<>(); + } + + /** + * Execute POST request + * @param url Request URL + * @param data Request data + * @param filePath File path for upload + * @return Response object + */ + private Object executePostRequest(String url, Map data, String filePath) { + try { + MultipartBody.Builder builder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + + // Add data parameters + if (data != null) { + for (Map.Entry entry : data.entrySet()) { + builder.addFormDataPart(entry.getKey(), entry.getValue().toString()); + } + } + + // Add file if provided + if (filePath != null) { + File file = new File(filePath); + if (file.exists()) { + RequestBody fileBody = RequestBody.create(file, MediaType.parse("application/pdf")); + builder.addFormDataPart("files", file.getName(), fileBody); + } + } + + RequestBody requestBody = builder.build(); + + Request.Builder requestBuilder = new Request.Builder() + .url(url) + .post(requestBody); + + // Add headers + if (authTokenObject.getHeaders() != null) { + for (Map.Entry header : authTokenObject.getHeaders().entrySet()) { + requestBuilder.addHeader(header.getKey(), header.getValue()); + } + } + + Request request = requestBuilder.build(); + + try (Response response = client.newCall(request).execute()) { + return response; + } + + } catch (Exception e) { + return "Error loading/reading file"; + } + } + + @Override + public Map getUser() { + try { + String url = api + "/v1/user"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map checkTest() { + try { + String url = api + "/v1/test"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map checkAccess(String documentId) { + try { + String url = api + "/files/giles/" + documentId + "/access/check"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getDataByEndpoint(String endpoint) { + try { + String url = api + "/v1" + endpoint; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getGroups() { + try { + String url = api + "/v1/groups"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getGroupInfo(String groupId) { + try { + String url = api + "/v1/groups/" + groupId; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getGroupItems(String zoteroGroupId) { + try { + String url = api + "/v1/groups/" + zoteroGroupId + "/items"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getCollections(String zoteroGroupId) { + try { + String url = api + "/v1/groups/" + zoteroGroupId + "/collections"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getCollectionItems(String zoteroGroupId, String collectionId, int pageNumber) { + try { + String url = api + "/v1/groups/" + zoteroGroupId + "/collections/" + collectionId + "/items"; + if (pageNumber > 0) { + url += "?&page=" + pageNumber; + } + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getCollectionItems(String zoteroGroupId, String collectionId) { + return getCollectionItems(zoteroGroupId, collectionId, 0); + } + + @Override + public Map getItemInfo(String zoteroGroupId, String itemId) { + try { + String url = api + "/v1/groups/" + zoteroGroupId + "/items/" + itemId; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Map getCollectionsByCollectionId(String zoteroGroupId, String collectionId) { + try { + String url = api + "/groups/" + zoteroGroupId + "/collections/" + collectionId + "/collections"; + return executeCommand(url); + } catch (CitesphereTokenException e) { + Map errorMap = new HashMap<>(); + errorMap.put("error_message", e.getMessage()); + errorMap.put("token_expired", e.isTokenExpired()); + return errorMap; + } + } + + @Override + public Object addItem(String groupId, Map data, String filePath) { + String url = api + "/v1/groups/" + groupId + "/items/create"; + return executePostRequest(url, data, filePath); + } + + @Override + public CitesphereAuthToken refreshToken() throws CitesphereTokenException { + if (authTokenObject.getRefreshToken() == null || authTokenObject.getRefreshToken().isEmpty()) { + throw new CitesphereTokenException("No refresh token available"); + } + + try { + String tokenUrl = api + "/oauth/token"; + + RequestBody formBody = new FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", authTokenObject.getRefreshToken()) + .build(); + + Request request = new Request.Builder() + .url(tokenUrl) + .post(formBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.code() == 401 || response.code() == 403) { + throw new CitesphereTokenException( + "Refresh token is invalid or expired", + response.code(), + true + ); + } + + if (response.isSuccessful() && response.body() != null) { + String responseBody = response.body().string(); + @SuppressWarnings("unchecked") + Map tokenResponse = objectMapper.readValue(responseBody, Map.class); + + String newAccessToken = (String) tokenResponse.get("access_token"); + String newRefreshToken = (String) tokenResponse.get("refresh_token"); + Number expiresIn = (Number) tokenResponse.get("expires_in"); + + if (newAccessToken != null) { + long expiryTime = 0; + if (expiresIn != null) { + expiryTime = System.currentTimeMillis() + (expiresIn.longValue() * 1000); + } + + // Update current token object + authTokenObject.setAccessToken(newAccessToken); + if (newRefreshToken != null) { + authTokenObject.setRefreshToken(newRefreshToken); + } + authTokenObject.setTokenExpiryTime(expiryTime); + + // Re-initialize headers with new token + handleApiParams(); + + return authTokenObject; + } + } + + throw new CitesphereTokenException("Failed to refresh token: " + response.code()); + } + } catch (CitesphereTokenException e) { + throw e; + } catch (Exception e) { + throw new CitesphereTokenException("Error refreshing token: " + e.getMessage(), e); + } + } + + @Override + public boolean isTokenValid() { + if (authTokenObject.getAccessToken() == null || authTokenObject.getAccessToken().isEmpty()) { + return false; + } + + // Check if token is expired based on stored expiry time + if (authTokenObject.isTokenExpired()) { + return false; + } + + // Optionally, test token with a simple API call + try { + String url = api + "/v1/test"; + Request.Builder requestBuilder = new Request.Builder().url(url); + + if (authTokenObject.getHeaders() != null) { + for (Map.Entry header : authTokenObject.getHeaders().entrySet()) { + requestBuilder.addHeader(header.getKey(), header.getValue()); + } + } + + Request request = requestBuilder.build(); + + try (Response response = client.newCall(request).execute()) { + return response.code() != 401 && response.code() != 403; + } + } catch (Exception e) { + return false; + } + } +} diff --git a/vspace/src/main/java/edu/asu/diging/vspace/web/staff/CitesphereController.java b/vspace/src/main/java/edu/asu/diging/vspace/web/staff/CitesphereController.java new file mode 100644 index 000000000..42296085b --- /dev/null +++ b/vspace/src/main/java/edu/asu/diging/vspace/web/staff/CitesphereController.java @@ -0,0 +1,480 @@ +package edu.asu.diging.vspace.web.staff; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.ui.Model; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.servlet.http.HttpSession; +import java.io.UnsupportedEncodingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import edu.asu.diging.vspace.core.model.IReference; +import edu.asu.diging.vspace.core.services.CitesphereAuthToken; +import edu.asu.diging.vspace.core.services.ICitesphereManager; +import edu.asu.diging.vspace.core.services.IReferenceManager; +import edu.asu.diging.vspace.core.services.impl.CitesphereManager; +import edu.asu.diging.vspace.core.exception.CitesphereTokenException; + +@Controller +public class CitesphereController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Value("${citesphere.api.url:}") + private String citesphereApiUrl; + + @Value("${citesphere.client.id:}") + private String citesphereClientId; + + @Value("${citesphere.client.secret:}") + private String citesphereClientSecret; + + @Value("${app_url}") + private String appBaseUrl; + + @Autowired + private IReferenceManager referenceManager; + + + // initiate oauth authorization with citesphere + @RequestMapping(value = "/staff/citesphere/oauth/authorize", method = RequestMethod.GET) + public String initiateOAuth(HttpSession session, RedirectAttributes redirectAttributes) { + + if (citesphereClientId == null || citesphereClientId.isEmpty()) { + logger.error("OAuth not configured - missing client ID"); + redirectAttributes.addFlashAttribute("error", "Citesphere OAuth is not configured. Please contact your administrator."); + return "redirect:/staff/dashboard"; + } + + try { + // generate state parameter for security + String state = java.util.UUID.randomUUID().toString(); + session.setAttribute("citesphere_oauth_state", state); + + // build authorization url + String baseUrl = citesphereApiUrl; + String redirectUri = getCurrentBaseUrl() + "/staff/citesphere/oauth/callback"; + + String authUrl = baseUrl + "/oauth/authorize" + + "?response_type=code" + + "&client_id=" + java.net.URLEncoder.encode(citesphereClientId, "UTF-8") + + "&state=" + java.net.URLEncoder.encode(state, "UTF-8") + + "&redirect_uri=" + java.net.URLEncoder.encode(redirectUri, "UTF-8"); + return "redirect:" + authUrl; + } catch (UnsupportedEncodingException e) { + logger.error("Error encoding OAuth parameters", e); + redirectAttributes.addFlashAttribute("error", "Error initiating OAuth flow."); + return "redirect:/staff/dashboard"; + } + } + + // handle oauth callback from citesphere + @RequestMapping(value = "/staff/citesphere/oauth/callback", method = RequestMethod.GET) + public String handleOAuthCallback( + @RequestParam String code, + @RequestParam String state, + HttpSession session, + RedirectAttributes redirectAttributes) { + + try { + // verify state parameter + String sessionState = (String) session.getAttribute("citesphere_oauth_state"); + + if (sessionState == null || !sessionState.equals(state)) { + logger.error("OAuth state validation failed"); + redirectAttributes.addFlashAttribute("error", "Invalid OAuth state. Please try again."); + return "redirect:/staff/dashboard"; + } + + // exchange code for access token + String accessToken = exchangeCodeForToken(code, session); + + if (accessToken != null) { + session.setAttribute("citesphere_access_token", accessToken); + redirectAttributes.addFlashAttribute("success", "Successfully connected to Citesphere!"); + } else { + redirectAttributes.addFlashAttribute("error", "Failed to obtain access token from Citesphere."); + } + + } catch (Exception e) { + logger.error("Error handling OAuth callback", e); + redirectAttributes.addFlashAttribute("error", "OAuth authentication failed: " + e.getMessage()); + } finally { + // clean up session + session.removeAttribute("citesphere_oauth_state"); + } + + return "redirect:/staff/dashboard"; + } + + + // get user groups from citesphere + @RequestMapping(value = "/staff/citesphere/groups", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity> getGroups(HttpSession session) { + + try { + ICitesphereManager citesphereManager = createCitesphereManager(session); + Map groups = citesphereManager.getGroups(); + + // check for token expiry in response + if (isTokenExpiredResponse(groups)) { + session.removeAttribute("citesphere_access_token"); + session.removeAttribute("citesphere_refresh_token"); + session.removeAttribute("citesphere_token_expiry"); + Map error = new HashMap<>(); + error.put("error", "Authentication expired"); + error.put("require_auth", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + return ResponseEntity.ok(groups); + } catch (IllegalStateException e) { + logger.error("Authentication error: {}", e.getMessage()); + Map error = new HashMap<>(); + error.put("error", e.getMessage()); + error.put("require_auth", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } catch (Exception e) { + logger.error("Error fetching groups from Citesphere", e); + Map error = new HashMap<>(); + error.put("error", "Failed to fetch groups: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + // get collections for a specific group + @RequestMapping(value = "/staff/citesphere/groups/{groupId}/collections", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity> getCollections(@PathVariable String groupId, HttpSession session) { + + try { + ICitesphereManager citesphereManager = createCitesphereManager(session); + Map collections = citesphereManager.getCollections(groupId); + return ResponseEntity.ok(collections); + } catch (Exception e) { + logger.error("Error fetching collections from Citesphere for group: " + groupId, e); + Map error = new HashMap<>(); + error.put("error", "Failed to fetch collections: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + // get items for a specific collection + @RequestMapping(value = "/staff/citesphere/groups/{groupId}/collections/{collectionId}/items", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity> getCollectionItems( + @PathVariable String groupId, + @PathVariable String collectionId, + @RequestParam(value = "page", defaultValue = "0") int page, + HttpSession session) { + + try { + ICitesphereManager citesphereManager = createCitesphereManager(session); + Map items = citesphereManager.getCollectionItems(groupId, collectionId, page); + return ResponseEntity.ok(items); + } catch (Exception e) { + logger.error("Error fetching collection items from Citesphere", e); + Map error = new HashMap<>(); + error.put("error", "Failed to fetch collection items: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + // get all items for a specific group + @RequestMapping(value = "/staff/citesphere/groups/{groupId}/items", method = RequestMethod.GET) + @ResponseBody + public ResponseEntity> getGroupItems(@PathVariable String groupId, HttpSession session) { + + try { + ICitesphereManager citesphereManager = createCitesphereManager(session); + Map items = citesphereManager.getGroupItems(groupId); + return ResponseEntity.ok(items); + } catch (Exception e) { + logger.error("Error fetching group items from Citesphere for group: " + groupId, e); + Map error = new HashMap<>(); + error.put("error", "Failed to fetch group items: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + // import selected references from citesphere to bibliography + @RequestMapping(value = "/staff/module/{moduleId}/slide/{slideId}/bibliography/{biblioId}/citesphere/import", method = RequestMethod.POST) + @ResponseBody + public ResponseEntity> importCitesphereReferences( + @PathVariable String moduleId, + @PathVariable String slideId, + @PathVariable String biblioId, + @RequestBody List> selectedReferences) { + + try { + List createdReferences = new ArrayList<>(); + + for (Map refData : selectedReferences) { + + String title = extractField(refData, "title"); + String author = extractCreators(refData); + String year = extractYear(refData); + String journal = extractField(refData, "publicationTitle"); + String url = extractField(refData, "url"); + String volume = extractField(refData, "volume"); + String issue = extractField(refData, "issue"); + String pages = extractField(refData, "pages"); + String editors = extractField(refData, "editor"); + String type = extractField(refData, "itemType"); + String note = extractField(refData, "note"); + + IReference reference = referenceManager.createReference( + biblioId, title, author, year, journal, url, volume, issue, pages, editors, type, note + ); + createdReferences.add(reference); + logger.info("Created reference: {}", title); + } + + Map response = new HashMap<>(); + response.put("success", true); + response.put("imported_count", createdReferences.size()); + response.put("references", createdReferences); + + + + if (!createdReferences.isEmpty()) { + IReference firstRef = createdReferences.get(0); + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error importing references from Citesphere", e); + Map error = new HashMap<>(); + error.put("error", "Failed to import references: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + private ICitesphereManager createCitesphereManager(HttpSession session) { + // check for oauth token + String accessToken = (String) session.getAttribute("citesphere_access_token"); + String refreshToken = (String) session.getAttribute("citesphere_refresh_token"); + Long expiryTime = (Long) session.getAttribute("citesphere_token_expiry"); + + if (accessToken != null) { + // create auth token with all available information + CitesphereAuthToken authToken; + if (refreshToken != null && expiryTime != null) { + authToken = new CitesphereAuthToken(accessToken, refreshToken, expiryTime); + } else { + authToken = new CitesphereAuthToken(accessToken); + } + + ICitesphereManager manager = new CitesphereManager(citesphereApiUrl, authToken); + + // check if token is valid and refresh if needed + if (!manager.isTokenValid()) { + try { + CitesphereAuthToken refreshedToken = manager.refreshToken(); + + // update session with new token information + session.setAttribute("citesphere_access_token", refreshedToken.getAccessToken()); + if (refreshedToken.getRefreshToken() != null) { + session.setAttribute("citesphere_refresh_token", refreshedToken.getRefreshToken()); + } + if (refreshedToken.getTokenExpiryTime() > 0) { + session.setAttribute("citesphere_token_expiry", refreshedToken.getTokenExpiryTime()); + } + + return manager; + + } catch (CitesphereTokenException e) { + logger.error("Token refresh failed: {}", e.getMessage()); + // clear invalid tokens from session + session.removeAttribute("citesphere_access_token"); + session.removeAttribute("citesphere_refresh_token"); + session.removeAttribute("citesphere_token_expiry"); + throw new IllegalStateException("Citesphere token is invalid and cannot be refreshed. Please re-authenticate.", e); + } + } + + return manager; + } else { + // no authentication available + logger.error("No access token available in session - authentication required"); + throw new IllegalStateException("No Citesphere access token available. Please authenticate first."); + } + } + + // exchange authorization code for access token + private String exchangeCodeForToken(String code, HttpSession session) { + + try { + OkHttpClient client = new OkHttpClient(); + String redirectUri = getCurrentBaseUrl() + "/staff/citesphere/oauth/callback"; + String tokenUrl = citesphereApiUrl + "oauth/token"; + + okhttp3.RequestBody formBody = new FormBody.Builder() + .add("grant_type", "authorization_code") + .add("code", code) + .add("client_id", citesphereClientId) + .add("client_secret", citesphereClientSecret) + .add("redirect_uri", redirectUri) + .build(); + + Request request = new Request.Builder() + .url(tokenUrl) + .post(formBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (response.isSuccessful() && !responseBody.isEmpty()) { + ObjectMapper mapper = new ObjectMapper(); + @SuppressWarnings("unchecked") + Map tokenResponse = mapper.readValue(responseBody, Map.class); + String accessToken = (String) tokenResponse.get("access_token"); + String refreshToken = (String) tokenResponse.get("refresh_token"); + Number expiresIn = (Number) tokenResponse.get("expires_in"); + + // store additional token information in session + if (refreshToken != null) { + session.setAttribute("citesphere_refresh_token", refreshToken); + } + if (expiresIn != null) { + long expiryTime = System.currentTimeMillis() + (expiresIn.longValue() * 1000); + session.setAttribute("citesphere_token_expiry", expiryTime); + } + + return accessToken; + } else { + logger.error("Token exchange failed - Response code: {}, Body: {}", response.code(), responseBody); + } + } + } catch (Exception e) { + logger.error("Error exchanging code for token", e); + } + return null; + } + + // get current base url for redirect uri + private String getCurrentBaseUrl() { + return appBaseUrl != null && !appBaseUrl.isEmpty() ? appBaseUrl : "http://localhost:8080"; + } + + // check if api response indicates token expiry + private boolean isTokenExpiredResponse(Map response) { + if (response == null) { + return false; + } + + // check for token_expired flag + Boolean tokenExpired = (Boolean) response.get("token_expired"); + if (Boolean.TRUE.equals(tokenExpired)) { + return true; + } + + // check for error messages indicating token issues + String errorMessage = (String) response.get("error_message"); + if (errorMessage != null) { + String lowerError = errorMessage.toLowerCase(); + return lowerError.contains("invalid") && lowerError.contains("token") || + lowerError.contains("expired") && lowerError.contains("token") || + lowerError.contains("unauthorized") || + lowerError.contains("forbidden"); + } + + return false; + } + + @SuppressWarnings("unchecked") + private String extractField(Map refData, String fieldName) { + try { + Map data = (Map) refData.get("data"); + if (data != null && data.containsKey(fieldName)) { + Object value = data.get(fieldName); + String result = value != null ? value.toString() : ""; + return result; + } + } catch (Exception e) { + logger.warn("Error extracting field {}: {}", fieldName, e.getMessage()); + } + return ""; + } + + // extract creators (authors) from citesphere reference data + @SuppressWarnings("unchecked") + private String extractCreators(Map refData) { + try { + Map data = (Map) refData.get("data"); + if (data != null && data.containsKey("creators")) { + List> creators = (List>) data.get("creators"); + StringBuilder authors = new StringBuilder(); + + for (Map creator : creators) { + if (authors.length() > 0) { + authors.append("; "); + } + String firstName = creator.getOrDefault("firstName", "").toString(); + String lastName = creator.getOrDefault("lastName", "").toString(); + + if (!lastName.isEmpty()) { + authors.append(lastName); + if (!firstName.isEmpty()) { + authors.append(", ").append(firstName); + } + } else if (!firstName.isEmpty()) { + authors.append(firstName); + } + } + + return authors.toString(); + } + } catch (Exception e) { + logger.warn("Error extracting creators: {}", e.getMessage()); + } + return ""; + } + + // extract year from citesphere reference data + @SuppressWarnings("unchecked") + private String extractYear(Map refData) { + try { + Map data = (Map) refData.get("data"); + if (data != null) { + String date = extractField(refData, "date"); + if (!date.isEmpty()) { + String[] parts = date.split("-"); + if (parts.length > 0 && parts[0].matches("\\d{4}")) { + return parts[0]; + } + } + + String accessDate = extractField(refData, "accessDate"); + if (!accessDate.isEmpty()) { + String[] parts = accessDate.split("-"); + if (parts.length > 0 && parts[0].matches("\\d{4}")) { + return parts[0]; + } + } + } + } catch (Exception e) { + logger.warn("Error extracting year: {}", e.getMessage()); + } + return ""; + } +} diff --git a/vspace/src/main/resources/app.properties b/vspace/src/main/resources/app.properties index bebd58784..48e5ebf0e 100644 --- a/vspace/src/main/resources/app.properties +++ b/vspace/src/main/resources/app.properties @@ -15,3 +15,7 @@ file_uploads_directory=${files.directory.path} hibernate_show_sql=${hibernate.show_sql} admin_username=${admin.username} + +citesphere.api.url=${citesphere_api_url} +citesphere.client.id=${citesphere_client_id} +citesphere.client.secret=${citesphere_client_secret} \ No newline at end of file diff --git a/vspace/src/main/webapp/WEB-INF/views/staff/modules/slides/slide.html b/vspace/src/main/webapp/WEB-INF/views/staff/modules/slides/slide.html index d1708279e..482236481 100644 --- a/vspace/src/main/webapp/WEB-INF/views/staff/modules/slides/slide.html +++ b/vspace/src/main/webapp/WEB-INF/views/staff/modules/slides/slide.html @@ -2668,6 +2668,777 @@ $("#addVideoAlert").show(); } }); + + + var citesphereData = { + selectedReferences: [], + groups: [], + collections: {}, + currentStep: 'connection', + currentGroup: null, + currentCollection: null + }; + + // Initialize Citesphere functionality when modal is shown + $('#addReferenceAlert').on('shown.bs.modal', function () { + // Show/hide appropriate tab content and buttons based on active tab + updateReferenceModalButtons(); + + // If Citesphere tab is active, initialize step-by-step flow + if ($('#citesphereRefTab').hasClass('active')) { + initializeCitesphereSteps(); + } + }); + + // Tab switching handlers + $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { + updateReferenceModalButtons(); + + // Initialize Citesphere step-by-step flow when switching to tab + if ($(e.target).attr('href') === '#citesphereRefTab') { + initializeCitesphereSteps(); + } + }); + + // Also trigger when tab link is clicked + $(document).on('click', 'a[href="#citesphereRefTab"]', function() { + setTimeout(function() { + updateReferenceModalButtons(); + initializeCitesphereSteps(); + }, 100); + }); + + // Handle manual tab click to update buttons + $(document).on('click', 'a[href="#manualRefTab"]', function() { + setTimeout(function() { + updateReferenceModalButtons(); + }, 100); + }); + + function updateReferenceModalButtons() { + var isCitesphereTab = $('#citesphereRefTab').hasClass('active'); + + if (isCitesphereTab) { + $('#submitReference').hide(); + $('#submitCitesphereReferences').show(); + } else { + $('#submitReference').show(); + $('#submitCitesphereReferences').hide(); + } + } + + // Initialize the step-by-step Citesphere flow + function initializeCitesphereSteps() { + + // Reset step data + citesphereData.currentStep = 'connection'; + citesphereData.currentGroup = null; + citesphereData.currentCollection = null; + citesphereData.selectedReferences = []; + + // Hide success message and all steps initially + hideImportSuccessMessage(); + $('.citesphere-step').hide(); + + // Show a loading message first + $('#connectionStatusContent').html( + '
' + + ' Checking Citesphere connection...' + + '
' + ); + + // Check connection status and show appropriate step + checkCitesphereConnection(); + } + + // Check if user is connected to Citesphere + function checkCitesphereConnection() { + var token = $('input[name="_csrf"]').attr('value'); + + $.ajax({ + url: '/vspace/staff/citesphere/groups', + type: 'GET', + headers: { + 'X-CSRF-Token': token + }, + success: function(data) { + // Connected successfully + showConnectionStatus(true); + citesphereData.groups = data; + citesphereData.currentStep = 'groups'; + showStep1Groups(data); + }, + error: function(xhr, status, error) { + // Not connected or error + showConnectionStatus(false); + citesphereData.currentStep = 'connection'; + } + }); + } + + // Show import success message + function showImportSuccessMessage(message) { + var successContainer = $('#importSuccessStatus'); + + successContainer.html( + '
' + + ' ' + message + '' + + '
' + ); + + successContainer.show(); + + // Auto-hide after 5 seconds + setTimeout(function() { + successContainer.fadeOut(); + }, 5000); + } + + // Function to hide success message + function hideImportSuccessMessage() { + $('#importSuccessStatus').hide(); + } + + // Show connection status at the top + function showConnectionStatus(isConnected) { + var statusContainer = $('#connectionStatusContent'); + + if (isConnected) { + statusContainer.html( + '
' + + ' Connected to Citesphere' + + '
' + ); + } else { + statusContainer.html( + '
' + + ' Not Connected to Citesphere
' + + 'You need to authenticate to access your bibliography data.
' + + '' + + ' Connect to Citesphere' + + '' + + '
' + ); + } + } + + // Show Step 1: Groups + function showStep1Groups(groupsData) { + $('#step1-groups').show(); + displayCitesphereGroups(groupsData); + } + + // Display groups in Step 1 + function displayCitesphereGroups(groupsData) { + var groupsContainer = $('#citesphereGroups'); + groupsContainer.empty(); + + if (!groupsData || !groupsData.data || !Array.isArray(groupsData.data)) { + groupsContainer.html('
No groups found.
'); + return; + } + + groupsData.data.forEach(function(group) { + + var groupElement = $('
' + + '
' + + '
' + + '
' + (group.name || 'Unnamed Group') + '
' + + 'Click to view collections' + + '
' + + '' + + '
' + + '
'); + + // Hover effect + groupElement.hover( + function() { $(this).css('background', '#e3f2fd'); }, + function() { $(this).css('background', '#f8f9fa'); } + ); + + groupElement.click(function() { + citesphereData.currentGroup = group; + showStep2Collections(group); + }); + + groupsContainer.append(groupElement); + }); + } + + // Show Step 2: Collections + function showStep2Collections(group) { + $('#step1-groups').hide(); + $('#step2-collections').show(); + $('#currentGroupName').text('Group: ' + (group.name || 'Unnamed Group')); + citesphereData.currentStep = 'collections'; + + // Show loading message + $('#citesphereCollections').html('
Loading collections...
'); + + // Extract group ID - try multiple possible fields + var groupId = group.zoteroGroupId || group.id || group.groupId || group.key; + + loadGroupCollections(groupId, group.name); + } + + // Load collections for a specific group + function loadGroupCollections(groupId, groupName) { + var token = $('input[name="_csrf"]').attr('value'); + + $.ajax({ + url: '/vspace/staff/citesphere/groups/' + groupId + '/collections', + type: 'GET', + headers: { + 'X-CSRF-Token': token + }, + success: function(data) { + displayCollections(data, groupId, groupName); + }, + error: function(xhr, status, error) { + $('#citesphereCollections').html('
Error loading collections: ' + error + ' (Status: ' + xhr.status + ')
'); + } + }); + } + + // Display collections for a group in Step 2 + function displayCollections(collectionsData, groupId, groupName) { + var collectionsContainer = $('#citesphereCollections'); + collectionsContainer.empty(); + + // Handle both possible response structures: {data: []} or {collections: []} + var collections = collectionsData.collections || collectionsData.data || []; + + if (!collections || !Array.isArray(collections) || collections.length === 0) { + collectionsContainer.html('
No collections found in this group.
'); + return; + } + + collections.forEach(function(collection) { + + // Extract collection name and key from different possible structures + var collectionName = collection.name || (collection.data && collection.data.name) || 'Unnamed Collection'; + var collectionKey = collection.key || (collection.data && collection.data.key) || collection.id; + + var collectionElement = $('
' + + '
' + + '
' + + '
' + collectionName + '
' + + 'Click to view references' + + '
' + + '' + + '
' + + '
'); + + // Hover effect + collectionElement.hover( + function() { $(this).css('background', '#e3f2fd'); }, + function() { $(this).css('background', '#f8f9fa'); } + ); + + collectionElement.click(function() { + citesphereData.currentCollection = collection; + showStep3References(groupId, collectionKey, collectionName); + }); + + collectionsContainer.append(collectionElement); + }); + } + + // Show Step 3: References + function showStep3References(groupId, collectionId, collectionName) { + $('#step2-collections').hide(); + $('#step3-references').show(); + $('#currentCollectionName').text('Collection: ' + (collectionName || 'Unnamed Collection')); + citesphereData.currentStep = 'references'; + + // Show loading message + $('#citesphereReferences').html('
Loading references...
'); + + loadCollectionItems(groupId, collectionId, collectionName); + } + + // Navigation handlers + $(document).on('click', '#backToGroups', function() { + $('#step2-collections').hide(); + $('#step1-groups').show(); + citesphereData.currentStep = 'groups'; + citesphereData.currentGroup = null; + }); + + $(document).on('click', '#backToCollections', function() { + $('#step3-references').hide(); + $('#step2-collections').show(); + citesphereData.currentStep = 'collections'; + citesphereData.currentCollection = null; + citesphereData.selectedReferences = []; + updateSelectedCount(); + }); + + // Load items from a specific collection + function loadCollectionItems(groupId, collectionId, collectionName) { + var token = $('input[name="_csrf"]').attr('value'); + + $.ajax({ + url: '/vspace/staff/citesphere/groups/' + groupId + '/collections/' + collectionId + '/items', + type: 'GET', + headers: { + 'X-CSRF-Token': token + }, + success: function(data) { + displayIndividualItems(data, 'Items in ' + collectionName); + }, + error: function(xhr, status, error) { + $('#citesphereReferences').html('
Error loading references: ' + error + ' (Status: ' + xhr.status + ')
'); + } + }); + } + + // Display individual references/items + function displayIndividualItems(itemsData, title) { + var referencesContainer = $('#citesphereReferences'); + referencesContainer.empty(); + + var titleElement = $('
' + title + '
'); + referencesContainer.append(titleElement); + + // Handle both possible response structures: {data: []} or direct array or {items: []} + var items = itemsData.items || itemsData.data || itemsData || []; + + // If itemsData is directly an array, use it + if (Array.isArray(itemsData)) { + items = itemsData; + } + + if (!items || !Array.isArray(items) || items.length === 0) { + referencesContainer.append('
No items found.
'); + return; + } + + items.forEach(function(item, index) { + + // Extract item data - try both direct properties and nested data structure + var itemData = item.data || item; + + // Better title extraction - handle empty or missing titles + var title = item.title || itemData.title || item.referenceString || 'Untitled Reference'; + if (title.trim() === '') { + title = 'Untitled Reference'; + } + + // Better author extraction - handle the complex authors array structure + var author = ''; + if (item.authors && Array.isArray(item.authors) && item.authors.length > 0) { + // Take the first few authors and format them properly + var displayAuthors = item.authors.slice(0, 3).map(function(auth) { + var firstName = (auth.firstName || '').trim(); + var lastName = (auth.lastName || '').trim(); + + if (firstName && lastName) { + return firstName + ' ' + lastName; + } else if (lastName) { + return lastName; + } else if (firstName) { + return firstName; + } else if (auth.name) { + return auth.name.trim(); + } + return ''; + }).filter(function(name) { return name !== ''; }); + + if (displayAuthors.length > 0) { + author = displayAuthors.join(', '); + if (item.authors.length > 3) { + author += ' et al.'; + } + } + } + + // Fallback to other author fields if authors array didn't work + if (!author) { + author = item.authorString || extractAuthorsFromItem(itemData) || item.author || 'Unknown Author'; + } + + // Better year extraction + var year = ''; + if (item.date) { + var match = item.date.match(/(\d{4})/); + year = match ? match[1] : ''; + } + if (!year && item.dateFreetext) { + var match = item.dateFreetext.match(/(\d{4})/); + year = match ? match[1] : ''; + } + if (!year) { + year = item.year || extractYearFromItem(itemData) || ''; + } + + // Better type extraction + var type = 'Reference'; + if (item.itemType) { + switch(item.itemType) { + case 'JOURNAL_ARTICLE': type = 'Journal Article'; break; + case 'BOOK': type = 'Book'; break; + case 'BOOK_SECTION': type = 'Book Section'; break; + case 'CONFERENCE_PAPER': type = 'Conference Paper'; break; + default: type = item.itemType.replace(/_/g, ' '); + } + } else if (item['@type']) { + type = item['@type']; + } else if (itemData.itemType) { + type = itemData.itemType; + } + + // Use radio button instead of checkbox for single selection + var radioButton = $(''); + radioButton.data('item', item); + radioButton.attr('value', item.key || item.id || index); + + var itemElement = $('
' + + '
' + + '
' + + '
' + + '
' + title + '
' + + '
' + author + (year ? ' (' + year + ')' : '') + '
' + + '
Type: ' + type + '
' + + '
' + + '
' + + '
'); + + itemElement.find('div').first().prepend(radioButton); + + // Add hover effects + itemElement.hover( + function() { $(this).css('background-color', '#f8f9fa'); }, + function() { + if (!radioButton.prop('checked')) { + $(this).css('background-color', 'white'); + } + } + ); + + // Handle selection - allow clicking on the whole item div + itemElement.click(function(e) { + if (e.target.type !== 'radio') { + radioButton.prop('checked', true); + radioButton.trigger('change'); + } + }); + + // Handle radio button selection + radioButton.change(function() { + // Clear visual selection from all items + $('.reference-item').css({ + 'background-color': 'white', + 'border-color': '#ddd' + }); + + // Clear previous selection and set new one + citesphereData.selectedReferences = []; + if (this.checked) { + citesphereData.selectedReferences.push(item); + // Highlight selected item + itemElement.css({ + 'background-color': '#e3f2fd', + 'border-color': '#2196f3' + }); + } + updateSelectedCount(); + }); + + referencesContainer.append(itemElement); + }); + } + + // Helper function to extract authors from Citesphere item + function extractAuthorsFromItem(itemData) { + // Handle different author field structures + if (itemData.authorString) { + return itemData.authorString; + } + + if (itemData.creators && Array.isArray(itemData.creators)) { + var authors = itemData.creators.map(function(creator) { + var firstName = creator.firstName || ''; + var lastName = creator.lastName || ''; + + if (lastName && firstName) { + return lastName + ', ' + firstName; + } else if (lastName) { + return lastName; + } else if (firstName) { + return firstName; + } + return ''; + }).filter(Boolean); + + return authors.length > 0 ? authors.join('; ') : 'Unknown Author'; + } + + return 'Unknown Author'; + } + + // Helper function to extract year from Citesphere item + function extractYearFromItem(itemData) { + // Handle different year field structures + if (itemData.year) { + return itemData.year; + } + + var date = itemData.date || itemData.accessDate || ''; + if (date) { + var match = date.match(/(\d{4})/); + return match ? match[1] : ''; + } + return ''; + } + + // Update selected count display + function updateSelectedCount() { + var count = citesphereData.selectedReferences.length; + if (count === 0) { + $('#selectedCountText').text('No reference selected'); + } else { + $('#selectedCountText').text('1 reference selected'); + } + } + + // Handle search functionality + $('#searchCitesphereBtn').click(function() { + var searchTerm = $('#citesphereSearch').val().trim(); + if (searchTerm) { + // For now, just show an informative message since Citesphere API doesn't have search endpoint yet + $('#citesphereReferences').html( + '
' + + '
Search Not Available
' + + '

Search functionality is not yet available in the Citesphere API. Please browse through your groups and collections above to find references.

' + + '
' + ); + } else { + // Reload current view + loadCitesphereGroups(); + } + }); + + // Allow search on Enter key + $('#citesphereSearch').keypress(function(e) { + if (e.which === 13) { + $('#searchCitesphereBtn').click(); + } + }); + + // Dedicated function to handle Citesphere import + function handleCitesphereImport() { + + if (citesphereData.selectedReferences.length === 0) { + alert('Please select a reference to import.'); + return; + } + + if (citesphereData.selectedReferences.length > 1) { + alert('Please select only one reference at a time.'); + return; + } + + // Continue with the import process + performCitesphereImport(); + } + + // Function to perform the actual import + function performCitesphereImport() { + var biblioId = $('#addReferenceTarget').val(); + if (!biblioId) { + alert('Error: Could not determine bibliography block.'); + return; + } + + + // Transform the data to match the expected backend format + var transformedReferences = citesphereData.selectedReferences.map(function(item) { + + // Extract creators from the authors array or fallback to authorString + var creators = []; + + if (item.authors && Array.isArray(item.authors)) { + creators = item.authors.map(function(author) { + return { + firstName: author.firstName || '', + lastName: author.lastName || author.name || '', + creatorType: 'author' + }; + }); + } else if (item.authorString) { + // Fallback to authorString parsing + var authors = item.authorString.split(';'); + creators = authors.map(function(author) { + var trimmedAuthor = author.trim(); + if (trimmedAuthor.includes(',')) { + var parts = trimmedAuthor.split(','); + return { + lastName: parts[0].trim(), + firstName: parts[1] ? parts[1].trim() : '', + creatorType: 'author' + }; + } else { + return { + lastName: trimmedAuthor, + firstName: '', + creatorType: 'author' + }; + } + }); + } + + // Extract date from various possible fields + var dateValue = ''; + if (item.date) { + dateValue = item.date; + } else if (item.dateFreetext) { + dateValue = item.dateFreetext; + } else if (item.year) { + dateValue = item.year; + } + + // Map itemType from Citesphere to Zotero format + var itemType = 'journalArticle'; // default + if (item.itemType) { + switch(item.itemType) { + case 'JOURNAL_ARTICLE': itemType = 'journalArticle'; break; + case 'BOOK': itemType = 'book'; break; + case 'BOOK_SECTION': itemType = 'bookSection'; break; + case 'CONFERENCE_PAPER': itemType = 'conferencePaper'; break; + default: itemType = 'journalArticle'; + } + } else if (item['@type']) { + itemType = item['@type']; + } + + // Create the nested data structure expected by the backend + + var transformedItem = { + data: { + title: item.title || 'Untitled', + itemType: itemType, + date: dateValue, + publicationTitle: item.publicationTitle || item.source || '', + url: item.url || '', + volume: item.volume || '', + issue: item.issue || '', + pages: item.pages || (item.firstPage && item.endPage ? item.firstPage + '-' + item.endPage : (item.firstPage || '')), + DOI: item.doi || (item.identifier && item.identifierType === 'doi' ? item.identifier : ''), + abstractNote: item.abstractNote || '', + language: item.language || '', + ISSN: item.issn || '', + journalAbbreviation: item.journalAbbreviation || '', + series: item.series || '', + seriesTitle: item.seriesTitle || '', + archive: item.archive || '', + archiveLocation: item.archiveLocation || '', + callNumber: item.callNumber || '', + rights: item.rights || '', + shortTitle: item.shortTitle || '', + note: item.note || item.referenceString || '', + extra: 'Imported from Citesphere - Key: ' + (item.key || item.id || ''), + creators: creators + } + }; + + return transformedItem; + }); + + var moduleId = '[[${module.id}]]'; + var slideId = '[[${slide.id}]]'; + var token = $('input[name="_csrf"]').attr('value'); + + + $.ajax({ + url: '/vspace/staff/module/' + moduleId + '/slide/' + slideId + '/bibliography/' + biblioId + '/citesphere/import', + type: 'POST', + contentType: 'application/json', + headers: { + 'X-CSRF-Token': token + }, + data: JSON.stringify(transformedReferences), + success: function(response) { + + if (response.success) { + // Show success message instead of alert + showImportSuccessMessage('Bibliography imported successfully!'); + + // Add the imported references to the bibliography block + var biblioBlock = $('[data-biblio-id="' + biblioId + '"]').closest('[data-biblio-block]'); + var referenceSpace = biblioBlock.find('#referenceSpace'); + + if (response.references && Array.isArray(response.references)) { + response.references.forEach(function(ref, index) { + + // Use the same format as existing references in the application + var referenceHtml = '
' + + '

' + + 'Reference Title: ' + (ref.title || 'NO TITLE') + ', ' + + 'Author: ' + (ref.author || 'NO AUTHOR') + ', ' + + 'Year: ' + (ref.year || 'NO YEAR') + ', ' + + 'Journal: ' + (ref.journal || 'NO JOURNAL') + ', ' + + 'Url: ' + (ref.url || '') + ', ' + + 'Volume: ' + (ref.volume || '') + ', ' + + 'Issue: ' + (ref.issue || '') + ', ' + + 'Pages: ' + (ref.pages || '') + ', ' + + 'Editors: ' + (ref.editors || '') + ', ' + + 'Type: ' + (ref.type || '') + ', ' + + 'Note: ' + (ref.note || '') + '' + + '

' + + '' + + '' + + '' + + '' + + '
'; + + var refElement = $(referenceHtml); + refElement.mouseenter(onMouseEnter).mouseleave(onMouseLeave).dblclick(referenceBlockDoubleClick); + referenceSpace.append(refElement); + }); + } + + // Reset and close modal + citesphereData.selectedReferences = []; + updateSelectedCount(); + $('#addReferenceAlert').modal('hide'); + } else { + alert('Error importing references: ' + (response.error || 'Unknown error')); + } + }, + error: function(xhr, status, error) { + alert('Error importing references: ' + error + ' (Status: ' + xhr.status + ')'); + } + }); + } + + // Handle importing selected reference (jQuery event handler for compatibility) + $('#submitCitesphereReferences').click(function() { + handleCitesphereImport(); + }); + + // Clear Citesphere data when modal is hidden + $('#addReferenceAlert').on('hidden.bs.modal', function () { + // Reset Citesphere data + citesphereData.selectedReferences = []; + citesphereData.currentStep = 'connection'; + citesphereData.currentGroup = null; + citesphereData.currentCollection = null; + updateSelectedCount(); + + // Hide success message + hideImportSuccessMessage(); + + // Clear all containers + $('#citesphereGroups').empty(); + $('#citesphereCollections').empty(); + $('#citesphereReferences').empty(); + $('#connectionStatusContent').empty(); + + // Hide all steps + $('.citesphere-step').hide(); + }); + @@ -3133,145 +3904,249 @@ +