Compare commits
5 Commits
feat/cover
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679abae736 | ||
|
|
b812c6d16c | ||
|
|
e93eec89e5 | ||
|
|
8818c890c5 | ||
|
|
c134bdbbde |
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.50.0"
|
version = "0.52.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.50.0"
|
version = "0.52.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
18
backend/migrations/0021_chapter_source_index.sql
Normal file
18
backend/migrations/0021_chapter_source_index.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Capture each chapter's position in the source site's chapter list so
|
||||||
|
-- the user-facing list can preserve site order: variants of the same
|
||||||
|
-- chapter number (e.g. "Ch.14 : PH" next to "Ch.14 : Official") stay
|
||||||
|
-- adjacent, and non-numeric entries like "notice. : Officials" land
|
||||||
|
-- where the site placed them rather than clustering at the top under
|
||||||
|
-- number = 0.
|
||||||
|
--
|
||||||
|
-- Lower source_index = closer to the top of the source DOM = newer
|
||||||
|
-- chapter on this site (it renders newest-first). The list query
|
||||||
|
-- reverses this with ORDER BY source_index DESC so the oldest chapter
|
||||||
|
-- appears first in our UI.
|
||||||
|
--
|
||||||
|
-- NULL is the sentinel for user-uploaded chapters (no source row) and
|
||||||
|
-- for crawled rows that pre-date this migration. The list query keeps
|
||||||
|
-- the existing (number, created_at) tiebreak via NULLS LAST so those
|
||||||
|
-- fall through to the prior behaviour until the next crawler tick
|
||||||
|
-- populates the column.
|
||||||
|
ALTER TABLE chapters ADD COLUMN source_index INTEGER;
|
||||||
@@ -104,6 +104,12 @@ pub async fn enqueue(pool: &PgPool, payload: &JobPayload) -> sqlx::Result<Enqueu
|
|||||||
///
|
///
|
||||||
/// `kind_filter` matches against `payload->>'kind'`; `None` means
|
/// `kind_filter` matches against `payload->>'kind'`; `None` means
|
||||||
/// any kind.
|
/// any kind.
|
||||||
|
///
|
||||||
|
/// Ties on `scheduled_at` (the common case: a cron batch enqueues
|
||||||
|
/// everything with the same default `now()`) break by `created_at`, so
|
||||||
|
/// jobs come off the queue in insertion order. The enqueue paths insert
|
||||||
|
/// chapter-content jobs in ascending `chapters.number` order, so this
|
||||||
|
/// tiebreaker is what propagates that intent through to dequeue.
|
||||||
pub async fn lease(
|
pub async fn lease(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
kind_filter: Option<&str>,
|
kind_filter: Option<&str>,
|
||||||
@@ -118,7 +124,7 @@ pub async fn lease(
|
|||||||
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
|
WHERE (state = 'pending' OR (state = 'running' AND leased_until < now()))
|
||||||
AND scheduled_at <= now()
|
AND scheduled_at <= now()
|
||||||
AND ($1::text IS NULL OR payload->>'kind' = $1)
|
AND ($1::text IS NULL OR payload->>'kind' = $1)
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at, created_at
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -429,8 +429,8 @@ pub async fn enqueue_bookmarked_pending(pool: &PgPool) -> anyhow::Result<Enqueue
|
|||||||
AND cj.state = 'dead'
|
AND cj.state = 'dead'
|
||||||
AND cj.updated_at > now() - ($1::bigint || ' days')::interval
|
AND cj.updated_at > now() - ($1::bigint || ' days')::interval
|
||||||
)
|
)
|
||||||
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.created_at
|
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.manga_id, c.number, c.created_at
|
||||||
ORDER BY c.manga_id, c.created_at ASC
|
ORDER BY c.manga_id, c.number ASC, c.created_at ASC
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(CHAPTER_DEAD_QUARANTINE_DAYS)
|
.bind(CHAPTER_DEAD_QUARANTINE_DAYS)
|
||||||
@@ -471,7 +471,7 @@ pub async fn enqueue_pending_for_manga(
|
|||||||
) -> anyhow::Result<EnqueueSummary> {
|
) -> anyhow::Result<EnqueueSummary> {
|
||||||
let rows: Vec<(String, Uuid, String)> = sqlx::query_as(
|
let rows: Vec<(String, Uuid, String)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT DISTINCT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
|
SELECT cs.source_id, c.id AS chapter_id, cs.source_chapter_key
|
||||||
FROM chapters c
|
FROM chapters c
|
||||||
JOIN chapter_sources cs ON cs.chapter_id = c.id
|
JOIN chapter_sources cs ON cs.chapter_id = c.id
|
||||||
WHERE c.manga_id = $1
|
WHERE c.manga_id = $1
|
||||||
@@ -484,7 +484,8 @@ pub async fn enqueue_pending_for_manga(
|
|||||||
AND cj.state = 'dead'
|
AND cj.state = 'dead'
|
||||||
AND cj.updated_at > now() - ($2::bigint || ' days')::interval
|
AND cj.updated_at > now() - ($2::bigint || ' days')::interval
|
||||||
)
|
)
|
||||||
ORDER BY cs.source_id, c.id
|
GROUP BY cs.source_id, c.id, cs.source_chapter_key, c.number, c.created_at
|
||||||
|
ORDER BY c.number ASC, c.created_at ASC, cs.source_id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(manga_id)
|
.bind(manga_id)
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ pub async fn list_for_manga(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> AppResult<Vec<Chapter>> {
|
) -> AppResult<Vec<Chapter>> {
|
||||||
// Secondary sort by created_at gives duplicate-numbered chapters
|
// Display order = source-site order reversed. The crawler stamps
|
||||||
// (multiple uploaders/translations of the same number) a stable
|
// `source_index` = position in the source DOM (0 = first = newest
|
||||||
// order in lists and prev/next reader navigation.
|
// on this site, see migration 0021), so DESC puts the oldest
|
||||||
|
// chapter first and keeps the site's variant grouping and the
|
||||||
|
// placement of non-numeric entries (e.g. "notice. : Officials")
|
||||||
|
// intact. NULLS LAST keeps user-uploaded chapters (no source row)
|
||||||
|
// and rows that pre-date the migration below crawled rows; the
|
||||||
|
// (number, created_at) tail then orders them deterministically.
|
||||||
let rows = sqlx::query_as::<_, Chapter>(
|
let rows = sqlx::query_as::<_, Chapter>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, manga_id, number, title, page_count, created_at
|
SELECT id, manga_id, number, title, page_count, created_at
|
||||||
FROM chapters
|
FROM chapters
|
||||||
WHERE manga_id = $1
|
WHERE manga_id = $1
|
||||||
ORDER BY number ASC, created_at ASC
|
ORDER BY source_index DESC NULLS LAST, number ASC, created_at ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -352,7 +352,14 @@ pub async fn sync_manga_chapters(
|
|||||||
.map(|c| c.source_chapter_key.clone())
|
.map(|c| c.source_chapter_key.clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for c in chapters {
|
for (idx, c) in chapters.iter().enumerate() {
|
||||||
|
// `source_index` captures the chapter's position in the source
|
||||||
|
// DOM (0 = first = newest on this site) so the list query can
|
||||||
|
// reverse it for the user-facing list — see migration 0021.
|
||||||
|
// Every sync overwrites the value on both branches, so a new
|
||||||
|
// chapter inserted at the top of the source shifts every other
|
||||||
|
// row down by one on the next tick.
|
||||||
|
let source_index = idx as i32;
|
||||||
// Lookup is constrained by manga_id (via the chapters join) so a
|
// Lookup is constrained by manga_id (via the chapters join) so a
|
||||||
// source whose chapter slugs collide across mangas (e.g.
|
// source whose chapter slugs collide across mangas (e.g.
|
||||||
// "chapter-1" appearing under two different mangas) attributes
|
// "chapter-1" appearing under two different mangas) attributes
|
||||||
@@ -382,14 +389,15 @@ pub async fn sync_manga_chapters(
|
|||||||
// identity is the UUID, not the number.
|
// identity is the UUID, not the number.
|
||||||
let (chapter_id,): (Uuid,) = sqlx::query_as(
|
let (chapter_id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO chapters (manga_id, number, title, page_count)
|
INSERT INTO chapters (manga_id, number, title, page_count, source_index)
|
||||||
VALUES ($1, $2, $3, 0)
|
VALUES ($1, $2, $3, 0, $4)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(manga_id)
|
.bind(manga_id)
|
||||||
.bind(c.number)
|
.bind(c.number)
|
||||||
.bind(c.title.as_deref())
|
.bind(c.title.as_deref())
|
||||||
|
.bind(source_index)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -408,8 +416,11 @@ pub async fn sync_manga_chapters(
|
|||||||
diff.new += 1;
|
diff.new += 1;
|
||||||
}
|
}
|
||||||
Some((chapter_id,)) => {
|
Some((chapter_id,)) => {
|
||||||
sqlx::query("UPDATE chapters SET title = $1 WHERE id = $2")
|
sqlx::query(
|
||||||
|
"UPDATE chapters SET title = $1, source_index = $2 WHERE id = $3",
|
||||||
|
)
|
||||||
.bind(c.title.as_deref())
|
.bind(c.title.as_deref())
|
||||||
|
.bind(source_index)
|
||||||
.bind(chapter_id)
|
.bind(chapter_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -517,3 +517,132 @@ async fn enqueue_bookmarked_pending_resumes_after_quarantine_expires(pool: PgPoo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper: insert a chapter with the given `number` and a non-dropped
|
||||||
|
/// source row, returning the chapter id. Used by the ordering tests so
|
||||||
|
/// the setup boilerplate doesn't drown the assertion.
|
||||||
|
async fn insert_pending_chapter(
|
||||||
|
pool: &PgPool,
|
||||||
|
manga_id: Uuid,
|
||||||
|
number: i32,
|
||||||
|
source_chapter_key: &str,
|
||||||
|
) -> Uuid {
|
||||||
|
let chapter_id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO chapters (manga_id, number, page_count) VALUES ($1, $2, 0) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(manga_id)
|
||||||
|
.bind(number)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO chapter_sources (source_id, source_chapter_key, chapter_id, source_url) \
|
||||||
|
VALUES ($1, $2, $3, $4)",
|
||||||
|
)
|
||||||
|
.bind("target")
|
||||||
|
.bind(source_chapter_key)
|
||||||
|
.bind(chapter_id)
|
||||||
|
.bind(format!("https://example.com/{source_chapter_key}"))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
chapter_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn enqueue_bookmarked_pending_queues_chapters_in_ascending_number_order(pool: PgPool) {
|
||||||
|
// Insert chapters with `number` values 3, 1, 2 in that insertion
|
||||||
|
// order — so `created_at` order (the previous tiebreaker) does NOT
|
||||||
|
// match number order. After enqueue + lease, the worker should see
|
||||||
|
// chapters 1, 2, 3 in that sequence.
|
||||||
|
let user_id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind("alice")
|
||||||
|
.bind("not-a-real-hash")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
||||||
|
.bind("Test")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind("target")
|
||||||
|
.bind("Target")
|
||||||
|
.bind("https://example.com")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
|
||||||
|
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
|
||||||
|
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
|
||||||
|
sqlx::query("INSERT INTO bookmarks (user_id, manga_id) VALUES ($1, $2)")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(manga_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let summary = pipeline::enqueue_bookmarked_pending(&pool).await.unwrap();
|
||||||
|
assert_eq!(summary.inserted, 3);
|
||||||
|
|
||||||
|
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let leased_chapter_ids: Vec<Uuid> = leases
|
||||||
|
.iter()
|
||||||
|
.map(|l| match &l.payload {
|
||||||
|
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
|
||||||
|
other => panic!("unexpected payload kind: {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
leased_chapter_ids,
|
||||||
|
vec![c1, c2, c3],
|
||||||
|
"chapters must be leased in ascending chapter-number order, not insertion order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn enqueue_pending_for_manga_queues_chapters_in_ascending_number_order(pool: PgPool) {
|
||||||
|
// Same scenario as above but exercising the bookmark-create hook path
|
||||||
|
// (`enqueue_pending_for_manga`) which has its own ORDER BY.
|
||||||
|
let manga_id: Uuid = sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
|
||||||
|
.bind("Test")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sources (id, name, base_url) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind("target")
|
||||||
|
.bind("Target")
|
||||||
|
.bind("https://example.com")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let c3 = insert_pending_chapter(&pool, manga_id, 3, "ch3").await;
|
||||||
|
let c1 = insert_pending_chapter(&pool, manga_id, 1, "ch1").await;
|
||||||
|
let c2 = insert_pending_chapter(&pool, manga_id, 2, "ch2").await;
|
||||||
|
|
||||||
|
let summary = pipeline::enqueue_pending_for_manga(&pool, manga_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.inserted, 3);
|
||||||
|
|
||||||
|
let leases = jobs::lease(&pool, None, 10, std::time::Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let leased_chapter_ids: Vec<Uuid> = leases
|
||||||
|
.iter()
|
||||||
|
.map(|l| match &l.payload {
|
||||||
|
JobPayload::SyncChapterContent { chapter_id, .. } => *chapter_id,
|
||||||
|
other => panic!("unexpected payload kind: {other:?}"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(leased_chapter_ids, vec![c1, c2, c3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -531,6 +531,89 @@ async fn reap_done_deletes_old_rows_keeps_fresh(pool: PgPool) {
|
|||||||
assert_eq!(remaining, vec![fresh_id], "only fresh row remains");
|
assert_eq!(remaining, vec![fresh_id], "only fresh row remains");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn lease_ties_on_scheduled_at_break_by_created_at(pool: PgPool) {
|
||||||
|
// Locks in the tiebreaker that lets enqueue order survive the lease
|
||||||
|
// step: when many jobs share `scheduled_at` (the common cron-batch
|
||||||
|
// case), the worker must pick the earliest-inserted row, not whatever
|
||||||
|
// Postgres returns in heap order. The enqueue path inserts chapters
|
||||||
|
// in chapter-number order, so this tiebreaker is what makes "queue
|
||||||
|
// in rising order" observable at the dequeue side too.
|
||||||
|
let a = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
EnqueueResult::Inserted(id) => id,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let b = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
EnqueueResult::Inserted(id) => id,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let c = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
EnqueueResult::Inserted(id) => id,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pin `scheduled_at` to a single literal instant (shared across all
|
||||||
|
// three rows — `now()` would yield a different microsecond per UPDATE
|
||||||
|
// and make scheduled_at the actual sort key). Reverse `created_at`
|
||||||
|
// against insertion order so heap order would give the wrong answer.
|
||||||
|
let shared_scheduled = chrono::Utc::now() - chrono::Duration::hours(1);
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE crawler_jobs \
|
||||||
|
SET scheduled_at = $2, \
|
||||||
|
created_at = $3 \
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(a)
|
||||||
|
.bind(shared_scheduled)
|
||||||
|
.bind(chrono::Utc::now() - chrono::Duration::seconds(10))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE crawler_jobs \
|
||||||
|
SET scheduled_at = $2, \
|
||||||
|
created_at = $3 \
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(b)
|
||||||
|
.bind(shared_scheduled)
|
||||||
|
.bind(chrono::Utc::now() - chrono::Duration::seconds(20))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE crawler_jobs \
|
||||||
|
SET scheduled_at = $2, \
|
||||||
|
created_at = $3 \
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(c)
|
||||||
|
.bind(shared_scheduled)
|
||||||
|
.bind(chrono::Utc::now() - chrono::Duration::seconds(30))
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let leases = jobs::lease(&pool, None, 10, Duration::from_secs(60))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let order: Vec<Uuid> = leases.iter().map(|l| l.id).collect();
|
||||||
|
assert_eq!(
|
||||||
|
order,
|
||||||
|
vec![c, b, a],
|
||||||
|
"lease must return jobs in created_at order when scheduled_at ties"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn reap_done_zero_is_a_no_op(pool: PgPool) {
|
async fn reap_done_zero_is_a_no_op(pool: PgPool) {
|
||||||
let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
let id = match jobs::enqueue(&pool, &chapter_content_payload(Uuid::new_v4()))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use mangalord::crawler::source::{SourceChapterRef, SourceManga};
|
use mangalord::crawler::source::{SourceChapterRef, SourceManga};
|
||||||
use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus};
|
use mangalord::repo::crawler::{self, ChapterDiff, UpsertStatus};
|
||||||
|
use mangalord::repo::chapter as chapter_repo;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -961,3 +962,261 @@ async fn re_appearing_manga_clears_dropped_at(pool: PgPool) {
|
|||||||
assert!(dropped.0.is_none());
|
assert!(dropped.0.is_none());
|
||||||
assert_eq!(dropped.1, up.manga_id);
|
assert_eq!(dropped.1, up.manga_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- source_index: site-order preservation ----
|
||||||
|
//
|
||||||
|
// The user-facing chapter list reverses the source-site order so that
|
||||||
|
// the oldest chapter appears first. The crawler records each row's DOM
|
||||||
|
// position in `chapters.source_index` (0 = first in source DOM = newest
|
||||||
|
// on this site) on every sync; the list query orders by source_index
|
||||||
|
// DESC NULLS LAST, falling through to number/created_at for rows with
|
||||||
|
// no source row (e.g. user uploads).
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn source_index_set_on_insert_matches_dom_order(pool: PgPool) {
|
||||||
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let m = sample_manga("foo", "Foo Manga", "hash-1");
|
||||||
|
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let chapters = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "a".into(),
|
||||||
|
number: 30,
|
||||||
|
title: Some("Ch.30".into()),
|
||||||
|
url: "https://x.example/foo/a".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "b".into(),
|
||||||
|
number: 29,
|
||||||
|
title: Some("Ch.29".into()),
|
||||||
|
url: "https://x.example/foo/b".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "c".into(),
|
||||||
|
number: 28,
|
||||||
|
title: Some("Ch.28".into()),
|
||||||
|
url: "https://x.example/foo/c".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
|
||||||
|
"SELECT cs.source_chapter_key, c.source_index \
|
||||||
|
FROM chapters c \
|
||||||
|
JOIN chapter_sources cs ON cs.chapter_id = c.id \
|
||||||
|
WHERE c.manga_id = $1 \
|
||||||
|
ORDER BY cs.source_chapter_key",
|
||||||
|
)
|
||||||
|
.bind(up.manga_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![
|
||||||
|
("a".to_string(), Some(0)),
|
||||||
|
("b".to_string(), Some(1)),
|
||||||
|
("c".to_string(), Some(2)),
|
||||||
|
],
|
||||||
|
"source_index reflects enumerate() position in the input slice",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn source_index_rewritten_on_resync_when_new_chapter_prepended(pool: PgPool) {
|
||||||
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let m = sample_manga("foo", "Foo Manga", "hash-1");
|
||||||
|
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "a".into(),
|
||||||
|
number: 1,
|
||||||
|
title: Some("Ch.1".into()),
|
||||||
|
url: "https://x.example/foo/a".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "b".into(),
|
||||||
|
number: 2,
|
||||||
|
title: Some("Ch.2".into()),
|
||||||
|
url: "https://x.example/foo/b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &first)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Second sync: a brand-new chapter appears at the top of the source
|
||||||
|
// (newest first on the site). All existing rows must shift their
|
||||||
|
// source_index down by one so the display order stays correct.
|
||||||
|
let second = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "new".into(),
|
||||||
|
number: 3,
|
||||||
|
title: Some("Ch.3".into()),
|
||||||
|
url: "https://x.example/foo/new".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "a".into(),
|
||||||
|
number: 1,
|
||||||
|
title: Some("Ch.1".into()),
|
||||||
|
url: "https://x.example/foo/a".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "b".into(),
|
||||||
|
number: 2,
|
||||||
|
title: Some("Ch.2".into()),
|
||||||
|
url: "https://x.example/foo/b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &second)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows: Vec<(String, Option<i32>)> = sqlx::query_as(
|
||||||
|
"SELECT cs.source_chapter_key, c.source_index \
|
||||||
|
FROM chapters c \
|
||||||
|
JOIN chapter_sources cs ON cs.chapter_id = c.id \
|
||||||
|
WHERE c.manga_id = $1 \
|
||||||
|
ORDER BY cs.source_chapter_key",
|
||||||
|
)
|
||||||
|
.bind(up.manga_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![
|
||||||
|
("a".to_string(), Some(1)),
|
||||||
|
("b".to_string(), Some(2)),
|
||||||
|
("new".to_string(), Some(0)),
|
||||||
|
],
|
||||||
|
"new chapter takes index 0, existing rows shift down on UPDATE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_for_manga_returns_source_order_reversed(pool: PgPool) {
|
||||||
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let m = sample_manga("foo", "Foo Manga", "hash-1");
|
||||||
|
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Site DOM order (top-down = newest-first):
|
||||||
|
// ch11 (number = 11)
|
||||||
|
// notice (number = 0, non-numeric label on the site)
|
||||||
|
// ch10 (number = 10)
|
||||||
|
// Numbers deliberately disagree with DOM order: a number-based sort
|
||||||
|
// would put notice first, but the site places it between ch10 and
|
||||||
|
// ch11. Reversed-DOM display should yield [ch10, notice, ch11].
|
||||||
|
let chapters = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "ch11".into(),
|
||||||
|
number: 11,
|
||||||
|
title: Some("Ch.11 : Official".into()),
|
||||||
|
url: "https://x.example/foo/11".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "notice".into(),
|
||||||
|
number: 0,
|
||||||
|
title: Some("notice. : Officials".into()),
|
||||||
|
url: "https://x.example/foo/notice".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "ch10".into(),
|
||||||
|
number: 10,
|
||||||
|
title: Some("Ch.10 : Official".into()),
|
||||||
|
url: "https://x.example/foo/10".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &chapters)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let keys: Vec<String> = listed
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.title.clone().unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
keys,
|
||||||
|
vec![
|
||||||
|
"Ch.10 : Official".to_string(),
|
||||||
|
"notice. : Officials".to_string(),
|
||||||
|
"Ch.11 : Official".to_string(),
|
||||||
|
],
|
||||||
|
"list returns chapters in reversed source-DOM order, so the \
|
||||||
|
oldest appears first and non-numeric entries land where the \
|
||||||
|
site placed them",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_for_manga_places_null_source_index_last(pool: PgPool) {
|
||||||
|
crawler::ensure_source(&pool, "target", "T", "https://x.example")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let m = sample_manga("foo", "Foo Manga", "hash-1");
|
||||||
|
let up = crawler::upsert_manga_from_source(&pool, "target", "https://x.example/foo", &m)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Crawled chapters get source_index 0 and 1; the upload path leaves
|
||||||
|
// it NULL. NULLS LAST plus the (number, created_at) tail means the
|
||||||
|
// upload sits after both crawled rows even though its number is in
|
||||||
|
// the middle.
|
||||||
|
let crawled = vec![
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "a".into(),
|
||||||
|
number: 1,
|
||||||
|
title: Some("Ch.1".into()),
|
||||||
|
url: "https://x.example/foo/a".into(),
|
||||||
|
},
|
||||||
|
SourceChapterRef {
|
||||||
|
source_chapter_key: "b".into(),
|
||||||
|
number: 3,
|
||||||
|
title: Some("Ch.3".into()),
|
||||||
|
url: "https://x.example/foo/b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crawler::sync_manga_chapters(&pool, "target", up.manga_id, &crawled)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
chapter_repo::create(&pool, up.manga_id, 2, Some("User upload Ch.2"), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listed = chapter_repo::list_for_manga(&pool, up.manga_id, 50, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let titles: Vec<String> = listed
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.title.clone().unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
titles,
|
||||||
|
vec![
|
||||||
|
"Ch.3".to_string(),
|
||||||
|
"Ch.1".to_string(),
|
||||||
|
"User upload Ch.2".to_string(),
|
||||||
|
],
|
||||||
|
"crawled rows ordered by reversed source_index; user upload \
|
||||||
|
(NULL source_index) falls through to the end",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
167
frontend/e2e/reader-chapter-select.spec.ts
Normal file
167
frontend/e2e/reader-chapter-select.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const mangaId = '33333333-3333-3333-3333-333333333333';
|
||||||
|
const chapter1Id = 'c1111111-3333-3333-3333-333333333333';
|
||||||
|
const chapter2Id = 'c2222222-3333-3333-3333-333333333333';
|
||||||
|
const chapter3Id = 'c3333333-3333-3333-3333-333333333333';
|
||||||
|
|
||||||
|
const mangaFixture = {
|
||||||
|
id: mangaId,
|
||||||
|
title: 'Vinland Saga',
|
||||||
|
author: 'Makoto Yukimura',
|
||||||
|
description: null,
|
||||||
|
cover_image_path: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const chaptersFixture = [
|
||||||
|
{
|
||||||
|
id: chapter1Id,
|
||||||
|
manga_id: mangaId,
|
||||||
|
number: 1,
|
||||||
|
title: 'Somewhere, Not Here',
|
||||||
|
page_count: 1,
|
||||||
|
created_at: '2026-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: chapter2Id,
|
||||||
|
manga_id: mangaId,
|
||||||
|
number: 2,
|
||||||
|
title: null,
|
||||||
|
page_count: 1,
|
||||||
|
created_at: '2026-01-02T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: chapter3Id,
|
||||||
|
manga_id: mangaId,
|
||||||
|
number: 3,
|
||||||
|
title: 'Sword Dance',
|
||||||
|
page_count: 1,
|
||||||
|
created_at: '2026-01-03T00:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function pageFixture(chapterId: string) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `p1111111-${chapterId.slice(1, 8)}-3333-3333-333333333333`,
|
||||||
|
chapter_id: chapterId,
|
||||||
|
page_number: 1,
|
||||||
|
storage_key: `mangas/${mangaId}/chapters/${chapterId}/pages/0001.png`,
|
||||||
|
content_type: 'image/png'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockReaderApis(page: Page) {
|
||||||
|
// Force public mode so the layout doesn't bounce anonymous visitors
|
||||||
|
// to /login (the dev backend on this machine runs with
|
||||||
|
// PRIVATE_MODE=true, which the layout's universal load respects).
|
||||||
|
await page.route('**/api/v1/auth/config', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ error: { code: 'unauthenticated', message: '' } })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(mangaFixture)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(new RegExp(`/api/v1/mangas/${mangaId}/chapters(\\?.*)?$`), (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: chaptersFixture,
|
||||||
|
page: { limit: 200, offset: 0, total: chaptersFixture.length }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
for (const c of chaptersFixture) {
|
||||||
|
await page.route(`**/api/v1/mangas/${mangaId}/chapters/${c.id}`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(c)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route(
|
||||||
|
`**/api/v1/mangas/${mangaId}/chapters/${c.id}/pages`,
|
||||||
|
(route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ pages: pageFixture(c.id) })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const png = Buffer.from(
|
||||||
|
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/files/**', (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: 'image/png', body: png })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reader chapter select lists every chapter with the manga-detail-style label', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapter2Id}`);
|
||||||
|
|
||||||
|
const select = page.getByTestId('reader-chapter-select');
|
||||||
|
await expect(select).toBeVisible();
|
||||||
|
|
||||||
|
// The current chapter is preselected.
|
||||||
|
await expect(select).toHaveValue(chapter2Id);
|
||||||
|
|
||||||
|
// Each chapter rendered as "Ch. N — Title" (or "Ch. N" when title is null),
|
||||||
|
// in ascending number order — matching the prev/next sort.
|
||||||
|
const labels = await select.locator('option').allTextContents();
|
||||||
|
expect(labels.map((l) => l.trim())).toEqual([
|
||||||
|
'Ch. 1 — Somewhere, Not Here',
|
||||||
|
'Ch. 2',
|
||||||
|
'Ch. 3 — Sword Dance'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing a chapter from the select navigates to that chapter', async ({ page }) => {
|
||||||
|
await mockReaderApis(page);
|
||||||
|
await page.goto(`/manga/${mangaId}/chapter/${chapter1Id}`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter1Id);
|
||||||
|
|
||||||
|
await page.getByTestId('reader-chapter-select').selectOption(chapter3Id);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/manga/${mangaId}/chapter/${chapter3Id}$`)
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId('reader-chapter-select')).toHaveValue(chapter3Id);
|
||||||
|
});
|
||||||
@@ -120,7 +120,7 @@ test('manga overview shows title, cover, and a chapter list', async ({ page }) =
|
|||||||
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
await expect(page.getByTestId('manga-title')).toHaveText('Berserk');
|
||||||
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
|
await expect(page.getByTestId('manga-author')).toContainText('Kentaro Miura');
|
||||||
await expect(page.getByTestId('manga-cover')).toBeVisible();
|
await expect(page.getByTestId('manga-cover')).toBeVisible();
|
||||||
await expect(page.getByTestId('chapter-list')).toContainText('Chapter 1');
|
await expect(page.getByTestId('chapter-list')).toContainText('The Brand');
|
||||||
await expect(page.getByTestId('bookmark-signin')).toBeVisible();
|
await expect(page.getByTestId('bookmark-signin')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.50.0",
|
"version": "0.52.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
listChapters,
|
listChapters,
|
||||||
getChapter,
|
getChapter,
|
||||||
getChapterPages,
|
getChapterPages,
|
||||||
createChapter
|
createChapter,
|
||||||
|
chapterLabel
|
||||||
} from './chapters';
|
} from './chapters';
|
||||||
|
|
||||||
function ok(body: unknown): Response {
|
function ok(body: unknown): Response {
|
||||||
@@ -129,6 +130,18 @@ describe('chapters api client', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chapterLabel', () => {
|
||||||
|
it('returns the site title verbatim when present', () => {
|
||||||
|
expect(chapterLabel({ number: 7, title: 'Ch.7 : Official' })).toBe(
|
||||||
|
'Ch.7 : Official'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "Chapter {number}" when title is null', () => {
|
||||||
|
expect(chapterLabel({ number: 3, title: null })).toBe('Chapter 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
|
it('getChapterPages unwraps the {pages} envelope into the array', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(
|
fetchSpy.mockResolvedValueOnce(
|
||||||
ok({
|
ok({
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type ChaptersPage = {
|
|||||||
page: Page;
|
page: Page;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function chapterLabel(c: Pick<Chapter, 'number' | 'title'>): string {
|
||||||
|
return c.title ?? `Chapter ${c.number}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type ListOptions = {
|
export type ListOptions = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
type TagRef
|
type TagRef
|
||||||
} from '$lib/api/mangas';
|
} from '$lib/api/mangas';
|
||||||
import { resyncManga } from '$lib/api/admin';
|
import { resyncManga } from '$lib/api/admin';
|
||||||
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { listTags, type Tag } from '$lib/api/tags';
|
import { listTags, type Tag } from '$lib/api/tags';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Chip from '$lib/components/Chip.svelte';
|
import Chip from '$lib/components/Chip.svelte';
|
||||||
@@ -45,6 +46,11 @@
|
|||||||
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
continueChapter?.number ?? readProgress?.chapter_number ?? null
|
||||||
);
|
);
|
||||||
const continueChapterTitle = $derived(continueChapter?.title ?? null);
|
const continueChapterTitle = $derived(continueChapter?.title ?? null);
|
||||||
|
const continueLabel = $derived(
|
||||||
|
continueChapterNumber != null
|
||||||
|
? chapterLabel({ number: continueChapterNumber, title: continueChapterTitle })
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
const authors = $derived<AuthorRef[]>(manga.authors);
|
const authors = $derived<AuthorRef[]>(manga.authors);
|
||||||
const genres = $derived<GenreRef[]>(manga.genres);
|
const genres = $derived<GenreRef[]>(manga.genres);
|
||||||
@@ -431,7 +437,7 @@
|
|||||||
>
|
>
|
||||||
<span class="continue-label">Continue reading</span>
|
<span class="continue-label">Continue reading</span>
|
||||||
<span class="continue-target">
|
<span class="continue-target">
|
||||||
Chapter {continueChapterNumber}{#if continueChapterTitle}: {continueChapterTitle}{/if}
|
{continueLabel}
|
||||||
{#if readProgress && readProgress.page > 1}
|
{#if readProgress && readProgress.page > 1}
|
||||||
— page {readProgress.page}
|
— page {readProgress.page}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -445,7 +451,7 @@
|
|||||||
{#each chapters as c (c.id)}
|
{#each chapters as c (c.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href="/manga/{manga.id}/chapter/{c.id}">
|
<a href="/manga/{manga.id}/chapter/{c.id}">
|
||||||
Chapter {c.number}{#if c.title}: {c.title}{/if}
|
{chapterLabel(c)}
|
||||||
</a>
|
</a>
|
||||||
<span class="pages">({c.page_count} pages)</span>
|
<span class="pages">({c.page_count} pages)</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
|
||||||
import { preferences } from '$lib/preferences.svelte';
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { updateReadProgress } from '$lib/api/read_progress';
|
import { updateReadProgress } from '$lib/api/read_progress';
|
||||||
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { resyncChapter } from '$lib/api/admin';
|
import { resyncChapter } from '$lib/api/admin';
|
||||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
@@ -28,28 +29,25 @@
|
|||||||
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
|
const gapPx = $derived(GAP_PX[preferences.readerPageGap]);
|
||||||
|
|
||||||
const pageTitle = $derived(
|
const pageTitle = $derived(
|
||||||
chapter.title
|
`Mangalord | ${manga.title} · ${chapterLabel(chapter)}`
|
||||||
? `Mangalord | ${manga.title} · Ch. ${chapter.number}: ${chapter.title}`
|
|
||||||
: `Mangalord | ${manga.title} · Ch. ${chapter.number}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prev/next chapter computed from the chapter list. listChapters
|
// Prev/next chapter computed from the chapter list. listChapters
|
||||||
// returns chapters in number ASC order; we still resolve via find
|
// returns chapters in display order (reversed source-site order, so
|
||||||
// rather than index because the current chapter's position may
|
// oldest first — see backend repo::chapter::list_for_manga), and
|
||||||
// not be `chapter.number - 1` (sparse numbering / chapter 0.5 /
|
// prev/next walks that order positionally. Resolving the current
|
||||||
// future skipped numbers).
|
// index via `find` rather than `chapter.number - 1` matters because
|
||||||
const sortedChapters = $derived(
|
// numbers aren't a reliable index: variants share numbers, non-
|
||||||
[...chapters].sort((a, b) => a.number - b.number)
|
// numeric entries pin to 0, and uploads can sparse-fill.
|
||||||
);
|
|
||||||
const currentIdx = $derived(
|
const currentIdx = $derived(
|
||||||
sortedChapters.findIndex((c) => c.id === chapter.id)
|
chapters.findIndex((c) => c.id === chapter.id)
|
||||||
);
|
);
|
||||||
const prevChapter = $derived(
|
const prevChapter = $derived(
|
||||||
currentIdx > 0 ? sortedChapters[currentIdx - 1] : null
|
currentIdx > 0 ? chapters[currentIdx - 1] : null
|
||||||
);
|
);
|
||||||
const nextChapter = $derived(
|
const nextChapter = $derived(
|
||||||
currentIdx >= 0 && currentIdx < sortedChapters.length - 1
|
currentIdx >= 0 && currentIdx < chapters.length - 1
|
||||||
? sortedChapters[currentIdx + 1]
|
? chapters[currentIdx + 1]
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -459,6 +457,27 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="controls" role="group" aria-label="reader options">
|
<div class="controls" role="group" aria-label="reader options">
|
||||||
|
<label class="chapter-field">
|
||||||
|
<span class="visually-hidden">Jump to chapter</span>
|
||||||
|
<select
|
||||||
|
class="chapter-select"
|
||||||
|
value={chapter.id}
|
||||||
|
onchange={(e) => {
|
||||||
|
const target = (e.currentTarget as HTMLSelectElement).value;
|
||||||
|
if (target && target !== chapter.id) {
|
||||||
|
void goto(`/manga/${manga.id}/chapter/${target}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-testid="reader-chapter-select"
|
||||||
|
>
|
||||||
|
{#each chapters as c (c.id)}
|
||||||
|
<option value={c.id}>
|
||||||
|
{chapterLabel(c)}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="mode-toggle" role="radiogroup" aria-label="layout">
|
<div class="mode-toggle" role="radiogroup" aria-label="layout">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -664,7 +683,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="chapter-bar-current" aria-hidden="true">
|
<span class="chapter-bar-current" aria-hidden="true">
|
||||||
Ch. {chapter.number}{#if chapter.title} — {chapter.title}{/if}
|
{chapterLabel(chapter)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -801,7 +820,8 @@
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gap-field select {
|
.gap-field select,
|
||||||
|
.chapter-select {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 var(--space-2);
|
padding: 0 var(--space-2);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -811,6 +831,13 @@
|
|||||||
font-size: var(--font-sm);
|
font-size: var(--font-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cap the chapter dropdown's resting width so long titles don't
|
||||||
|
push the rest of the nav off-screen; the native control's
|
||||||
|
expanded menu still shows full option text on focus. */
|
||||||
|
.chapter-select {
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
.visually-hidden {
|
.visually-hidden {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fileUrl } from '$lib/api/client';
|
import { fileUrl } from '$lib/api/client';
|
||||||
|
import { chapterLabel } from '$lib/api/chapters';
|
||||||
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
import { clearReadProgress, type ReadProgressSummary } from '$lib/api/read_progress';
|
||||||
import BookImage from '@lucide/svelte/icons/book-image';
|
import BookImage from '@lucide/svelte/icons/book-image';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
@@ -186,7 +187,7 @@
|
|||||||
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
|
<a href="/manga/{u.manga_id}" class="title">{u.manga_title}</a>
|
||||||
<span class="target">
|
<span class="target">
|
||||||
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
|
<a href="/manga/{u.manga_id}/chapter/{u.chapter.id}">
|
||||||
Chapter {u.chapter.number}{#if u.chapter.title}: {u.chapter.title}{/if}
|
{chapterLabel(u.chapter)}
|
||||||
</a>
|
</a>
|
||||||
<span class="muted">({u.chapter.page_count} pages)</span>
|
<span class="muted">({u.chapter.page_count} pages)</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user