From 7dbd87482c8cb638a5978bc393f4ecd1f80d52d0 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 18 Jul 2026 19:41:15 -0400 Subject: [PATCH] feat: walkthrough documents with reading progress and GameFAQs import Unify manuals and walkthroughs on one document model instead of a separate walkthrough subsystem. Walkthroughs are RomFiles under a new WALKTHROUGH category with an optional RomFileDocMeta provenance sidecar, and reading progress is a per-user RomFileUser row keyed on rom_file_id so the same mechanism covers manuals and walkthroughs. Backend: - models: RomFileCategory.WALKTHROUGH, DocSource, RomFileDocMeta, RomFileUser; migration 0098 (enum value + two tables, cross-DB) - serve .txt/.html documents inline (HTML under a sandboxing CSP); add .txt to allowed manual files - endpoints: walkthrough upload / GameFAQs fetch / delete, and per-user reading-progress GET/PUT (ROMS_READ, like notes) - GameFAQs import extracts text only (stored as .txt); no remote HTML is persisted or served. Host-allowlisted on top of the SSRF-safe client - tests for CRUD, progress, and the GameFAQs parser/URL validation Frontend (v2): - TextViewer (.txt/.html), useReadingProgress composable, WalkthroughSubtab (upload + add-from-GameFAQs-URL + delete + progress), wired into MediaTab - ManualSubtab renders .txt manuals; walkthrough category surfaced in FilesTab; regenerated types; i18n keys across all locales AI assistance: implemented with Claude Code (Opus 4.8). The data-model design, backend, migration, tests, and v2 UI were AI-authored under human direction. Alternative approach to #2892. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../alembic/versions/0098_walkthrough_docs.py | 159 ++++++++ backend/endpoints/responses/rom.py | 30 +- backend/endpoints/roms/__init__.py | 2 + backend/endpoints/roms/files.py | 124 ++++++- backend/endpoints/roms/walkthrough.py | 348 ++++++++++++++++++ backend/handler/database/roms_handler.py | 75 +++- .../handler/filesystem/resources_handler.py | 6 +- backend/handler/walkthrough/__init__.py | 15 + backend/handler/walkthrough/gamefaqs.py | 168 +++++++++ backend/models/rom.py | 91 +++++ .../endpoints/roms/test_file_progress.py | 93 +++++ backend/tests/endpoints/roms/test_manual.py | 10 +- .../tests/endpoints/roms/test_walkthrough.py | 223 +++++++++++ .../filesystem/test_resources_handler.py | 8 +- backend/tests/handler/walkthrough/__init__.py | 0 .../handler/walkthrough/test_gamefaqs.py | 60 +++ backend/utils/media_types.py | 16 +- frontend/src/__generated__/index.ts | 3 + .../src/__generated__/models/DocMetaSchema.ts | 12 + .../src/__generated__/models/DocSource.ts | 8 + .../__generated__/models/RomFileCategory.ts | 2 +- .../src/__generated__/models/RomFileSchema.ts | 2 + .../__generated__/models/RomFileUserSchema.ts | 13 + frontend/src/locales/bg_BG/rom.json | 11 + frontend/src/locales/cs_CZ/rom.json | 11 + frontend/src/locales/de_DE/rom.json | 11 + frontend/src/locales/en_GB/rom.json | 11 + frontend/src/locales/en_US/rom.json | 11 + frontend/src/locales/es_ES/rom.json | 11 + frontend/src/locales/fr_FR/rom.json | 11 + frontend/src/locales/hu_HU/rom.json | 11 + frontend/src/locales/it_IT/rom.json | 11 + frontend/src/locales/ja_JP/rom.json | 11 + frontend/src/locales/ko_KR/rom.json | 11 + frontend/src/locales/pl_PL/rom.json | 11 + frontend/src/locales/pt_BR/rom.json | 11 + frontend/src/locales/ro_RO/rom.json | 11 + frontend/src/locales/ru_RU/rom.json | 11 + frontend/src/locales/tr_TR/rom.json | 11 + frontend/src/locales/zh_CN/rom.json | 11 + frontend/src/locales/zh_TW/rom.json | 11 + frontend/src/services/api/rom.ts | 88 +++++ .../GameDetails/FilesTab/FileRow.vue | 4 + .../GameDetails/FilesTab/FilesTab.vue | 9 +- .../components/GameDetails/ManualSubtab.vue | 29 +- .../v2/components/GameDetails/MediaTab.vue | 16 + .../v2/components/GameDetails/TextViewer.vue | 241 ++++++++++++ .../GameDetails/WalkthroughSubtab.vue | 339 +++++++++++++++++ .../composables/useReadingProgress/index.ts | 92 +++++ 49 files changed, 2453 insertions(+), 31 deletions(-) create mode 100644 backend/alembic/versions/0098_walkthrough_docs.py create mode 100644 backend/endpoints/roms/walkthrough.py create mode 100644 backend/handler/walkthrough/__init__.py create mode 100644 backend/handler/walkthrough/gamefaqs.py create mode 100644 backend/tests/endpoints/roms/test_file_progress.py create mode 100644 backend/tests/endpoints/roms/test_walkthrough.py create mode 100644 backend/tests/handler/walkthrough/__init__.py create mode 100644 backend/tests/handler/walkthrough/test_gamefaqs.py create mode 100644 frontend/src/__generated__/models/DocMetaSchema.ts create mode 100644 frontend/src/__generated__/models/DocSource.ts create mode 100644 frontend/src/__generated__/models/RomFileUserSchema.ts create mode 100644 frontend/src/v2/components/GameDetails/TextViewer.vue create mode 100644 frontend/src/v2/components/GameDetails/WalkthroughSubtab.vue create mode 100644 frontend/src/v2/composables/useReadingProgress/index.ts diff --git a/backend/alembic/versions/0098_walkthrough_docs.py b/backend/alembic/versions/0098_walkthrough_docs.py new file mode 100644 index 0000000000..bb1db1ceac --- /dev/null +++ b/backend/alembic/versions/0098_walkthrough_docs.py @@ -0,0 +1,159 @@ +"""Add walkthrough document support. + +Adds the WALKTHROUGH value to the rom_files.category enum, plus two tables: +- rom_file_doc_meta: provenance sidecar for document-category files +- rom_file_user: per-user reading progress for document-category files + +Revision ID: 0098_walkthrough_docs +Revises: 0097_roms_platform_fs_size_index +Create Date: 2026-07-18 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op # type: ignore[attr-defined] + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0098_walkthrough_docs" +down_revision = "0097_roms_platform_fs_size_index" +branch_labels = None +depends_on = None + +# Full enum member set including WALKTHROUGH, in model declaration order. +ROM_FILE_CATEGORY_VALUES = ( + "GAME", + "DLC", + "HACK", + "MANUAL", + "WALKTHROUGH", + "PATCH", + "UPDATE", + "MOD", + "DEMO", + "TRANSLATION", + "PROTOTYPE", + "CHEAT", + "SOUNDTRACK", + "SCREENSHOT", +) + +# The pre-walkthrough set, used to narrow the enum back on downgrade. +ROM_FILE_CATEGORY_VALUES_PRE = tuple( + v for v in ROM_FILE_CATEGORY_VALUES if v != "WALKTHROUGH" +) + + +def upgrade() -> None: + connection = op.get_bind() + + # 1. Extend the rom_files.category enum with WALKTHROUGH. + if is_postgresql(connection): + # `ALTER TYPE ... ADD VALUE` must run outside a transaction in PostgreSQL. + with op.get_context().autocommit_block(): + op.execute( + "ALTER TYPE romfilecategory ADD VALUE IF NOT EXISTS 'WALKTHROUGH'" + ) + else: + with op.batch_alter_table("rom_files", schema=None) as batch_op: + batch_op.alter_column( + "category", + type_=sa.Enum(*ROM_FILE_CATEGORY_VALUES, name="romfilecategory"), + nullable=True, + ) + + # 2. Provenance sidecar for document-category files. + op.create_table( + "rom_file_doc_meta", + sa.Column("rom_file_id", sa.Integer(), nullable=False), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column( + "source", + sa.Enum("UPLOAD", "GAMEFAQS", "SCRAPER", name="docsource"), + nullable=False, + ), + sa.Column("source_url", sa.Text(), nullable=True), + sa.Column("author", sa.String(length=512), nullable=True), + sa.Column("title", sa.String(length=512), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint(["rom_file_id"], ["rom_files.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("rom_file_id"), + if_not_exists=True, + ) + with op.batch_alter_table("rom_file_doc_meta", schema=None) as batch_op: + batch_op.create_index( + "idx_rom_file_doc_meta_rom_id", + ["rom_id"], + unique=False, + if_not_exists=True, + ) + + # 3. Per-user reading progress for document-category files. + op.create_table( + "rom_file_user", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("rom_file_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("progress", sa.Float(), nullable=False, server_default=sa.text("0")), + sa.Column("last_page", sa.Integer(), nullable=True), + sa.Column("finished", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("last_read_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint(["rom_file_id"], ["rom_files.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("rom_file_id", "user_id", name="unique_rom_file_user"), + if_not_exists=True, + ) + with op.batch_alter_table("rom_file_user", schema=None) as batch_op: + batch_op.create_index( + "idx_rom_file_user", + ["rom_file_id", "user_id"], + unique=False, + if_not_exists=True, + ) + + +def downgrade() -> None: + connection = op.get_bind() + + op.drop_table("rom_file_user", if_exists=True) + op.drop_table("rom_file_doc_meta", if_exists=True) + + # PostgreSQL cannot drop an enum value, so leave WALKTHROUGH in place there. + # The docsource type is dropped with its table on PG; drop it explicitly in + # case create_table left it behind. + if is_postgresql(connection): + op.execute("DROP TYPE IF EXISTS docsource") + return + + with op.batch_alter_table("rom_files", schema=None) as batch_op: + batch_op.alter_column( + "category", + type_=sa.Enum(*ROM_FILE_CATEGORY_VALUES_PRE, name="romfilecategory"), + nullable=True, + ) diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 75094a90bb..aa97cd94a9 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -26,7 +26,14 @@ from handler.metadata.ra_handler import RAMetadata from handler.metadata.ss_handler import SSMetadata from models.collection import Collection -from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus +from models.rom import ( + DocSource, + Rom, + RomArchiveMember, + RomFile, + RomFileCategory, + RomUserStatus, +) from .base import BaseModel, UTCDatetime @@ -173,6 +180,26 @@ class TrackMetaSchema(BaseModel): cover_path: str | None = None +class DocMetaSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + source: DocSource + source_url: str | None = None + author: str | None = None + title: str | None = None + + +class RomFileUserSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + rom_file_id: int + user_id: int + progress: float + last_page: int | None = None + finished: bool = False + last_read_at: UTCDatetime | None = None + + class RomFileSchema(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -194,6 +221,7 @@ class RomFileSchema(BaseModel): archive_members: list[RomArchiveMember] | None category: RomFileCategory | None track_meta: TrackMetaSchema | None = None + doc_meta: DocMetaSchema | None = None @model_validator(mode="after") def default_category_for_non_nested(self) -> RomFileSchema: diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 49336e286c..14dcb7d535 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -89,6 +89,7 @@ from .screenshot import router as screenshot_router from .soundtrack import router as soundtrack_router from .upload import router as upload_router +from .walkthrough import router as walkthrough_router router = APIRouter( prefix="/roms", @@ -97,6 +98,7 @@ router.include_router(upload_router) router.include_router(files_router) router.include_router(manual_router) +router.include_router(walkthrough_router) router.include_router(soundtrack_router) router.include_router(screenshot_router) router.include_router(notes_router) diff --git a/backend/endpoints/roms/files.py b/backend/endpoints/roms/files.py index ba6519c840..d99350c9e3 100644 --- a/backend/endpoints/roms/files.py +++ b/backend/endpoints/roms/files.py @@ -1,7 +1,8 @@ +from datetime import datetime, timezone from typing import Annotated from anyio import Path -from fastapi import HTTPException +from fastapi import Body, HTTPException from fastapi import Path as PathVar from fastapi import Request, status from fastapi.responses import Response @@ -9,7 +10,7 @@ from config import DEV_MODE, DISABLE_DOWNLOAD_ENDPOINT_AUTH from decorators.auth import protected_route -from endpoints.responses.rom import RomFileSchema +from endpoints.responses.rom import RomFileSchema, RomFileUserSchema from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from handler.auth.constants import Scope from handler.auth.dependencies import assert_rom_visible @@ -18,12 +19,13 @@ from logger.formatter import BLUE from logger.formatter import highlight as hl from logger.logger import log -from models.rom import RomFileCategory +from models.rom import DOCUMENT_CATEGORIES, RomFileCategory from utils.audio_tags import guess_audio_media_type from utils.media_types import ( guess_media_file_type, is_allowed_document_file, is_allowed_media_file, + is_html_document_file, ) from utils.nginx import FileRedirectResponse from utils.router import APIRouter @@ -110,12 +112,12 @@ async def get_romfile_content( if file.category == RomFileCategory.SOUNDTRACK: media_type = guess_audio_media_type(file.file_name) disposition = "inline" - elif file.category == RomFileCategory.MANUAL and is_allowed_document_file( + elif file.category in DOCUMENT_CATEGORIES and is_allowed_document_file( file.file_name ): - # Only manuals are served inline as documents so the in-page viewer can - # render them; a game/extra file that happens to end in .pdf/.md still - # downloads. + # Only document-category files (manuals, walkthroughs) are served inline + # so the in-page viewer can render them; a game/extra file that happens + # to end in .pdf/.md/.txt still downloads. media_type = guess_media_file_type(file.file_name) disposition = "inline" elif is_allowed_media_file(file.file_name): @@ -129,6 +131,11 @@ async def get_romfile_content( # keeps the browser from sniffing them into anything script-capable (e.g. a # Markdown manual into HTML). headers = {"X-Content-Type-Options": "nosniff"} if disposition == "inline" else {} + # HTML documents are sanitized on ingest, but serve them under a sandboxing + # CSP anyway so a crafted document can never run scripts or reach the + # session origin if it is opened directly. + if disposition == "inline" and is_html_document_file(file.file_name): + headers["Content-Security-Policy"] = "sandbox; default-src 'none'" # Serve the file directly in development mode for emulatorjs if DEV_MODE: @@ -202,3 +209,106 @@ async def delete_rom_file( ) return Response() + + +def _assert_document_file( + rom_id: int, file_id: int, request: Request +) -> RomFileCategory: + """Resolve a document-category file, enforcing parent-rom visibility.""" + rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise RomNotFoundInDatabaseException(rom_id) + assert_rom_visible(request, rom, not_found_detail="File not found") + + rom_file = db_rom_handler.get_rom_file_by_id(file_id) + if ( + not rom_file + or rom_file.rom_id != rom.id + or rom_file.category not in DOCUMENT_CATEGORIES + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document file not found", + ) + return rom_file.category # type: ignore[return-value] + + +@protected_route( + router.get, + "/{rom_id}/files/{file_id}/progress", + [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def get_rom_file_progress( + request: Request, + rom_id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], +) -> RomFileUserSchema: + """Get the current user's reading progress for a document file.""" + + _assert_document_file(rom_id, file_id, request) + + state = db_rom_handler.get_rom_file_user( + rom_file_id=file_id, user_id=request.user.id + ) + if state: + return RomFileUserSchema.model_validate(state) + + # No stored progress yet: return a zeroed default so the client has a shape. + return RomFileUserSchema( + rom_file_id=file_id, + user_id=request.user.id, + progress=0.0, + last_page=None, + finished=False, + last_read_at=None, + ) + + +@protected_route( + router.put, + "/{rom_id}/files/{file_id}/progress", + [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def update_rom_file_progress( + request: Request, + rom_id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], + body: Annotated[dict, Body()], +) -> RomFileUserSchema: + """Upsert the current user's reading progress for a document file. + + Reading progress is per-user state (like notes), so only ROMS_READ is + required, not ROMS_WRITE. + """ + + _assert_document_file(rom_id, file_id, request) + + values: dict = {"last_read_at": datetime.now(timezone.utc)} + if "progress" in body: + try: + values["progress"] = min(1.0, max(0.0, float(body["progress"]))) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="progress must be a number between 0 and 1", + ) from exc + if "last_page" in body: + last_page = body["last_page"] + if last_page is not None: + try: + last_page = max(0, int(last_page)) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="last_page must be an integer", + ) from exc + values["last_page"] = last_page + if "finished" in body: + values["finished"] = bool(body["finished"]) + + state = db_rom_handler.upsert_rom_file_user( + rom_file_id=file_id, user_id=request.user.id, values=values + ) + return RomFileUserSchema.model_validate(state) diff --git a/backend/endpoints/roms/walkthrough.py b/backend/endpoints/roms/walkthrough.py new file mode 100644 index 0000000000..016e514f6e --- /dev/null +++ b/backend/endpoints/roms/walkthrough.py @@ -0,0 +1,348 @@ +import os +import re +from typing import Annotated +from urllib.parse import unquote + +from fastapi import Body, Header, HTTPException +from fastapi import Path as PathVar +from fastapi import Request, status +from fastapi.responses import Response +from starlette.requests import ClientDisconnect +from streaming_form_data import StreamingFormDataParser +from streaming_form_data.targets import FileTarget, NullTarget + +from decorators.auth import protected_route +from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException +from exceptions.fs_exceptions import RomAlreadyExistsException +from handler.auth.constants import Scope +from handler.database import db_rom_handler +from handler.filesystem import fs_rom_handler +from handler.rom_conversion import promote_single_file_to_folder +from handler.walkthrough import fetch_gamefaqs_guide, validate_gamefaqs_url +from handler.walkthrough.gamefaqs import GameFAQsFetchError +from logger.formatter import BLUE +from logger.formatter import highlight as hl +from logger.logger import log +from models.rom import DocSource, RomFile, RomFileCategory +from utils.media_types import ALLOWED_DOCUMENT_EXTENSIONS +from utils.router import APIRouter + +router = APIRouter() + +WALKTHROUGH_FOLDER = "walkthrough" + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _is_allowed_walkthrough_file(file_name: str) -> bool: + _, ext = os.path.splitext(file_name) + return ext.lower() in ALLOWED_DOCUMENT_EXTENSIONS + + +def _slugify(value: str, fallback: str = "walkthrough") -> str: + slug = _SLUG_RE.sub("-", value.lower()).strip("-") + return (slug[:80] or fallback).strip("-") or fallback + + +def _decode_header(value: str | None) -> str | None: + """Percent-decode an optional header value, trimming to the DB column max.""" + if not value: + return None + decoded = unquote(value).strip() + return decoded[:512] or None + + +@protected_route( + router.post, + "/{id}/walkthroughs/files", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_400_BAD_REQUEST: {}, + }, +) +async def add_rom_walkthrough_file( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + filename: Annotated[ + str, + Header( + description="The name of the file being uploaded.", + alias="x-upload-filename", + ), + ], + author: Annotated[ + str | None, + Header(description="Optional walkthrough author.", alias="x-doc-author"), + ] = None, + title: Annotated[ + str | None, + Header(description="Optional walkthrough title.", alias="x-doc-title"), + ] = None, +) -> Response: + """Upload a walkthrough document into the ROM's own walkthrough/ subfolder.""" + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + if rom.has_simple_single_file: + try: + rom = await promote_single_file_to_folder(rom) + except RomAlreadyExistsException as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + + try: + safe_filename = fs_rom_handler._sanitize_filename(filename) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid upload filename: {exc}", + ) from exc + + if safe_filename != filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Upload filename must be a plain file name, not a path", + ) + + if not _is_allowed_walkthrough_file(safe_filename): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Unsupported walkthrough file type. Allowed: " + f"{', '.join(sorted(ALLOWED_DOCUMENT_EXTENSIONS))}" + ), + ) + + walkthrough_dir_rel = f"{rom.full_path}/{WALKTHROUGH_FOLDER}" + file_rel_path = f"{walkthrough_dir_rel}/{safe_filename}" + file_location = fs_rom_handler.validate_path(file_rel_path) + log.info(f"Uploading walkthrough file to {hl(str(file_location))}") + + await fs_rom_handler.make_directory(walkthrough_dir_rel) + + parser = StreamingFormDataParser(headers=request.headers) + parser.register("x-upload-platform", NullTarget()) + parser.register(safe_filename, FileTarget(str(file_location))) + + def cleanup_partial_file(): + if file_location.exists(): + file_location.unlink() + + try: + async for chunk in request.stream(): + parser.data_received(chunk) + except ClientDisconnect: + log.error("Client disconnected during upload") + cleanup_partial_file() + raise + except Exception as exc: + log.error("Error uploading walkthrough file", exc_info=exc) + cleanup_partial_file() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error uploading the walkthrough file", + ) from exc + + stat = os.stat(file_location) + existing = db_rom_handler.get_rom_file_by_path( + rom_id=rom.id, file_path=walkthrough_dir_rel, file_name=safe_filename + ) + if existing: + db_rom_handler.update_rom_file( + existing.id, + { + "file_size_bytes": stat.st_size, + "last_modified": stat.st_mtime, + "category": RomFileCategory.WALKTHROUGH, + "missing_from_fs": False, + }, + ) + rom_file_id = existing.id + else: + created = db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=safe_filename, + file_path=walkthrough_dir_rel, + file_size_bytes=stat.st_size, + last_modified=stat.st_mtime, + category=RomFileCategory.WALKTHROUGH, + ) + ) + rom_file_id = created.id + + db_rom_handler.upsert_doc_meta( + rom_file_id=rom_file_id, + rom_id=rom.id, + values={ + "source": DocSource.UPLOAD, + "author": _decode_header(author), + "title": _decode_header(title), + }, + ) + + return Response(status_code=status.HTTP_201_CREATED) + + +@protected_route( + router.post, + "/{id}/walkthroughs/gamefaqs", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={ + status.HTTP_404_NOT_FOUND: {}, + status.HTTP_400_BAD_REQUEST: {}, + status.HTTP_502_BAD_GATEWAY: {}, + }, +) +async def add_rom_gamefaqs_walkthrough( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + body: Annotated[dict, Body()], +) -> Response: + """Fetch a GameFAQs text guide by URL and store it as a walkthrough. + + The remote page is reduced to plain text on ingest; no HTML is persisted. + """ + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + url = (body.get("url") or "").strip() + try: + validate_gamefaqs_url(url) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + + try: + guide = await fetch_gamefaqs_guide(url) + except GameFAQsFetchError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc) + ) from exc + + if rom.has_simple_single_file: + try: + rom = await promote_single_file_to_folder(rom) + except RomAlreadyExistsException as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + + walkthrough_dir_rel = f"{rom.full_path}/{WALKTHROUGH_FOLDER}" + base_name = _slugify(guide["title"] or f"gamefaqs-{rom.id}") + file_name = f"{base_name}.txt" + + # Avoid clobbering an existing walkthrough with the same slug. + suffix = 1 + while db_rom_handler.get_rom_file_by_path( + rom_id=rom.id, file_path=walkthrough_dir_rel, file_name=file_name + ): + suffix += 1 + file_name = f"{base_name}-{suffix}.txt" + + try: + await fs_rom_handler.write_file( + file=guide["text"].encode("utf-8"), + path=walkthrough_dir_rel, + filename=file_name, + ) + except Exception as exc: + log.error("Error writing GameFAQs walkthrough", exc_info=exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error saving the walkthrough", + ) from exc + + file_location = fs_rom_handler.validate_path(f"{walkthrough_dir_rel}/{file_name}") + stat = os.stat(file_location) + created = db_rom_handler.add_rom_file( + RomFile( + rom_id=rom.id, + file_name=file_name, + file_path=walkthrough_dir_rel, + file_size_bytes=stat.st_size, + last_modified=stat.st_mtime, + category=RomFileCategory.WALKTHROUGH, + ) + ) + db_rom_handler.upsert_doc_meta( + rom_file_id=created.id, + rom_id=rom.id, + values={ + "source": DocSource.GAMEFAQS, + "source_url": url, + "author": guide["author"], + "title": guide["title"], + }, + ) + + log.info( + f"Saved GameFAQs walkthrough {hl(file_name)} for " + f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]" + ) + + return Response(status_code=status.HTTP_201_CREATED) + + +@protected_route( + router.delete, + "/{id}/walkthroughs/files/{file_id}", + [Scope.ROMS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def delete_rom_walkthrough_file( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], +) -> Response: + """Delete a single walkthrough file from a ROM's walkthrough/ subfolder.""" + + rom = db_rom_handler.get_rom(id) + if not rom: + raise RomNotFoundInDatabaseException(id) + + rom_file = db_rom_handler.get_rom_file_by_id(file_id) + if ( + not rom_file + or rom_file.rom_id != rom.id + or rom_file.category != RomFileCategory.WALKTHROUGH + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Walkthrough file not found", + ) + + file_rel_path = rom_file.full_path + + try: + await fs_rom_handler.remove_file(file_rel_path) + except FileNotFoundError: + log.warning( + f"Walkthrough file {hl(file_rel_path)} not found on disk; " + f"removing DB row anyway" + ) + except Exception as exc: + log.error(f"Error deleting walkthrough file {hl(file_rel_path)}", exc_info=exc) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="There was an error deleting the walkthrough file", + ) from exc + + # doc_meta and rom_file_user rows cascade on the RomFile delete. + db_rom_handler.delete_rom_file(file_id) + + log.info( + f"Deleted walkthrough file {hl(rom_file.file_name)} from " + f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]" + ) + + return Response() diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 772b769fb8..6ffd29b0ec 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -48,6 +48,8 @@ Rom, RomFile, RomFileCategory, + RomFileDocMeta, + RomFileUser, RomMetadata, RomNote, RomUser, @@ -215,6 +217,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name), selectinload(RomFile.track_meta), + selectinload(RomFile.doc_meta), ), selectinload(Rom.sibling_roms).options( noload(Rom.platform), @@ -262,6 +265,7 @@ def wrapper(*args, **kwargs): selectinload(Rom.files).options( joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name), selectinload(RomFile.track_meta), + selectinload(RomFile.doc_meta), ), selectinload(Rom.sibling_roms).options( noload(Rom.platform), @@ -1687,7 +1691,7 @@ def get_rom_file_by_id( ) -> RomFile | None: return session.scalar( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options(selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta)) .filter_by(id=id) .limit(1) ) @@ -1702,7 +1706,7 @@ def get_rom_file_by_path( ) -> RomFile | None: return session.scalar( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options(selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta)) .filter_by(rom_id=rom_id, file_path=file_path, file_name=file_name) .limit(1) ) @@ -1718,7 +1722,9 @@ def get_rom_files_by_category( return ( session.scalars( select(RomFile) - .options(selectinload(RomFile.track_meta)) + .options( + selectinload(RomFile.track_meta), selectinload(RomFile.doc_meta) + ) .filter_by(rom_id=rom_id, category=category) .order_by(RomFile.file_name.asc()) ) @@ -1787,6 +1793,69 @@ def delete_track_meta( ) -> None: session.execute(delete(TrackMeta).where(TrackMeta.rom_file_id == rom_file_id)) + # ------------------------------------------------------- document metadata + + @begin_session + def upsert_doc_meta( + self, + rom_file_id: int, + rom_id: int, + values: dict, + session: Session = None, # type: ignore + ) -> RomFileDocMeta: + """Create or update the provenance sidecar for a document file.""" + existing = session.get(RomFileDocMeta, rom_file_id) + if existing: + for key, val in values.items(): + setattr(existing, key, val) + session.flush() + return existing + + doc = RomFileDocMeta(rom_file_id=rom_file_id, rom_id=rom_id, **values) + session.add(doc) + session.flush() + return doc + + # ------------------------------------------------ document reading progress + + @begin_session + def get_rom_file_user( + self, + rom_file_id: int, + user_id: int, + session: Session = None, # type: ignore + ) -> RomFileUser | None: + return session.scalar( + select(RomFileUser) + .filter_by(rom_file_id=rom_file_id, user_id=user_id) + .limit(1) + ) + + @begin_session + def upsert_rom_file_user( + self, + rom_file_id: int, + user_id: int, + values: dict, + session: Session = None, # type: ignore + ) -> RomFileUser: + """Create or update a user's reading progress for a document file.""" + existing = session.scalar( + select(RomFileUser) + .filter_by(rom_file_id=rom_file_id, user_id=user_id) + .limit(1) + ) + if existing: + for key, val in values.items(): + setattr(existing, key, val) + session.flush() + return existing + + state = RomFileUser(rom_file_id=rom_file_id, user_id=user_id, **values) + session.add(state) + session.flush() + return state + # ----------------------------------------------------------------- music def _music_where( diff --git a/backend/handler/filesystem/resources_handler.py b/backend/handler/filesystem/resources_handler.py index 6492467540..8a84877cbb 100644 --- a/backend/handler/filesystem/resources_handler.py +++ b/backend/handler/filesystem/resources_handler.py @@ -20,7 +20,11 @@ LOCAL_FILE_SCHEMES = ("file://", "launchbox-file://") -ALLOWED_MANUAL_EXTENSIONS = frozenset({".pdf", ".md"}) +# Manual files (primary resources slot + rom manual/ folder). HTML is +# intentionally excluded here: the primary manual is served straight from the +# static resources path without a sandboxing CSP. HTML walkthroughs go through +# the content endpoint instead, which sandboxes them. +ALLOWED_MANUAL_EXTENSIONS = frozenset({".pdf", ".md", ".txt"}) def _resolve_local_file_uri(uri: str) -> Path | None: diff --git a/backend/handler/walkthrough/__init__.py b/backend/handler/walkthrough/__init__.py new file mode 100644 index 0000000000..c872b5c876 --- /dev/null +++ b/backend/handler/walkthrough/__init__.py @@ -0,0 +1,15 @@ +from .gamefaqs import ( + GAMEFAQS_HOSTS, + ParsedGuide, + fetch_gamefaqs_guide, + parse_gamefaqs_guide, + validate_gamefaqs_url, +) + +__all__ = [ + "GAMEFAQS_HOSTS", + "ParsedGuide", + "fetch_gamefaqs_guide", + "parse_gamefaqs_guide", + "validate_gamefaqs_url", +] diff --git a/backend/handler/walkthrough/gamefaqs.py b/backend/handler/walkthrough/gamefaqs.py new file mode 100644 index 0000000000..139609aee0 --- /dev/null +++ b/backend/handler/walkthrough/gamefaqs.py @@ -0,0 +1,168 @@ +"""Fetch GameFAQs text guides into the walkthrough document model. + +Design note: we extract plain text only and never store or serve remote HTML. +GameFAQs text guides live inside a `
` (or a `#faqtext` container), so
+pulling the text out sidesteps HTML sanitization entirely: nothing script
+capable is ever persisted. The outbound request also rides the shared,
+SSRF-protected httpx client, with an extra host allowlist below.
+"""
+
+from __future__ import annotations
+
+from html.parser import HTMLParser
+from typing import TypedDict
+from urllib.parse import urlparse
+
+from utils.context import ctx_httpx_client
+
+# Only these hosts may be fetched. The SSRF backend already blocks internal
+# addresses; this narrows outbound walkthrough fetches to GameFAQs on top.
+GAMEFAQS_HOSTS = frozenset(
+    {
+        "gamefaqs.gamespot.com",
+        "www.gamefaqs.com",
+        "gamefaqs.com",
+    }
+)
+
+# Cap the response we will read into memory (guides are text; 8 MiB is plenty).
+MAX_GUIDE_BYTES = 8 * 1024 * 1024
+
+_TITLE_SUFFIXES = (" - Guide and Walkthrough", " - FAQ", " - GameFAQs")
+
+
+class ParsedGuide(TypedDict):
+    title: str | None
+    author: str | None
+    text: str
+
+
+class GameFAQsFetchError(Exception):
+    """Raised when a GameFAQs guide cannot be fetched or parsed."""
+
+
+def validate_gamefaqs_url(url: str) -> str:
+    """Return the URL if it is a well-formed https GameFAQs URL, else raise."""
+    parsed = urlparse(url)
+    if parsed.scheme != "https":
+        raise ValueError("Walkthrough URL must use https")
+    host = (parsed.hostname or "").lower()
+    if host not in GAMEFAQS_HOSTS:
+        raise ValueError(
+            "Only GameFAQs guide URLs are supported "
+            f"(allowed hosts: {', '.join(sorted(GAMEFAQS_HOSTS))})"
+        )
+    return url
+
+
+class _GuideExtractor(HTMLParser):
+    """Pull the guide title, author, and body text out of a GameFAQs page.
+
+    Body text is captured from any `
` element or any element whose id or
+    class contains "faqtext". Meta tags supply the title/author when present.
+    """
+
+    def __init__(self) -> None:
+        super().__init__(convert_charrefs=True)
+        self.title: str | None = None
+        self.author: str | None = None
+        self._meta_title: str | None = None
+        self._capture_depth = 0
+        self._in_title = False
+        self._title_buf: list[str] = []
+        self._body_buf: list[str] = []
+
+    @staticmethod
+    def _is_capture_tag(tag: str, attrs: dict[str, str]) -> bool:
+        if tag == "pre":
+            return True
+        ident = f"{attrs.get('id', '')} {attrs.get('class', '')}".lower()
+        return "faqtext" in ident
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+        attr_map = {k: (v or "") for k, v in attrs}
+
+        if tag == "title":
+            self._in_title = True
+        elif tag == "meta":
+            name = attr_map.get("name", "").lower()
+            prop = attr_map.get("property", "").lower()
+            content = attr_map.get("content", "").strip()
+            if content and name == "author" and not self.author:
+                self.author = content[:512]
+            if content and prop == "og:title" and not self._meta_title:
+                self._meta_title = content
+
+        if self._capture_depth > 0:
+            self._capture_depth += 1
+        elif self._is_capture_tag(tag, attr_map):
+            self._capture_depth = 1
+
+    def handle_endtag(self, tag: str) -> None:
+        if tag == "title":
+            self._in_title = False
+        if self._capture_depth > 0:
+            self._capture_depth -= 1
+
+    def handle_data(self, data: str) -> None:
+        if self._in_title:
+            self._title_buf.append(data)
+        if self._capture_depth > 0:
+            self._body_buf.append(data)
+
+    def finalize(self) -> ParsedGuide:
+        raw_title = self._meta_title or "".join(self._title_buf).strip() or None
+        title = _clean_title(raw_title)
+        text = "".join(self._body_buf).strip()
+        return ParsedGuide(title=title, author=self.author, text=text)
+
+
+def _clean_title(title: str | None) -> str | None:
+    if not title:
+        return None
+    cleaned = title.strip()
+    for suffix in _TITLE_SUFFIXES:
+        if cleaned.endswith(suffix):
+            cleaned = cleaned[: -len(suffix)].strip()
+    return cleaned[:512] or None
+
+
+def parse_gamefaqs_guide(html: str) -> ParsedGuide:
+    """Parse a GameFAQs guide HTML string into title/author/text.
+
+    Pure function (no I/O) so it can be unit-tested against saved fixtures.
+    """
+    extractor = _GuideExtractor()
+    extractor.feed(html)
+    extractor.close()
+    return extractor.finalize()
+
+
+async def fetch_gamefaqs_guide(url: str) -> ParsedGuide:
+    """Fetch and parse a GameFAQs guide. Raises GameFAQsFetchError on failure."""
+    validate_gamefaqs_url(url)
+
+    client = ctx_httpx_client.get()
+    try:
+        response = await client.get(
+            url,
+            headers={
+                "User-Agent": "Mozilla/5.0 (compatible; RomM/1.0; +https://romm.app)",
+                "Accept": "text/html,application/xhtml+xml",
+            },
+            follow_redirects=True,
+            timeout=30.0,
+        )
+        response.raise_for_status()
+    except Exception as exc:
+        raise GameFAQsFetchError(f"Could not fetch guide: {exc}") from exc
+
+    content = response.content[:MAX_GUIDE_BYTES]
+    guide = parse_gamefaqs_guide(content.decode("utf-8", errors="replace"))
+
+    if not guide["text"]:
+        raise GameFAQsFetchError(
+            "No guide text found at that URL. It may be a formatted guide or "
+            "an unsupported page."
+        )
+    return guide
diff --git a/backend/models/rom.py b/backend/models/rom.py
index 4059bd2d01..bbeeb0142e 100644
--- a/backend/models/rom.py
+++ b/backend/models/rom.py
@@ -73,6 +73,7 @@ class RomFileCategory(enum.StrEnum):
     DLC = "dlc"
     HACK = "hack"
     MANUAL = "manual"
+    WALKTHROUGH = "walkthrough"
     PATCH = "patch"
     UPDATE = "update"
     MOD = "mod"
@@ -84,6 +85,19 @@ class RomFileCategory(enum.StrEnum):
     SCREENSHOT = "screenshot"
 
 
+# Document-category files (manuals, walkthroughs) share one substrate: a
+# RomFile plus an optional RomFileDocMeta sidecar for provenance.
+DOCUMENT_CATEGORIES = frozenset({RomFileCategory.MANUAL, RomFileCategory.WALKTHROUGH})
+
+
+class DocSource(enum.StrEnum):
+    """Where a document (manual/walkthrough) came from."""
+
+    UPLOAD = "upload"  # User-uploaded file
+    GAMEFAQS = "gamefaqs"  # Fetched from a GameFAQs guide URL
+    SCRAPER = "scraper"  # Downloaded by a metadata provider
+
+
 class SiblingRom(BaseModel):
     __tablename__ = "sibling_roms"
 
@@ -133,6 +147,16 @@ class RomFile(BaseModel):
         uselist=False,
         cascade="all, delete-orphan",
     )
+    doc_meta: Mapped[RomFileDocMeta | None] = relationship(
+        back_populates="rom_file",
+        uselist=False,
+        cascade="all, delete-orphan",
+    )
+    user_states: Mapped[list[RomFileUser]] = relationship(
+        back_populates="rom_file",
+        cascade="all, delete-orphan",
+        lazy="raise",
+    )
 
     @cached_property
     def full_path(self) -> str:
@@ -214,6 +238,73 @@ class TrackMeta(BaseModel):
     rom_file: Mapped[RomFile] = relationship(back_populates="track_meta")
 
 
+# Max length for free-text document metadata (author / title).
+DOC_META_MAX_LENGTH = 512
+
+
+class RomFileDocMeta(BaseModel):
+    """Provenance sidecar for document-category files (manuals, walkthroughs).
+
+    Only doc-category RomFiles carry a row here, so these fields don't bloat
+    every rom_files row. Format is intentionally not stored: it is derived from
+    the file extension.
+    """
+
+    __tablename__ = "rom_file_doc_meta"
+
+    __table_args__ = (Index("idx_rom_file_doc_meta_rom_id", "rom_id"),)
+
+    rom_file_id: Mapped[int] = mapped_column(
+        ForeignKey("rom_files.id", ondelete="CASCADE"), primary_key=True
+    )
+    rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE"))
+    source: Mapped[DocSource] = mapped_column(
+        Enum(DocSource), default=DocSource.UPLOAD, nullable=False
+    )
+    source_url: Mapped[str | None] = mapped_column(Text, default=None)
+    author: Mapped[str | None] = mapped_column(
+        String(length=DOC_META_MAX_LENGTH), default=None
+    )
+    title: Mapped[str | None] = mapped_column(
+        String(length=DOC_META_MAX_LENGTH), default=None
+    )
+
+    rom_file: Mapped[RomFile] = relationship(back_populates="doc_meta")
+
+
+class RomFileUser(BaseModel):
+    """Per-user reading state for a document-category file.
+
+    Keyed on (rom_file_id, user_id) so one mechanism covers both manuals and
+    walkthroughs. `progress` is a 0.0-1.0 scroll fraction; `last_page` tracks
+    the page for paginated (PDF) documents.
+    """
+
+    __tablename__ = "rom_file_user"
+
+    __table_args__ = (
+        UniqueConstraint("rom_file_id", "user_id", name="unique_rom_file_user"),
+        Index("idx_rom_file_user", "rom_file_id", "user_id"),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+
+    rom_file_id: Mapped[int] = mapped_column(
+        ForeignKey("rom_files.id", ondelete="CASCADE")
+    )
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
+
+    progress: Mapped[float] = mapped_column(Float(), default=0.0, nullable=False)
+    last_page: Mapped[int | None] = mapped_column(Integer(), default=None)
+    finished: Mapped[bool] = mapped_column(default=False, nullable=False)
+    last_read_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+
+    rom_file: Mapped[RomFile] = relationship(
+        lazy="joined", back_populates="user_states"
+    )
+    user: Mapped[User] = relationship(lazy="joined")
+
+
 class RomMetadata(BaseModel):
     __tablename__ = "roms_metadata"
 
diff --git a/backend/tests/endpoints/roms/test_file_progress.py b/backend/tests/endpoints/roms/test_file_progress.py
new file mode 100644
index 0000000000..4222610311
--- /dev/null
+++ b/backend/tests/endpoints/roms/test_file_progress.py
@@ -0,0 +1,93 @@
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from handler.database import db_rom_handler
+from models.rom import Rom, RomFile, RomFileCategory
+
+
+def _auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def walkthrough_file(game_folder_rom: Rom) -> RomFile:
+    return db_rom_handler.add_rom_file(
+        RomFile(
+            rom_id=game_folder_rom.id,
+            file_name="guide.txt",
+            file_path=f"{game_folder_rom.full_path}/walkthrough",
+            file_size_bytes=10,
+            category=RomFileCategory.WALKTHROUGH,
+        )
+    )
+
+
+def test_get_progress_defaults_to_zero(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    response = client.get(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_200_OK
+    body = response.json()
+    assert body["progress"] == 0.0
+    assert body["finished"] is False
+
+
+def test_put_then_get_progress_roundtrip(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 0.42, "last_page": 3, "finished": False},
+    )
+    assert put.status_code == status.HTTP_200_OK
+    assert put.json()["progress"] == pytest.approx(0.42)
+    assert put.json()["last_page"] == 3
+
+    got = client.get(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+    )
+    assert got.json()["progress"] == pytest.approx(0.42)
+    assert got.json()["last_page"] == 3
+
+
+def test_put_progress_clamps_out_of_range(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_file: RomFile,
+):
+    put = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{walkthrough_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 5.0},
+    )
+    assert put.status_code == status.HTTP_200_OK
+    assert put.json()["progress"] == 1.0
+
+
+def test_progress_rejects_non_document_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+):
+    game_file = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.GAME
+    )[0]
+    response = client.put(
+        f"/api/roms/{game_folder_rom.id}/files/{game_file.id}/progress",
+        headers=_auth(access_token),
+        json={"progress": 0.5},
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
diff --git a/backend/tests/endpoints/roms/test_manual.py b/backend/tests/endpoints/roms/test_manual.py
index 31e4888fe6..5300161e14 100644
--- a/backend/tests/endpoints/roms/test_manual.py
+++ b/backend/tests/endpoints/roms/test_manual.py
@@ -119,8 +119,10 @@ def test_upload_manual_to_resources_rejects_unsupported_extension(
 ):
     response = client.post(
         f"/api/roms/{rom.id}/manuals",
-        headers={**_auth(access_token), "x-upload-filename": "manual.txt"},
-        files={"manual.txt": ("manual.txt", b"not allowed", "text/plain")},
+        headers={**_auth(access_token), "x-upload-filename": "manual.exe"},
+        files={
+            "manual.exe": ("manual.exe", b"not allowed", "application/octet-stream")
+        },
     )
 
     assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -220,8 +222,8 @@ def test_upload_manual_to_folder_rejects_non_pdf(
 ):
     response = client.post(
         f"/api/roms/{game_folder_rom.id}/manuals/files",
-        headers={**_auth(access_token), "x-upload-filename": "manual.txt"},
-        files={"manual.txt": ("manual.txt", b"not a pdf", "text/plain")},
+        headers={**_auth(access_token), "x-upload-filename": "manual.exe"},
+        files={"manual.exe": ("manual.exe", b"not a pdf", "application/octet-stream")},
     )
 
     assert response.status_code == status.HTTP_400_BAD_REQUEST
diff --git a/backend/tests/endpoints/roms/test_walkthrough.py b/backend/tests/endpoints/roms/test_walkthrough.py
new file mode 100644
index 0000000000..3e5b0e6aac
--- /dev/null
+++ b/backend/tests/endpoints/roms/test_walkthrough.py
@@ -0,0 +1,223 @@
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from endpoints.roms import walkthrough as walkthrough_endpoint
+from handler.database import db_rom_handler
+from models.rom import DocSource, Rom, RomFile, RomFileCategory
+
+TXT_BYTES = b"Chrono Trigger walkthrough\n\nStep 1: leave home.\n"
+GAMEFAQS_URL = "https://gamefaqs.gamespot.com/snes/563538-chrono-trigger/faqs/1"
+
+
+def _auth(token: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def walkthrough_fs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+    """Mock fs_rom_handler so walkthrough writes land in tmp_path."""
+    folder_dir = tmp_path / "library"
+    folder_dir.mkdir()
+
+    def validate_path(path: str) -> Path:
+        return folder_dir / Path(path).name
+
+    async def remove_file(path: str) -> None:
+        target = folder_dir / Path(path).name
+        if target.exists():
+            target.unlink()
+        else:
+            raise FileNotFoundError(path)
+
+    async def write_file(file, path: str, filename: str) -> None:
+        (folder_dir / filename).write_bytes(file)
+
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler, "validate_path", validate_path
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "make_directory",
+        AsyncMock(return_value=None),
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "remove_file",
+        AsyncMock(side_effect=remove_file),
+    )
+    monkeypatch.setattr(
+        walkthrough_endpoint.fs_rom_handler,
+        "write_file",
+        AsyncMock(side_effect=write_file),
+    )
+    return folder_dir
+
+
+# ---------- POST /api/roms/{id}/walkthroughs/files ----------
+
+
+def test_upload_walkthrough_file_success(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files",
+        headers={
+            **_auth(access_token),
+            "x-upload-filename": "guide.txt",
+            "x-doc-author": "John%20Doe",
+            "x-doc-title": "Full%20Walkthrough",
+        },
+        files={"guide.txt": ("guide.txt", TXT_BYTES, "text/plain")},
+    )
+
+    assert response.status_code == status.HTTP_201_CREATED
+
+    files = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.WALKTHROUGH
+    )
+    assert len(files) == 1
+    created = files[0]
+    assert created.file_name == "guide.txt"
+    assert created.doc_meta is not None
+    assert created.doc_meta.source == DocSource.UPLOAD
+    assert created.doc_meta.author == "John Doe"
+    assert created.doc_meta.title == "Full Walkthrough"
+
+
+def test_upload_walkthrough_rejects_unsupported_extension(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files",
+        headers={**_auth(access_token), "x-upload-filename": "guide.exe"},
+        files={"guide.exe": ("guide.exe", b"nope", "application/octet-stream")},
+    )
+
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
+    assert "Unsupported walkthrough file type" in response.json()["detail"]
+
+
+def test_upload_walkthrough_rom_not_found(
+    client: TestClient,
+    access_token: str,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        "/api/roms/999999/walkthroughs/files",
+        headers={**_auth(access_token), "x-upload-filename": "guide.txt"},
+        files={"guide.txt": ("guide.txt", TXT_BYTES, "text/plain")},
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------- DELETE /api/roms/{id}/walkthroughs/files/{file_id} ----------
+
+
+def test_delete_walkthrough_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    (walkthrough_fs / "guide.txt").write_bytes(TXT_BYTES)
+    rom_file = db_rom_handler.add_rom_file(
+        RomFile(
+            rom_id=game_folder_rom.id,
+            file_name="guide.txt",
+            file_path=f"{game_folder_rom.full_path}/walkthrough",
+            file_size_bytes=len(TXT_BYTES),
+            category=RomFileCategory.WALKTHROUGH,
+        )
+    )
+
+    response = client.delete(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files/{rom_file.id}",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_200_OK
+    assert db_rom_handler.get_rom_file_by_id(rom_file.id) is None
+
+
+def test_delete_walkthrough_rejects_non_walkthrough_file(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    game_file = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.GAME
+    )[0]
+
+    response = client.delete(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/files/{game_file.id}",
+        headers=_auth(access_token),
+    )
+    assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+# ---------- POST /api/roms/{id}/walkthroughs/gamefaqs ----------
+
+
+def test_add_gamefaqs_walkthrough_success(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+    monkeypatch: pytest.MonkeyPatch,
+):
+    monkeypatch.setattr(
+        walkthrough_endpoint,
+        "fetch_gamefaqs_guide",
+        AsyncMock(
+            return_value={
+                "title": "Chrono Trigger FAQ",
+                "author": "Jane Roe",
+                "text": "This is the guide body.",
+            }
+        ),
+    )
+
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/gamefaqs",
+        headers=_auth(access_token),
+        json={"url": GAMEFAQS_URL},
+    )
+
+    assert response.status_code == status.HTTP_201_CREATED
+
+    files = db_rom_handler.get_rom_files_by_category(
+        game_folder_rom.id, RomFileCategory.WALKTHROUGH
+    )
+    assert len(files) == 1
+    created = files[0]
+    assert created.file_name == "chrono-trigger-faq.txt"
+    assert created.doc_meta.source == DocSource.GAMEFAQS
+    assert created.doc_meta.source_url == GAMEFAQS_URL
+    assert created.doc_meta.author == "Jane Roe"
+    assert (walkthrough_fs / "chrono-trigger-faq.txt").read_bytes() == (
+        b"This is the guide body."
+    )
+
+
+def test_add_gamefaqs_walkthrough_rejects_bad_host(
+    client: TestClient,
+    access_token: str,
+    game_folder_rom: Rom,
+    walkthrough_fs: Path,
+):
+    response = client.post(
+        f"/api/roms/{game_folder_rom.id}/walkthroughs/gamefaqs",
+        headers=_auth(access_token),
+        json={"url": "https://evil.example.com/guide"},
+    )
+    assert response.status_code == status.HTTP_400_BAD_REQUEST
diff --git a/backend/tests/handler/filesystem/test_resources_handler.py b/backend/tests/handler/filesystem/test_resources_handler.py
index 7f1d5ff10c..e43056288e 100644
--- a/backend/tests/handler/filesystem/test_resources_handler.py
+++ b/backend/tests/handler/filesystem/test_resources_handler.py
@@ -446,7 +446,7 @@ def test_get_manual_path_no_manual(self, handler: FSResourcesHandler, rom: Rom):
         result = handler._get_manual_path(rom)
         assert result is None
 
-    @pytest.mark.parametrize("ext", [".pdf", ".md"])
+    @pytest.mark.parametrize("ext", [".pdf", ".md", ".txt"])
     def test_manual_exists_finds_extension(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
@@ -458,7 +458,7 @@ def test_manual_exists_finds_extension(
 
         assert handler.manual_exists(rom)
 
-    @pytest.mark.parametrize("ext", [".pdf", ".md"])
+    @pytest.mark.parametrize("ext", [".pdf", ".md", ".txt"])
     def test_get_manual_path_finds_extension(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
@@ -471,11 +471,11 @@ def test_get_manual_path_finds_extension(
         result = handler._get_manual_path(rom)
         assert result == f"{rom.fs_resources_path}/manual/{rom.id}{ext}"
 
-    @pytest.mark.parametrize("ext", [".part", ".bak", ".tmp", ".txt"])
+    @pytest.mark.parametrize("ext", [".part", ".bak", ".tmp", ".exe"])
     def test_manual_exists_ignores_disallowed_extensions(
         self, handler: FSResourcesHandler, rom: Rom, tmp_path, ext: str
     ):
-        """Files that aren't PDF or Markdown must not be treated as manuals."""
+        """Files that aren't an allowed manual document must not count as manuals."""
         handler.base_path = tmp_path
         manual_dir = tmp_path / rom.fs_resources_path / "manual"
         manual_dir.mkdir(parents=True)
diff --git a/backend/tests/handler/walkthrough/__init__.py b/backend/tests/handler/walkthrough/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/backend/tests/handler/walkthrough/test_gamefaqs.py b/backend/tests/handler/walkthrough/test_gamefaqs.py
new file mode 100644
index 0000000000..0b5c5f7894
--- /dev/null
+++ b/backend/tests/handler/walkthrough/test_gamefaqs.py
@@ -0,0 +1,60 @@
+import pytest
+
+from handler.walkthrough import parse_gamefaqs_guide, validate_gamefaqs_url
+
+
+def test_parse_extracts_title_author_and_pre_text():
+    html = (
+        ""
+        "Chrono Trigger - FAQ/Walkthrough - GameFAQs"
+        ''
+        ''
+        ""
+        '
Line 1\nLine 2 & more
' + "" + ) + guide = parse_gamefaqs_guide(html) + assert guide["title"] == "Chrono Trigger FAQ" + assert guide["author"] == "John Doe" + assert guide["text"] == "Line 1\nLine 2 & more" + + +def test_parse_falls_back_to_title_tag_and_strips_suffix(): + html = ( + "Some Guide - GameFAQs" + "
body
" + ) + guide = parse_gamefaqs_guide(html) + assert guide["title"] == "Some Guide" + assert guide["text"] == "body" + + +def test_parse_no_guide_text_returns_empty(): + guide = parse_gamefaqs_guide("

nothing here

") + assert guide["text"] == "" + + +@pytest.mark.parametrize( + "url", + [ + "http://gamefaqs.gamespot.com/x", # not https + "https://evil.example.com/x", # wrong host + "ftp://gamefaqs.com/x", # wrong scheme + "https://notgamefaqs.com/x", # lookalike host + ], +) +def test_validate_rejects_bad_urls(url): + with pytest.raises(ValueError): + validate_gamefaqs_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "https://gamefaqs.gamespot.com/snes/563538-chrono-trigger/faqs/1", + "https://www.gamefaqs.com/snes/563538/faqs/1", + "https://gamefaqs.com/snes/563538/faqs/1", + ], +) +def test_validate_accepts_gamefaqs_urls(url): + assert validate_gamefaqs_url(url) == url diff --git a/backend/utils/media_types.py b/backend/utils/media_types.py index 9e70cea288..d8f06bc19e 100644 --- a/backend/utils/media_types.py +++ b/backend/utils/media_types.py @@ -15,7 +15,11 @@ {".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff", ".tif", ".bmp", ".avif"} ) ALLOWED_VIDEO_EXTENSIONS = frozenset({".mp4", ".webm", ".ogv", ".mov", ".m4v"}) -ALLOWED_DOCUMENT_EXTENSIONS = frozenset({".pdf", ".md"}) +ALLOWED_DOCUMENT_EXTENSIONS = frozenset({".pdf", ".md", ".txt", ".html", ".htm"}) + +# Document extensions that are HTML and therefore must be served under a +# sandboxing CSP even after ingest-time sanitization (defense in depth). +HTML_DOCUMENT_EXTENSIONS = frozenset({".html", ".htm"}) # MIME types the stdlib mimetypes module guesses inconsistently (or not at # all) across platforms. @@ -29,8 +33,18 @@ ".mov": "video/quicktime", ".pdf": "application/pdf", ".md": "text/markdown", + ".txt": "text/plain", + ".html": "text/html", + ".htm": "text/html", } + +def is_html_document_file(file_name: str) -> bool: + """Whether a document is HTML and needs CSP sandboxing when served inline.""" + ext = os.path.splitext(file_name)[1].lower() + return ext in HTML_DOCUMENT_EXTENSIONS + + # Image MIME types we trust to (a) accept as avatar uploads and (b) serve # inline from the raw asset endpoint. Maps each trusted MIME type to its # canonical file extension. diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 79b398b263..713763f342 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -69,6 +69,8 @@ export type { DeviceHeartbeatPayload } from './models/DeviceHeartbeatPayload'; export type { DeviceSchema } from './models/DeviceSchema'; export type { DeviceSyncSchema } from './models/DeviceSyncSchema'; export type { DeviceUpdatePayload } from './models/DeviceUpdatePayload'; +export type { DocMetaSchema } from './models/DocMetaSchema'; +export type { DocSource } from './models/DocSource'; export type { EarnedAchievement } from './models/EarnedAchievement'; export type { EjsControls } from './models/EjsControls'; export type { EjsControlsButton } from './models/EjsControlsButton'; @@ -132,6 +134,7 @@ export type { Role } from './models/Role'; export type { RomArchiveMember } from './models/RomArchiveMember'; export type { RomFileCategory } from './models/RomFileCategory'; export type { RomFileSchema } from './models/RomFileSchema'; +export type { RomFileUserSchema } from './models/RomFileUserSchema'; export type { RomFiltersDict } from './models/RomFiltersDict'; export type { RomFlashpointMetadata } from './models/RomFlashpointMetadata'; export type { RomGamelistMetadata } from './models/RomGamelistMetadata'; diff --git a/frontend/src/__generated__/models/DocMetaSchema.ts b/frontend/src/__generated__/models/DocMetaSchema.ts new file mode 100644 index 0000000000..6f9ba850c1 --- /dev/null +++ b/frontend/src/__generated__/models/DocMetaSchema.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DocSource } from './DocSource'; +export type DocMetaSchema = { + source: DocSource; + source_url?: (string | null); + author?: (string | null); + title?: (string | null); +}; + diff --git a/frontend/src/__generated__/models/DocSource.ts b/frontend/src/__generated__/models/DocSource.ts new file mode 100644 index 0000000000..85f338529e --- /dev/null +++ b/frontend/src/__generated__/models/DocSource.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Where a document (manual/walkthrough) came from. + */ +export type DocSource = 'upload' | 'gamefaqs' | 'scraper'; diff --git a/frontend/src/__generated__/models/RomFileCategory.ts b/frontend/src/__generated__/models/RomFileCategory.ts index 8f3295fc30..10319cb436 100644 --- a/frontend/src/__generated__/models/RomFileCategory.ts +++ b/frontend/src/__generated__/models/RomFileCategory.ts @@ -2,4 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export type RomFileCategory = 'game' | 'dlc' | 'hack' | 'manual' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype' | 'cheat' | 'soundtrack' | 'screenshot'; +export type RomFileCategory = 'game' | 'dlc' | 'hack' | 'manual' | 'walkthrough' | 'patch' | 'update' | 'mod' | 'demo' | 'translation' | 'prototype' | 'cheat' | 'soundtrack' | 'screenshot'; diff --git a/frontend/src/__generated__/models/RomFileSchema.ts b/frontend/src/__generated__/models/RomFileSchema.ts index 5d672771c7..449120e09e 100644 --- a/frontend/src/__generated__/models/RomFileSchema.ts +++ b/frontend/src/__generated__/models/RomFileSchema.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { DocMetaSchema } from './DocMetaSchema'; import type { RomArchiveMember } from './RomArchiveMember'; import type { RomFileCategory } from './RomFileCategory'; import type { TrackMetaSchema } from './TrackMetaSchema'; @@ -24,5 +25,6 @@ export type RomFileSchema = { archive_members: (Array | null); category: (RomFileCategory | null); track_meta?: (TrackMetaSchema | null); + doc_meta?: (DocMetaSchema | null); }; diff --git a/frontend/src/__generated__/models/RomFileUserSchema.ts b/frontend/src/__generated__/models/RomFileUserSchema.ts new file mode 100644 index 0000000000..a5a586f552 --- /dev/null +++ b/frontend/src/__generated__/models/RomFileUserSchema.ts @@ -0,0 +1,13 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RomFileUserSchema = { + rom_file_id: number; + user_id: number; + progress: number; + last_page?: (number | null); + finished?: boolean; + last_read_at?: (string | null); +}; + diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index b4db226f03..07456ed7fa 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -18,6 +18,7 @@ "add-note": "Добави бележка", "add-to-collection": "Добави в колекция", "add-to-favorites": "Добави в любими", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Добавяне на {n} ROM-а в колекция", "additional-content": "Допълнително съдържание", "additional-details": "Допълнителни детайли", @@ -47,6 +48,7 @@ "category-prototype": "Прототип", "category-translation": "Превод", "category-update": "Обновление", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Диск {n}", "chip-track-n": "Песен {n}", "clear-all": "Изчисти всички", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Качи файлове на саундтрака", "upload-states": "Качи бързи записи", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Потвърждение", "verified-rom": "Потвърден ROM", "versions-count": "{n} версии", "volume-mute": "Заглуши", "volume-unmute": "Включи звука", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube видео ID" } diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index ea4cc7101c..d07e2262f7 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -18,6 +18,7 @@ "add-note": "Přidat poznámku", "add-to-collection": "Přidat do kolekce", "add-to-favorites": "Přidat mezi oblíbené", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Přidávání {n} ROM do kolekce", "additional-content": "Další obsah", "additional-details": "Další detaily", @@ -47,6 +48,7 @@ "category-prototype": "Prototyp", "category-translation": "Překlad", "category-update": "Aktualizace", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disk {n}", "chip-track-n": "Skladba {n}", "clear-all": "Vymazat vše", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Nahrát soubory soundtracku", "upload-states": "Nahrát stavy", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Ověření", "verified-rom": "Ověřená ROM", "versions-count": "{n} verzí", "volume-mute": "Ztlumit", "volume-unmute": "Zrušit ztlumení", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID YouTube videa" } diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 0e0ee792b5..0434908c9a 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -18,6 +18,7 @@ "add-note": "Notiz hinzufügen", "add-to-collection": "Zu Sammlung hinzufügen", "add-to-favorites": "Zu Favoriten hinzufügen", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Füge {n} ROMs zu Sammlung hinzu", "additional-content": "Zustätliche Inhalte", "additional-details": "Zusätzliche Details", @@ -47,6 +48,7 @@ "category-prototype": "Prototyp", "category-translation": "Übersetzung", "category-update": "Update", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disc {n}", "chip-track-n": "Track {n}", "clear-all": "Alle löschen", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Soundtrack-Dateien hochladen", "upload-states": "Zustände hochladen", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verifizierung", "verified-rom": "Verifizierte ROM", "versions-count": "{n} Versionen", "volume-mute": "Stummschalten", "volume-unmute": "Stummschaltung aufheben", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube-Video-ID" } diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 1664bfc198..8263c6d546 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -18,6 +18,7 @@ "add-note": "Add Note", "add-to-collection": "Add to collection", "add-to-favorites": "Add to favourites", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Adding {n} ROMs to collection", "additional-content": "Additional content", "additional-details": "Additional details", @@ -47,6 +48,7 @@ "category-prototype": "Prototype", "category-translation": "Translation", "category-update": "Update", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disc {n}", "chip-track-n": "Track {n}", "clear-all": "Clear all", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Upload soundtrack files", "upload-states": "Upload states", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verification", "verified-rom": "Verified ROM", "versions-count": "{n} versions", "volume-mute": "Mute", "volume-unmute": "Unmute", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube video ID" } diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index 7bbf82f58d..f3703d0f67 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -18,6 +18,7 @@ "add-note": "Add Note", "add-to-collection": "Add to collection", "add-to-favorites": "Add to favourites", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Adding {n} ROMs to collection", "additional-content": "Additional content", "additional-details": "Additional details", @@ -47,6 +48,7 @@ "category-prototype": "Prototype", "category-translation": "Translation", "category-update": "Update", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disc {n}", "chip-track-n": "Track {n}", "clear-all": "Clear all", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Upload soundtrack files", "upload-states": "Upload states", "upload-tracks": "Upload tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verification", "verified-rom": "Verified ROM", "versions-count": "{n} versions", "volume-mute": "Mute", "volume-unmute": "Unmute", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube video ID" } diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index ce8f6197de..2362762541 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -18,6 +18,7 @@ "add-note": "Agregar Nota", "add-to-collection": "Añadir a la colección", "add-to-favorites": "Añadir a favoritos", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Añadiendo {n} ROMs a la colección", "additional-content": "Contenido adicional", "additional-details": "Detalles adicionales", @@ -47,6 +48,7 @@ "category-prototype": "Prototipo", "category-translation": "Traducción", "category-update": "Actualización", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disco {n}", "chip-track-n": "Pista {n}", "clear-all": "Limpiar todo", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Subir bandas sonoras", "upload-states": "Subir estados", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verificación", "verified-rom": "ROM verificada", "versions-count": "{n} versiones", "volume-mute": "Silenciar", "volume-unmute": "Quitar silencio", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Tu progreso", "youtube-video-id": "ID de vídeo de YouTube" } diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index c8d6d13149..0f624e2ae0 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -18,6 +18,7 @@ "add-note": "Ajouter une Note", "add-to-collection": "Ajouter à la collection", "add-to-favorites": "Ajouter aux favoris", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Ajout de {n} ROMs à la collection", "additional-content": "Contenu supplémentaire", "additional-details": "Détails supplémentaires", @@ -47,6 +48,7 @@ "category-prototype": "Prototype", "category-translation": "Traduction", "category-update": "Mise à jour", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disque {n}", "chip-track-n": "Piste {n}", "clear-all": "Tout effacer", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Téléverser les fichiers de la bande son", "upload-states": "Téléverser des états", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Vérification", "verified-rom": "ROM vérifiée", "versions-count": "{n} versions", "volume-mute": "Couper le son", "volume-unmute": "Activer le son", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID vidéo YouTube" } diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index aff9cc28f7..c12c07a66b 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -18,6 +18,7 @@ "add-note": "Megjegyzés hozzáadása", "add-to-collection": "Hozzáadás a Gyűjteményhez", "add-to-favorites": "Hozzáadás a Kedvencekhez", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "{n} ROM-ok hozzáadva a gyűjteményhez", "additional-content": "További tartalom", "additional-details": "További részletek", @@ -47,6 +48,7 @@ "category-prototype": "Prototípus", "category-translation": "Fordítás", "category-update": "Frissítés", + "category-walkthrough": "Walkthrough", "chip-disc-n": "{n}. lemez", "chip-track-n": "{n}. szám", "clear-all": "Összes törlése", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Zenei fájlok feltöltése", "upload-states": "Állások feltöltése", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Ellenőrzés", "verified-rom": "Ellenőrzött ROM", "versions-count": "{n} verzió", "volume-mute": "Némítás", "volume-unmute": "Némítás feloldása", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube videó azonosító" } diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index cc05f926d3..80cb9e3630 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -18,6 +18,7 @@ "add-note": "Aggiungi Nota", "add-to-collection": "Aggiungi alla collezione", "add-to-favorites": "Aggiungi ai preferiti", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Aggiungendo {n} ROM alla collezione", "additional-content": "Contenuti aggiuntivi", "additional-details": "Dettagli aggiuntivi", @@ -47,6 +48,7 @@ "category-prototype": "Prototipo", "category-translation": "Traduzione", "category-update": "Aggiornamento", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disco {n}", "chip-track-n": "Traccia {n}", "clear-all": "Pulisci tutto", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Carica file colonna sonora", "upload-states": "Carica stati", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verifica", "verified-rom": "ROM verificata", "versions-count": "{n} versioni", "volume-mute": "Disattiva audio", "volume-unmute": "Riattiva audio", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID video YouTube" } diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 19ddca2bff..8b902d83e2 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -18,6 +18,7 @@ "add-note": "ノートを追加", "add-to-collection": "コレクションに追加", "add-to-favorites": "お気に入りに追加", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "コレクションにROM {n} を追加しています", "additional-content": "追加コンテンツ", "additional-details": "追加の詳細", @@ -47,6 +48,7 @@ "category-prototype": "プロトタイプ", "category-translation": "翻訳", "category-update": "アップデート", + "category-walkthrough": "Walkthrough", "chip-disc-n": "ディスク{n}", "chip-track-n": "トラック{n}", "clear-all": "すべてクリア", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "サウンドトラックファイルをアップロード", "upload-states": "ステートセーブをアップロード", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "検証", "verified-rom": "確認済みROM", "versions-count": "{n}件のバージョン", "volume-mute": "ミュート", "volume-unmute": "ミュート解除", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube動画ID" } diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 1f2029a4f0..64fa369fc6 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -18,6 +18,7 @@ "add-note": "노트 추가", "add-to-collection": "모음집에 추가", "add-to-favorites": "즐겨찾기에 추가", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "{n} 개의 롬을 모음집에 추가합니다", "additional-content": "추가 컨텐츠", "additional-details": "추가 세부 정보", @@ -47,6 +48,7 @@ "category-prototype": "프로토타입", "category-translation": "번역", "category-update": "업데이트", + "category-walkthrough": "Walkthrough", "chip-disc-n": "디스크 {n}", "chip-track-n": "트랙 {n}", "clear-all": "모두 지우기", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "사운드트랙 파일 업로드", "upload-states": "상태 업로드", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "검증", "verified-rom": "검증된 ROM", "versions-count": "{n}개 버전", "volume-mute": "음소거", "volume-unmute": "음소거 해제", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube 동영상 ID" } diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index d0b2296f6d..a52aa7f157 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -18,6 +18,7 @@ "add-note": "Dodaj Notatkę", "add-to-collection": "Dodaj do kolekcji", "add-to-favorites": "Dodaj do ulubionych", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Dodawanie {n} ROM-ów do kolekcji", "additional-content": "Dodatkowa zawartość", "additional-details": "Dodatkowe szczegóły", @@ -47,6 +48,7 @@ "category-prototype": "Prototyp", "category-translation": "Tłumaczenie", "category-update": "Aktualizacja", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Płyta {n}", "chip-track-n": "Utwór {n}", "clear-all": "Wyczyść wszystko", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Prześlij pliki ścieżki dźwiękowej", "upload-states": "Prześlij stany", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Weryfikacja", "verified-rom": "Zweryfikowany ROM", "versions-count": "{n} wersji", "volume-mute": "Wycisz", "volume-unmute": "Wyłącz wyciszenie", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID filmu YouTube" } diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 5a3b7b3ddc..e3d1d72c49 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -18,6 +18,7 @@ "add-note": "Adicionar Nota", "add-to-collection": "Adicionar à coleção", "add-to-favorites": "Adicionar aos favoritos", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Adicionando {n} ROMs à coleção", "additional-content": "Conteúdo adicional", "additional-details": "Detalhes adicionais", @@ -47,6 +48,7 @@ "category-prototype": "Protótipo", "category-translation": "Tradução", "category-update": "Atualização", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Disco {n}", "chip-track-n": "Faixa {n}", "clear-all": "Limpar tudo", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Enviar arquivos de trilha sonora", "upload-states": "Enviar states", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verificação", "verified-rom": "ROM verificada", "versions-count": "{n} versões", "volume-mute": "Silenciar", "volume-unmute": "Reativar som", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID do vídeo do YouTube" } diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index 597571206f..cf22c8c6e7 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -18,6 +18,7 @@ "add-note": "Adaugă Notație", "add-to-collection": "Adaugă la colecție", "add-to-favorites": "Adaugă la favorite", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Adăugare a {n} ROM-urilor la colecție", "additional-content": "Conținut suplimentar", "additional-details": "Detalii suplimentare", @@ -47,6 +48,7 @@ "category-prototype": "Prototip", "category-translation": "Traducere", "category-update": "Update", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Discul {n}", "chip-track-n": "Piesa {n}", "clear-all": "Șterge tot", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Încarcă fișiere de coloană sonoră", "upload-states": "Încarcă stări", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Verificare", "verified-rom": "ROM verificat", "versions-count": "{n} versiuni", "volume-mute": "Mut", "volume-unmute": "Activează sunetul", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID video YouTube" } diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 2c3fa134b6..1185c49949 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -18,6 +18,7 @@ "add-note": "Добавить Заметку", "add-to-collection": "Добавить в коллекцию", "add-to-favorites": "Добавить в избранное", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "Добавление {n} ромов в коллекцию", "additional-content": "Дополнительный контент", "additional-details": "Дополнительные сведения", @@ -47,6 +48,7 @@ "category-prototype": "Прототип", "category-translation": "Перевод", "category-update": "Обновление", + "category-walkthrough": "Walkthrough", "chip-disc-n": "Диск {n}", "chip-track-n": "Трек {n}", "clear-all": "Очистить всё", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Загрузить файлы саундтрека", "upload-states": "Загрузить состояния", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "Проверка", "verified-rom": "Проверенный ROM", "versions-count": "{n} версий", "volume-mute": "Отключить звук", "volume-unmute": "Включить звук", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "ID видео YouTube" } diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index 728e9ce000..6fe2e85282 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -18,6 +18,7 @@ "add-note": "Not ekle", "add-to-collection": "Koleksiyona ekle", "add-to-favorites": "Favorilere ekle", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "{n} ROM koleksiyona ekleniyor", "additional-content": "Ek içerik", "additional-details": "Ek ayrıntılar", @@ -47,6 +48,7 @@ "category-prototype": "Prototip", "category-translation": "Çeviri", "category-update": "Güncelleme", + "category-walkthrough": "Walkthrough", "chip-disc-n": "{n}. Disk", "chip-track-n": "{n}. Parça", "clear-all": "Tümünü temizle", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "Müzik paketi dosyaları yükle", "upload-states": "Durum kaydı yükle", "upload-tracks": "Parça yükle", + "upload-walkthrough": "Upload walkthrough", "verification": "Doğrulama", "verified-rom": "Doğrulanmış ROM", "versions-count": "{n} sürüm", "volume-mute": "Sesi kapat", "volume-unmute": "Sesi aç", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube video ID" } diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index baa8476cf5..c9373cb29c 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -18,6 +18,7 @@ "add-note": "添加笔记", "add-to-collection": "添加至收藏", "add-to-favorites": "添加至喜好", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "添加 {n} ROMs 至收藏", "additional-content": "附加内容", "additional-details": "其他详情", @@ -47,6 +48,7 @@ "category-prototype": "原型", "category-translation": "翻译", "category-update": "更新", + "category-walkthrough": "Walkthrough", "chip-disc-n": "碟片 {n}", "chip-track-n": "曲目 {n}", "clear-all": "全部清除", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "上传原声带文件", "upload-states": "上传状态", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "验证", "verified-rom": "已验证 ROM", "versions-count": "{n} 个版本", "volume-mute": "静音", "volume-unmute": "取消静音", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube 视频 ID" } diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 1c16930ae7..f2a17de720 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -18,6 +18,7 @@ "add-note": "新增筆記", "add-to-collection": "加入收藏庫", "add-to-favorites": "加入喜好", + "add-walkthrough-from-url": "Add from GameFAQs URL", "adding-to-collection": "加入 {n} 個 ROM 至收藏庫", "additional-content": "附加内容", "additional-details": "其他詳細資訊", @@ -47,6 +48,7 @@ "category-prototype": "原型", "category-translation": "翻譯", "category-update": "更新", + "category-walkthrough": "Walkthrough", "chip-disc-n": "光碟 {n}", "chip-track-n": "音軌 {n}", "clear-all": "全部清除", @@ -429,11 +431,20 @@ "upload-soundtrack-files": "上傳原聲帶檔案", "upload-states": "上傳即時存檔", "upload-tracks": "Add tracks", + "upload-walkthrough": "Upload walkthrough", "verification": "驗證", "verified-rom": "已驗證 ROM", "versions-count": "{n} 個版本", "volume-mute": "靜音", "volume-unmute": "取消靜音", + "walkthrough": "Walkthrough", + "walkthrough-add-failed": "Could not add walkthrough: {error}", + "walkthrough-added": "Walkthrough added", + "walkthrough-empty": "No walkthroughs yet", + "walkthrough-remove-failed": "Failed to remove walkthrough: {error}", + "walkthrough-removed": "Walkthrough removed", + "walkthrough-url-label": "GameFAQs guide URL", + "walkthroughs": "Walkthroughs", "your-progress": "Your progress", "youtube-video-id": "YouTube 影片 ID" } diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index a73c456ddd..0e24345788 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -8,6 +8,7 @@ import type { ManualMetadata, RomUserData, RomUserSchema, + RomFileUserSchema, SearchRomSchema, SimpleRomSchema, SoundtrackTrackMetaSchema, @@ -805,6 +806,88 @@ async function deleteRomFile({ return api.delete(`/roms/${romId}/files/${fileId}`); } +async function uploadWalkthroughFiles({ + romId, + filesToUpload, +}: { + romId: number; + filesToUpload: File[]; +}) { + const uploadStore = storeUpload(); + + const promises = filesToUpload.map((file) => { + const formData = new FormData(); + formData.append(file.name, file); + + uploadStore.start(file.name); + return new Promise((resolve, reject) => { + api + .post(`/roms/${romId}/walkthroughs/files`, formData, { + headers: { + "Content-Type": "multipart/form-data", + "X-Upload-Filename": file.name, + }, + params: {}, + onUploadProgress: (progressEvent: AxiosProgressEvent) => { + uploadStore.update(file.name, progressEvent); + }, + }) + .then(resolve) + .catch((error) => { + uploadStore.fail(file.name, error.response?.data?.detail); + reject(error); + }); + }); + }); + + return Promise.allSettled(promises); +} + +async function deleteWalkthroughFile({ + romId, + fileId, +}: { + romId: number; + fileId: number; +}) { + return api.delete(`/roms/${romId}/walkthroughs/files/${fileId}`); +} + +async function addGamefaqsWalkthrough({ + romId, + url, +}: { + romId: number; + url: string; +}) { + return api.post(`/roms/${romId}/walkthroughs/gamefaqs`, { url }); +} + +async function getFileProgress({ + romId, + fileId, +}: { + romId: number; + fileId: number; +}) { + return api.get(`/roms/${romId}/files/${fileId}/progress`); +} + +async function updateFileProgress({ + romId, + fileId, + data, +}: { + romId: number; + fileId: number; + data: { progress?: number; last_page?: number | null; finished?: boolean }; +}) { + return api.put( + `/roms/${romId}/files/${fileId}/progress`, + data, + ); +} + async function updateUserRomProps({ romId, data, @@ -928,6 +1011,11 @@ export default { uploadManualFiles, deleteManualFile, deleteRomFile, + uploadWalkthroughFiles, + deleteWalkthroughFile, + addGamefaqsWalkthrough, + getFileProgress, + updateFileProgress, uploadSoundtracks, removeSoundtrack, getSoundtrackMetadata, diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue index a857631564..a7e70a380c 100644 --- a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue +++ b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue @@ -67,6 +67,10 @@ const CATEGORY_META = computed< label: t("rom.manual"), icon: "mdi-book-open-page-variant-outline", }, + walkthrough: { + label: t("rom.walkthrough"), + icon: "mdi-map-legend", + }, soundtrack: { label: t("rom.soundtrack"), icon: "mdi-music-note-outline", diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue b/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue index a4c2a5eef1..80911a5482 100644 --- a/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue +++ b/frontend/src/v2/components/GameDetails/FilesTab/FilesTab.vue @@ -104,6 +104,10 @@ const CATEGORY_META = computed< label: t("rom.manual"), icon: "mdi-book-open-page-variant-outline", }, + walkthrough: { + label: t("rom.walkthrough"), + icon: "mdi-map-legend", + }, soundtrack: { label: t("rom.soundtrack"), icon: "mdi-music-note-outline", @@ -150,6 +154,8 @@ const FOLDER_META = computed>(() => { cheats: c.cheat, manual: c.manual, manuals: c.manual, + walkthrough: c.walkthrough, + walkthroughs: c.walkthrough, soundtrack: c.soundtrack, soundtracks: c.soundtrack, // Non-category folders that conventionally appear in ROM directories. @@ -520,8 +526,7 @@ async function deleteSelectedFiles() { } if (failed > 0) { const firstError = results.find((r) => r.status === "rejected") as - | PromiseRejectedResult - | undefined; + PromiseRejectedResult | undefined; snackbar.error( t("rom.file-delete-failed", { error: firstError ? errorMessage(firstError.reason) : "", diff --git a/frontend/src/v2/components/GameDetails/ManualSubtab.vue b/frontend/src/v2/components/GameDetails/ManualSubtab.vue index 275e2f6b89..b4a0cc915d 100644 --- a/frontend/src/v2/components/GameDetails/ManualSubtab.vue +++ b/frontend/src/v2/components/GameDetails/ManualSubtab.vue @@ -25,6 +25,9 @@ const PdfViewer = defineAsyncComponent( const MarkdownViewer = defineAsyncComponent( () => import("@/v2/components/GameDetails/MarkdownViewer.vue"), ); +const TextViewer = defineAsyncComponent( + () => import("@/v2/components/GameDetails/TextViewer.vue"), +); function errorMessage(err: unknown): string { if (axios.isAxiosError(err)) { @@ -48,11 +51,16 @@ type ManualEntry = { label: string; url: string; isPrimary: boolean; - // Manuals can be PDF or Markdown; the viewer is picked by extension. - kind: "pdf" | "md"; + // Manuals can be PDF, Markdown, or plain text; the viewer is picked by + // extension. + kind: "pdf" | "md" | "text"; }; -const isMarkdown = (name: string) => /\.md$/i.test(name); +const kindFor = (name: string): ManualEntry["kind"] => { + if (/\.md$/i.test(name)) return "md"; + if (/\.(txt|html?|htm)$/i.test(name)) return "text"; + return "pdf"; +}; const manualEntries = computed(() => { const entries: ManualEntry[] = []; @@ -63,7 +71,7 @@ const manualEntries = computed(() => { label: t("rom.scraped-manual"), url: `${FRONTEND_RESOURCES_PATH}/${props.rom.path_manual}?v=${cacheBust}`, isPrimary: true, - kind: isMarkdown(props.rom.path_manual) ? "md" : "pdf", + kind: kindFor(props.rom.path_manual), }); } for (const file of props.rom.files ?? []) { @@ -75,7 +83,7 @@ const manualEntries = computed(() => { file.file_name, )}?v=${cacheBust}`, isPrimary: false, - kind: isMarkdown(file.file_name) ? "md" : "pdf", + kind: kindFor(file.file_name), }); } } @@ -207,7 +215,7 @@ function requestDeleteManual() { :hint="t('common.dropzone-hint')" :active-title="t('common.dropzone-drag-over')" :input-label="t('rom.upload-manual')" - accept="application/pdf,.md" + accept="application/pdf,.md,.txt" multiple @files="handleManualFiles" > @@ -231,7 +239,7 @@ function requestDeleteManual() { class="r-v2-manual__fill" :release-label="t('common.dropzone-drag-over')" :input-label="t('rom.upload-manual')" - accept="application/pdf,.md" + accept="application/pdf,.md,.txt" multiple @files="handleManualFiles" > @@ -246,6 +254,13 @@ function requestDeleteManual() { @delete="requestDeleteManual" @redownload="redownloadManual" /> + import("@/v2/components/GameDetails/ManualSubtab.vue"), ); +const WalkthroughSubtab = defineAsyncComponent( + () => import("@/v2/components/GameDetails/WalkthroughSubtab.vue"), +); const SoundtrackPanel = defineAsyncComponent( () => import("@/v2/components/GameDetails/SoundtrackPanel.vue"), ); @@ -56,6 +59,7 @@ const { t } = useI18n(); // player is visible here and hide itself to avoid duplication. const validSubtabs = [ "manual", + "walkthrough", "screenshots", "artwork", "soundtrack", @@ -127,6 +131,11 @@ const subtabDefs = computed(() => [ label: t("rom.manual"), icon: "mdi-book-open-page-variant-outline", }, + { + id: "walkthrough", + label: t("rom.walkthrough"), + icon: "mdi-map-legend", + }, { id: "screenshots", label: t("rom.screenshots"), @@ -268,6 +277,13 @@ async function deleteSoundtrack(fileId: number) { + +
+ +
+
diff --git a/frontend/src/v2/components/GameDetails/TextViewer.vue b/frontend/src/v2/components/GameDetails/TextViewer.vue new file mode 100644 index 0000000000..b28476befd --- /dev/null +++ b/frontend/src/v2/components/GameDetails/TextViewer.vue @@ -0,0 +1,241 @@ + + +