Skip to content

Commit 425a046

Browse files
Merge pull request #21 from CanYouTeachMeHowToCode/feature/bilingual
Add an English / Chinese interface (V3 Milestone 4)
2 parents e895f8d + 4f17423 commit 425a046

59 files changed

Lines changed: 2884 additions & 420 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CLAUDE.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,10 @@ Completed:
125125
1. Accounts and authentication
126126
2. User-owned attempts
127127
3. Per-user analytics and isolation
128+
4. Bilingual interface
128129

129130
Remaining:
130131

131-
4. Bilingual interface
132132
5. Existing-content cleanup
133133
6. V3 hardening and release
134134

@@ -371,7 +371,7 @@ Do not change the mathematical definitions of V2 analytics solely because user i
371371

372372
---
373373

374-
# 11. V3 Milestone 4 — Bilingual Interface
374+
# 11. V3 Milestone 4 — Bilingual Interface ✅
375375

376376
Implement English / Chinese product support.
377377

@@ -444,6 +444,20 @@ unless there is a deliberate product reason.
444444

445445
---
446446

447+
## Delivered
448+
449+
- language switch in the nav bar; `users.preferred_language` (set through `PATCH /api/auth/me`) for logged-in users, `localStorage` for visitors, and the browser language on a first visit
450+
- every interface string in `frontend/src/i18n/messages.ts`; the Chinese table must have exactly the same keys as the English one, enforced by the TypeScript type
451+
- Chinese boss names, locations, phase names, move names, descriptions, telegraphs, counters and common mistakes for the existing bosses
452+
- the seed rejects an English text field without its Chinese version, and a move without a Chinese name
453+
- `moves.name_zh_source` is `wiki` (with `name_zh_source_url`, checked by a database constraint) only when the full name appears on a Chinese wiki page; everything else is `translation`
454+
- Chinese move descriptions are translations of the sourced English text; the Chinese moveset shows one note saying so
455+
- move descriptions appear as a hover tooltip and under the move select when recording an attempt
456+
457+
The user reviewed all Chinese content.
458+
459+
---
460+
447461
# 12. V3 Milestone 5 — Existing-Content Cleanup
448462

449463
This milestone has changed significantly from the old roadmap.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ V3 turns the single-user local app into a foundation that several players can us
195195
* ✅ **Accounts and authentication:** registration, login and logout, argon2-hashed passwords, and server-side sessions in an httpOnly cookie. Boss data stays browsable without an account.
196196
* ✅ **User-owned attempts:** every attempt belongs to the player who recorded it. Attempts recorded before accounts existed are kept and can be claimed with `scripts.claim_attempts`.
197197
* ✅ **Per-user analytics:** attempt history, boss analytics, progression, and the Sekiro dashboard only cover your own attempts.
198-
* 🚧 **English / 中文 interface:** a language switch, with each language showing only its own text.
198+
* ✅ **English / 中文 interface:** a language switch, with each language showing only its own text. The choice is saved on the account, or in the browser for visitors. Most Chinese move names and descriptions are translations of the English source material.
199199
* **Existing content cleanup:** correcting the current boss data rather than adding more bosses from wikis.
200200
* **Release `v3.0.0`.**
201201

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ V3 把单用户的本地应用,变成可以供多名玩家使用、每人数
195195
* ✅ **账号与认证:** 注册、登录、登出;密码用 argon2 哈希保存;登录状态是存在 httpOnly cookie 里的服务器端会话。不登录也能浏览 Boss 资料。
196196
* ✅ **攻略记录归属用户:** 每条记录都属于记录它的玩家。有账号系统之前的旧记录都保留着,可以用 `scripts.claim_attempts` 认领。
197197
* ✅ **按用户的分析:** 攻略历史、Boss 分析、进度图和只狼总览都只统计你自己的记录。
198-
* 🚧 **中英文界面:** 可以切换语言,每种语言只显示该语言的文字。
198+
* ✅ **中英文界面:** 可以切换语言,每种语言只显示该语言的文字。登录用户的选择保存在账号里,访客的选择保存在浏览器里。大部分中文招式名和说明译自英文资料。
199199
* **清理现有内容:** 修正现有的 Boss 数据,而不是继续从 Wiki 补充更多 Boss。
200200
* **发布 `v3.0.0`。**
201201

‎ROADMAP.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,11 @@ A user must never read, modify, or influence another user's attempt analytics.
173173

174174
---
175175

176-
## Milestone 4 — Bilingual Product Interface
176+
## Milestone 4 — Bilingual Product Interface ✅
177177

178178
Add English / Chinese language support for the existing product.
179179

180-
Requirements:
180+
Delivered:
181181

182182
- language switch in the navigation
183183
- logged-in users store their language preference in their account
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""add move chinese names
2+
3+
Revision ID: 01e9ec08d5ac
4+
Revises: 30b90438d428
5+
Create Date: 2026-09-23 22:26:38.514977
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '01e9ec08d5ac'
16+
down_revision: Union[str, Sequence[str], None] = '30b90438d428'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema: Chinese move names, from a Chinese wiki or marked as translations.
23+
24+
Existing rows get NULLs, which satisfy the checks; the next seed sync fills them in.
25+
"""
26+
op.add_column('moves', sa.Column('name_zh', sa.String(length=200), nullable=True))
27+
op.add_column('moves', sa.Column('name_zh_source', sa.String(length=20), nullable=True))
28+
op.add_column('moves', sa.Column('name_zh_source_url', sa.Text(), nullable=True))
29+
# Autogenerate does not detect check constraints; these mirror models.Move.
30+
op.create_check_constraint(
31+
'ck_moves_name_zh_source', 'moves', "name_zh_source IS NULL OR name_zh_source IN ('wiki', 'translation')"
32+
)
33+
op.create_check_constraint('ck_moves_name_zh_has_source', 'moves', '(name_zh IS NULL) = (name_zh_source IS NULL)')
34+
op.create_check_constraint(
35+
'ck_moves_name_zh_source_url', 'moves', "(name_zh_source = 'wiki') = (name_zh_source_url IS NOT NULL)"
36+
)
37+
38+
39+
def downgrade() -> None:
40+
"""Downgrade schema."""
41+
op.drop_constraint('ck_moves_name_zh_source_url', 'moves', type_='check')
42+
op.drop_constraint('ck_moves_name_zh_has_source', 'moves', type_='check')
43+
op.drop_constraint('ck_moves_name_zh_source', 'moves', type_='check')
44+
op.drop_column('moves', 'name_zh_source_url')
45+
op.drop_column('moves', 'name_zh_source')
46+
op.drop_column('moves', 'name_zh')
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""bilingual content and language preference
2+
3+
Revision ID: 4b0d6abd4d80
4+
Revises: 01e9ec08d5ac
5+
Create Date: 2026-09-23 23:11:42.011448
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '4b0d6abd4d80'
16+
down_revision: Union[str, Sequence[str], None] = '01e9ec08d5ac'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema: Chinese boss content and each user's interface language.
23+
24+
Existing rows get NULLs; the next seed sync fills in the Chinese content.
25+
"""
26+
op.add_column('boss_phases', sa.Column('name_zh', sa.String(length=100), nullable=True))
27+
op.add_column('bosses', sa.Column('location_zh', sa.String(length=200), nullable=True))
28+
op.add_column('moves', sa.Column('description_zh', sa.Text(), nullable=True))
29+
op.add_column('moves', sa.Column('telegraph_zh', sa.Text(), nullable=True))
30+
op.add_column('moves', sa.Column('counter_zh', sa.Text(), nullable=True))
31+
op.add_column('moves', sa.Column('common_mistakes_zh', sa.Text(), nullable=True))
32+
op.add_column('users', sa.Column('preferred_language', sa.String(length=5), nullable=True))
33+
# Autogenerate does not detect check constraints; this mirrors models.User.
34+
op.create_check_constraint(
35+
'ck_users_preferred_language', 'users', "preferred_language IS NULL OR preferred_language IN ('en', 'zh')"
36+
)
37+
38+
39+
def downgrade() -> None:
40+
"""Downgrade schema."""
41+
op.drop_constraint('ck_users_preferred_language', 'users', type_='check')
42+
op.drop_column('users', 'preferred_language')
43+
op.drop_column('moves', 'common_mistakes_zh')
44+
op.drop_column('moves', 'counter_zh')
45+
op.drop_column('moves', 'telegraph_zh')
46+
op.drop_column('moves', 'description_zh')
47+
op.drop_column('bosses', 'location_zh')
48+
op.drop_column('boss_phases', 'name_zh')

‎backend/app/db/models.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class Boss(Base):
3535
name: Mapped[str] = mapped_column(String(200))
3636
name_zh: Mapped[str | None] = mapped_column(String(200))
3737
location: Mapped[str] = mapped_column(String(200))
38+
location_zh: Mapped[str | None] = mapped_column(String(200))
3839
# Where this boss's phase and move data came from; moves share their boss's source.
3940
source_name: Mapped[str | None] = mapped_column(String(200))
4041
source_url: Mapped[str | None] = mapped_column(Text)
@@ -54,6 +55,7 @@ class BossPhase(Base):
5455
boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE"))
5556
phase_number: Mapped[int] = mapped_column(Integer)
5657
name: Mapped[str] = mapped_column(String(100))
58+
name_zh: Mapped[str | None] = mapped_column(String(100))
5759

5860
boss: Mapped[Boss] = relationship(back_populates="phases")
5961
move_links: Mapped[list["PhaseMove"]] = relationship(
@@ -63,7 +65,18 @@ class BossPhase(Base):
6365

6466
class Move(Base):
6567
__tablename__ = "moves"
66-
__table_args__ = (UniqueConstraint("boss_id", "slug"),)
68+
__table_args__ = (
69+
UniqueConstraint("boss_id", "slug"),
70+
CheckConstraint(
71+
"name_zh_source IS NULL OR name_zh_source IN ('wiki', 'translation')",
72+
name="ck_moves_name_zh_source",
73+
),
74+
CheckConstraint("(name_zh IS NULL) = (name_zh_source IS NULL)", name="ck_moves_name_zh_has_source"),
75+
# A name taken from a Chinese wiki must say which page; a translation has no page.
76+
CheckConstraint(
77+
"(name_zh_source = 'wiki') = (name_zh_source_url IS NOT NULL)", name="ck_moves_name_zh_source_url"
78+
),
79+
)
6780

6881
id: Mapped[int] = mapped_column(primary_key=True)
6982
boss_id: Mapped[int] = mapped_column(ForeignKey("bosses.id", ondelete="CASCADE"))
@@ -74,6 +87,16 @@ class Move(Base):
7487
telegraph: Mapped[str | None] = mapped_column(Text)
7588
counter: Mapped[str | None] = mapped_column(Text)
7689
common_mistakes: Mapped[str | None] = mapped_column(Text)
90+
# Chinese versions of the text fields above: translations of the sourced English text.
91+
description_zh: Mapped[str | None] = mapped_column(Text)
92+
telegraph_zh: Mapped[str | None] = mapped_column(Text)
93+
counter_zh: Mapped[str | None] = mapped_column(Text)
94+
common_mistakes_zh: Mapped[str | None] = mapped_column(Text)
95+
# Chinese name: either the name used by a Chinese wiki ('wiki', with the page in
96+
# name_zh_source_url) or a translation ('translation'), never presented as official.
97+
name_zh: Mapped[str | None] = mapped_column(String(200))
98+
name_zh_source: Mapped[str | None] = mapped_column(String(20))
99+
name_zh_source_url: Mapped[str | None] = mapped_column(Text)
77100

78101
boss: Mapped[Boss] = relationship(back_populates="moves")
79102
phase_links: Mapped[list["PhaseMove"]] = relationship(back_populates="move", cascade="all, delete-orphan")
@@ -133,12 +156,17 @@ class User(Base):
133156
__tablename__ = "users"
134157
__table_args__ = (
135158
CheckConstraint("username = lower(username)", name="ck_users_username_lowercase"),
159+
CheckConstraint(
160+
"preferred_language IS NULL OR preferred_language IN ('en', 'zh')", name="ck_users_preferred_language"
161+
),
136162
)
137163

138164
id: Mapped[int] = mapped_column(primary_key=True)
139165
# Stored lowercase so usernames are unique regardless of case.
140166
username: Mapped[str] = mapped_column(String(30), unique=True)
141167
password_hash: Mapped[str] = mapped_column(String(255))
168+
# Interface language chosen in the app; null until the user picks one.
169+
preferred_language: Mapped[str | None] = mapped_column(String(5))
142170
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
143171

144172
sessions: Mapped[list["UserSession"]] = relationship(back_populates="user", cascade="all, delete-orphan")

‎backend/app/models/analytics.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ class RecentAttempt(BaseModel):
5353
phase_reached: int
5454
failure_move_id: str | None = None
5555
failure_move_name: str | None = None
56+
failure_move_name_zh: str | None = None
57+
boss_name_zh: str | None = None
5658
failure_category: FailureCategory | None = None
5759

5860

‎backend/app/models/auth.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from typing import Literal
23

34
from pydantic import BaseModel, Field, field_validator
45

@@ -28,6 +29,14 @@ class LoginRequest(BaseModel):
2829
password: str = Field(max_length=128)
2930

3031

32+
Language = Literal["en", "zh"]
33+
34+
3135
class User(BaseModel):
3236
id: str
3337
username: str
38+
preferred_language: Language | None = None
39+
40+
41+
class UpdateUserRequest(BaseModel):
42+
preferred_language: Language

‎backend/app/models/boss.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from pydantic import BaseModel
1+
from typing import Literal
2+
3+
from pydantic import BaseModel, model_validator
24

35

46
class BossMove(BaseModel):
@@ -9,11 +11,29 @@ class BossMove(BaseModel):
911
telegraph: str | None = None
1012
counter: str | None = None
1113
common_mistakes: str | None = None
14+
name_zh: str | None = None
15+
description_zh: str | None = None
16+
telegraph_zh: str | None = None
17+
counter_zh: str | None = None
18+
common_mistakes_zh: str | None = None
19+
# "wiki": the name used by a Chinese wiki, see name_zh_source_url.
20+
# "translation": translated for this app; not an official name.
21+
name_zh_source: Literal["wiki", "translation"] | None = None
22+
name_zh_source_url: str | None = None
23+
24+
@model_validator(mode="after")
25+
def check_chinese_name_source(self) -> "BossMove":
26+
if (self.name_zh is None) != (self.name_zh_source is None):
27+
raise ValueError(f"move '{self.id}': name_zh and name_zh_source must be set together")
28+
if (self.name_zh_source == "wiki") != (self.name_zh_source_url is not None):
29+
raise ValueError(f"move '{self.id}': a wiki name needs name_zh_source_url; a translation must not have one")
30+
return self
1231

1332

1433
class BossPhase(BaseModel):
1534
phase_number: int
1635
name: str
36+
name_zh: str | None = None
1737
moves: list[BossMove] = []
1838

1939

@@ -23,6 +43,7 @@ class Boss(BaseModel):
2343
name_zh: str | None = None
2444
game: str
2545
location: str
46+
location_zh: str | None = None
2647
phases: list[BossPhase]
2748
source_name: str | None = None
2849
source_url: str | None = None
@@ -33,3 +54,4 @@ class BossSummary(BaseModel):
3354
name: str
3455
name_zh: str | None = None
3556
location: str
57+
location_zh: str | None = None

0 commit comments

Comments
 (0)