diff --git a/alembic/versions/0042_series_chapter_stated_part.py b/alembic/versions/0042_series_chapter_stated_part.py new file mode 100644 index 0000000..f898e56 --- /dev/null +++ b/alembic/versions/0042_series_chapter_stated_part.py @@ -0,0 +1,32 @@ +"""series chapter stated_part: operator-facing Part N label (FC-6.4) + +Revision ID: 0042 +Revises: 0041 +Create Date: 2026-06-07 + +A chapter's positional chapter_number is auto-managed (rewritten 1..N on +reorder/delete), so it can't double as the installment number the operator wants +to type (e.g. a series authored from a post that is Part 2). Add a nullable +stated_part alongside it — the same split as series_page.page_number (order) vs +series_page.stated_page (printed number). Nullable; the UI falls back to +chapter_number when unset. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0042" +down_revision: Union[str, None] = "0041" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "series_chapter", sa.Column("stated_part", sa.Integer, nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("series_chapter", "stated_part") diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 918fa42..59dc286 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -528,6 +528,11 @@ async def series_chapter_update(tag_id: int, chapter_id: int): if body["title"] is not None and not isinstance(body["title"], str): return jsonify({"error": "title must be a string"}), 400 kwargs.update(set_title=True, title=body["title"]) + if "stated_part" in body: + part, perr = _opt_int(body, "stated_part") + if perr: + return perr + kwargs.update(set_part=True, stated_part=part) if "stated_page_start" in body: start, serr = _opt_int(body, "stated_page_start") if serr: diff --git a/backend/app/models/series_chapter.py b/backend/app/models/series_chapter.py index 4012174..9695272 100644 --- a/backend/app/models/series_chapter.py +++ b/backend/app/models/series_chapter.py @@ -12,6 +12,12 @@ A chapter may be a placeholder (is_placeholder=True) — a reserved empty slot f a section the operator doesn't have yet; it holds no pages and shows as a gap in the reader. stated_page_start/end carry the page range parsed from the source post (FC-6.2), used to flag missing-page gaps; both are nullable when unknown. + +stated_part is the operator-facing "Part N" label (FC-6.4), separate from the +positional chapter_number: chapter_number is auto-managed ordering (rewritten +1..N on reorder/delete), while stated_part is the real installment number the +operator types — e.g. a series authored from a post that is Part 2 of a story. +Nullable when unset (the UI then falls back to showing chapter_number). """ from datetime import datetime @@ -30,6 +36,7 @@ class SeriesChapter(Base): ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True ) chapter_number: Mapped[int] = mapped_column(Integer, nullable=False) + stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True) title: Mapped[str | None] = mapped_column(Text, nullable=True) is_placeholder: Mapped[bool] = mapped_column( Boolean, nullable=False, server_default="false" diff --git a/backend/app/services/series_service.py b/backend/app/services/series_service.py index 7c943a1..9fbb634 100644 --- a/backend/app/services/series_service.py +++ b/backend/app/services/series_service.py @@ -131,6 +131,29 @@ class SeriesService: prev = ch return out + @staticmethod + def _part_gaps(chapters: list[dict]) -> list[dict]: + """Missing-Part gaps between consecutive chapters whose stated_part + numbers jump by more than 1 (e.g. a series with Part 1 and Part 3, or one + authored straight from a Part 2 post). Mirrors _gaps but on stated_part — + only chapters that actually carry a stated_part participate.""" + out: list[dict] = [] + prev = None + for ch in chapters: + cur = ch["stated_part"] + if cur is None: + continue + if prev is not None and cur > prev["stated_part"] + 1: + out.append( + { + "after_chapter_id": prev["id"], + "start": prev["stated_part"] + 1, + "end": cur - 1, + } + ) + prev = ch + return out + async def list_pages(self, series_tag_id: int) -> dict: tag = await self._require_series(series_tag_id) rows = ( @@ -138,6 +161,7 @@ class SeriesService: select( SeriesChapter.id.label("chapter_id"), SeriesChapter.chapter_number, + SeriesChapter.stated_part, SeriesChapter.title, SeriesChapter.is_placeholder, SeriesChapter.stated_page_start, @@ -149,10 +173,13 @@ class SeriesService: ImageRecord.mime, ImageRecord.path, ImageRecord.thumbnail_path, + ImageRecord.primary_post_id, + Post.post_title, ) .select_from(SeriesChapter) .outerjoin(SeriesPage, SeriesPage.chapter_id == SeriesChapter.id) .outerjoin(ImageRecord, ImageRecord.id == SeriesPage.image_id) + .outerjoin(Post, Post.id == ImageRecord.primary_post_id) .where(SeriesChapter.series_tag_id == series_tag_id) .order_by( SeriesChapter.chapter_number.asc(), @@ -164,22 +191,30 @@ class SeriesService: chapters: list[dict] = [] flat: list[dict] = [] by_id: dict[int, dict] = {} + # chapter_id -> {post_id: title} seen across its pages, so we can label a + # chapter with its source post when all its pages come from one post. + posts_seen: dict[int, dict[int, str | None]] = {} for r in rows: ch = by_id.get(r.chapter_id) if ch is None: ch = { "id": r.chapter_id, "chapter_number": r.chapter_number, + "stated_part": r.stated_part, "title": r.title, "is_placeholder": r.is_placeholder, "stated_page_start": r.stated_page_start, "stated_page_end": r.stated_page_end, + "source_post": None, "pages": [], } by_id[r.chapter_id] = ch + posts_seen[r.chapter_id] = {} chapters.append(ch) if r.image_id is None: continue # placeholder / empty chapter + if r.primary_post_id is not None: + posts_seen[r.chapter_id][r.primary_post_id] = r.post_title page = { "image_id": r.image_id, "chapter_id": r.chapter_id, @@ -191,11 +226,21 @@ class SeriesService: ch["pages"].append(page) flat.append(page) + # A chapter's source_post is set only when every page shares one post — + # the common case (a series authored from a post). Mixed chapters stay + # null rather than guessing. + for ch in chapters: + seen = posts_seen.get(ch["id"], {}) + if len(seen) == 1: + pid, title = next(iter(seen.items())) + ch["source_post"] = {"id": pid, "title": title} + return { "series": {"id": tag.id, "name": tag.name}, "chapters": chapters, "pages": flat, # back-compat: flat reading order across chapters "gaps": self._gaps(chapters), + "part_gaps": self._part_gaps(chapters), } # ---- pages ------------------------------------------------------------ @@ -353,19 +398,23 @@ class SeriesService: chapter_id: int, *, title: str | None = None, + stated_part: int | None = None, stated_page_start: int | None = None, stated_page_end: int | None = None, set_title: bool = False, + set_part: bool = False, set_start: bool = False, set_end: bool = False, ) -> None: """Partial chapter edit. The set_* flags say which fields to write (so - None can be written explicitly, e.g. clearing a stated page).""" + None can be written explicitly, e.g. clearing a stated page or part).""" await self._require_series(series_tag_id) await self._require_chapter(series_tag_id, chapter_id) values: dict = {} if set_title: values["title"] = title + if set_part: + values["stated_part"] = stated_part if set_start: values["stated_page_start"] = stated_page_start if set_end: diff --git a/frontend/src/stores/seriesManage.js b/frontend/src/stores/seriesManage.js index 9f7e628..e09da26 100644 --- a/frontend/src/stores/seriesManage.js +++ b/frontend/src/stores/seriesManage.js @@ -16,8 +16,9 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { const tagId = ref(null) const series = ref(null) - const chapters = ref([]) // [{id, chapter_number, title, is_placeholder, stated_page_start/end, pages:[...]}] - const gaps = ref([]) // [{after_chapter_id, start, end}] + const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}] + const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}] + const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}] const pageCount = ref(0) const targetChapterId = ref(null) // which chapter the picker adds into const picker = ref([]) // gallery scroll results @@ -33,6 +34,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { series.value = body.series chapters.value = body.chapters || [] gaps.value = body.gaps || [] + partGaps.value = body.part_gaps || [] pageCount.value = (body.pages || []).length // Keep a valid add-target: the selected chapter, else the first one. const ids = chapters.value.map(c => c.id) @@ -52,6 +54,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { return gaps.value.find(g => g.after_chapter_id === chapterId) || null } + function partGapAfter(chapterId) { + return partGaps.value.find(g => g.after_chapter_id === chapterId) || null + } + // ---- chapters ---- async function createChapter({ title = null, isPlaceholder = false } = {}) { await api.post(`/api/series/${tagId.value}/chapters`, { @@ -67,6 +73,13 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { await refresh() } + async function setChapterPart(chapterId, part) { + await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, { + body: { stated_part: part } + }) + await refresh() + } + async function setChapterStated(chapterId, start, end) { await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, { body: { stated_page_start: start, stated_page_end: end } @@ -151,11 +164,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => { } return { - tagId, series, chapters, gaps, pageCount, targetChapterId, + tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId, picker, pickerCursor, pickerSelection, loading, - load, refresh, gapAfter, - createChapter, renameChapter, setChapterStated, reorderChapters, - moveChapter, deleteChapter, mergeChapter, reorderPages, + load, refresh, gapAfter, partGapAfter, + createChapter, renameChapter, setChapterPart, setChapterStated, + reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages, loadPicker, togglePick, addSelected, remove, setCover } }) diff --git a/frontend/src/views/SeriesManageView.vue b/frontend/src/views/SeriesManageView.vue index 52feba1..6f9b36e 100644 --- a/frontend/src/views/SeriesManageView.vue +++ b/frontend/src/views/SeriesManageView.vue @@ -1,159 +1,227 @@ @@ -167,20 +235,29 @@ const route = useRoute() const store = useSeriesManageStore() const sentinel = ref(null) const drag = ref(null) // { chapterId, idx } -const titleDraft = reactive({}) // chapterId -> draft string +const titleDraft = reactive({}) // chapterId -> draft title +const partDraft = reactive({}) // chapterId -> draft stated_part (string) +const pickerOpen = ref(false) -// Keep local title drafts in sync with the loaded chapters. Edits commit on -// blur/Enter, which refreshes and resets the draft to the saved value. +// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter, +// which refreshes and resets the draft to the saved value. watch(() => store.chapters, (chs) => { - Object.keys(titleDraft).forEach(k => delete titleDraft[k]) - for (const c of chs) titleDraft[c.id] = c.title || '' + for (const k of Object.keys(titleDraft)) delete titleDraft[k] + for (const k of Object.keys(partDraft)) delete partDraft[k] + for (const c of chs) { + titleDraft[c.id] = c.title || '' + partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part) + } }, { immediate: true }) -const targetLabel = computed(() => { - const ch = store.chapters.find(c => c.id === store.targetChapterId) - if (!ch) return 'chapter' - return ch.title || `Chapter ${ch.chapter_number}` -}) +const chapterItems = computed(() => + store.chapters.map(c => ({ + id: c.id, + label: `Part ${c.stated_part ?? c.chapter_number}` + + (c.title ? ` — ${c.title}` : '') + + (c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`), + })) +) function commitTitle(ch) { const v = (titleDraft[ch.id] || '').trim() @@ -188,6 +265,17 @@ function commitTitle(ch) { store.renameChapter(ch.id, v || null) } +function commitPart(ch) { + const raw = (partDraft[ch.id] ?? '').trim() + const next = raw === '' ? null : parseInt(raw, 10) + if (raw !== '' && (Number.isNaN(next) || next < 1)) { + partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part) + return + } + if (next === (ch.stated_part ?? null)) return + store.setChapterPart(ch.id, next) +} + function onStated(ch, which, ev) { const raw = ev.target.value const n = raw === '' ? null : parseInt(raw, 10) @@ -206,6 +294,20 @@ function onPageDrop(chapter, toIdx) { store.reorderPages(chapter.id, ordered) } +function confirmDelete(ch) { + const label = `Part ${ch.stated_part ?? ch.chapter_number}` + const n = ch.pages.length + const msg = n + ? `Delete ${label} and remove its ${n} page(s) from the series?` + : `Delete ${label}?` + if (window.confirm(msg)) store.deleteChapter(ch.id) +} + +function openPicker(chapterId) { + store.targetChapterId = chapterId + pickerOpen.value = true +} + useInfiniteScroll(sentinel, () => store.loadPicker()) onMounted(async () => { @@ -216,91 +318,146 @@ onMounted(async () => { diff --git a/frontend/test/seriesManage.spec.js b/frontend/test/seriesManage.spec.js index dc185d5..8553766 100644 --- a/frontend/test/seriesManage.spec.js +++ b/frontend/test/seriesManage.spec.js @@ -75,6 +75,33 @@ describe('seriesManage', () => { expect(r.body).toEqual({ chapter_ids: [2, 1, 3] }) }) + it('setChapterPart patches stated_part on the chapter', async () => { + const s = useSeriesManageStore() + s.tagId = 7 + const calls = [] + stubFetch((url, init) => { + calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null }) + if (init.method === 'PATCH') return { status: 200, body: { ok: true } } + return { status: 200, body: SERIES_BODY } + }) + await s.setChapterPart(1, 2) + const p = calls.find(c => c.method === 'PATCH') + expect(p.url).toContain('/api/series/7/chapters/1') + expect(p.body).toEqual({ stated_part: 2 }) + }) + + it('load surfaces part_gaps and partGapAfter looks them up', async () => { + const s = useSeriesManageStore() + stubFetch(() => ({ + status: 200, + body: { ...SERIES_BODY, part_gaps: [{ after_chapter_id: 1, start: 2, end: 2 }] } + })) + await s.load(7) + expect(s.partGaps).toHaveLength(1) + expect(s.partGapAfter(1)).toEqual({ after_chapter_id: 1, start: 2, end: 2 }) + expect(s.partGapAfter(99)).toBeNull() + }) + it('addSelected posts selection + target chapter then clears', async () => { const s = useSeriesManageStore() s.tagId = 7 diff --git a/tests/test_series_from_post.py b/tests/test_series_from_post.py index abd63f8..3c45bd2 100644 --- a/tests/test_series_from_post.py +++ b/tests/test_series_from_post.py @@ -113,3 +113,52 @@ async def test_list_series_cards(db): # artist filter keeps it; a different artist id drops it. assert any(r["id"] == out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id)) assert all(r["id"] != out["series_tag_id"] for r in await svc.list_series(artist_id=artist.id + 99999)) + + +# --- FC-6.4: stated_part, part_gaps, source_post label --------------------- + + +@pytest.mark.asyncio +async def test_update_chapter_sets_and_clears_stated_part(db): + svc = SeriesService(db) + sid = (await TagService(db).find_or_create("Part Series", TagKind.series)).id + ch = await svc.create_chapter(sid) + # Set the installment to Part 2 (chapter_number stays its positional value). + await svc.update_chapter(sid, ch["id"], stated_part=2, set_part=True) + data = await svc.list_pages(sid) + assert data["chapters"][0]["stated_part"] == 2 + assert data["chapters"][0]["chapter_number"] == 1 + # Clearing it writes NULL back. + await svc.update_chapter(sid, ch["id"], stated_part=None, set_part=True) + data = await svc.list_pages(sid) + assert data["chapters"][0]["stated_part"] is None + + +@pytest.mark.asyncio +async def test_part_gaps_flagged_from_stated_part(db): + svc = SeriesService(db) + sid = (await TagService(db).find_or_create("Gappy Series", TagKind.series)).id + c1 = await svc.create_chapter(sid) + c3 = await svc.create_chapter(sid) + await svc.update_chapter(sid, c1["id"], stated_part=1, set_part=True) + await svc.update_chapter(sid, c3["id"], stated_part=3, set_part=True) + data = await svc.list_pages(sid) + assert len(data["part_gaps"]) == 1 + gap = data["part_gaps"][0] + assert gap["after_chapter_id"] == c1["id"] + assert gap["start"] == 2 + assert gap["end"] == 2 + + +@pytest.mark.asyncio +async def test_source_post_label_when_pages_share_one_post(db): + svc = SeriesService(db) + artist = await _artist(db, "Src Artist") + post = await _post(db, artist, "Source Comic pages 1-2", "pp5") + await _post_images(db, post, artist, 2) + out = await svc.promote_post_to_series(post.id) + data = await svc.list_pages(out["series_tag_id"]) + sp = data["chapters"][0]["source_post"] + assert sp is not None + assert sp["id"] == post.id + assert sp["title"] == "Source Comic pages 1-2"