feat: change volume from string to integer type
Parser: - Change volume type from Option<String> to Option<i32> - Parse volume as integer to remove leading zeros - Keep original title with volume info Indexer: - Update SQL queries to insert volume as integer - Add volume column to INSERT and UPDATE statements API: - Change BookItem.volume and BookDetails.volume to Option<i32> - Add natural sorting for books Backoffice: - Update volume type to number - Update book detail page - Add CSS styles
This commit is contained in:
@@ -31,7 +31,7 @@ pub struct BookItem {
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
pub page_count: Option<i32>,
|
||||
#[schema(value_type = String)]
|
||||
@@ -55,7 +55,7 @@ pub struct BookDetails {
|
||||
pub title: String,
|
||||
pub author: Option<String>,
|
||||
pub series: Option<String>,
|
||||
pub volume: Option<String>,
|
||||
pub volume: Option<i32>,
|
||||
pub language: Option<String>,
|
||||
pub page_count: Option<i32>,
|
||||
pub file_path: Option<String>,
|
||||
@@ -102,7 +102,16 @@ pub async fn list_books(
|
||||
AND ($2::text IS NULL OR kind = $2)
|
||||
AND ($3::uuid IS NULL OR id > $3)
|
||||
{}
|
||||
ORDER BY id ASC
|
||||
ORDER BY
|
||||
-- Extract text part before numbers (case insensitive)
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||
-- Extract first number group and convert to integer for numeric sort
|
||||
COALESCE(
|
||||
NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int,
|
||||
0
|
||||
),
|
||||
-- Then by full title as fallback
|
||||
title ASC
|
||||
LIMIT $4
|
||||
"#,
|
||||
series_condition
|
||||
@@ -235,11 +244,18 @@ pub async fn list_series(
|
||||
) -> Result<Json<Vec<SeriesItem>>, ApiError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
WITH series_books AS (
|
||||
WITH sorted_books AS (
|
||||
SELECT
|
||||
COALESCE(NULLIF(series, ''), 'unclassified') as name,
|
||||
id,
|
||||
ROW_NUMBER() OVER (PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') ORDER BY id) as rn
|
||||
-- Natural sort order for books within series
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||
ORDER BY
|
||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||
COALESCE(NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int, 0),
|
||||
title ASC
|
||||
) as rn
|
||||
FROM books
|
||||
WHERE library_id = $1
|
||||
),
|
||||
@@ -247,7 +263,7 @@ pub async fn list_series(
|
||||
SELECT
|
||||
name,
|
||||
COUNT(*) as book_count
|
||||
FROM series_books
|
||||
FROM sorted_books
|
||||
GROUP BY name
|
||||
)
|
||||
SELECT
|
||||
@@ -255,8 +271,16 @@ pub async fn list_series(
|
||||
sc.book_count,
|
||||
sb.id as first_book_id
|
||||
FROM series_counts sc
|
||||
JOIN series_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
ORDER BY sc.name ASC
|
||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
||||
ORDER BY
|
||||
-- Natural sort: extract text part before numbers
|
||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
|
||||
-- Extract first number group and convert to integer
|
||||
COALESCE(
|
||||
NULLIF(REGEXP_REPLACE(LOWER(sc.name), '^[^0-9]*', '', 'g'), '')::int,
|
||||
0
|
||||
),
|
||||
sc.name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(library_id)
|
||||
|
||||
@@ -68,6 +68,13 @@ export default async function BookDetailPage({
|
||||
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
|
||||
</div>
|
||||
|
||||
{book.volume && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Volume:</span>
|
||||
<span>{book.volume}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.language && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Language:</span>
|
||||
@@ -87,10 +94,50 @@ export default async function BookDetailPage({
|
||||
<span>{library?.name || book.library_id}</span>
|
||||
</div>
|
||||
|
||||
{book.series && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Series:</span>
|
||||
<span>{book.series}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_format && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">File Format:</span>
|
||||
<span>{book.file_format.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_parse_status && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Parse Status:</span>
|
||||
<span className={`status-${book.file_parse_status}`}>{book.file_parse_status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{book.file_path && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">File Path:</span>
|
||||
<code className="file-path">{book.file_path}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">ID:</span>
|
||||
<span className="meta-label">Book ID:</span>
|
||||
<code className="book-id">{book.id}</code>
|
||||
</div>
|
||||
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Library ID:</span>
|
||||
<code className="book-id">{book.library_id}</code>
|
||||
</div>
|
||||
|
||||
{book.updated_at && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Updated:</span>
|
||||
<span>{new Date(book.updated_at).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,9 @@ export default async function BooksPage({
|
||||
volume: hit.volume,
|
||||
language: hit.language,
|
||||
page_count: null,
|
||||
file_path: null,
|
||||
file_format: null,
|
||||
file_parse_status: null,
|
||||
updated_at: ""
|
||||
}));
|
||||
totalHits = searchResponse.estimated_total_hits;
|
||||
|
||||
@@ -706,3 +706,26 @@ button:hover {
|
||||
.dark .series-cover {
|
||||
background: linear-gradient(135deg, hsl(221 24% 20%), hsl(221 24% 15%));
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-ok {
|
||||
color: hsl(142 60% 45%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: hsl(2 72% 48%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: hsl(45 93% 47%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
max-width: 400px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -37,9 +37,12 @@ export type BookDto = {
|
||||
title: string;
|
||||
author: string | null;
|
||||
series: string | null;
|
||||
volume: string | null;
|
||||
volume: number | null;
|
||||
language: string | null;
|
||||
page_count: number | null;
|
||||
file_path: string | null;
|
||||
file_format: string | null;
|
||||
file_parse_status: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -54,7 +57,7 @@ export type SearchHitDto = {
|
||||
title: string;
|
||||
author: string | null;
|
||||
series: string | null;
|
||||
volume: string | null;
|
||||
volume: number | null;
|
||||
kind: string;
|
||||
language: string | null;
|
||||
};
|
||||
|
||||
@@ -231,12 +231,13 @@ async fn scan_library(
|
||||
match parse_metadata(path, format, root) {
|
||||
Ok(parsed) => {
|
||||
sqlx::query(
|
||||
"UPDATE books SET title = $2, kind = $3, series = $4, page_count = $5, updated_at = NOW() WHERE id = $1",
|
||||
"UPDATE books SET title = $2, kind = $3, series = $4, volume = $5, page_count = $6, updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(book_id)
|
||||
.bind(parsed.title)
|
||||
.bind(&parsed.title)
|
||||
.bind(kind_from_format(format))
|
||||
.bind(parsed.series)
|
||||
.bind(&parsed.series)
|
||||
.bind(&parsed.volume)
|
||||
.bind(parsed.page_count)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
@@ -274,13 +275,14 @@ async fn scan_library(
|
||||
let book_id = Uuid::new_v4();
|
||||
let file_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO books (id, library_id, kind, title, series, page_count) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"INSERT INTO books (id, library_id, kind, title, series, volume, page_count) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(book_id)
|
||||
.bind(library_id)
|
||||
.bind(kind_from_format(format))
|
||||
.bind(parsed.title)
|
||||
.bind(parsed.series)
|
||||
.bind(&parsed.title)
|
||||
.bind(&parsed.series)
|
||||
.bind(&parsed.volume)
|
||||
.bind(parsed.page_count)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user