initial statistics implementation

This commit is contained in:
Stefan Hardegger
2025-10-20 08:50:12 +02:00
parent 30c0132a92
commit 378265c3a3
6 changed files with 596 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -385,9 +385,69 @@ public class SolrService {
logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage()); 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; 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) { private SolrInputDocument createAuthorDocument(Author author) {
SolrInputDocument doc = new SolrInputDocument(); SolrInputDocument doc = new SolrInputDocument();

View File

@@ -1053,6 +1053,14 @@ export const clearLibraryCache = (): void => {
currentLibraryId = null; currentLibraryId = null;
}; };
// Library statistics endpoints
export const statisticsApi = {
getOverviewStatistics: async (libraryId: string): Promise<import('../types/api').LibraryOverviewStats> => {
const response = await api.get(`/libraries/${libraryId}/statistics/overview`);
return response.data;
},
};
// Image utility - now library-aware // Image utility - now library-aware
export const getImageUrl = (path: string): string => { export const getImageUrl = (path: string): string => {
if (!path) return ''; if (!path) return '';

View File

@@ -205,3 +205,32 @@ export interface FilterPreset {
filters: Partial<AdvancedFilters>; filters: Partial<AdvancedFilters>;
category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization'; 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;
}