diff --git a/backend/src/main/java/com/storycove/controller/LibraryStatisticsController.java b/backend/src/main/java/com/storycove/controller/LibraryStatisticsController.java new file mode 100644 index 0000000..1ddffda --- /dev/null +++ b/backend/src/main/java/com/storycove/controller/LibraryStatisticsController.java @@ -0,0 +1,57 @@ +package com.storycove.controller; + +import com.storycove.dto.LibraryOverviewStatsDto; +import com.storycove.service.LibraryService; +import com.storycove.service.LibraryStatisticsService; +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.*; + +@RestController +@RequestMapping("/api/libraries/{libraryId}/statistics") +public class LibraryStatisticsController { + + private static final Logger logger = LoggerFactory.getLogger(LibraryStatisticsController.class); + + @Autowired + private LibraryStatisticsService statisticsService; + + @Autowired + private LibraryService libraryService; + + /** + * Get overview statistics for a library + */ + @GetMapping("/overview") + public ResponseEntity getOverviewStatistics(@PathVariable String libraryId) { + try { + // Verify library exists + if (libraryService.getLibraryById(libraryId) == null) { + return ResponseEntity.notFound().build(); + } + + LibraryOverviewStatsDto stats = statisticsService.getOverviewStatistics(libraryId); + return ResponseEntity.ok(stats); + + } catch (Exception e) { + logger.error("Failed to get overview statistics for library: {}", libraryId, e); + return ResponseEntity.internalServerError() + .body(new ErrorResponse("Failed to retrieve statistics: " + e.getMessage())); + } + } + + // Error response DTO + private static class ErrorResponse { + private String error; + + public ErrorResponse(String error) { + this.error = error; + } + + public String getError() { + return error; + } + } +} diff --git a/backend/src/main/java/com/storycove/dto/LibraryOverviewStatsDto.java b/backend/src/main/java/com/storycove/dto/LibraryOverviewStatsDto.java new file mode 100644 index 0000000..655303b --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/LibraryOverviewStatsDto.java @@ -0,0 +1,183 @@ +package com.storycove.dto; + +public class LibraryOverviewStatsDto { + + // Collection Overview + private long totalStories; + private long totalAuthors; + private long totalSeries; + private long totalTags; + private long totalCollections; + private long uniqueSourceDomains; + + // Content Metrics + private long totalWordCount; + private double averageWordsPerStory; + private StoryWordCountDto longestStory; + private StoryWordCountDto shortestStory; + + // Reading Time (based on 250 words/minute) + private long totalReadingTimeMinutes; + private double averageReadingTimeMinutes; + + // Constructor + public LibraryOverviewStatsDto() { + } + + // Getters and Setters + public long getTotalStories() { + return totalStories; + } + + public void setTotalStories(long totalStories) { + this.totalStories = totalStories; + } + + public long getTotalAuthors() { + return totalAuthors; + } + + public void setTotalAuthors(long totalAuthors) { + this.totalAuthors = totalAuthors; + } + + public long getTotalSeries() { + return totalSeries; + } + + public void setTotalSeries(long totalSeries) { + this.totalSeries = totalSeries; + } + + public long getTotalTags() { + return totalTags; + } + + public void setTotalTags(long totalTags) { + this.totalTags = totalTags; + } + + public long getTotalCollections() { + return totalCollections; + } + + public void setTotalCollections(long totalCollections) { + this.totalCollections = totalCollections; + } + + public long getUniqueSourceDomains() { + return uniqueSourceDomains; + } + + public void setUniqueSourceDomains(long uniqueSourceDomains) { + this.uniqueSourceDomains = uniqueSourceDomains; + } + + public long getTotalWordCount() { + return totalWordCount; + } + + public void setTotalWordCount(long totalWordCount) { + this.totalWordCount = totalWordCount; + } + + public double getAverageWordsPerStory() { + return averageWordsPerStory; + } + + public void setAverageWordsPerStory(double averageWordsPerStory) { + this.averageWordsPerStory = averageWordsPerStory; + } + + public StoryWordCountDto getLongestStory() { + return longestStory; + } + + public void setLongestStory(StoryWordCountDto longestStory) { + this.longestStory = longestStory; + } + + public StoryWordCountDto getShortestStory() { + return shortestStory; + } + + public void setShortestStory(StoryWordCountDto shortestStory) { + this.shortestStory = shortestStory; + } + + public long getTotalReadingTimeMinutes() { + return totalReadingTimeMinutes; + } + + public void setTotalReadingTimeMinutes(long totalReadingTimeMinutes) { + this.totalReadingTimeMinutes = totalReadingTimeMinutes; + } + + public double getAverageReadingTimeMinutes() { + return averageReadingTimeMinutes; + } + + public void setAverageReadingTimeMinutes(double averageReadingTimeMinutes) { + this.averageReadingTimeMinutes = averageReadingTimeMinutes; + } + + // Nested DTO for story word count info + public static class StoryWordCountDto { + private String id; + private String title; + private String authorName; + private int wordCount; + private long readingTimeMinutes; + + public StoryWordCountDto() { + } + + public StoryWordCountDto(String id, String title, String authorName, int wordCount, long readingTimeMinutes) { + this.id = id; + this.title = title; + this.authorName = authorName; + this.wordCount = wordCount; + this.readingTimeMinutes = readingTimeMinutes; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public int getWordCount() { + return wordCount; + } + + public void setWordCount(int wordCount) { + this.wordCount = wordCount; + } + + public long getReadingTimeMinutes() { + return readingTimeMinutes; + } + + public void setReadingTimeMinutes(long readingTimeMinutes) { + this.readingTimeMinutes = readingTimeMinutes; + } + } +} diff --git a/backend/src/main/java/com/storycove/service/LibraryStatisticsService.java b/backend/src/main/java/com/storycove/service/LibraryStatisticsService.java new file mode 100644 index 0000000..9d2a29c --- /dev/null +++ b/backend/src/main/java/com/storycove/service/LibraryStatisticsService.java @@ -0,0 +1,257 @@ +package com.storycove.service; + +import com.storycove.config.SolrProperties; +import com.storycove.dto.LibraryOverviewStatsDto; +import com.storycove.dto.LibraryOverviewStatsDto.StoryWordCountDto; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrQuery; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.response.FacetField; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.params.StatsParams; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.Map; + +@Service +@ConditionalOnProperty( + value = "storycove.search.engine", + havingValue = "solr", + matchIfMissing = false +) +public class LibraryStatisticsService { + + private static final Logger logger = LoggerFactory.getLogger(LibraryStatisticsService.class); + private static final int WORDS_PER_MINUTE = 250; + + @Autowired(required = false) + private SolrClient solrClient; + + @Autowired + private SolrProperties properties; + + @Autowired + private LibraryService libraryService; + + /** + * Get overview statistics for a library + */ + public LibraryOverviewStatsDto getOverviewStatistics(String libraryId) throws IOException, SolrServerException { + LibraryOverviewStatsDto stats = new LibraryOverviewStatsDto(); + + // Collection Overview + stats.setTotalStories(getTotalStories(libraryId)); + stats.setTotalAuthors(getTotalAuthors(libraryId)); + stats.setTotalSeries(getTotalSeries(libraryId)); + stats.setTotalTags(getTotalTags(libraryId)); + stats.setTotalCollections(getTotalCollections(libraryId)); + stats.setUniqueSourceDomains(getUniqueSourceDomains(libraryId)); + + // Content Metrics - use Solr Stats Component + WordCountStats wordStats = getWordCountStatistics(libraryId); + stats.setTotalWordCount(wordStats.sum); + stats.setAverageWordsPerStory(wordStats.mean); + stats.setLongestStory(getLongestStory(libraryId)); + stats.setShortestStory(getShortestStory(libraryId)); + + // Reading Time + stats.setTotalReadingTimeMinutes(wordStats.sum / WORDS_PER_MINUTE); + stats.setAverageReadingTimeMinutes(wordStats.mean / WORDS_PER_MINUTE); + + return stats; + } + + /** + * Get total number of stories in library + */ + private long getTotalStories(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.setRows(0); // We only want the count + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + return response.getResults().getNumFound(); + } + + /** + * Get total number of authors in library + */ + private long getTotalAuthors(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.setRows(0); + + QueryResponse response = solrClient.query(properties.getCores().getAuthors(), query); + return response.getResults().getNumFound(); + } + + /** + * Get total number of series using faceting on seriesId + */ + private long getTotalSeries(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.addFilterQuery("seriesId:[* TO *]"); // Only stories that have a series + query.setRows(0); + query.setFacet(true); + query.addFacetField("seriesId"); + query.setFacetLimit(-1); // Get all unique series + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + FacetField seriesFacet = response.getFacetField("seriesId"); + + return (seriesFacet != null && seriesFacet.getValues() != null) + ? seriesFacet.getValueCount() + : 0; + } + + /** + * Get total number of unique tags using faceting + */ + private long getTotalTags(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.setRows(0); + query.setFacet(true); + query.addFacetField("tagNames"); + query.setFacetLimit(-1); // Get all unique tags + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + FacetField tagsFacet = response.getFacetField("tagNames"); + + return (tagsFacet != null && tagsFacet.getValues() != null) + ? tagsFacet.getValueCount() + : 0; + } + + /** + * Get total number of collections + */ + private long getTotalCollections(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.setRows(0); + + QueryResponse response = solrClient.query(properties.getCores().getCollections(), query); + return response.getResults().getNumFound(); + } + + /** + * Get number of unique source domains using faceting + */ + private long getUniqueSourceDomains(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.addFilterQuery("sourceDomain:[* TO *]"); // Only stories with a source domain + query.setRows(0); + query.setFacet(true); + query.addFacetField("sourceDomain"); + query.setFacetLimit(-1); + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + FacetField domainFacet = response.getFacetField("sourceDomain"); + + return (domainFacet != null && domainFacet.getValues() != null) + ? domainFacet.getValueCount() + : 0; + } + + /** + * Get word count statistics using Solr Stats Component + */ + private WordCountStats getWordCountStatistics(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.setRows(0); + query.setParam(StatsParams.STATS, true); + query.setParam(StatsParams.STATS_FIELD, "wordCount"); + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + + WordCountStats stats = new WordCountStats(); + + // Extract stats from response + var fieldStatsInfo = response.getFieldStatsInfo(); + if (fieldStatsInfo != null && fieldStatsInfo.get("wordCount") != null) { + var fieldStat = fieldStatsInfo.get("wordCount"); + + Object sumObj = fieldStat.getSum(); + Object meanObj = fieldStat.getMean(); + + stats.sum = (sumObj != null) ? ((Number) sumObj).longValue() : 0L; + stats.mean = (meanObj != null) ? ((Number) meanObj).doubleValue() : 0.0; + } + + return stats; + } + + /** + * Get the longest story in the library + */ + private StoryWordCountDto getLongestStory(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.addFilterQuery("wordCount:[1 TO *]"); // Exclude stories with 0 words + query.setSort("wordCount", SolrQuery.ORDER.desc); + query.setRows(1); + query.setFields("id", "title", "authorName", "wordCount"); + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + + if (response.getResults().isEmpty()) { + return null; + } + + SolrDocument doc = response.getResults().get(0); + return createStoryWordCountDto(doc); + } + + /** + * Get the shortest story in the library (excluding 0 word count) + */ + private StoryWordCountDto getShortestStory(String libraryId) throws IOException, SolrServerException { + SolrQuery query = new SolrQuery("*:*"); + query.addFilterQuery("libraryId:" + libraryId); + query.addFilterQuery("wordCount:[1 TO *]"); // Exclude stories with 0 words + query.setSort("wordCount", SolrQuery.ORDER.asc); + query.setRows(1); + query.setFields("id", "title", "authorName", "wordCount"); + + QueryResponse response = solrClient.query(properties.getCores().getStories(), query); + + if (response.getResults().isEmpty()) { + return null; + } + + SolrDocument doc = response.getResults().get(0); + return createStoryWordCountDto(doc); + } + + /** + * Helper method to create StoryWordCountDto from Solr document + */ + private StoryWordCountDto createStoryWordCountDto(SolrDocument doc) { + String id = (String) doc.getFieldValue("id"); + String title = (String) doc.getFieldValue("title"); + String authorName = (String) doc.getFieldValue("authorName"); + Object wordCountObj = doc.getFieldValue("wordCount"); + int wordCount = (wordCountObj != null) ? ((Number) wordCountObj).intValue() : 0; + long readingTime = wordCount / WORDS_PER_MINUTE; + + return new StoryWordCountDto(id, title, authorName, wordCount, readingTime); + } + + /** + * Helper class to hold word count statistics + */ + private static class WordCountStats { + long sum = 0; + double mean = 0.0; + } +} diff --git a/backend/src/main/java/com/storycove/service/SolrService.java b/backend/src/main/java/com/storycove/service/SolrService.java index 25cec5d..444319e 100644 --- a/backend/src/main/java/com/storycove/service/SolrService.java +++ b/backend/src/main/java/com/storycove/service/SolrService.java @@ -385,9 +385,69 @@ public class SolrService { logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage()); } + // Add derived fields for statistics (Phase 1) + addDerivedStatisticsFields(doc, story); + return doc; } + /** + * Add derived fields to support statistics queries + */ + private void addDerivedStatisticsFields(SolrInputDocument doc, Story story) { + try { + // Boolean flags for filtering + doc.addField("hasDescription", story.getDescription() != null && !story.getDescription().trim().isEmpty()); + doc.addField("hasCoverImage", story.getCoverPath() != null && !story.getCoverPath().trim().isEmpty()); + doc.addField("hasRating", story.getRating() != null && story.getRating() > 0); + + // Extract source domain from URL + if (story.getSourceUrl() != null && !story.getSourceUrl().trim().isEmpty()) { + String domain = extractDomain(story.getSourceUrl()); + if (domain != null) { + doc.addField("sourceDomain", domain); + } + } + + // Tag count for statistics + int tagCount = (story.getTags() != null) ? story.getTags().size() : 0; + doc.addField("tagCount", tagCount); + + } catch (Exception e) { + // Don't fail indexing if derived fields can't be added + logger.debug("Could not add some derived statistics fields: {}", e.getMessage()); + } + } + + /** + * Extract domain from URL for source statistics + */ + private String extractDomain(String url) { + try { + if (url == null || url.trim().isEmpty()) { + return null; + } + + // Handle URLs without protocol + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + + java.net.URL parsedUrl = new java.net.URL(url); + String host = parsedUrl.getHost(); + + // Remove www. prefix if present + if (host.startsWith("www.")) { + host = host.substring(4); + } + + return host; + } catch (Exception e) { + logger.debug("Failed to extract domain from URL: {}", url); + return null; + } + } + private SolrInputDocument createAuthorDocument(Author author) { SolrInputDocument doc = new SolrInputDocument(); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bbd57ec..581ec01 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1053,15 +1053,23 @@ export const clearLibraryCache = (): void => { currentLibraryId = null; }; +// Library statistics endpoints +export const statisticsApi = { + getOverviewStatistics: async (libraryId: string): Promise => { + const response = await api.get(`/libraries/${libraryId}/statistics/overview`); + return response.data; + }, +}; + // Image utility - now library-aware export const getImageUrl = (path: string): string => { if (!path) return ''; - + // For compatibility during transition, handle both patterns if (path.startsWith('http')) { return path; // External URL } - + // Use library-aware API endpoint const libraryId = getCurrentLibraryId(); return `/api/files/images/${libraryId}/${path}`; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 029a223..20cd32d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -204,4 +204,33 @@ export interface FilterPreset { description?: string; filters: Partial; category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization'; +} + +// Library Statistics +export interface LibraryOverviewStats { + // Collection Overview + totalStories: number; + totalAuthors: number; + totalSeries: number; + totalTags: number; + totalCollections: number; + uniqueSourceDomains: number; + + // Content Metrics + totalWordCount: number; + averageWordsPerStory: number; + longestStory: StoryWordCount | null; + shortestStory: StoryWordCount | null; + + // Reading Time + totalReadingTimeMinutes: number; + averageReadingTimeMinutes: number; +} + +export interface StoryWordCount { + id: string; + title: string; + authorName: string; + wordCount: number; + readingTimeMinutes: number; } \ No newline at end of file