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,9 +67,37 @@ public class AuthorService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Author findById(UUID id) {
|
||||
// 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)
|
||||
public Optional<Author> findByIdOptional(UUID id) {
|
||||
@@ -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;
|
||||
@@ -45,6 +47,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,15 +85,54 @@ public class StoryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Story findById(UUID id) {
|
||||
// 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) {
|
||||
return storyRepository.findByTitle(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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ services:
|
||||
- STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
|
||||
volumes:
|
||||
- images_data:/app/images
|
||||
- library_config:/app/config
|
||||
depends_on:
|
||||
- postgres
|
||||
- typesense
|
||||
@@ -75,6 +76,7 @@ volumes:
|
||||
postgres_data:
|
||||
typesense_data:
|
||||
images_data:
|
||||
library_config:
|
||||
|
||||
configs:
|
||||
nginx_config:
|
||||
@@ -91,7 +93,7 @@ configs:
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size 10M;
|
||||
client_max_body_size 256M;
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from '../../lib/theme';
|
||||
import Button from '../../components/ui/Button';
|
||||
import { storyApi, authorApi, databaseApi } from '../../lib/api';
|
||||
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
|
||||
import LibrarySettings from '../../components/library/LibrarySettings';
|
||||
|
||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
@@ -774,6 +775,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Library Settings */}
|
||||
<LibrarySettings />
|
||||
|
||||
{/* Tag Management */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold theme-header mb-4">Tag Management</h2>
|
||||
|
||||
602
frontend/src/components/library/LibrarySettings.tsx
Normal file
602
frontend/src/components/library/LibrarySettings.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Button from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import LibrarySwitchLoader from '../ui/LibrarySwitchLoader';
|
||||
import { useLibrarySwitch } from '../../hooks/useLibrarySwitch';
|
||||
|
||||
interface Library {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
export default function LibrarySettings() {
|
||||
const router = useRouter();
|
||||
const { state: switchState, switchLibrary, clearError, reset } = useLibrarySwitch();
|
||||
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
const [currentLibrary, setCurrentLibrary] = useState<Library | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [switchPassword, setSwitchPassword] = useState('');
|
||||
const [showSwitchForm, setShowSwitchForm] = useState(false);
|
||||
const [passwordChangeForm, setPasswordChangeForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [showPasswordChangeForm, setShowPasswordChangeForm] = useState(false);
|
||||
const [passwordChangeLoading, setPasswordChangeLoading] = useState(false);
|
||||
const [passwordChangeMessage, setPasswordChangeMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||
const [createLibraryForm, setCreateLibraryForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [showCreateLibraryForm, setShowCreateLibraryForm] = useState(false);
|
||||
const [createLibraryLoading, setCreateLibraryLoading] = useState(false);
|
||||
const [createLibraryMessage, setCreateLibraryMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadLibraries();
|
||||
loadCurrentLibrary();
|
||||
}, []);
|
||||
|
||||
const loadLibraries = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/libraries');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLibraries(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load libraries:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentLibrary = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/libraries/current');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentLibrary(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load current library:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchLibrary = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!switchPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await switchLibrary(switchPassword);
|
||||
if (success) {
|
||||
// The LibrarySwitchLoader will handle the rest
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchComplete = () => {
|
||||
// Refresh the page to reload with new library context
|
||||
router.refresh();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleSwitchError = (error: string) => {
|
||||
console.error('Library switch error:', error);
|
||||
reset();
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (passwordChangeForm.newPassword !== passwordChangeForm.confirmPassword) {
|
||||
setPasswordChangeMessage({type: 'error', text: 'New passwords do not match'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordChangeForm.newPassword.length < 8) {
|
||||
setPasswordChangeMessage({type: 'error', text: 'Password must be at least 8 characters long'});
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordChangeLoading(true);
|
||||
setPasswordChangeMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/libraries/password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword: passwordChangeForm.currentPassword,
|
||||
newPassword: passwordChangeForm.newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setPasswordChangeMessage({type: 'success', text: 'Password changed successfully'});
|
||||
setPasswordChangeForm({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setShowPasswordChangeForm(false);
|
||||
} else {
|
||||
setPasswordChangeMessage({type: 'error', text: data.error || 'Failed to change password'});
|
||||
}
|
||||
} catch (error) {
|
||||
setPasswordChangeMessage({type: 'error', text: 'Network error occurred'});
|
||||
} finally {
|
||||
setPasswordChangeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateLibrary = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (createLibraryForm.password !== createLibraryForm.confirmPassword) {
|
||||
setCreateLibraryMessage({type: 'error', text: 'Passwords do not match'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (createLibraryForm.password.length < 8) {
|
||||
setCreateLibraryMessage({type: 'error', text: 'Password must be at least 8 characters long'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (createLibraryForm.name.trim().length < 2) {
|
||||
setCreateLibraryMessage({type: 'error', text: 'Library name must be at least 2 characters long'});
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateLibraryLoading(true);
|
||||
setCreateLibraryMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/libraries/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: createLibraryForm.name.trim(),
|
||||
description: createLibraryForm.description.trim(),
|
||||
password: createLibraryForm.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
setCreateLibraryMessage({
|
||||
type: 'success',
|
||||
text: `Library "${data.library.name}" created successfully! You can now log out and log in with the new password to access it.`
|
||||
});
|
||||
setCreateLibraryForm({
|
||||
name: '',
|
||||
description: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setShowCreateLibraryForm(false);
|
||||
loadLibraries(); // Refresh the library list
|
||||
} else {
|
||||
setCreateLibraryMessage({type: 'error', text: data.error || 'Failed to create library'});
|
||||
}
|
||||
} catch (error) {
|
||||
setCreateLibraryMessage({type: 'error', text: 'Network error occurred'});
|
||||
} finally {
|
||||
setCreateLibraryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Library Settings
|
||||
</h2>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Library Settings
|
||||
</h2>
|
||||
|
||||
{/* Current Library Info */}
|
||||
{currentLibrary && (
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
Active Library
|
||||
</h3>
|
||||
<p className="text-blue-700 dark:text-blue-300 text-sm">
|
||||
<strong>{currentLibrary.name}</strong>
|
||||
</p>
|
||||
<p className="text-blue-600 dark:text-blue-400 text-xs mt-1">
|
||||
{currentLibrary.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Password Section */}
|
||||
<div className="mb-6 border-t pt-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Change Library Password
|
||||
</h3>
|
||||
|
||||
{passwordChangeMessage && (
|
||||
<div className={`p-3 rounded-lg mb-4 ${
|
||||
passwordChangeMessage.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<p className={`text-sm ${
|
||||
passwordChangeMessage.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{passwordChangeMessage.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showPasswordChangeForm ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
Change the password for the current library ({currentLibrary?.name}).
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowPasswordChangeForm(true)}
|
||||
variant="secondary"
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Current Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordChangeForm.currentPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordChangeForm(prev => ({ ...prev, currentPassword: e.target.value }))
|
||||
}
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordChangeForm.newPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordChangeForm(prev => ({ ...prev, newPassword: e.target.value }))
|
||||
}
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordChangeForm.confirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPasswordChangeForm(prev => ({ ...prev, confirmPassword: e.target.value }))
|
||||
}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={passwordChangeLoading}
|
||||
loading={passwordChangeLoading}
|
||||
>
|
||||
{passwordChangeLoading ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowPasswordChangeForm(false);
|
||||
setPasswordChangeForm({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setPasswordChangeMessage(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Libraries */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Available Libraries
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{libraries.map((library) => (
|
||||
<div
|
||||
key={library.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
library.isActive
|
||||
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{library.name}
|
||||
{library.isActive && (
|
||||
<span className="ml-2 text-xs px-2 py-1 bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{library.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!library.isActive && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
ID: {library.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch Library Section */}
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Switch Library
|
||||
</h3>
|
||||
|
||||
{!showSwitchForm ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
Enter the password for a different library to switch to it.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowSwitchForm(true)}
|
||||
variant="secondary"
|
||||
>
|
||||
Switch to Different Library
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSwitchLibrary} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Library Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={switchPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSwitchPassword(e.target.value)}
|
||||
placeholder="Enter password for the library you want to access"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{switchState.error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
{switchState.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<Button type="submit" disabled={switchState.isLoading}>
|
||||
{switchState.isLoading ? 'Switching...' : 'Switch Library'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowSwitchForm(false);
|
||||
setSwitchPassword('');
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create New Library Section */}
|
||||
<div className="border-t pt-4 mb-6">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
Create New Library
|
||||
</h3>
|
||||
|
||||
{createLibraryMessage && (
|
||||
<div className={`p-3 rounded-lg mb-4 ${
|
||||
createLibraryMessage.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<p className={`text-sm ${
|
||||
createLibraryMessage.type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{createLibraryMessage.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showCreateLibraryForm ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
Create a completely separate library with its own stories, authors, and password.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowCreateLibraryForm(true)}
|
||||
variant="secondary"
|
||||
>
|
||||
Create New Library
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleCreateLibrary} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Library Name *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createLibraryForm.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateLibraryForm(prev => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g., Private Stories, Work Collection"
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={createLibraryForm.description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateLibraryForm(prev => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional description for this library"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password *
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={createLibraryForm.password}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateLibraryForm(prev => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
placeholder="Enter password (min 8 characters)"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm Password *
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={createLibraryForm.confirmPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCreateLibraryForm(prev => ({ ...prev, confirmPassword: e.target.value }))
|
||||
}
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createLibraryLoading}
|
||||
loading={createLibraryLoading}
|
||||
>
|
||||
{createLibraryLoading ? 'Creating...' : 'Create Library'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateLibraryForm(false);
|
||||
setCreateLibraryForm({
|
||||
name: '',
|
||||
description: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
setCreateLibraryMessage(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Libraries are completely separate datasets. Switching libraries
|
||||
will reload the application with a different set of stories, authors, and settings.
|
||||
Each library has its own password for security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Library Switch Loader */}
|
||||
<LibrarySwitchLoader
|
||||
isVisible={switchState.isLoading}
|
||||
targetLibraryName={switchState.targetLibraryName || undefined}
|
||||
onComplete={handleSwitchComplete}
|
||||
onError={handleSwitchError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/ui/LibrarySwitchLoader.tsx
Normal file
106
frontend/src/components/ui/LibrarySwitchLoader.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface LibrarySwitchLoaderProps {
|
||||
isVisible: boolean;
|
||||
targetLibraryName?: string;
|
||||
onComplete: () => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export default function LibrarySwitchLoader({
|
||||
isVisible,
|
||||
targetLibraryName,
|
||||
onComplete,
|
||||
onError
|
||||
}: LibrarySwitchLoaderProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
// Animate dots
|
||||
const dotsInterval = setInterval(() => {
|
||||
setDots(prev => prev.length >= 3 ? '' : prev + '.');
|
||||
}, 500);
|
||||
|
||||
// Track time elapsed
|
||||
const timeInterval = setInterval(() => {
|
||||
setTimeElapsed(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// Poll for completion
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/libraries/switch/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.ready) {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(dotsInterval);
|
||||
clearInterval(timeInterval);
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling switch status:', error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(dotsInterval);
|
||||
clearInterval(timeInterval);
|
||||
onError('Library switch timed out. Please try again.');
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(dotsInterval);
|
||||
clearInterval(timeInterval);
|
||||
clearInterval(pollInterval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isVisible, onComplete, onError]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
|
||||
<div className="mb-6">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
|
||||
Switching Libraries
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{targetLibraryName ?
|
||||
`Loading "${targetLibraryName}"${dots}` :
|
||||
`Preparing your library${dots}`
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>This may take a few seconds...</p>
|
||||
{timeElapsed > 5 && (
|
||||
<p className="mt-2 text-orange-600 dark:text-orange-400">
|
||||
Still working ({timeElapsed}s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
💡 Libraries are completely separate datasets with their own stories, authors, and settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
frontend/src/hooks/useLibrarySwitch.ts
Normal file
118
frontend/src/hooks/useLibrarySwitch.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface LibrarySwitchState {
|
||||
isLoading: boolean;
|
||||
targetLibraryName: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface LibrarySwitchResult {
|
||||
state: LibrarySwitchState;
|
||||
switchLibrary: (password: string) => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useLibrarySwitch(): LibrarySwitchResult {
|
||||
const [state, setState] = useState<LibrarySwitchState>({
|
||||
isLoading: false,
|
||||
targetLibraryName: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const switchLibrary = useCallback(async (password: string): Promise<boolean> => {
|
||||
setState({
|
||||
isLoading: true,
|
||||
targetLibraryName: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/libraries/switch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: data.error || 'Failed to switch library',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.status === 'already_active') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: data.message,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.status === 'switching') {
|
||||
// Get library name if available
|
||||
try {
|
||||
const librariesResponse = await fetch('/api/libraries');
|
||||
if (librariesResponse.ok) {
|
||||
const libraries = await librariesResponse.json();
|
||||
const targetLibrary = libraries.find((lib: any) => lib.id === data.targetLibrary);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
targetLibraryName: targetLibrary?.name || data.targetLibrary,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue without library name
|
||||
}
|
||||
|
||||
return true; // Switch initiated successfully
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Unexpected response from server',
|
||||
}));
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Network error occurred',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
isLoading: false,
|
||||
targetLibraryName: null,
|
||||
error: null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
switchLibrary,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user