Library Switching functionality

This commit is contained in:
Stefan Hardegger
2025-08-20 15:10:40 +02:00
parent 5e347f2e2e
commit 6128d61349
24 changed files with 2934 additions and 94 deletions

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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"));
}

View File

@@ -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"));
}
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}
/**

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;
}
}
}

View 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;
}
}

View File

@@ -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";
}
}

View File

@@ -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;
}
}

View File

@@ -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());
}

View File

@@ -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;
}
}
}

View File

@@ -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
);
}

View File

@@ -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;

View File

@@ -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>

View 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}
/>
</>
);
}

View 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>
);
}

View 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