diff --git a/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py b/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py new file mode 100644 index 0000000..96e2f93 --- /dev/null +++ b/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py @@ -0,0 +1,148 @@ +"""move a paper's abstract into its body, then drop the column + +``paper.abstract`` was a second home for something the outline already has a +place for. A template's abstract paragraph (``0 Abstract`` in the seeded +library) *is* the paper's abstract — it has a position in the document, a +heading the template styles, and it sits in the same order as everything else. +A separate column put the same text somewhere the document never reads, so a +paper could show two different abstracts, or an abstract that no longer matched +the paper it belonged to. + +The column is therefore dropped. The text in it is not: this revision writes +each stored abstract into the paper's body first, one sentence per ``。``, at +the paragraph that carries the abstract heading. Papers whose template has no +such heading keep their text too — it goes to the position just above the first +paragraph, where the document renders it under 未设定 rather than discarding it. + +Revision ID: a83f5c21d7b6 +Revises: f27a1c6d9e04 +Create Date: 2026-09-18 + +""" + +import re +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a83f5c21d7b6" +down_revision: str | None = "f27a1c6d9e04" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +#: Split *after* each full stop, so the stop stays with the sentence it ends. +#: An abstract with no full stop at all stays one sentence, which is what a +#: one-line abstract is. +_SENTENCE_BREAK = re.compile(r"(?<=。)") + +#: The heading that means "this paragraph is the abstract". Matched on either +#: language because the library is user-editable and both spellings are in use. +_ABSTRACT_HEADING = "(LOWER(name) LIKE '%abstract%' OR name LIKE '%摘要%')" + + +def _split_sentences(text: str) -> list[str]: + """One line per sentence, with whitespace folded as the API folds it.""" + parts = (" ".join(part.split()) for part in _SENTENCE_BREAK.split(text.strip())) + return [part for part in parts if part] + + +def _target_position(bind: sa.Connection, template_id: int | None) -> int: + """Where the abstract should land in the paper's document. + + The template's abstract paragraph when it has one; otherwise the position + just before the first paragraph, so the text keeps its place at the top of + the document even though no heading describes it; otherwise 1. + """ + if template_id is None: + return 1 + + row = bind.execute( + sa.text( + f""" + SELECT tf.sort + FROM template_field AS tf + JOIN template_field_library AS lib ON lib.id = tf.field_id + WHERE tf.template_id = :template_id AND {_ABSTRACT_HEADING} + ORDER BY tf.sort ASC, tf.id ASC + LIMIT 1 + """ + ), + {"template_id": template_id}, + ).first() + if row is not None: + return int(row[0]) + + first = bind.execute( + sa.text( + "SELECT MIN(sort) FROM template_field WHERE template_id = :template_id" + ), + {"template_id": template_id}, + ).scalar() + return int(first) - 1 if first is not None else 1 + + +def upgrade() -> None: + bind = op.get_bind() + + papers = bind.execute( + sa.text( + """ + SELECT id, template_id, abstract + FROM paper + WHERE abstract IS NOT NULL AND TRIM(abstract) <> '' + """ + ) + ).all() + + for paper_id, template_id, abstract in papers: + sentences = _split_sentences(abstract) + if not sentences: + continue + + position = _target_position(bind, template_id) + # Appended rather than replacing: anything already written in that + # paragraph is the writer's, and losing it to a migration would be a + # far worse outcome than a duplicate they can delete in one click. + start = bind.execute( + sa.text( + """ + SELECT COALESCE(MAX(sort), 0) + FROM paper_sentence + WHERE paper_id = :paper_id AND paper_template_filed_sort = :position + """ + ), + {"paper_id": paper_id, "position": position}, + ).scalar() or 0 + + for offset, content in enumerate(sentences, start=1): + bind.execute( + sa.text( + """ + INSERT INTO paper_sentence + (paper_id, template_id, paper_template_filed_sort, sort, content) + VALUES + (:paper_id, :template_id, :position, :sort, :content) + """ + ), + { + "paper_id": paper_id, + "template_id": template_id, + "position": position, + "sort": int(start) + offset, + "content": content, + }, + ) + + op.drop_column("paper", "abstract") + + +def downgrade() -> None: + """Re-add the column, empty. + + The text is not moved back: it is body content now, and the column it came + from is the thing this revision exists to remove. Reverting the model is + enough to leave the column unused again. + """ + op.add_column("paper", sa.Column("abstract", sa.Text(), nullable=True)) diff --git a/backend/app/crud/paper.py b/backend/app/crud/paper.py index ce06228..5416c9d 100644 --- a/backend/app/crud/paper.py +++ b/backend/app/crud/paper.py @@ -224,7 +224,7 @@ def read(paper: Paper) -> PaperRead: else 0 ), ) - return PaperRead(**item.model_dump(), abstract=paper.abstract) + return PaperRead(**item.model_dump()) # --- document assembly ------------------------------------------------------- @@ -425,7 +425,7 @@ def update( ``values`` holds only the keys the client actually sent — the route derives it from ``model_fields_set``, which is the only way to tell "clear the - abstract" apart from "leave it alone"; both arrive as ``None``. + author" apart from "leave it alone"; both arrive as ``None``. When the template changes, every sentence is re-stamped with the new template id. The sentences themselves keep their positions, which is what diff --git a/backend/app/models/paper.py b/backend/app/models/paper.py index b4d23f7..e43a88b 100644 --- a/backend/app/models/paper.py +++ b/backend/app/models/paper.py @@ -25,7 +25,7 @@ without a migration. from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Integer, String, Text, text +from sqlalchemy import ForeignKey, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -69,9 +69,6 @@ class Paper(TimestampMixin, Base): nullable=True, ) - #: 摘要 — the paper's own abstract, free text. - abstract: Mapped[str | None] = mapped_column(Text, nullable=True) - #: 作者. Free text, since authorship is written as it will be printed. author: Mapped[str | None] = mapped_column(String(255), nullable=True) @@ -91,6 +88,12 @@ class Paper(TimestampMixin, Base): #: 投稿目标期刊. target_journal: Mapped[str | None] = mapped_column(String(255), nullable=True) + #: There is deliberately no ``abstract`` column. A paper's abstract is a + #: paragraph of its body — the template's ``0 Abstract`` field — so it has a + #: position in the document, the template's own heading, and the same + #: sentence-by-sentence editing as everything else. A column here would be a + #: second home for the same text, and the document never reads it. + #: Eager-loaded with the paper: every read of a paper shows its template #: name, and a lazy load there would be one query per row in the table. template: Mapped["Template | None"] = relationship(lazy="joined") diff --git a/backend/app/schemas/paper.py b/backend/app/schemas/paper.py index 7903cdb..8dc5937 100644 --- a/backend/app/schemas/paper.py +++ b/backend/app/schemas/paper.py @@ -217,7 +217,6 @@ class PaperBase(BaseModel): title: str = Field(min_length=1, max_length=255) template_id: int | None = None - abstract: str | None = None author: str | None = Field(default=None, max_length=255) status: PaperStatus = "draft" keywords: str | None = Field(default=None, max_length=255) @@ -264,7 +263,6 @@ class PaperUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=255) template_id: int | None = None - abstract: str | None = None author: str | None = Field(default=None, max_length=255) status: PaperStatus | None = None keywords: str | None = Field(default=None, max_length=255) @@ -317,9 +315,14 @@ class PaperListItem(BaseModel): class PaperRead(PaperListItem): - """A paper with everything the table does not need.""" + """A paper as its own page needs it. - abstract: str | None + Kept separate from :class:`PaperListItem` even though it currently adds no + field, because the two are different contracts: the table's row and the + page's subject. The paper's abstract is not here — it is a paragraph of the + document (see ``app.crud.paper.build_document``), not a property of the + paper. + """ class PaperDocumentRead(BaseModel): diff --git a/backend/scripts/smoke_papers.py b/backend/scripts/smoke_papers.py index 1ccead7..9de0d06 100644 --- a/backend/scripts/smoke_papers.py +++ b/backend/scripts/smoke_papers.py @@ -65,6 +65,11 @@ def main() -> int: created_papers: list[int] = [] try: + # The database may already hold real papers, so "nothing of ours is + # left" is measured against a baseline rather than against zero. + baseline = call("GET", "/papers")["total"] + print(f"0. baseline: {baseline} paper(s) already in the database") + print("1. pick two templates with different paragraph positions") templates = call("GET", "/templates?page_size=50")["items"] check("templates exist", len(templates) >= 2, f"got {len(templates)}") @@ -85,7 +90,6 @@ def main() -> int: "status": "writing", "keywords": "关键词A,关键词B;关键词A", "target_journal": "测试期刊", - "abstract": "用于验证论文功能的临时数据。", }, expect=201, ) @@ -310,7 +314,11 @@ def main() -> int: print("14. delete the paper") call("DELETE", f"/papers/{paper['id']}", expect=204) created_papers.clear() - check("gone from the list", call("GET", "/papers")["total"] == 0) + check( + "gone from the list", + call("GET", "/papers")["total"] == baseline, + f"expected {baseline} paper(s) to remain", + ) print(f"\nall {_checks} checks passed") return 0 diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index e602773..21827cf 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -72,8 +72,7 @@ template_field a field placed in a template — and the only field_id → template_field_library, sort paper the document - id, title, template_id, abstract, author, status, keywords, - target_journal + id, title, template_id, author, status, keywords, target_journal paper_sentence one sentence, at one position, in one paper id, paper_id → paper, template_id, paper_template_filed_sort, sort, content @@ -82,6 +81,16 @@ paper_sentence_reference the citations of one sentence id, sentence_id → paper_sentence, reference_id, quote, sort ``` +`paper` has no `abstract` column, and that is a decision rather than an +omission. A paper's abstract *is* a paragraph of its body — the template's +`0 Abstract` field — so it has a position in the document, the heading and +typography the template gives it, and the same sentence-by-sentence editing as +every other paragraph. A column for it would be a second home for the same +text, one the document never reads: a paper could show two different abstracts, +and the one in the column could drift from the one in the body. Revision +`a83f5c21d7b6` removed it, moving each stored abstract into the abstract +paragraph first. + `template_field` and `template_field_library` are one word apart and mean opposite things. The first is a *placement* — this template puts this field here, `sort` included. The second is the *catalogue* the field was picked from, diff --git a/frontend/src/api/papers.ts b/frontend/src/api/papers.ts index 02e58a6..643acd0 100644 --- a/frontend/src/api/papers.ts +++ b/frontend/src/api/papers.ts @@ -56,10 +56,13 @@ export interface PaperListItem { updated_at: string } -/** A paper with everything the table does not need. */ -export interface PaperDetail extends PaperListItem { - abstract: string | null -} +/** + * A paper as its own page needs it. + * + * There is no `abstract` here on purpose: a paper's abstract is the template's + * `0 Abstract` paragraph, written and edited like any other paragraph. + */ +export interface PaperDetail extends PaperListItem {} /** A stored citation of one sentence. */ export interface Citation { @@ -143,7 +146,6 @@ export interface SentenceInput { export interface PaperPayload { title: string template_id: number | null - abstract: string | null author: string | null status: PaperStatus keywords: string | null diff --git a/frontend/src/components/papers/PaperFormDialog.vue b/frontend/src/components/papers/PaperFormDialog.vue index dc479ce..b299dce 100644 --- a/frontend/src/components/papers/PaperFormDialog.vue +++ b/frontend/src/components/papers/PaperFormDialog.vue @@ -53,7 +53,6 @@ const templates = ref([]) const form = reactive({ title: '', template_id: null as number | null, - abstract: '', author: '', status: 'draft' as PaperStatus, keywords: [] as string[], @@ -78,7 +77,6 @@ watch( formRef.value?.clearValidate() form.title = props.paper?.title ?? '' form.template_id = props.paper?.template_id ?? null - form.abstract = '' form.author = props.paper?.author ?? '' form.status = props.paper?.status ?? 'draft' form.keywords = splitKeywords(props.paper?.keywords) @@ -98,7 +96,6 @@ watch( const detail = await getPaper(props.paper.id) form.title = detail.title form.template_id = detail.template_id - form.abstract = detail.abstract ?? '' form.author = detail.author ?? '' form.status = detail.status form.keywords = splitKeywords(detail.keywords) @@ -121,7 +118,6 @@ async function submit(): Promise { const payload = { title: form.title.trim(), template_id: form.template_id, - abstract: form.abstract.trim() || null, author: form.author.trim() || null, status: form.status, keywords: form.keywords.length ? form.keywords.join(', ') : null, @@ -226,15 +222,6 @@ async function submit(): Promise { - - - - @@ -390,16 +387,6 @@ onMounted(async () => { text-align: left; } -.abstract { - margin-top: 2px; - max-width: 320px; - font-size: 12px; - color: var(--el-text-color-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .progress { font-size: 13px; font-variant-numeric: tabular-nums; diff --git a/frontend/src/views/papers/PaperDetailView.vue b/frontend/src/views/papers/PaperDetailView.vue index 0b8ae2b..fa76a4c 100644 --- a/frontend/src/views/papers/PaperDetailView.vue +++ b/frontend/src/views/papers/PaperDetailView.vue @@ -222,8 +222,6 @@ onMounted(load) {{ word }} - -

{{ paper.abstract }}

@@ -384,13 +382,6 @@ onMounted(load) margin-top: 8px; } -.head-abstract { - margin: 10px 0 0; - font-size: 13px; - line-height: 1.8; - color: var(--el-text-color-regular); -} - .head-actions { display: flex; align-items: center;