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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("common.try-again") }}
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
diff --git a/frontend/src/v2/components/GameDetails/WalkthroughSubtab.vue b/frontend/src/v2/components/GameDetails/WalkthroughSubtab.vue
new file mode 100644
index 0000000000..2a1719ffeb
--- /dev/null
+++ b/frontend/src/v2/components/GameDetails/WalkthroughSubtab.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t("rom.add-walkthrough-from-url") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("common.upload") }}
+
+
+
+
+
+
diff --git a/frontend/src/v2/composables/useReadingProgress/index.ts b/frontend/src/v2/composables/useReadingProgress/index.ts
new file mode 100644
index 0000000000..863fada400
--- /dev/null
+++ b/frontend/src/v2/composables/useReadingProgress/index.ts
@@ -0,0 +1,92 @@
+// useReadingProgress — persists and restores a document's per-user reading
+// position for the current user. Works for any document-category rom file
+// (manual or walkthrough): pass the scroll container ref and the composable
+// restores the saved scroll fraction on load and saves it (debounced) as the
+// user scrolls. Progress is a 0..1 scroll fraction stored server-side.
+import { onBeforeUnmount, ref, watch, type Ref } from "vue";
+import romApi from "@/services/api/rom";
+
+const SAVE_DEBOUNCE_MS = 800;
+// Below this fraction we treat the document as "not started" and skip saving
+// noise from the initial layout settling.
+const MIN_MEANINGFUL_PROGRESS = 0.02;
+
+export function useReadingProgress(
+ romId: Ref,
+ fileId: Ref,
+ scrollEl: Ref,
+) {
+ const progress = ref(0);
+ const restoring = ref(false);
+ let saveTimer: ReturnType | null = null;
+ let pending = 0;
+
+ function fraction(el: HTMLElement): number {
+ const scrollable = el.scrollHeight - el.clientHeight;
+ if (scrollable <= 0) return 0;
+ return Math.min(1, Math.max(0, el.scrollTop / scrollable));
+ }
+
+ async function restore() {
+ if (fileId.value == null) return;
+ try {
+ const { data } = await romApi.getFileProgress({
+ romId: romId.value,
+ fileId: fileId.value,
+ });
+ progress.value = data.progress ?? 0;
+ const el = scrollEl.value;
+ if (el && progress.value > MIN_MEANINGFUL_PROGRESS) {
+ restoring.value = true;
+ const scrollable = el.scrollHeight - el.clientHeight;
+ el.scrollTop = progress.value * scrollable;
+ // Let the scroll event from this programmatic set pass without
+ // scheduling a redundant save.
+ requestAnimationFrame(() => {
+ restoring.value = false;
+ });
+ }
+ } catch (err) {
+ console.error("Failed to restore reading progress", err);
+ }
+ }
+
+ function flush() {
+ if (fileId.value == null) return;
+ romApi
+ .updateFileProgress({
+ romId: romId.value,
+ fileId: fileId.value,
+ data: { progress: pending, finished: pending >= 0.99 },
+ })
+ .catch((err) => console.error("Failed to save reading progress", err));
+ }
+
+ function onScroll() {
+ const el = scrollEl.value;
+ if (!el || restoring.value || fileId.value == null) return;
+ pending = fraction(el);
+ progress.value = pending;
+ if (saveTimer) clearTimeout(saveTimer);
+ saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
+ }
+
+ // Re-restore whenever the tracked file changes (viewer reused across docs).
+ watch(fileId, () => {
+ if (saveTimer) {
+ clearTimeout(saveTimer);
+ saveTimer = null;
+ }
+ progress.value = 0;
+ void restore();
+ });
+
+ onBeforeUnmount(() => {
+ if (saveTimer) {
+ clearTimeout(saveTimer);
+ flush();
+ }
+ });
+
+ return { progress, restore, onScroll };
+}