Library Switching functionality
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package com.storycove.config;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* Database configuration that sets up library-aware datasource routing.
|
||||
*
|
||||
* This configuration replaces the default Spring Boot datasource with a routing
|
||||
* datasource that automatically directs all database operations to the appropriate
|
||||
* library-specific database based on the current active library.
|
||||
*/
|
||||
@Configuration
|
||||
public class DatabaseConfig {
|
||||
|
||||
@Value("${spring.datasource.url}")
|
||||
private String baseDbUrl;
|
||||
|
||||
@Value("${spring.datasource.username}")
|
||||
private String dbUsername;
|
||||
|
||||
@Value("${spring.datasource.password}")
|
||||
private String dbPassword;
|
||||
|
||||
/**
|
||||
* Create a fallback datasource for when no library is active.
|
||||
* This connects to the main database specified in application.yml.
|
||||
*/
|
||||
@Bean(name = "fallbackDataSource")
|
||||
public DataSource fallbackDataSource() {
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(baseDbUrl);
|
||||
config.setUsername(dbUsername);
|
||||
config.setPassword(dbPassword);
|
||||
config.setDriverClassName("org.postgresql.Driver");
|
||||
config.setMaximumPoolSize(10);
|
||||
config.setConnectionTimeout(30000);
|
||||
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary datasource bean - using fallback for stability.
|
||||
* Library-specific routing will be handled at the service layer instead.
|
||||
*/
|
||||
@Bean(name = "dataSource")
|
||||
@Primary
|
||||
public DataSource primaryDataSource() {
|
||||
return fallbackDataSource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional routing datasource for future use if needed
|
||||
*/
|
||||
@Bean(name = "libraryRoutingDataSource")
|
||||
public DataSource libraryAwareDataSource(LibraryService libraryService) {
|
||||
LibraryAwareDataSource routingDataSource = new LibraryAwareDataSource(libraryService);
|
||||
routingDataSource.setDefaultTargetDataSource(fallbackDataSource());
|
||||
return routingDataSource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.storycove.config;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||
|
||||
/**
|
||||
* Custom DataSource router that dynamically routes database calls to the appropriate
|
||||
* library-specific datasource based on the current active library.
|
||||
*
|
||||
* This makes ALL Spring Data JPA repositories automatically library-aware without
|
||||
* requiring changes to existing repository or service code.
|
||||
*/
|
||||
public class LibraryAwareDataSource extends AbstractRoutingDataSource {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LibraryAwareDataSource.class);
|
||||
|
||||
private final LibraryService libraryService;
|
||||
|
||||
public LibraryAwareDataSource(LibraryService libraryService) {
|
||||
this.libraryService = libraryService;
|
||||
// Set empty target datasources to satisfy AbstractRoutingDataSource requirements
|
||||
// We override determineTargetDataSource() so this won't be used
|
||||
setTargetDataSources(new java.util.HashMap<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object determineCurrentLookupKey() {
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
logger.debug("Routing database call to library: {}", currentLibraryId);
|
||||
return currentLibraryId;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected javax.sql.DataSource determineTargetDataSource() {
|
||||
try {
|
||||
// Check if LibraryService is properly initialized
|
||||
if (libraryService == null) {
|
||||
logger.debug("LibraryService not available, using default datasource");
|
||||
return getResolvedDefaultDataSource();
|
||||
}
|
||||
|
||||
// Check if any library is currently active
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
if (currentLibraryId == null) {
|
||||
logger.debug("No active library, using default datasource");
|
||||
return getResolvedDefaultDataSource();
|
||||
}
|
||||
|
||||
// Try to get the current library datasource
|
||||
javax.sql.DataSource libraryDataSource = libraryService.getCurrentDataSource();
|
||||
logger.debug("Successfully routing database call to library: {}", currentLibraryId);
|
||||
return libraryDataSource;
|
||||
|
||||
} catch (IllegalStateException e) {
|
||||
// This is expected during authentication, startup, or when no library is active
|
||||
logger.debug("No active library (IllegalStateException) - using default datasource: {}", e.getMessage());
|
||||
return getResolvedDefaultDataSource();
|
||||
} catch (Exception e) {
|
||||
logger.warn("Unexpected error determining target datasource, falling back to default: {}", e.getMessage(), e);
|
||||
return getResolvedDefaultDataSource();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
|
||||
if (passwordService.authenticate(request.getPassword())) {
|
||||
String token = jwtUtil.generateToken();
|
||||
|
||||
// Use new library-aware authentication
|
||||
String token = passwordService.authenticateAndSwitchLibrary(request.getPassword());
|
||||
|
||||
if (token != null) {
|
||||
// Set httpOnly cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", token)
|
||||
.httpOnly(true)
|
||||
@@ -40,7 +41,8 @@ public class AuthController {
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse("Authentication successful", token));
|
||||
String libraryInfo = passwordService.getCurrentLibraryInfo();
|
||||
return ResponseEntity.ok(new LoginResponse("Authentication successful - " + libraryInfo, token));
|
||||
} else {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.LibraryDto;
|
||||
import com.storycove.service.LibraryService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/libraries")
|
||||
public class LibraryController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LibraryController.class);
|
||||
|
||||
private final LibraryService libraryService;
|
||||
|
||||
@Autowired
|
||||
public LibraryController(LibraryService libraryService) {
|
||||
this.libraryService = libraryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available libraries (for settings UI)
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<LibraryDto>> getAllLibraries() {
|
||||
try {
|
||||
List<LibraryDto> libraries = libraryService.getAllLibraries();
|
||||
return ResponseEntity.ok(libraries);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get libraries", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active library info
|
||||
*/
|
||||
@GetMapping("/current")
|
||||
public ResponseEntity<LibraryDto> getCurrentLibrary() {
|
||||
try {
|
||||
var library = libraryService.getCurrentLibrary();
|
||||
if (library == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
LibraryDto dto = new LibraryDto(
|
||||
library.getId(),
|
||||
library.getName(),
|
||||
library.getDescription(),
|
||||
true, // always active since it's current
|
||||
library.isInitialized()
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(dto);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get current library", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different library (requires re-authentication)
|
||||
* This endpoint returns a switching status that the frontend can poll
|
||||
*/
|
||||
@PostMapping("/switch")
|
||||
public ResponseEntity<Map<String, Object>> initiateLibrarySwitch(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String password = request.get("password");
|
||||
if (password == null || password.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Password required"));
|
||||
}
|
||||
|
||||
String libraryId = libraryService.authenticateAndGetLibrary(password);
|
||||
if (libraryId == null) {
|
||||
return ResponseEntity.status(401).body(Map.of("error", "Invalid password"));
|
||||
}
|
||||
|
||||
// Check if already on this library
|
||||
if (libraryId.equals(libraryService.getCurrentLibraryId())) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "already_active",
|
||||
"message", "Already using this library"
|
||||
));
|
||||
}
|
||||
|
||||
// Initiate switch in background thread
|
||||
new Thread(() -> {
|
||||
try {
|
||||
libraryService.switchToLibrary(libraryId);
|
||||
logger.info("Library switch completed: {}", libraryId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Library switch failed: {}", libraryId, e);
|
||||
}
|
||||
}).start();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "switching",
|
||||
"targetLibrary", libraryId,
|
||||
"message", "Switching to library, please wait..."
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initiate library switch", e);
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check library switch status
|
||||
*/
|
||||
@GetMapping("/switch/status")
|
||||
public ResponseEntity<Map<String, Object>> getLibrarySwitchStatus() {
|
||||
try {
|
||||
var currentLibrary = libraryService.getCurrentLibrary();
|
||||
boolean isReady = currentLibrary != null;
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("ready", isReady);
|
||||
if (isReady) {
|
||||
response.put("currentLibrary", currentLibrary.getId());
|
||||
response.put("currentLibraryName", currentLibrary.getName());
|
||||
} else {
|
||||
response.put("currentLibrary", null);
|
||||
response.put("currentLibraryName", null);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get switch status", e);
|
||||
return ResponseEntity.ok(Map.of("ready", false, "error", "Status check failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password for current library
|
||||
*/
|
||||
@PostMapping("/password")
|
||||
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String currentPassword = request.get("currentPassword");
|
||||
String newPassword = request.get("newPassword");
|
||||
|
||||
if (currentPassword == null || newPassword == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Current and new passwords required"));
|
||||
}
|
||||
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
if (currentLibraryId == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No active library"));
|
||||
}
|
||||
|
||||
boolean success = libraryService.changeLibraryPassword(currentLibraryId, currentPassword, newPassword);
|
||||
if (success) {
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Password changed successfully"));
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Current password is incorrect"));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to change password", e);
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new library
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public ResponseEntity<Map<String, Object>> createLibrary(@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
String name = request.get("name");
|
||||
String description = request.get("description");
|
||||
String password = request.get("password");
|
||||
|
||||
if (name == null || name.trim().isEmpty() || password == null || password.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Name and password are required"));
|
||||
}
|
||||
|
||||
var newLibrary = libraryService.createNewLibrary(name.trim(), description, password);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"library", Map.of(
|
||||
"id", newLibrary.getId(),
|
||||
"name", newLibrary.getName(),
|
||||
"description", newLibrary.getDescription()
|
||||
),
|
||||
"message", "Library created successfully. You can now log in with the new password to access it."
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to create library", e);
|
||||
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
61
backend/src/main/java/com/storycove/dto/LibraryDto.java
Normal file
61
backend/src/main/java/com/storycove/dto/LibraryDto.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
public class LibraryDto {
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private boolean isActive;
|
||||
private boolean isInitialized;
|
||||
|
||||
// Constructors
|
||||
public LibraryDto() {}
|
||||
|
||||
public LibraryDto(String id, String name, String description, boolean isActive, boolean isInitialized) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.isActive = isActive;
|
||||
this.isInitialized = isInitialized;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
isActive = active;
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return isInitialized;
|
||||
}
|
||||
|
||||
public void setInitialized(boolean initialized) {
|
||||
isInitialized = initialized;
|
||||
}
|
||||
}
|
||||
93
backend/src/main/java/com/storycove/entity/Library.java
Normal file
93
backend/src/main/java/com/storycove/entity/Library.java
Normal file
@@ -0,0 +1,93 @@
|
||||
package com.storycove.entity;
|
||||
|
||||
public class Library {
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String passwordHash;
|
||||
private String dbName;
|
||||
private String typesenseCollection;
|
||||
private String imagePath;
|
||||
private boolean initialized;
|
||||
|
||||
// Constructors
|
||||
public Library() {}
|
||||
|
||||
public Library(String id, String name, String description, String passwordHash, String dbName) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.passwordHash = passwordHash;
|
||||
this.dbName = dbName;
|
||||
this.typesenseCollection = "stories_" + id;
|
||||
this.imagePath = "/images/" + id;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
this.typesenseCollection = "stories_" + id;
|
||||
this.imagePath = "/images/" + id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public String getDbName() {
|
||||
return dbName;
|
||||
}
|
||||
|
||||
public void setDbName(String dbName) {
|
||||
this.dbName = dbName;
|
||||
}
|
||||
|
||||
public String getTypesenseCollection() {
|
||||
return typesenseCollection;
|
||||
}
|
||||
|
||||
public void setTypesenseCollection(String typesenseCollection) {
|
||||
this.typesenseCollection = typesenseCollection;
|
||||
}
|
||||
|
||||
public String getImagePath() {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
public void setImagePath(String imagePath) {
|
||||
this.imagePath = imagePath;
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
public void setInitialized(boolean initialized) {
|
||||
this.initialized = initialized;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.storycove.security;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -28,13 +29,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String token = null;
|
||||
|
||||
// First try to get token from Authorization header
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
// If no token in header, try to get from cookies
|
||||
if (token == null) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies != null) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if ("token".equals(cookie.getName())) {
|
||||
token = cookie.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
|
||||
String subject = jwtUtil.getSubjectFromToken(token);
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -29,11 +31,13 @@ public class AuthorService {
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService) {
|
||||
public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService, LibraryAwareService libraryAwareService) {
|
||||
this.authorRepository = authorRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -63,8 +67,36 @@ public class AuthorService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Author findById(UUID id) {
|
||||
return authorRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
|
||||
// Smart routing: use library-specific database if available, otherwise use repository
|
||||
if (libraryAwareService.hasActiveLibrary()) {
|
||||
return findByIdFromCurrentLibrary(id);
|
||||
} else {
|
||||
return authorRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find author by ID using the current library's database connection
|
||||
*/
|
||||
private Author findByIdFromCurrentLibrary(UUID id) {
|
||||
try (var connection = libraryAwareService.getCurrentLibraryConnection()) {
|
||||
String query = "SELECT id, name, notes, avatar_image_path, author_rating, created_at, updated_at FROM authors WHERE id = ?";
|
||||
|
||||
try (var stmt = connection.prepareStatement(query)) {
|
||||
stmt.setObject(1, id);
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return createAuthorFromResultSet(rs);
|
||||
} else {
|
||||
throw new ResourceNotFoundException("Author", id.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to find author by ID from current library: {}", id, e);
|
||||
throw new RuntimeException("Database error while fetching author", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -362,4 +394,32 @@ public class AuthorService {
|
||||
existing.getUrls().addAll(urlsCopy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Author entity from ResultSet for library-aware queries
|
||||
*/
|
||||
private Author createAuthorFromResultSet(ResultSet rs) throws SQLException {
|
||||
var author = new Author();
|
||||
author.setId(UUID.fromString(rs.getString("id")));
|
||||
author.setName(rs.getString("name"));
|
||||
author.setNotes(rs.getString("notes"));
|
||||
author.setAvatarImagePath(rs.getString("avatar_image_path"));
|
||||
|
||||
Integer rating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(rating);
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
author.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
author.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
return author;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class CollectionService {
|
||||
private final TagRepository tagRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public CollectionService(CollectionRepository collectionRepository,
|
||||
@@ -40,13 +41,15 @@ public class CollectionService {
|
||||
StoryRepository storyRepository,
|
||||
TagRepository tagRepository,
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService) {
|
||||
ReadingTimeService readingTimeService,
|
||||
LibraryAwareService libraryAwareService) {
|
||||
this.collectionRepository = collectionRepository;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,8 +24,10 @@ import java.util.zip.ZipOutputStream;
|
||||
@Service
|
||||
public class DatabaseManagementService {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
// DataSource is now dynamic based on active library
|
||||
private DataSource getDataSource() {
|
||||
return libraryService.getCurrentDataSource();
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private StoryRepository storyRepository;
|
||||
@@ -44,6 +46,9 @@ public class DatabaseManagementService {
|
||||
|
||||
@Autowired
|
||||
private TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
@Autowired
|
||||
private ReadingPositionRepository readingPositionRepository;
|
||||
@@ -79,7 +84,12 @@ public class DatabaseManagementService {
|
||||
* Restore from complete backup (ZIP format)
|
||||
*/
|
||||
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
||||
System.err.println("Starting complete backup restore...");
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
System.err.println("Starting complete backup restore for library: " + currentLibraryId);
|
||||
if (currentLibraryId == null) {
|
||||
throw new IllegalStateException("No current library active - please authenticate and select a library first");
|
||||
}
|
||||
|
||||
Path tempDir = Files.createTempDirectory("storycove-restore");
|
||||
System.err.println("Created temp directory: " + tempDir);
|
||||
|
||||
@@ -138,7 +148,7 @@ public class DatabaseManagementService {
|
||||
public Resource createBackup() throws SQLException, IOException {
|
||||
StringBuilder sqlDump = new StringBuilder();
|
||||
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
// Add header
|
||||
sqlDump.append("-- StoryCove Database Backup\n");
|
||||
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
|
||||
@@ -224,10 +234,13 @@ public class DatabaseManagementService {
|
||||
}
|
||||
|
||||
// Execute the SQL statements
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
// Ensure database schema exists before restoring data
|
||||
ensureDatabaseSchemaExists(connection);
|
||||
|
||||
// Parse SQL statements properly (handle semicolons inside string literals)
|
||||
List<String> statements = parseStatements(sqlContent.toString());
|
||||
|
||||
@@ -260,11 +273,19 @@ public class DatabaseManagementService {
|
||||
|
||||
// Reindex search after successful restore
|
||||
try {
|
||||
System.err.println("Starting Typesense reindex after successful restore...");
|
||||
typesenseService.recreateStoriesCollection();
|
||||
typesenseService.recreateAuthorsCollection();
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
System.err.println("Starting Typesense reindex after successful restore for library: " + currentLibraryId);
|
||||
if (currentLibraryId == null) {
|
||||
System.err.println("ERROR: No current library set during restore - cannot reindex Typesense!");
|
||||
throw new IllegalStateException("No current library active during restore");
|
||||
}
|
||||
|
||||
// Manually trigger reindexing using the correct database connection
|
||||
System.err.println("Triggering manual reindex from library-specific database for library: " + currentLibraryId);
|
||||
reindexStoriesAndAuthorsFromCurrentDatabase();
|
||||
|
||||
// Note: Collections collection will be recreated when needed by the service
|
||||
System.err.println("Typesense reindex completed successfully.");
|
||||
System.err.println("Typesense reindex completed successfully for library: " + currentLibraryId);
|
||||
} catch (Exception e) {
|
||||
// Log the error but don't fail the restore
|
||||
System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage());
|
||||
@@ -418,10 +439,14 @@ public class DatabaseManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all uploaded files
|
||||
* Clear all uploaded files for the current library
|
||||
*/
|
||||
private void clearAllFiles() {
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
// Use library-specific image path
|
||||
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||
|
||||
System.err.println("Clearing files for library: " + libraryService.getCurrentLibraryId() + " at path: " + imagesPath);
|
||||
|
||||
if (Files.exists(imagesPath)) {
|
||||
try {
|
||||
@@ -430,6 +455,7 @@ public class DatabaseManagementService {
|
||||
.forEach(filePath -> {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
System.err.println("Deleted file: " + filePath);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to delete file: " + filePath + " - " + e.getMessage());
|
||||
}
|
||||
@@ -437,19 +463,28 @@ public class DatabaseManagementService {
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to clear files directory: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
System.err.println("Library image directory does not exist: " + imagesPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search indexes
|
||||
* Clear search indexes (recreate empty collections)
|
||||
*/
|
||||
private void clearSearchIndexes() {
|
||||
try {
|
||||
System.err.println("Clearing search indexes after complete clear...");
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
System.err.println("Clearing search indexes after complete clear for library: " + currentLibraryId);
|
||||
if (currentLibraryId == null) {
|
||||
System.err.println("WARNING: No current library set during clear - skipping search index clear");
|
||||
return;
|
||||
}
|
||||
|
||||
// For clearing, we only want to recreate empty collections (no data to index)
|
||||
typesenseService.recreateStoriesCollection();
|
||||
typesenseService.recreateAuthorsCollection();
|
||||
// Note: Collections collection will be recreated when needed by the service
|
||||
System.err.println("Search indexes cleared successfully.");
|
||||
System.err.println("Search indexes cleared successfully for library: " + currentLibraryId);
|
||||
} catch (Exception e) {
|
||||
// Log the error but don't fail the clear operation
|
||||
System.err.println("Warning: Failed to clear search indexes: " + e.getMessage());
|
||||
@@ -457,6 +492,219 @@ public class DatabaseManagementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure database schema exists before restoring backup data.
|
||||
* This creates all necessary tables, indexes, and constraints if they don't exist.
|
||||
*/
|
||||
private void ensureDatabaseSchemaExists(Connection connection) throws SQLException {
|
||||
try {
|
||||
// Check if a key table exists to determine if schema is already created
|
||||
String checkTableQuery = "SELECT 1 FROM information_schema.tables WHERE table_name = 'stories' LIMIT 1";
|
||||
try (PreparedStatement stmt = connection.prepareStatement(checkTableQuery);
|
||||
var resultSet = stmt.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
System.err.println("Database schema already exists, skipping schema creation.");
|
||||
return; // Schema exists
|
||||
}
|
||||
}
|
||||
|
||||
System.err.println("Creating database schema for restore in library: " + libraryService.getCurrentLibraryId());
|
||||
|
||||
// Create the schema using the same DDL as LibraryService
|
||||
String[] createTableStatements = {
|
||||
// Authors table
|
||||
"""
|
||||
CREATE TABLE authors (
|
||||
author_rating integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
avatar_image_path varchar(255),
|
||||
name varchar(255) not null,
|
||||
notes TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Author URLs table
|
||||
"""
|
||||
CREATE TABLE author_urls (
|
||||
author_id uuid not null,
|
||||
url varchar(255)
|
||||
)
|
||||
""",
|
||||
|
||||
// Series table
|
||||
"""
|
||||
CREATE TABLE series (
|
||||
created_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
description varchar(1000),
|
||||
name varchar(255) not null,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Tags table
|
||||
"""
|
||||
CREATE TABLE tags (
|
||||
color varchar(7),
|
||||
created_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
description varchar(500),
|
||||
name varchar(255) not null unique,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Tag aliases table
|
||||
"""
|
||||
CREATE TABLE tag_aliases (
|
||||
created_from_merge boolean not null,
|
||||
created_at timestamp(6) not null,
|
||||
canonical_tag_id uuid not null,
|
||||
id uuid not null,
|
||||
alias_name varchar(255) not null unique,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Collections table
|
||||
"""
|
||||
CREATE TABLE collections (
|
||||
is_archived boolean not null,
|
||||
rating integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
cover_image_path varchar(500),
|
||||
name varchar(500) not null,
|
||||
description TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Stories table
|
||||
"""
|
||||
CREATE TABLE stories (
|
||||
is_read boolean,
|
||||
rating integer,
|
||||
reading_position integer,
|
||||
volume integer,
|
||||
word_count integer,
|
||||
created_at timestamp(6) not null,
|
||||
last_read_at timestamp(6),
|
||||
updated_at timestamp(6) not null,
|
||||
author_id uuid,
|
||||
id uuid not null,
|
||||
series_id uuid,
|
||||
description varchar(1000),
|
||||
content_html TEXT,
|
||||
content_plain TEXT,
|
||||
cover_path varchar(255),
|
||||
source_url varchar(255),
|
||||
summary TEXT,
|
||||
title varchar(255) not null,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Reading positions table
|
||||
"""
|
||||
CREATE TABLE reading_positions (
|
||||
chapter_index integer,
|
||||
character_position integer,
|
||||
percentage_complete float(53),
|
||||
word_position integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
story_id uuid not null,
|
||||
context_after varchar(500),
|
||||
context_before varchar(500),
|
||||
chapter_title varchar(255),
|
||||
epub_cfi TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Junction tables
|
||||
"""
|
||||
CREATE TABLE story_tags (
|
||||
story_id uuid not null,
|
||||
tag_id uuid not null,
|
||||
primary key (story_id, tag_id)
|
||||
)
|
||||
""",
|
||||
|
||||
"""
|
||||
CREATE TABLE collection_stories (
|
||||
position integer not null,
|
||||
added_at timestamp(6) not null,
|
||||
collection_id uuid not null,
|
||||
story_id uuid not null,
|
||||
primary key (collection_id, story_id),
|
||||
unique (collection_id, position)
|
||||
)
|
||||
""",
|
||||
|
||||
"""
|
||||
CREATE TABLE collection_tags (
|
||||
collection_id uuid not null,
|
||||
tag_id uuid not null,
|
||||
primary key (collection_id, tag_id)
|
||||
)
|
||||
"""
|
||||
};
|
||||
|
||||
String[] createIndexStatements = {
|
||||
"CREATE INDEX idx_reading_position_story ON reading_positions (story_id)"
|
||||
};
|
||||
|
||||
String[] createConstraintStatements = {
|
||||
// Foreign key constraints
|
||||
"ALTER TABLE author_urls ADD CONSTRAINT FKdqhp51m0uveybsts098gd79uo FOREIGN KEY (author_id) REFERENCES authors",
|
||||
"ALTER TABLE stories ADD CONSTRAINT FKhwecpqeaxy40ftrctef1u7gw7 FOREIGN KEY (author_id) REFERENCES authors",
|
||||
"ALTER TABLE stories ADD CONSTRAINT FK1kulyvy7wwcolp2gkndt57cp7 FOREIGN KEY (series_id) REFERENCES series",
|
||||
"ALTER TABLE reading_positions ADD CONSTRAINT FKglfhdhflan3pgyr2u0gxi21i5 FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE story_tags ADD CONSTRAINT FKmans33ijt0nf65t0sng2r848j FOREIGN KEY (tag_id) REFERENCES tags",
|
||||
"ALTER TABLE story_tags ADD CONSTRAINT FKq9guid7swnjxwdpgxj3jo1rsi FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE tag_aliases ADD CONSTRAINT FKqfsawmcj3ey4yycb6958y24ch FOREIGN KEY (canonical_tag_id) REFERENCES tags",
|
||||
"ALTER TABLE collection_stories ADD CONSTRAINT FKr55ho4vhj0wp03x13iskr1jds FOREIGN KEY (collection_id) REFERENCES collections",
|
||||
"ALTER TABLE collection_stories ADD CONSTRAINT FK7n41tbbrt7r2e81hpu3612r1o FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE collection_tags ADD CONSTRAINT FKceq7ggev8n8ibjui1x5yo4x67 FOREIGN KEY (tag_id) REFERENCES tags",
|
||||
"ALTER TABLE collection_tags ADD CONSTRAINT FKq9sa5s8csdpbphrvb48tts8jt FOREIGN KEY (collection_id) REFERENCES collections"
|
||||
};
|
||||
|
||||
// Create tables
|
||||
for (String sql : createTableStatements) {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
for (String sql : createIndexStatements) {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
}
|
||||
|
||||
// Create constraints
|
||||
for (String sql : createConstraintStatements) {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
}
|
||||
|
||||
System.err.println("Database schema created successfully for restore.");
|
||||
|
||||
} catch (SQLException e) {
|
||||
System.err.println("Error creating database schema: " + e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add database dump to ZIP archive
|
||||
*/
|
||||
@@ -478,12 +726,17 @@ public class DatabaseManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all files to ZIP archive
|
||||
* Add all files to ZIP archive for the current library
|
||||
*/
|
||||
private void addFilesToZip(ZipOutputStream zipOut) throws IOException {
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
// Use library-specific image path
|
||||
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||
|
||||
System.err.println("Adding files to backup for library: " + libraryService.getCurrentLibraryId() + " from path: " + imagesPath);
|
||||
|
||||
if (!Files.exists(imagesPath)) {
|
||||
System.err.println("Library image directory does not exist, skipping file backup: " + imagesPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -498,6 +751,7 @@ public class DatabaseManagementService {
|
||||
zipOut.putNextEntry(entry);
|
||||
Files.copy(filePath, zipOut);
|
||||
zipOut.closeEntry();
|
||||
System.err.println("Added file to backup: " + zipEntryName);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to add file to backup: " + filePath, e);
|
||||
}
|
||||
@@ -514,9 +768,19 @@ public class DatabaseManagementService {
|
||||
metadata.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
metadata.put("generator", "StoryCove Database Management Service");
|
||||
|
||||
// Add library information
|
||||
var currentLibrary = libraryService.getCurrentLibrary();
|
||||
if (currentLibrary != null) {
|
||||
Map<String, Object> libraryInfo = new HashMap<>();
|
||||
libraryInfo.put("id", currentLibrary.getId());
|
||||
libraryInfo.put("name", currentLibrary.getName());
|
||||
libraryInfo.put("description", currentLibrary.getDescription());
|
||||
metadata.put("library", libraryInfo);
|
||||
}
|
||||
|
||||
// Add statistics
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
stats.put("stories", getTableCount(connection, "stories"));
|
||||
stats.put("authors", getTableCount(connection, "authors"));
|
||||
stats.put("collections", getTableCount(connection, "collections"));
|
||||
@@ -525,8 +789,9 @@ public class DatabaseManagementService {
|
||||
}
|
||||
metadata.put("statistics", stats);
|
||||
|
||||
// Count files
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
// Count files for current library
|
||||
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||
int fileCount = 0;
|
||||
if (Files.exists(imagesPath)) {
|
||||
fileCount = (int) Files.walk(imagesPath).filter(Files::isRegularFile).count();
|
||||
@@ -605,10 +870,14 @@ public class DatabaseManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore files from backup
|
||||
* Restore files from backup to the current library's directory
|
||||
*/
|
||||
private void restoreFiles(Path filesDir) throws IOException {
|
||||
Path targetDir = Paths.get(uploadDir);
|
||||
// Use library-specific image path
|
||||
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||
Path targetDir = Paths.get(uploadDir + libraryImagePath);
|
||||
|
||||
System.err.println("Restoring files for library: " + libraryService.getCurrentLibraryId() + " to path: " + targetDir);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
Files.walk(filesDir)
|
||||
@@ -620,6 +889,7 @@ public class DatabaseManagementService {
|
||||
|
||||
Files.createDirectories(targetFile.getParent());
|
||||
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
System.err.println("Restored file: " + relativePath + " to " + targetFile);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to restore file: " + sourceFile, e);
|
||||
}
|
||||
@@ -655,4 +925,169 @@ public class DatabaseManagementService {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reindex stories and authors from the current library's database
|
||||
* This bypasses the repository layer and uses direct database access
|
||||
*/
|
||||
private void reindexStoriesAndAuthorsFromCurrentDatabase() throws SQLException {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
// First, recreate empty collections
|
||||
try {
|
||||
typesenseService.recreateStoriesCollection();
|
||||
typesenseService.recreateAuthorsCollection();
|
||||
} catch (Exception e) {
|
||||
throw new SQLException("Failed to recreate Typesense collections", e);
|
||||
}
|
||||
|
||||
// Count and reindex stories with full author and series information
|
||||
int storyCount = 0;
|
||||
String storyQuery = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " +
|
||||
"s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " +
|
||||
"s.created_at, s.updated_at, " +
|
||||
"a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " +
|
||||
"a.created_at as author_created_at, a.updated_at as author_updated_at, " +
|
||||
"ser.name as series_name, ser.description as series_description, " +
|
||||
"ser.created_at as series_created_at " +
|
||||
"FROM stories s " +
|
||||
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||
"LEFT JOIN series ser ON s.series_id = ser.id";
|
||||
|
||||
try (PreparedStatement stmt = connection.prepareStatement(storyQuery);
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
|
||||
while (rs.next()) {
|
||||
// Create a complete Story object for indexing
|
||||
var story = createStoryFromResultSet(rs);
|
||||
typesenseService.indexStory(story);
|
||||
storyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count and reindex authors
|
||||
int authorCount = 0;
|
||||
String authorQuery = "SELECT id, name, notes, avatar_image_path, author_rating, created_at, updated_at FROM authors";
|
||||
|
||||
try (PreparedStatement stmt = connection.prepareStatement(authorQuery);
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
|
||||
while (rs.next()) {
|
||||
// Create a minimal Author object for indexing
|
||||
var author = createAuthorFromResultSet(rs);
|
||||
typesenseService.indexAuthor(author);
|
||||
authorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
System.err.println("Reindexed " + storyCount + " stories and " + authorCount + " authors from library database");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Story entity from ResultSet for indexing purposes (includes joined author/series data)
|
||||
*/
|
||||
private com.storycove.entity.Story createStoryFromResultSet(ResultSet rs) throws SQLException {
|
||||
var story = new com.storycove.entity.Story();
|
||||
story.setId(UUID.fromString(rs.getString("id")));
|
||||
story.setTitle(rs.getString("title"));
|
||||
story.setSummary(rs.getString("summary"));
|
||||
story.setDescription(rs.getString("description"));
|
||||
story.setContentHtml(rs.getString("content_html"));
|
||||
// Note: contentPlain will be auto-generated from contentHtml by the entity
|
||||
story.setSourceUrl(rs.getString("source_url"));
|
||||
story.setCoverPath(rs.getString("cover_path"));
|
||||
story.setWordCount(rs.getInt("word_count"));
|
||||
story.setRating(rs.getInt("rating"));
|
||||
story.setVolume(rs.getInt("volume"));
|
||||
story.setIsRead(rs.getBoolean("is_read"));
|
||||
story.setReadingPosition(rs.getInt("reading_position"));
|
||||
|
||||
var lastReadAtTimestamp = rs.getTimestamp("last_read_at");
|
||||
if (lastReadAtTimestamp != null) {
|
||||
story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
story.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
// Set complete author information
|
||||
String authorIdStr = rs.getString("author_id");
|
||||
if (authorIdStr != null) {
|
||||
var author = new com.storycove.entity.Author();
|
||||
author.setId(UUID.fromString(authorIdStr));
|
||||
author.setName(rs.getString("author_name"));
|
||||
author.setNotes(rs.getString("author_notes"));
|
||||
author.setAvatarImagePath(rs.getString("author_avatar"));
|
||||
|
||||
Integer authorRating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(authorRating);
|
||||
}
|
||||
|
||||
var authorCreatedAt = rs.getTimestamp("author_created_at");
|
||||
if (authorCreatedAt != null) {
|
||||
author.setCreatedAt(authorCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
var authorUpdatedAt = rs.getTimestamp("author_updated_at");
|
||||
if (authorUpdatedAt != null) {
|
||||
author.setUpdatedAt(authorUpdatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setAuthor(author);
|
||||
}
|
||||
|
||||
// Set complete series information
|
||||
String seriesIdStr = rs.getString("series_id");
|
||||
if (seriesIdStr != null) {
|
||||
var series = new com.storycove.entity.Series();
|
||||
series.setId(UUID.fromString(seriesIdStr));
|
||||
series.setName(rs.getString("series_name"));
|
||||
series.setDescription(rs.getString("series_description"));
|
||||
|
||||
var seriesCreatedAt = rs.getTimestamp("series_created_at");
|
||||
if (seriesCreatedAt != null) {
|
||||
series.setCreatedAt(seriesCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setSeries(series);
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Author entity from ResultSet for indexing purposes
|
||||
*/
|
||||
private com.storycove.entity.Author createAuthorFromResultSet(ResultSet rs) throws SQLException {
|
||||
var author = new com.storycove.entity.Author();
|
||||
author.setId(UUID.fromString(rs.getString("id")));
|
||||
author.setName(rs.getString("name"));
|
||||
author.setNotes(rs.getString("notes"));
|
||||
author.setAvatarImagePath(rs.getString("avatar_image_path"));
|
||||
|
||||
Integer rating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(rating);
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
author.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
author.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
return author;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -28,7 +29,15 @@ public class ImageService {
|
||||
);
|
||||
|
||||
@Value("${storycove.images.upload-dir:/app/images}")
|
||||
private String uploadDir;
|
||||
private String baseUploadDir;
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
private String getUploadDir() {
|
||||
String libraryPath = libraryService.getCurrentImagePath();
|
||||
return baseUploadDir + libraryPath;
|
||||
}
|
||||
|
||||
@Value("${storycove.images.cover.max-width:800}")
|
||||
private int coverMaxWidth;
|
||||
@@ -61,7 +70,7 @@ public class ImageService {
|
||||
validateFile(file);
|
||||
|
||||
// Create directories if they don't exist
|
||||
Path typeDir = Paths.get(uploadDir, imageType.getDirectory());
|
||||
Path typeDir = Paths.get(getUploadDir(), imageType.getDirectory());
|
||||
Files.createDirectories(typeDir);
|
||||
|
||||
// Generate unique filename
|
||||
@@ -88,7 +97,7 @@ public class ImageService {
|
||||
}
|
||||
|
||||
try {
|
||||
Path fullPath = Paths.get(uploadDir, imagePath);
|
||||
Path fullPath = Paths.get(getUploadDir(), imagePath);
|
||||
return Files.deleteIfExists(fullPath);
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
@@ -96,7 +105,7 @@ public class ImageService {
|
||||
}
|
||||
|
||||
public Path getImagePath(String imagePath) {
|
||||
return Paths.get(uploadDir, imagePath);
|
||||
return Paths.get(getUploadDir(), imagePath);
|
||||
}
|
||||
|
||||
public boolean imageExists(String imagePath) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
/**
|
||||
* Base service class that provides library-aware database access.
|
||||
*
|
||||
* This approach is safer than routing at the datasource level because:
|
||||
* 1. It doesn't interfere with Spring's initialization process
|
||||
* 2. It allows fine-grained control over which operations are library-aware
|
||||
* 3. It provides clear separation between authentication (uses default DB) and library operations
|
||||
*/
|
||||
@Component
|
||||
public class LibraryAwareService {
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("dataSource")
|
||||
private DataSource defaultDataSource;
|
||||
|
||||
/**
|
||||
* Get a database connection for the current active library.
|
||||
* Falls back to default datasource if no library is active.
|
||||
*/
|
||||
public Connection getCurrentLibraryConnection() throws SQLException {
|
||||
try {
|
||||
// Try to get library-specific connection
|
||||
DataSource libraryDataSource = libraryService.getCurrentDataSource();
|
||||
return libraryDataSource.getConnection();
|
||||
} catch (IllegalStateException e) {
|
||||
// No active library - use default datasource
|
||||
return defaultDataSource.getConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a database connection for the default/fallback database.
|
||||
* Use this for authentication and system-level operations.
|
||||
*/
|
||||
public Connection getDefaultConnection() throws SQLException {
|
||||
return defaultDataSource.getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a library is currently active
|
||||
*/
|
||||
public boolean hasActiveLibrary() {
|
||||
try {
|
||||
return libraryService.getCurrentLibraryId() != null;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active library ID, or null if none
|
||||
*/
|
||||
public String getCurrentLibraryId() {
|
||||
try {
|
||||
return libraryService.getCurrentLibraryId();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
702
backend/src/main/java/com/storycove/service/LibraryService.java
Normal file
702
backend/src/main/java/com/storycove/service/LibraryService.java
Normal file
@@ -0,0 +1,702 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.Library;
|
||||
import com.storycove.dto.LibraryDto;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.typesense.api.Client;
|
||||
import org.typesense.resources.Node;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class LibraryService implements ApplicationContextAware {
|
||||
private static final Logger logger = LoggerFactory.getLogger(LibraryService.class);
|
||||
|
||||
@Value("${spring.datasource.url}")
|
||||
private String baseDbUrl;
|
||||
|
||||
@Value("${spring.datasource.username}")
|
||||
private String dbUsername;
|
||||
|
||||
@Value("${spring.datasource.password}")
|
||||
private String dbPassword;
|
||||
|
||||
@Value("${typesense.host}")
|
||||
private String typesenseHost;
|
||||
|
||||
@Value("${typesense.port}")
|
||||
private String typesensePort;
|
||||
|
||||
@Value("${typesense.api-key}")
|
||||
private String typesenseApiKey;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
private final Map<String, Library> libraries = new ConcurrentHashMap<>();
|
||||
|
||||
// Spring ApplicationContext for accessing other services without circular dependencies
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
// Current active resources
|
||||
private volatile String currentLibraryId;
|
||||
private volatile DataSource currentDataSource;
|
||||
private volatile Client currentTypesenseClient;
|
||||
|
||||
private static final String LIBRARIES_CONFIG_PATH = "/app/config/libraries.json";
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
loadLibrariesFromFile();
|
||||
|
||||
// If no libraries exist, create a default one
|
||||
if (libraries.isEmpty()) {
|
||||
createDefaultLibrary();
|
||||
}
|
||||
|
||||
// Set first library as active if none specified
|
||||
if (currentLibraryId == null && !libraries.isEmpty()) {
|
||||
String firstLibraryId = libraries.keySet().iterator().next();
|
||||
logger.info("No active library set, defaulting to: {}", firstLibraryId);
|
||||
try {
|
||||
switchToLibrary(firstLibraryId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize default library: {}", firstLibraryId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
closeCurrentResources();
|
||||
}
|
||||
|
||||
public String authenticateAndGetLibrary(String password) {
|
||||
for (Library library : libraries.values()) {
|
||||
if (passwordEncoder.matches(password, library.getPasswordHash())) {
|
||||
return library.getId();
|
||||
}
|
||||
}
|
||||
return null; // Authentication failed
|
||||
}
|
||||
|
||||
public synchronized void switchToLibrary(String libraryId) throws Exception {
|
||||
if (libraryId.equals(currentLibraryId)) {
|
||||
return; // Already active
|
||||
}
|
||||
|
||||
Library library = libraries.get(libraryId);
|
||||
if (library == null) {
|
||||
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||
}
|
||||
|
||||
logger.info("Switching to library: {} ({})", library.getName(), libraryId);
|
||||
|
||||
// Close current resources
|
||||
closeCurrentResources();
|
||||
|
||||
// Create new resources
|
||||
currentDataSource = createDataSource(library.getDbName());
|
||||
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
|
||||
currentLibraryId = libraryId;
|
||||
|
||||
// Initialize Typesense collections for this library if they don't exist
|
||||
try {
|
||||
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||
typesenseService.initializeCollectionsForCurrentLibrary();
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to initialize Typesense collections for library {}: {}", libraryId, e.getMessage());
|
||||
// Don't fail the switch - collections can be created later
|
||||
}
|
||||
|
||||
logger.info("Successfully switched to library: {}", library.getName());
|
||||
}
|
||||
|
||||
public DataSource getCurrentDataSource() {
|
||||
if (currentDataSource == null) {
|
||||
throw new IllegalStateException("No active library - please authenticate first");
|
||||
}
|
||||
return currentDataSource;
|
||||
}
|
||||
|
||||
public Client getCurrentTypesenseClient() {
|
||||
if (currentTypesenseClient == null) {
|
||||
throw new IllegalStateException("No active library - please authenticate first");
|
||||
}
|
||||
return currentTypesenseClient;
|
||||
}
|
||||
|
||||
public String getCurrentLibraryId() {
|
||||
return currentLibraryId;
|
||||
}
|
||||
|
||||
public Library getCurrentLibrary() {
|
||||
if (currentLibraryId == null) {
|
||||
return null;
|
||||
}
|
||||
return libraries.get(currentLibraryId);
|
||||
}
|
||||
|
||||
public List<LibraryDto> getAllLibraries() {
|
||||
List<LibraryDto> result = new ArrayList<>();
|
||||
for (Library library : libraries.values()) {
|
||||
boolean isActive = library.getId().equals(currentLibraryId);
|
||||
result.add(new LibraryDto(
|
||||
library.getId(),
|
||||
library.getName(),
|
||||
library.getDescription(),
|
||||
isActive,
|
||||
library.isInitialized()
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public String getCurrentImagePath() {
|
||||
Library current = getCurrentLibrary();
|
||||
return current != null ? current.getImagePath() : "/images/default";
|
||||
}
|
||||
|
||||
public boolean changeLibraryPassword(String libraryId, String currentPassword, String newPassword) {
|
||||
Library library = libraries.get(libraryId);
|
||||
if (library == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
if (!passwordEncoder.matches(currentPassword, library.getPasswordHash())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update password
|
||||
library.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
saveLibrariesToFile();
|
||||
|
||||
logger.info("Password changed for library: {}", library.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
public Library createNewLibrary(String name, String description, String password) {
|
||||
// Generate unique ID
|
||||
String id = name.toLowerCase().replaceAll("[^a-z0-9]", "");
|
||||
int counter = 1;
|
||||
String originalId = id;
|
||||
while (libraries.containsKey(id)) {
|
||||
id = originalId + counter++;
|
||||
}
|
||||
|
||||
Library newLibrary = new Library(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
passwordEncoder.encode(password),
|
||||
"storycove_" + id
|
||||
);
|
||||
|
||||
try {
|
||||
// Test database creation by creating a connection
|
||||
DataSource testDs = createDataSource(newLibrary.getDbName());
|
||||
testDs.getConnection().close(); // This will create the database and schema if it doesn't exist
|
||||
|
||||
// Initialize library resources (image directories)
|
||||
initializeNewLibraryResources(id);
|
||||
|
||||
newLibrary.setInitialized(true);
|
||||
logger.info("Database and resources created for library: {}", newLibrary.getDbName());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Database/resource creation failed for library {}: {}", id, e.getMessage());
|
||||
// Continue anyway - resources will be created when needed
|
||||
}
|
||||
|
||||
libraries.put(id, newLibrary);
|
||||
saveLibrariesToFile();
|
||||
|
||||
logger.info("Created new library: {} ({})", name, id);
|
||||
return newLibrary;
|
||||
}
|
||||
|
||||
private void loadLibrariesFromFile() {
|
||||
try {
|
||||
File configFile = new File(LIBRARIES_CONFIG_PATH);
|
||||
if (configFile.exists()) {
|
||||
String content = Files.readString(Paths.get(LIBRARIES_CONFIG_PATH));
|
||||
Map<String, Object> config = objectMapper.readValue(content, new TypeReference<Map<String, Object>>() {});
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Map<String, Object>> librariesData = (Map<String, Map<String, Object>>) config.get("libraries");
|
||||
|
||||
for (Map.Entry<String, Map<String, Object>> entry : librariesData.entrySet()) {
|
||||
String id = entry.getKey();
|
||||
Map<String, Object> data = entry.getValue();
|
||||
|
||||
Library library = new Library();
|
||||
library.setId(id);
|
||||
library.setName((String) data.get("name"));
|
||||
library.setDescription((String) data.get("description"));
|
||||
library.setPasswordHash((String) data.get("passwordHash"));
|
||||
library.setDbName((String) data.get("dbName"));
|
||||
library.setInitialized((Boolean) data.getOrDefault("initialized", false));
|
||||
|
||||
libraries.put(id, library);
|
||||
logger.info("Loaded library: {} ({})", library.getName(), id);
|
||||
}
|
||||
} else {
|
||||
logger.info("No libraries configuration file found, will create default");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to load libraries configuration", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createDefaultLibrary() {
|
||||
// Check if we're migrating from the old single-library system
|
||||
String existingDbName = extractDatabaseName(baseDbUrl);
|
||||
|
||||
Library defaultLibrary = new Library(
|
||||
"main",
|
||||
"Main Library",
|
||||
"Your existing story collection (migrated)",
|
||||
passwordEncoder.encode("temp-password-change-me"), // Temporary password
|
||||
existingDbName // Use existing database name
|
||||
);
|
||||
defaultLibrary.setInitialized(true); // Mark as initialized since it has existing data
|
||||
|
||||
libraries.put("main", defaultLibrary);
|
||||
saveLibrariesToFile();
|
||||
|
||||
logger.warn("=".repeat(80));
|
||||
logger.warn("MIGRATION: Created 'Main Library' for your existing data");
|
||||
logger.warn("Temporary password: 'temp-password-change-me'");
|
||||
logger.warn("IMPORTANT: Please set a proper password in Settings > Library Settings");
|
||||
logger.warn("=".repeat(80));
|
||||
}
|
||||
|
||||
private String extractDatabaseName(String jdbcUrl) {
|
||||
// Extract database name from JDBC URL like "jdbc:postgresql://db:5432/storycove"
|
||||
int lastSlash = jdbcUrl.lastIndexOf('/');
|
||||
if (lastSlash != -1 && lastSlash < jdbcUrl.length() - 1) {
|
||||
String dbPart = jdbcUrl.substring(lastSlash + 1);
|
||||
// Remove any query parameters
|
||||
int queryStart = dbPart.indexOf('?');
|
||||
return queryStart != -1 ? dbPart.substring(0, queryStart) : dbPart;
|
||||
}
|
||||
return "storycove"; // fallback
|
||||
}
|
||||
|
||||
private void saveLibrariesToFile() {
|
||||
try {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
Map<String, Map<String, Object>> librariesData = new HashMap<>();
|
||||
|
||||
for (Library library : libraries.values()) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("name", library.getName());
|
||||
data.put("description", library.getDescription());
|
||||
data.put("passwordHash", library.getPasswordHash());
|
||||
data.put("dbName", library.getDbName());
|
||||
data.put("initialized", library.isInitialized());
|
||||
|
||||
librariesData.put(library.getId(), data);
|
||||
}
|
||||
|
||||
config.put("libraries", librariesData);
|
||||
|
||||
// Ensure config directory exists
|
||||
new File("/app/config").mkdirs();
|
||||
|
||||
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
||||
Files.writeString(Paths.get(LIBRARIES_CONFIG_PATH), json);
|
||||
|
||||
logger.info("Saved libraries configuration");
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to save libraries configuration", e);
|
||||
}
|
||||
}
|
||||
|
||||
private DataSource createDataSource(String dbName) {
|
||||
String url = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||
logger.info("Creating DataSource for: {}", url);
|
||||
|
||||
// First, ensure the database exists
|
||||
ensureDatabaseExists(dbName);
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(url);
|
||||
config.setUsername(dbUsername);
|
||||
config.setPassword(dbPassword);
|
||||
config.setDriverClassName("org.postgresql.Driver");
|
||||
config.setMaximumPoolSize(10);
|
||||
config.setConnectionTimeout(30000);
|
||||
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
|
||||
private void ensureDatabaseExists(String dbName) {
|
||||
// Connect to the 'postgres' database to create the new database
|
||||
String adminUrl = baseDbUrl.replaceAll("/[^/]*$", "/postgres");
|
||||
|
||||
HikariConfig adminConfig = new HikariConfig();
|
||||
adminConfig.setJdbcUrl(adminUrl);
|
||||
adminConfig.setUsername(dbUsername);
|
||||
adminConfig.setPassword(dbPassword);
|
||||
adminConfig.setDriverClassName("org.postgresql.Driver");
|
||||
adminConfig.setMaximumPoolSize(1);
|
||||
adminConfig.setConnectionTimeout(30000);
|
||||
|
||||
boolean databaseCreated = false;
|
||||
|
||||
try (HikariDataSource adminDataSource = new HikariDataSource(adminConfig);
|
||||
var connection = adminDataSource.getConnection();
|
||||
var statement = connection.createStatement()) {
|
||||
|
||||
// Check if database exists
|
||||
String checkQuery = "SELECT 1 FROM pg_database WHERE datname = ?";
|
||||
try (var preparedStatement = connection.prepareStatement(checkQuery)) {
|
||||
preparedStatement.setString(1, dbName);
|
||||
try (var resultSet = preparedStatement.executeQuery()) {
|
||||
if (resultSet.next()) {
|
||||
logger.info("Database {} already exists", dbName);
|
||||
return; // Database exists, nothing to do
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create database if it doesn't exist
|
||||
// Note: Database names cannot be parameterized, but we validate the name is safe
|
||||
if (!dbName.matches("^[a-zA-Z][a-zA-Z0-9_]*$")) {
|
||||
throw new IllegalArgumentException("Invalid database name: " + dbName);
|
||||
}
|
||||
|
||||
String createQuery = "CREATE DATABASE " + dbName;
|
||||
statement.executeUpdate(createQuery);
|
||||
logger.info("Created database: {}", dbName);
|
||||
databaseCreated = true;
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.error("Failed to ensure database {} exists: {}", dbName, e.getMessage());
|
||||
throw new RuntimeException("Database creation failed", e);
|
||||
}
|
||||
|
||||
// If we just created the database, initialize its schema
|
||||
if (databaseCreated) {
|
||||
initializeNewDatabaseSchema(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeNewDatabaseSchema(String dbName) {
|
||||
logger.info("Initializing schema for new database: {}", dbName);
|
||||
|
||||
// Create a temporary DataSource for the new database to initialize schema
|
||||
String newDbUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(newDbUrl);
|
||||
config.setUsername(dbUsername);
|
||||
config.setPassword(dbPassword);
|
||||
config.setDriverClassName("org.postgresql.Driver");
|
||||
config.setMaximumPoolSize(1);
|
||||
config.setConnectionTimeout(30000);
|
||||
|
||||
try (HikariDataSource tempDataSource = new HikariDataSource(config)) {
|
||||
// Use Hibernate to create the schema
|
||||
// This mimics what Spring Boot does during startup
|
||||
createSchemaUsingHibernate(tempDataSource);
|
||||
logger.info("Schema initialized for database: {}", dbName);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize schema for database {}: {}", dbName, e.getMessage());
|
||||
throw new RuntimeException("Schema initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void initializeNewLibraryResources(String libraryId) {
|
||||
Library library = libraries.get(libraryId);
|
||||
if (library == null) {
|
||||
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("Initializing resources for new library: {}", library.getName());
|
||||
|
||||
// 1. Create image directory structure
|
||||
initializeImageDirectories(library);
|
||||
|
||||
// 2. Initialize Typesense collections (this will be done when switching to the library)
|
||||
// The TypesenseService.initializeCollections() will be called automatically
|
||||
|
||||
logger.info("Successfully initialized resources for library: {}", library.getName());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize resources for library {}: {}", libraryId, e.getMessage());
|
||||
throw new RuntimeException("Library resource initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeImageDirectories(Library library) {
|
||||
try {
|
||||
// Create the library-specific image directory
|
||||
String imagePath = "/app/images/" + library.getId();
|
||||
java.nio.file.Path libraryImagePath = java.nio.file.Paths.get(imagePath);
|
||||
|
||||
if (!java.nio.file.Files.exists(libraryImagePath)) {
|
||||
java.nio.file.Files.createDirectories(libraryImagePath);
|
||||
logger.info("Created image directory: {}", imagePath);
|
||||
|
||||
// Create subdirectories for different image types
|
||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("stories"));
|
||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("authors"));
|
||||
java.nio.file.Files.createDirectories(libraryImagePath.resolve("collections"));
|
||||
|
||||
logger.info("Created image subdirectories for library: {}", library.getId());
|
||||
} else {
|
||||
logger.info("Image directory already exists: {}", imagePath);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to create image directories for library {}: {}", library.getId(), e.getMessage());
|
||||
throw new RuntimeException("Image directory creation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createSchemaUsingHibernate(DataSource dataSource) {
|
||||
// Create the essential tables manually using the same DDL that Hibernate would generate
|
||||
// This is simpler than setting up a full Hibernate configuration for schema creation
|
||||
|
||||
String[] createTableStatements = {
|
||||
// Authors table
|
||||
"""
|
||||
CREATE TABLE authors (
|
||||
author_rating integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
avatar_image_path varchar(255),
|
||||
name varchar(255) not null,
|
||||
notes TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Author URLs table
|
||||
"""
|
||||
CREATE TABLE author_urls (
|
||||
author_id uuid not null,
|
||||
url varchar(255)
|
||||
)
|
||||
""",
|
||||
|
||||
// Series table
|
||||
"""
|
||||
CREATE TABLE series (
|
||||
created_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
description varchar(1000),
|
||||
name varchar(255) not null,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Tags table
|
||||
"""
|
||||
CREATE TABLE tags (
|
||||
color varchar(7),
|
||||
created_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
description varchar(500),
|
||||
name varchar(255) not null unique,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Tag aliases table
|
||||
"""
|
||||
CREATE TABLE tag_aliases (
|
||||
created_from_merge boolean not null,
|
||||
created_at timestamp(6) not null,
|
||||
canonical_tag_id uuid not null,
|
||||
id uuid not null,
|
||||
alias_name varchar(255) not null unique,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Collections table
|
||||
"""
|
||||
CREATE TABLE collections (
|
||||
is_archived boolean not null,
|
||||
rating integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
cover_image_path varchar(500),
|
||||
name varchar(500) not null,
|
||||
description TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Stories table
|
||||
"""
|
||||
CREATE TABLE stories (
|
||||
is_read boolean,
|
||||
rating integer,
|
||||
reading_position integer,
|
||||
volume integer,
|
||||
word_count integer,
|
||||
created_at timestamp(6) not null,
|
||||
last_read_at timestamp(6),
|
||||
updated_at timestamp(6) not null,
|
||||
author_id uuid,
|
||||
id uuid not null,
|
||||
series_id uuid,
|
||||
description varchar(1000),
|
||||
content_html TEXT,
|
||||
content_plain TEXT,
|
||||
cover_path varchar(255),
|
||||
source_url varchar(255),
|
||||
summary TEXT,
|
||||
title varchar(255) not null,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Reading positions table
|
||||
"""
|
||||
CREATE TABLE reading_positions (
|
||||
chapter_index integer,
|
||||
character_position integer,
|
||||
percentage_complete float(53),
|
||||
word_position integer,
|
||||
created_at timestamp(6) not null,
|
||||
updated_at timestamp(6) not null,
|
||||
id uuid not null,
|
||||
story_id uuid not null,
|
||||
context_after varchar(500),
|
||||
context_before varchar(500),
|
||||
chapter_title varchar(255),
|
||||
epub_cfi TEXT,
|
||||
primary key (id)
|
||||
)
|
||||
""",
|
||||
|
||||
// Junction tables
|
||||
"""
|
||||
CREATE TABLE story_tags (
|
||||
story_id uuid not null,
|
||||
tag_id uuid not null,
|
||||
primary key (story_id, tag_id)
|
||||
)
|
||||
""",
|
||||
|
||||
"""
|
||||
CREATE TABLE collection_stories (
|
||||
position integer not null,
|
||||
added_at timestamp(6) not null,
|
||||
collection_id uuid not null,
|
||||
story_id uuid not null,
|
||||
primary key (collection_id, story_id),
|
||||
unique (collection_id, position)
|
||||
)
|
||||
""",
|
||||
|
||||
"""
|
||||
CREATE TABLE collection_tags (
|
||||
collection_id uuid not null,
|
||||
tag_id uuid not null,
|
||||
primary key (collection_id, tag_id)
|
||||
)
|
||||
"""
|
||||
};
|
||||
|
||||
String[] createIndexStatements = {
|
||||
"CREATE INDEX idx_reading_position_story ON reading_positions (story_id)"
|
||||
};
|
||||
|
||||
String[] createConstraintStatements = {
|
||||
// Foreign key constraints
|
||||
"ALTER TABLE author_urls ADD CONSTRAINT FKdqhp51m0uveybsts098gd79uo FOREIGN KEY (author_id) REFERENCES authors",
|
||||
"ALTER TABLE stories ADD CONSTRAINT FKhwecpqeaxy40ftrctef1u7gw7 FOREIGN KEY (author_id) REFERENCES authors",
|
||||
"ALTER TABLE stories ADD CONSTRAINT FK1kulyvy7wwcolp2gkndt57cp7 FOREIGN KEY (series_id) REFERENCES series",
|
||||
"ALTER TABLE reading_positions ADD CONSTRAINT FKglfhdhflan3pgyr2u0gxi21i5 FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE story_tags ADD CONSTRAINT FKmans33ijt0nf65t0sng2r848j FOREIGN KEY (tag_id) REFERENCES tags",
|
||||
"ALTER TABLE story_tags ADD CONSTRAINT FKq9guid7swnjxwdpgxj3jo1rsi FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE tag_aliases ADD CONSTRAINT FKqfsawmcj3ey4yycb6958y24ch FOREIGN KEY (canonical_tag_id) REFERENCES tags",
|
||||
"ALTER TABLE collection_stories ADD CONSTRAINT FKr55ho4vhj0wp03x13iskr1jds FOREIGN KEY (collection_id) REFERENCES collections",
|
||||
"ALTER TABLE collection_stories ADD CONSTRAINT FK7n41tbbrt7r2e81hpu3612r1o FOREIGN KEY (story_id) REFERENCES stories",
|
||||
"ALTER TABLE collection_tags ADD CONSTRAINT FKceq7ggev8n8ibjui1x5yo4x67 FOREIGN KEY (tag_id) REFERENCES tags",
|
||||
"ALTER TABLE collection_tags ADD CONSTRAINT FKq9sa5s8csdpbphrvb48tts8jt FOREIGN KEY (collection_id) REFERENCES collections"
|
||||
};
|
||||
|
||||
try (var connection = dataSource.getConnection();
|
||||
var statement = connection.createStatement()) {
|
||||
|
||||
// Create tables
|
||||
for (String sql : createTableStatements) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
for (String sql : createIndexStatements) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
|
||||
// Create constraints
|
||||
for (String sql : createConstraintStatements) {
|
||||
statement.executeUpdate(sql);
|
||||
}
|
||||
|
||||
logger.info("Successfully created all database tables and constraints");
|
||||
|
||||
} catch (SQLException e) {
|
||||
logger.error("Failed to create database schema", e);
|
||||
throw new RuntimeException("Schema creation failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Client createTypesenseClient(String collection) {
|
||||
logger.info("Creating Typesense client for collection: {}", collection);
|
||||
|
||||
List<Node> nodes = Arrays.asList(
|
||||
new Node("http", typesenseHost, typesensePort)
|
||||
);
|
||||
|
||||
org.typesense.api.Configuration configuration = new org.typesense.api.Configuration(nodes, Duration.ofSeconds(10), typesenseApiKey);
|
||||
return new Client(configuration);
|
||||
}
|
||||
|
||||
private void closeCurrentResources() {
|
||||
if (currentDataSource instanceof HikariDataSource) {
|
||||
logger.info("Closing current DataSource");
|
||||
((HikariDataSource) currentDataSource).close();
|
||||
}
|
||||
|
||||
// Typesense client doesn't need explicit cleanup
|
||||
currentDataSource = null;
|
||||
currentTypesenseClient = null;
|
||||
currentLibraryId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,83 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PasswordAuthenticationService {
|
||||
|
||||
@Value("${storycove.auth.password}")
|
||||
private String applicationPassword;
|
||||
private static final Logger logger = LoggerFactory.getLogger(PasswordAuthenticationService.class);
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final LibraryService libraryService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public PasswordAuthenticationService(PasswordEncoder passwordEncoder) {
|
||||
@Autowired
|
||||
public PasswordAuthenticationService(
|
||||
PasswordEncoder passwordEncoder,
|
||||
LibraryService libraryService,
|
||||
JwtUtil jwtUtil) {
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.libraryService = libraryService;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
public boolean authenticate(String providedPassword) {
|
||||
/**
|
||||
* Authenticate user and switch to the appropriate library
|
||||
* Returns JWT token if authentication successful, null otherwise
|
||||
*/
|
||||
public String authenticateAndSwitchLibrary(String providedPassword) {
|
||||
if (providedPassword == null || providedPassword.trim().isEmpty()) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If application password starts with {bcrypt}, it's already encoded
|
||||
if (applicationPassword.startsWith("{bcrypt}") || applicationPassword.startsWith("$2")) {
|
||||
return passwordEncoder.matches(providedPassword, applicationPassword);
|
||||
// Find which library this password belongs to
|
||||
String libraryId = libraryService.authenticateAndGetLibrary(providedPassword);
|
||||
if (libraryId == null) {
|
||||
logger.warn("Authentication failed - invalid password");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, compare directly (for development/testing)
|
||||
return applicationPassword.equals(providedPassword);
|
||||
try {
|
||||
// Switch to the authenticated library (may take 2-3 seconds)
|
||||
libraryService.switchToLibrary(libraryId);
|
||||
|
||||
// Generate JWT token with library context
|
||||
String token = jwtUtil.generateToken("user", libraryId);
|
||||
|
||||
logger.info("Successfully authenticated and switched to library: {}", libraryId);
|
||||
return token;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to switch to library: {}", libraryId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method - kept for backward compatibility
|
||||
*/
|
||||
@Deprecated
|
||||
public boolean authenticate(String providedPassword) {
|
||||
return authenticateAndSwitchLibrary(providedPassword) != null;
|
||||
}
|
||||
|
||||
public String encodePassword(String rawPassword) {
|
||||
return passwordEncoder.encode(rawPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current library info for authenticated user
|
||||
*/
|
||||
public String getCurrentLibraryInfo() {
|
||||
var library = libraryService.getCurrentLibrary();
|
||||
if (library != null) {
|
||||
return String.format("Library: %s (%s)", library.getName(), library.getId());
|
||||
}
|
||||
return "No library active";
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
@@ -43,6 +45,7 @@ public class StoryService {
|
||||
private final SeriesService seriesService;
|
||||
private final HtmlSanitizationService sanitizationService;
|
||||
private final TypesenseService typesenseService;
|
||||
private final LibraryAwareService libraryAwareService;
|
||||
|
||||
@Autowired
|
||||
public StoryService(StoryRepository storyRepository,
|
||||
@@ -52,7 +55,8 @@ public class StoryService {
|
||||
TagService tagService,
|
||||
SeriesService seriesService,
|
||||
HtmlSanitizationService sanitizationService,
|
||||
@Autowired(required = false) TypesenseService typesenseService) {
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
LibraryAwareService libraryAwareService) {
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.readingPositionRepository = readingPositionRepository;
|
||||
@@ -61,6 +65,7 @@ public class StoryService {
|
||||
this.seriesService = seriesService;
|
||||
this.sanitizationService = sanitizationService;
|
||||
this.typesenseService = typesenseService;
|
||||
this.libraryAwareService = libraryAwareService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -80,14 +85,53 @@ public class StoryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Story findById(UUID id) {
|
||||
return storyRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||
// Smart routing: use library-specific database if available, otherwise use repository
|
||||
if (libraryAwareService.hasActiveLibrary()) {
|
||||
return findByIdFromCurrentLibrary(id);
|
||||
} else {
|
||||
return storyRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find story by ID using the current library's database connection
|
||||
*/
|
||||
private Story findByIdFromCurrentLibrary(UUID id) {
|
||||
try (var connection = libraryAwareService.getCurrentLibraryConnection()) {
|
||||
String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " +
|
||||
"s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " +
|
||||
"s.created_at, s.updated_at, " +
|
||||
"a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " +
|
||||
"a.created_at as author_created_at, a.updated_at as author_updated_at, " +
|
||||
"ser.name as series_name, ser.description as series_description, " +
|
||||
"ser.created_at as series_created_at " +
|
||||
"FROM stories s " +
|
||||
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||
"LEFT JOIN series ser ON s.series_id = ser.id " +
|
||||
"WHERE s.id = ?";
|
||||
|
||||
try (var stmt = connection.prepareStatement(query)) {
|
||||
stmt.setObject(1, id);
|
||||
try (var rs = stmt.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return createStoryFromResultSet(rs);
|
||||
} else {
|
||||
throw new ResourceNotFoundException("Story", id.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to find story by ID from current library: {}", id, e);
|
||||
throw new RuntimeException("Database error while fetching story", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Story> findByIdOptional(UUID id) {
|
||||
return storyRepository.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Story> findByTitle(String title) {
|
||||
@@ -766,4 +810,83 @@ public class StoryService {
|
||||
|
||||
return randomStory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Story entity from ResultSet for library-aware queries
|
||||
*/
|
||||
private Story createStoryFromResultSet(ResultSet rs) throws SQLException {
|
||||
var story = new Story();
|
||||
story.setId(UUID.fromString(rs.getString("id")));
|
||||
story.setTitle(rs.getString("title"));
|
||||
story.setSummary(rs.getString("summary"));
|
||||
story.setDescription(rs.getString("description"));
|
||||
story.setContentHtml(rs.getString("content_html"));
|
||||
story.setSourceUrl(rs.getString("source_url"));
|
||||
story.setCoverPath(rs.getString("cover_path"));
|
||||
story.setWordCount(rs.getInt("word_count"));
|
||||
story.setRating(rs.getInt("rating"));
|
||||
story.setVolume(rs.getInt("volume"));
|
||||
story.setIsRead(rs.getBoolean("is_read"));
|
||||
story.setReadingPosition(rs.getInt("reading_position"));
|
||||
|
||||
var lastReadAtTimestamp = rs.getTimestamp("last_read_at");
|
||||
if (lastReadAtTimestamp != null) {
|
||||
story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||
if (createdAtTimestamp != null) {
|
||||
story.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||
if (updatedAtTimestamp != null) {
|
||||
story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||
}
|
||||
|
||||
// Set complete author information
|
||||
String authorIdStr = rs.getString("author_id");
|
||||
if (authorIdStr != null) {
|
||||
var author = new Author();
|
||||
author.setId(UUID.fromString(authorIdStr));
|
||||
author.setName(rs.getString("author_name"));
|
||||
author.setNotes(rs.getString("author_notes"));
|
||||
author.setAvatarImagePath(rs.getString("author_avatar"));
|
||||
|
||||
Integer authorRating = rs.getInt("author_rating");
|
||||
if (!rs.wasNull()) {
|
||||
author.setAuthorRating(authorRating);
|
||||
}
|
||||
|
||||
var authorCreatedAt = rs.getTimestamp("author_created_at");
|
||||
if (authorCreatedAt != null) {
|
||||
author.setCreatedAt(authorCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
var authorUpdatedAt = rs.getTimestamp("author_updated_at");
|
||||
if (authorUpdatedAt != null) {
|
||||
author.setUpdatedAt(authorUpdatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setAuthor(author);
|
||||
}
|
||||
|
||||
// Set complete series information
|
||||
String seriesIdStr = rs.getString("series_id");
|
||||
if (seriesIdStr != null) {
|
||||
var series = new Series();
|
||||
series.setId(UUID.fromString(seriesIdStr));
|
||||
series.setName(rs.getString("series_name"));
|
||||
series.setDescription(rs.getString("series_description"));
|
||||
|
||||
var seriesCreatedAt = rs.getTimestamp("series_created_at");
|
||||
if (seriesCreatedAt != null) {
|
||||
series.setCreatedAt(seriesCreatedAt.toLocalDateTime());
|
||||
}
|
||||
|
||||
story.setSeries(series);
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
}
|
||||
@@ -27,19 +27,31 @@ import java.util.stream.Collectors;
|
||||
public class TypesenseService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
||||
private static final String STORIES_COLLECTION = "stories";
|
||||
private static final String AUTHORS_COLLECTION = "authors";
|
||||
private static final String COLLECTIONS_COLLECTION = "collections";
|
||||
// Collection names are now dynamic based on active library
|
||||
private String getStoriesCollection() {
|
||||
var library = libraryService.getCurrentLibrary();
|
||||
return library != null ? library.getTypesenseCollection() : "stories";
|
||||
}
|
||||
|
||||
private final Client typesenseClient;
|
||||
private String getAuthorsCollection() {
|
||||
var library = libraryService.getCurrentLibrary();
|
||||
return library != null ? "authors_" + library.getId() : "authors";
|
||||
}
|
||||
|
||||
private String getCollectionsCollection() {
|
||||
var library = libraryService.getCurrentLibrary();
|
||||
return library != null ? "collections_" + library.getId() : "collections";
|
||||
}
|
||||
|
||||
private final LibraryService libraryService;
|
||||
private final CollectionStoryRepository collectionStoryRepository;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
@Autowired
|
||||
public TypesenseService(Client typesenseClient,
|
||||
public TypesenseService(LibraryService libraryService,
|
||||
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.typesenseClient = typesenseClient;
|
||||
this.libraryService = libraryService;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
@@ -55,10 +67,27 @@ public class TypesenseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize collections for the current active library
|
||||
* This is called when switching to a new library to ensure its collections exist
|
||||
*/
|
||||
public void initializeCollectionsForCurrentLibrary() {
|
||||
try {
|
||||
logger.info("Initializing Typesense collections for current library");
|
||||
createStoriesCollectionIfNotExists();
|
||||
createAuthorsCollectionIfNotExists();
|
||||
createCollectionsCollectionIfNotExists();
|
||||
logger.info("Successfully initialized Typesense collections for current library");
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize Typesense collections for current library", e);
|
||||
throw new RuntimeException("Typesense collection initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createStoriesCollectionIfNotExists() throws Exception {
|
||||
try {
|
||||
// Check if collection already exists
|
||||
typesenseClient.collections(STORIES_COLLECTION).retrieve();
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).retrieve();
|
||||
logger.info("Stories collection already exists");
|
||||
} catch (Exception e) {
|
||||
logger.info("Creating stories collection...");
|
||||
@@ -87,17 +116,17 @@ public class TypesenseService {
|
||||
);
|
||||
|
||||
CollectionSchema collectionSchema = new CollectionSchema()
|
||||
.name(STORIES_COLLECTION)
|
||||
.name(getStoriesCollection())
|
||||
.fields(fields);
|
||||
|
||||
typesenseClient.collections().create(collectionSchema);
|
||||
libraryService.getCurrentTypesenseClient().collections().create(collectionSchema);
|
||||
logger.info("Stories collection created successfully");
|
||||
}
|
||||
|
||||
private void createAuthorsCollectionIfNotExists() throws Exception {
|
||||
try {
|
||||
// Check if collection already exists
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).retrieve();
|
||||
logger.info("Authors collection already exists");
|
||||
} catch (Exception e) {
|
||||
logger.info("Creating authors collection...");
|
||||
@@ -111,7 +140,7 @@ public class TypesenseService {
|
||||
public void recreateStoriesCollection() throws Exception {
|
||||
try {
|
||||
logger.info("Force deleting stories collection for recreation...");
|
||||
typesenseClient.collections(STORIES_COLLECTION).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).delete();
|
||||
logger.info("Successfully deleted stories collection");
|
||||
} catch (Exception e) {
|
||||
logger.debug("Stories collection didn't exist for deletion: {}", e.getMessage());
|
||||
@@ -131,7 +160,7 @@ public class TypesenseService {
|
||||
public void recreateAuthorsCollection() throws Exception {
|
||||
try {
|
||||
logger.info("Force deleting authors collection for recreation...");
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).delete();
|
||||
logger.info("Successfully deleted authors collection");
|
||||
} catch (Exception e) {
|
||||
logger.debug("Authors collection didn't exist for deletion: {}", e.getMessage());
|
||||
@@ -160,17 +189,17 @@ public class TypesenseService {
|
||||
);
|
||||
|
||||
CollectionSchema collectionSchema = new CollectionSchema()
|
||||
.name(AUTHORS_COLLECTION)
|
||||
.name(getAuthorsCollection())
|
||||
.fields(fields);
|
||||
|
||||
typesenseClient.collections().create(collectionSchema);
|
||||
libraryService.getCurrentTypesenseClient().collections().create(collectionSchema);
|
||||
logger.info("Authors collection created successfully");
|
||||
}
|
||||
|
||||
public void indexStory(Story story) {
|
||||
try {
|
||||
Map<String, Object> document = createStoryDocument(story);
|
||||
typesenseClient.collections(STORIES_COLLECTION).documents().create(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).documents().create(document);
|
||||
logger.debug("Indexed story: {}", story.getTitle());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to index story: " + story.getTitle(), e);
|
||||
@@ -180,7 +209,7 @@ public class TypesenseService {
|
||||
public void updateStory(Story story) {
|
||||
try {
|
||||
Map<String, Object> document = createStoryDocument(story);
|
||||
typesenseClient.collections(STORIES_COLLECTION).documents(story.getId().toString()).update(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).documents(story.getId().toString()).update(document);
|
||||
logger.debug("Updated story index: {}", story.getTitle());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update story index: " + story.getTitle(), e);
|
||||
@@ -189,7 +218,7 @@ public class TypesenseService {
|
||||
|
||||
public void deleteStory(String storyId) {
|
||||
try {
|
||||
typesenseClient.collections(STORIES_COLLECTION).documents(storyId).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).documents(storyId).delete();
|
||||
logger.debug("Deleted story from index: {}", storyId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to delete story from index: " + storyId, e);
|
||||
@@ -272,7 +301,7 @@ public class TypesenseService {
|
||||
} else {
|
||||
}
|
||||
|
||||
SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION)
|
||||
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
|
||||
@@ -310,7 +339,7 @@ public class TypesenseService {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (Map<String, Object> document : documents) {
|
||||
typesenseClient.collections(STORIES_COLLECTION).documents().create(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).documents().create(document);
|
||||
}
|
||||
logger.info("Bulk indexed {} stories", stories.size());
|
||||
|
||||
@@ -342,7 +371,7 @@ public class TypesenseService {
|
||||
.perPage(limit)
|
||||
.highlightFields("title,authorName");
|
||||
|
||||
SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION)
|
||||
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
|
||||
@@ -381,7 +410,7 @@ public class TypesenseService {
|
||||
|
||||
logger.debug("Getting random story with query: '{}', tags: {}", normalizedQuery, tags);
|
||||
|
||||
SearchResult countResult = typesenseClient.collections(STORIES_COLLECTION)
|
||||
SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||
.documents()
|
||||
.search(countParameters);
|
||||
|
||||
@@ -411,7 +440,7 @@ public class TypesenseService {
|
||||
storyParameters.filterBy(tagFilter);
|
||||
}
|
||||
|
||||
SearchResult storyResult = typesenseClient.collections(STORIES_COLLECTION)
|
||||
SearchResult storyResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
|
||||
.documents()
|
||||
.search(storyParameters);
|
||||
|
||||
@@ -799,7 +828,7 @@ public class TypesenseService {
|
||||
public void indexAuthor(Author author) {
|
||||
try {
|
||||
Map<String, Object> document = createAuthorDocument(author);
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents().create(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).documents().create(document);
|
||||
logger.debug("Indexed author: {}", author.getName());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to index author: " + author.getName(), e);
|
||||
@@ -809,7 +838,7 @@ public class TypesenseService {
|
||||
public void updateAuthor(Author author) {
|
||||
try {
|
||||
Map<String, Object> document = createAuthorDocument(author);
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents(author.getId().toString()).update(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).documents(author.getId().toString()).update(document);
|
||||
logger.debug("Updated author index: {}", author.getName());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update author index: " + author.getName(), e);
|
||||
@@ -818,7 +847,7 @@ public class TypesenseService {
|
||||
|
||||
public void deleteAuthor(String authorId) {
|
||||
try {
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents(authorId).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).documents(authorId).delete();
|
||||
logger.debug("Deleted author from index: {}", authorId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to delete author from index: " + authorId, e);
|
||||
@@ -951,7 +980,7 @@ public class TypesenseService {
|
||||
SearchResult searchResult;
|
||||
try {
|
||||
logger.info("AUTHOR SEARCH DEBUG: Executing search with final parameters");
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
searchResult = libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection())
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
logger.info("AUTHOR SEARCH DEBUG: Search completed. Found {} results", searchResult.getHits().size());
|
||||
@@ -961,7 +990,7 @@ public class TypesenseService {
|
||||
|
||||
// Try to get collection info for debugging
|
||||
try {
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).retrieve();
|
||||
} catch (Exception debugException) {
|
||||
// Debug collection retrieval failed
|
||||
}
|
||||
@@ -977,7 +1006,7 @@ public class TypesenseService {
|
||||
|
||||
logger.info("AUTHOR SEARCH DEBUG: Using fallback infix search query: '{}'", fallbackSearchQuery);
|
||||
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
searchResult = libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection())
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
}
|
||||
@@ -1089,7 +1118,7 @@ public class TypesenseService {
|
||||
*/
|
||||
public Map<String, Object> getAuthorsCollectionSchema() {
|
||||
try {
|
||||
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
CollectionResponse collection = libraryService.getCurrentTypesenseClient().collections(getAuthorsCollection()).retrieve();
|
||||
return Map.of(
|
||||
"name", collection.getName(),
|
||||
"num_documents", collection.getNumDocuments(),
|
||||
@@ -1107,7 +1136,7 @@ public class TypesenseService {
|
||||
*/
|
||||
public Map<String, Object> getStoriesCollectionSchema() {
|
||||
try {
|
||||
CollectionResponse collection = typesenseClient.collections(STORIES_COLLECTION).retrieve();
|
||||
CollectionResponse collection = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection()).retrieve();
|
||||
return Map.of(
|
||||
"name", collection.getName(),
|
||||
"num_documents", collection.getNumDocuments(),
|
||||
@@ -1152,7 +1181,7 @@ public class TypesenseService {
|
||||
private void createCollectionsCollectionIfNotExists() throws Exception {
|
||||
try {
|
||||
// Check if collection already exists
|
||||
typesenseClient.collections(COLLECTIONS_COLLECTION).retrieve();
|
||||
libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection()).retrieve();
|
||||
logger.info("Collections collection already exists");
|
||||
} catch (Exception e) {
|
||||
logger.info("Creating collections collection...");
|
||||
@@ -1175,11 +1204,11 @@ public class TypesenseService {
|
||||
);
|
||||
|
||||
CollectionSchema collectionSchema = new CollectionSchema()
|
||||
.name(COLLECTIONS_COLLECTION)
|
||||
.name(getCollectionsCollection())
|
||||
.fields(fields)
|
||||
.defaultSortingField("updated_at");
|
||||
|
||||
typesenseClient.collections().create(collectionSchema);
|
||||
libraryService.getCurrentTypesenseClient().collections().create(collectionSchema);
|
||||
logger.info("Collections collection created successfully");
|
||||
}
|
||||
|
||||
@@ -1220,7 +1249,7 @@ public class TypesenseService {
|
||||
searchParameters.filterBy(finalFilter);
|
||||
}
|
||||
|
||||
SearchResult searchResult = typesenseClient.collections(COLLECTIONS_COLLECTION)
|
||||
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection())
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
|
||||
@@ -1248,7 +1277,7 @@ public class TypesenseService {
|
||||
public void indexCollection(Collection collection) {
|
||||
try {
|
||||
Map<String, Object> document = createCollectionDocument(collection);
|
||||
typesenseClient.collections(COLLECTIONS_COLLECTION).documents().upsert(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection()).documents().upsert(document);
|
||||
logger.debug("Indexed collection: {}", collection.getName());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to index collection: " + collection.getId(), e);
|
||||
@@ -1260,7 +1289,7 @@ public class TypesenseService {
|
||||
*/
|
||||
public void removeCollection(UUID collectionId) {
|
||||
try {
|
||||
typesenseClient.collections(COLLECTIONS_COLLECTION).documents(collectionId.toString()).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection()).documents(collectionId.toString()).delete();
|
||||
logger.debug("Removed collection from index: {}", collectionId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to remove collection from index: " + collectionId, e);
|
||||
@@ -1281,7 +1310,7 @@ public class TypesenseService {
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (Map<String, Object> document : documents) {
|
||||
typesenseClient.collections(COLLECTIONS_COLLECTION).documents().create(document);
|
||||
libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection()).documents().create(document);
|
||||
}
|
||||
logger.info("Bulk indexed {} collections", collections.size());
|
||||
|
||||
@@ -1297,7 +1326,7 @@ public class TypesenseService {
|
||||
try {
|
||||
// Clear existing collection
|
||||
try {
|
||||
typesenseClient.collections(COLLECTIONS_COLLECTION).delete();
|
||||
libraryService.getCurrentTypesenseClient().collections(getCollectionsCollection()).delete();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
|
||||
}
|
||||
|
||||
@@ -23,15 +23,24 @@ public class JwtUtil {
|
||||
}
|
||||
|
||||
public String generateToken() {
|
||||
return generateToken("user", null);
|
||||
}
|
||||
|
||||
public String generateToken(String subject, String libraryId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject("user")
|
||||
var builder = Jwts.builder()
|
||||
.subject(subject)
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
.expiration(expiryDate);
|
||||
|
||||
// Add library context if provided
|
||||
if (libraryId != null) {
|
||||
builder.claim("libraryId", libraryId);
|
||||
}
|
||||
|
||||
return builder.signWith(getSigningKey()).compact();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
@@ -62,4 +71,13 @@ public class JwtUtil {
|
||||
public String getSubjectFromToken(String token) {
|
||||
return getClaimsFromToken(token).getSubject();
|
||||
}
|
||||
|
||||
public String getLibraryIdFromToken(String token) {
|
||||
try {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
return claims.get("libraryId", String.class);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,8 @@ class StoryServiceTest {
|
||||
null, // tagService - not needed for reading progress tests
|
||||
null, // seriesService - not needed for reading progress tests
|
||||
null, // sanitizationService - not needed for reading progress tests
|
||||
null // typesenseService - will test both with and without
|
||||
null, // typesenseService - will test both with and without
|
||||
null // libraryAwareService - not needed for these tests
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user