initial statistics implementation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1053,15 +1053,23 @@ export const clearLibraryCache = (): void => {
|
||||
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
|
||||
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}`;
|
||||
|
||||
@@ -204,4 +204,33 @@ export interface FilterPreset {
|
||||
description?: string;
|
||||
filters: Partial<AdvancedFilters>;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user