diff --git a/apps/spsvalidator/README.md b/apps/spsvalidator/README.md new file mode 100644 index 0000000..d12727d --- /dev/null +++ b/apps/spsvalidator/README.md @@ -0,0 +1,73 @@ +# spsvalidator + +Aplicação standalone para validação de pacotes SPS (`.zip`) com Flask, Pywebview e SQLite. + +## Executar em desenvolvimento + +```bash +cd apps/spsvalidator +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +spsvalidator +``` + +## Modo navegador (sem janela Pywebview) + +```bash +spsvalidator --browser +``` + +Abre em `http://127.0.0.1:5000`. + +## Build por sistema operacional + +Scripts em `packaging/`. + +```bash +cd apps/spsvalidator +source .venv/bin/activate +bash packaging/build_macos.sh +``` + +Saída: `dist/spsvalidator.app` + +## Se o `.app` não abrir ao clicar + +1. Rebuild após atualizar o código (build antigo pode não chamar `main()` nem incluir templates). +2. Rode pelo terminal para ver erros: + +```bash +apps/spsvalidator/dist/spsvalidator.app/Contents/MacOS/spsvalidator +``` + +3. Em desenvolvimento, prefira: + +```bash +spsvalidator --browser +``` + +## Erro `No module named 'pkg_resources'` + +O `packtools` ainda depende de `pkg_resources` (fornecido pelo `setuptools<82`). Após atualizar o código: + +```bash +cd apps/spsvalidator +source .venv/bin/activate +pip install -e ".[dev]" +bash packaging/build_macos.sh +open dist/spsvalidator.app +``` + +## Erro `No module named 'requests'` (ou `request`) + +O `packtools` usa dependências transitivas (`requests`, `tenacity`, `langdetect`) que já estão no `pyproject.toml`. Reinstale e rebuild: + +```bash +cd apps/spsvalidator +source .venv/bin/activate +pip install -e ".[dev]" +bash packaging/build_macos.sh +``` + +Dados locais ficam em `~/.spsvalidator/spsvalidator.sqlite3`. diff --git a/apps/spsvalidator/packaging/README.md b/apps/spsvalidator/packaging/README.md new file mode 100644 index 0000000..49c2b8b --- /dev/null +++ b/apps/spsvalidator/packaging/README.md @@ -0,0 +1,5 @@ +# Packaging + +- macOS: `bash packaging/build_macos.sh` gera bundle em `dist/`. +- Linux: `bash packaging/build_linux.sh` gera binário em `dist/` para converter em AppImage. +- Windows: `powershell -ExecutionPolicy Bypass -File packaging/build_windows.ps1` gera `.exe` em `dist/`. diff --git a/apps/spsvalidator/packaging/build_linux.sh b/apps/spsvalidator/packaging/build_linux.sh new file mode 100644 index 0000000..b235689 --- /dev/null +++ b/apps/spsvalidator/packaging/build_linux.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +python -m pip install -e ".[dev]" +python -m pip install pyinstaller +pyinstaller --noconfirm --windowed \ + --name spsvalidator \ + --icon src/spsvalidator/web/static/img/icon.png \ + --paths src \ + --collect-all packtools \ + --collect-all webview \ + --collect-data spsvalidator \ + --hidden-import pkg_resources \ + --hidden-import requests \ + --hidden-import tenacity \ + --hidden-import langdetect \ + --copy-metadata setuptools \ + src/spsvalidator/main.py +echo "Use linuxdeploy/appimagetool to convert dist/spsvalidator into AppImage." diff --git a/apps/spsvalidator/packaging/build_macos.sh b/apps/spsvalidator/packaging/build_macos.sh new file mode 100644 index 0000000..e056da6 --- /dev/null +++ b/apps/spsvalidator/packaging/build_macos.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +python -m pip install -e ".[dev]" +python -m pip install pyinstaller +bash packaging/generate_icns.sh +bash packaging/generate_build_info.sh +pyinstaller --noconfirm --windowed \ + --name spsvalidator \ + --icon packaging/icon.icns \ + --paths src \ + --collect-all packtools \ + --collect-all webview \ + --collect-data spsvalidator \ + --hidden-import pkg_resources \ + --hidden-import requests \ + --hidden-import tenacity \ + --hidden-import langdetect \ + --copy-metadata setuptools \ + src/spsvalidator/main.py diff --git a/apps/spsvalidator/packaging/build_windows.ps1 b/apps/spsvalidator/packaging/build_windows.ps1 new file mode 100644 index 0000000..94fbbf7 --- /dev/null +++ b/apps/spsvalidator/packaging/build_windows.ps1 @@ -0,0 +1,21 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RootDir = Split-Path -Path $PSScriptRoot -Parent +Set-Location $RootDir + +python -m pip install -e ".[dev]" +python -m pip install pyinstaller +pyinstaller --noconfirm --windowed ` + --name spsvalidator ` + --icon src/spsvalidator/web/static/img/icon.png ` + --paths src ` + --collect-all packtools ` + --collect-all webview ` + --collect-data spsvalidator ` + --hidden-import pkg_resources ` + --hidden-import requests ` + --hidden-import tenacity ` + --hidden-import langdetect ` + --copy-metadata setuptools ` + src/spsvalidator/main.py diff --git a/apps/spsvalidator/packaging/generate_build_info.sh b/apps/spsvalidator/packaging/generate_build_info.sh new file mode 100755 index 0000000..7d4bf01 --- /dev/null +++ b/apps/spsvalidator/packaging/generate_build_info.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET="$ROOT_DIR/src/spsvalidator/build_info.py" +cd "$ROOT_DIR" + +APP_VERSION="$(python - <<'PY' +import tomllib +from pathlib import Path + +with Path("pyproject.toml").open("rb") as file_pointer: + data = tomllib.load(file_pointer) +print(data["project"]["version"]) +PY +)" + +if [[ "$(uname -s)" == "Darwin" ]]; then + MACOS_VERSION="$(sw_vers -productVersion)" + MACOS_BUILD="$(sw_vers -buildVersion)" + cat > "$TARGET" < "$TARGET" </dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$ICON_SRC" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done + +iconutil -c icns "$ICONSET" -o "$OUTPUT" +rm -rf "$ICONSET" diff --git a/apps/spsvalidator/packaging/icon.icns b/apps/spsvalidator/packaging/icon.icns new file mode 100644 index 0000000..efd521b Binary files /dev/null and b/apps/spsvalidator/packaging/icon.icns differ diff --git a/apps/spsvalidator/pyproject.toml b/apps/spsvalidator/pyproject.toml new file mode 100644 index 0000000..68db3dc --- /dev/null +++ b/apps/spsvalidator/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "spsvalidator" +version = "1.0.0" +description = "Standalone SPS package validator desktop webapp" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "Flask>=3.0.0", + "lxml>=5.0.0", + "packtools @ git+https://git@github.com/scieloorg/packtools@4.12.6", + "pywebview>=5.1", + "setuptools>=68,<82", + "requests>=2.31.0", + "tenacity>=8.2.0", + "langdetect~=1.0.9", +] + +[project.optional-dependencies] +dev = [ + "black>=24.0.0", + "isort>=5.13.0", + "pytest>=8.0.0", +] + +[project.scripts] +spsvalidator = "spsvalidator.main:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +spsvalidator = ["web/templates/*.html", "web/static/**/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.isort] +profile = "black" +line_length = 88 + +[tool.black] +line-length = 88 diff --git a/apps/spsvalidator/spsvalidator.spec b/apps/spsvalidator/spsvalidator.spec new file mode 100644 index 0000000..3708c6f --- /dev/null +++ b/apps/spsvalidator/spsvalidator.spec @@ -0,0 +1,64 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_all +from PyInstaller.utils.hooks import copy_metadata + +datas = [] +binaries = [] +hiddenimports = ['pkg_resources', 'requests', 'tenacity', 'langdetect'] +datas += collect_data_files('spsvalidator') +datas += copy_metadata('setuptools') +tmp_ret = collect_all('packtools') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('webview') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['src/spsvalidator/main.py'], + pathex=['src'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='spsvalidator', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['packaging/icon.icns'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='spsvalidator', +) +app = BUNDLE( + coll, + name='spsvalidator.app', + icon='packaging/icon.icns', + bundle_identifier=None, +) diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/PKG-INFO b/apps/spsvalidator/src/spsvalidator.egg-info/PKG-INFO new file mode 100644 index 0000000..ac54d3e --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/PKG-INFO @@ -0,0 +1,92 @@ +Metadata-Version: 2.4 +Name: spsvalidator +Version: 1.0.0 +Summary: Standalone SPS package validator desktop webapp +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +Requires-Dist: Flask>=3.0.0 +Requires-Dist: lxml>=5.0.0 +Requires-Dist: packtools @ git+https://git@github.com/scieloorg/packtools@4.12.6 +Requires-Dist: pywebview>=5.1 +Requires-Dist: setuptools<82,>=68 +Requires-Dist: requests>=2.31.0 +Requires-Dist: tenacity>=8.2.0 +Requires-Dist: langdetect~=1.0.9 +Provides-Extra: dev +Requires-Dist: black>=24.0.0; extra == "dev" +Requires-Dist: isort>=5.13.0; extra == "dev" +Requires-Dist: pytest>=8.0.0; extra == "dev" + +# spsvalidator + +Aplicação standalone para validação de pacotes SPS (`.zip`) com Flask, Pywebview e SQLite. + +## Executar em desenvolvimento + +```bash +cd apps/spsvalidator +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +spsvalidator +``` + +## Modo navegador (sem janela Pywebview) + +```bash +spsvalidator --browser +``` + +Abre em `http://127.0.0.1:5000`. + +## Build por sistema operacional + +Scripts em `packaging/`. + +```bash +cd apps/spsvalidator +source .venv/bin/activate +bash packaging/build_macos.sh +``` + +Saída: `dist/spsvalidator.app` + +## Se o `.app` não abrir ao clicar + +1. Rebuild após atualizar o código (build antigo pode não chamar `main()` nem incluir templates). +2. Rode pelo terminal para ver erros: + +```bash +apps/spsvalidator/dist/spsvalidator.app/Contents/MacOS/spsvalidator +``` + +3. Em desenvolvimento, prefira: + +```bash +spsvalidator --browser +``` + +## Erro `No module named 'pkg_resources'` + +O `packtools` ainda depende de `pkg_resources` (fornecido pelo `setuptools<82`). Após atualizar o código: + +```bash +cd apps/spsvalidator +source .venv/bin/activate +pip install -e ".[dev]" +bash packaging/build_macos.sh +open dist/spsvalidator.app +``` + +## Erro `No module named 'requests'` (ou `request`) + +O `packtools` usa dependências transitivas (`requests`, `tenacity`, `langdetect`) que já estão no `pyproject.toml`. Reinstale e rebuild: + +```bash +cd apps/spsvalidator +source .venv/bin/activate +pip install -e ".[dev]" +bash packaging/build_macos.sh +``` + +Dados locais ficam em `~/.spsvalidator/spsvalidator.sqlite3`. diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/SOURCES.txt b/apps/spsvalidator/src/spsvalidator.egg-info/SOURCES.txt new file mode 100644 index 0000000..0f390d4 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/SOURCES.txt @@ -0,0 +1,36 @@ +README.md +pyproject.toml +src/spsvalidator/__init__.py +src/spsvalidator/app.py +src/spsvalidator/build_info.py +src/spsvalidator/build_metadata.py +src/spsvalidator/desktop_api.py +src/spsvalidator/main.py +src/spsvalidator/version.py +src/spsvalidator.egg-info/PKG-INFO +src/spsvalidator.egg-info/SOURCES.txt +src/spsvalidator.egg-info/dependency_links.txt +src/spsvalidator.egg-info/entry_points.txt +src/spsvalidator.egg-info/requires.txt +src/spsvalidator.egg-info/top_level.txt +src/spsvalidator/db/__init__.py +src/spsvalidator/db/repository.py +src/spsvalidator/domain/__init__.py +src/spsvalidator/domain/export.py +src/spsvalidator/domain/metadata.py +src/spsvalidator/domain/validation.py +src/spsvalidator/services/__init__.py +src/spsvalidator/services/validation_service.py +src/spsvalidator/web/__init__.py +src/spsvalidator/web/i18n.py +src/spsvalidator/web/routes.py +src/spsvalidator/web/static/img/icon.png +src/spsvalidator/web/static/img/logo-scielo-no-label.svg +src/spsvalidator/web/templates/index.html +tests/test_desktop_api.py +tests/test_export.py +tests/test_i18n.py +tests/test_metadata.py +tests/test_service_and_web.py +tests/test_validation_integration.py +tests/test_version.py \ No newline at end of file diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/dependency_links.txt b/apps/spsvalidator/src/spsvalidator.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/entry_points.txt b/apps/spsvalidator/src/spsvalidator.egg-info/entry_points.txt new file mode 100644 index 0000000..e0bac07 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +spsvalidator = spsvalidator.main:main diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/requires.txt b/apps/spsvalidator/src/spsvalidator.egg-info/requires.txt new file mode 100644 index 0000000..4630f83 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/requires.txt @@ -0,0 +1,13 @@ +Flask>=3.0.0 +lxml>=5.0.0 +packtools @ git+https://git@github.com/scieloorg/packtools@4.12.6 +pywebview>=5.1 +setuptools<82,>=68 +requests>=2.31.0 +tenacity>=8.2.0 +langdetect~=1.0.9 + +[dev] +black>=24.0.0 +isort>=5.13.0 +pytest>=8.0.0 diff --git a/apps/spsvalidator/src/spsvalidator.egg-info/top_level.txt b/apps/spsvalidator/src/spsvalidator.egg-info/top_level.txt new file mode 100644 index 0000000..befbab8 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator.egg-info/top_level.txt @@ -0,0 +1 @@ +spsvalidator diff --git a/apps/spsvalidator/src/spsvalidator/__init__.py b/apps/spsvalidator/src/spsvalidator/__init__.py new file mode 100644 index 0000000..7d88c76 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/__init__.py @@ -0,0 +1,3 @@ +from spsvalidator.app import create_app + +__all__ = ["create_app"] diff --git a/apps/spsvalidator/src/spsvalidator/app.py b/apps/spsvalidator/src/spsvalidator/app.py new file mode 100644 index 0000000..384aaac --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/app.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from flask import Flask, request + +from spsvalidator.build_metadata import get_footer_build_label +from spsvalidator.db.repository import init_db +from spsvalidator.version import APP_DISPLAY_NAME +from spsvalidator.web.i18n import LANGUAGE_OPTIONS, get_translations, normalize_language +from spsvalidator.web.routes import web_blueprint + + +def create_app(data_dir: str | None = None) -> Flask: + app = Flask(__name__, static_folder=None) + app.secret_key = "spsvalidator-local-secret" + target_dir = Path(data_dir) if data_dir else Path.home() / ".spsvalidator" + target_dir.mkdir(parents=True, exist_ok=True) + db_path = target_dir / "spsvalidator.sqlite3" + init_db(str(db_path)) + app.config["DB_PATH"] = str(db_path) + app.config["APP_DISPLAY_NAME"] = APP_DISPLAY_NAME + + @app.context_processor + def inject_app_info(): + language = normalize_language(request.cookies.get("lang")) + translations = get_translations(language) + return { + "app_display_name": app.config["APP_DISPLAY_NAME"], + "current_language": language, + "language_options": LANGUAGE_OPTIONS, + "t": translations, + "footer_build_label": get_footer_build_label(language, translations), + } + + app.register_blueprint(web_blueprint) + return app diff --git a/apps/spsvalidator/src/spsvalidator/build_info.py b/apps/spsvalidator/src/spsvalidator/build_info.py new file mode 100644 index 0000000..5af6df3 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/build_info.py @@ -0,0 +1,3 @@ +APP_VERSION = "1.0.0" +BUILD_MACOS_VERSION = "26.3.1 (25D2128)" +BUILD_PLATFORM = "macOS" diff --git a/apps/spsvalidator/src/spsvalidator/build_metadata.py b/apps/spsvalidator/src/spsvalidator/build_metadata.py new file mode 100644 index 0000000..36864fa --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/build_metadata.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import platform + +from spsvalidator import build_info + + +def get_footer_build_label(language: str, translations: dict[str, str]) -> str: + if ( + build_info.BUILD_MACOS_VERSION != "development" + and build_info.BUILD_PLATFORM == "macOS" + ): + return translations["footer_built_for_macos"].format( + version=build_info.BUILD_MACOS_VERSION + ) + runtime_platform = platform.system() + if runtime_platform == "Darwin": + mac_version = platform.mac_ver()[0] + build_number = platform.mac_ver()[2] + version_label = mac_version + if build_number: + version_label = f"{mac_version} ({build_number})" + return translations["footer_built_for_macos"].format(version=version_label) + return translations["footer_dev_build"].format(platform=runtime_platform) diff --git a/apps/spsvalidator/src/spsvalidator/db/__init__.py b/apps/spsvalidator/src/spsvalidator/db/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/db/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/apps/spsvalidator/src/spsvalidator/db/repository.py b/apps/spsvalidator/src/spsvalidator/db/repository.py new file mode 100644 index 0000000..dfe1839 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/db/repository.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import UTC, datetime + + +def init_db(db_path: str) -> None: + with sqlite3.connect(db_path) as connection: + connection.execute(""" + CREATE TABLE IF NOT EXISTS package_validation_history ( + id TEXT PRIMARY KEY, + validated_at TEXT NOT NULL, + package_name TEXT NOT NULL, + package_sha256 TEXT NOT NULL, + xml_count INTEGER NOT NULL, + issues_count INTEGER NOT NULL, + exceptions_count INTEGER NOT NULL, + status TEXT NOT NULL, + report_json TEXT NOT NULL, + exceptions_json TEXT NOT NULL + ) + """) + connection.execute(""" + CREATE TABLE IF NOT EXISTS package_article_snapshot ( + id TEXT PRIMARY KEY, + history_id TEXT NOT NULL, + xml_path TEXT NOT NULL, + title TEXT NOT NULL, + authors_text TEXT NOT NULL, + doi TEXT NOT NULL, + pid TEXT NOT NULL, + article_status TEXT NOT NULL, + issue_count INTEGER NOT NULL, + FOREIGN KEY(history_id) REFERENCES package_validation_history(id) + ) + """) + connection.commit() + + +def insert_validation_result( + db_path: str, + package_name: str, + package_sha256: str, + rows: list[dict], + exceptions: list[dict], + articles: list[dict], + status: str, +) -> str: + history_id = str(uuid.uuid4()) + validated_at = datetime.now(UTC).isoformat() + with sqlite3.connect(db_path) as connection: + connection.execute( + """ + INSERT INTO package_validation_history ( + id, validated_at, package_name, package_sha256, xml_count, + issues_count, exceptions_count, status, report_json, exceptions_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + history_id, + validated_at, + package_name, + package_sha256, + len(articles), + len(rows), + len(exceptions), + status, + json.dumps(rows, ensure_ascii=False), + json.dumps(exceptions, ensure_ascii=False), + ), + ) + for article in articles: + connection.execute( + """ + INSERT INTO package_article_snapshot ( + id, history_id, xml_path, title, authors_text, doi, pid, + article_status, issue_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), + history_id, + article.get("xml_path", ""), + article.get("title", ""), + article.get("authors_text", ""), + article.get("doi", ""), + article.get("pid", ""), + article.get("article_status", "ok"), + int(article.get("issue_count", 0)), + ), + ) + connection.commit() + return history_id + + +def list_validations(db_path: str) -> list[dict]: + with sqlite3.connect(db_path) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute(""" + SELECT id, validated_at, package_name, xml_count, issues_count, + exceptions_count, status + FROM package_validation_history + ORDER BY datetime(validated_at) DESC + """).fetchall() + return [dict(row) for row in rows] + + +def get_validation_details(db_path: str, history_id: str) -> dict | None: + with sqlite3.connect(db_path) as connection: + connection.row_factory = sqlite3.Row + history = connection.execute( + "SELECT * FROM package_validation_history WHERE id = ?", + (history_id,), + ).fetchone() + if history is None: + return None + articles = connection.execute( + """ + SELECT xml_path, title, authors_text, doi, pid, article_status, issue_count + FROM package_article_snapshot + WHERE history_id = ? + ORDER BY xml_path + """, + (history_id,), + ).fetchall() + history_dict = dict(history) + history_dict["rows"] = json.loads(history_dict["report_json"]) + history_dict["exceptions"] = json.loads(history_dict["exceptions_json"]) + history_dict["articles"] = [dict(row) for row in articles] + return history_dict diff --git a/apps/spsvalidator/src/spsvalidator/desktop_api.py b/apps/spsvalidator/src/spsvalidator/desktop_api.py new file mode 100644 index 0000000..0cfb897 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/desktop_api.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import platform +import subprocess +from pathlib import Path + +from spsvalidator.db.repository import get_validation_details +from spsvalidator.domain.export import build_validation_csv + + +def _reveal_in_file_manager(path: str) -> None: + target = Path(path) + system = platform.system() + if system == "Darwin": + subprocess.run(["open", "-R", str(target)], check=False) + elif system == "Windows": + subprocess.run(["explorer", "/select,", str(target)], check=False) + else: + subprocess.run(["xdg-open", str(target.parent)], check=False) + + +class DesktopApi: + def __init__(self, db_path: str) -> None: + self.db_path = db_path + + def save_validation_csv(self, history_id: str) -> dict: + details = get_validation_details(self.db_path, history_id) + if details is None: + return {"ok": False, "error": "not_found"} + csv_content = build_validation_csv(details["rows"]) + package_stem = details["package_name"].rsplit(".", 1)[0] + save_filename = f"{package_stem}.validation.csv" + downloads_dir = Path.home() / "Downloads" + downloads_dir.mkdir(parents=True, exist_ok=True) + target_path = downloads_dir / save_filename + target_path.write_text(csv_content, encoding="utf-8") + _reveal_in_file_manager(str(target_path)) + return {"ok": True, "path": str(target_path)} diff --git a/apps/spsvalidator/src/spsvalidator/domain/__init__.py b/apps/spsvalidator/src/spsvalidator/domain/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/domain/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/apps/spsvalidator/src/spsvalidator/domain/export.py b/apps/spsvalidator/src/spsvalidator/domain/export.py new file mode 100644 index 0000000..fc915ec --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/domain/export.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import csv +import io + +VALIDATION_CSV_COLUMNS = [ + "group", + "title", + "parent", + "parent_id", + "parent_article_type", + "item", + "sub_item", + "attribute", + "validation_type", + "response", + "expected_value", + "got_value", + "advice", +] + + +def build_validation_csv(rows: list[dict]) -> str: + buffer = io.StringIO() + writer = csv.DictWriter( + buffer, + fieldnames=VALIDATION_CSV_COLUMNS, + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(rows) + return buffer.getvalue() diff --git a/apps/spsvalidator/src/spsvalidator/domain/metadata.py b/apps/spsvalidator/src/spsvalidator/domain/metadata.py new file mode 100644 index 0000000..6f7d417 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/domain/metadata.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from lxml import etree + + +def extract_article_snapshot(xml_content: bytes, xml_path: str) -> dict: + tree = etree.fromstring(xml_content) + title_nodes = tree.xpath(".//article-meta/title-group/article-title") + title = " ".join(title_nodes[0].itertext()).strip() if title_nodes else "" + doi = tree.xpath("string(.//article-id[@pub-id-type='doi'][1])").strip().lower() + pid = ( + tree.xpath("string(.//article-id[@pub-id-type='publisher-id'][1])").strip() + or tree.xpath("string(.//article-id[@pub-id-type='other'][1])").strip() + ) + authors = [] + for contrib in tree.xpath( + ".//article-meta/contrib-group/contrib[@contrib-type='author']" + ): + surname = contrib.xpath("string(./name/surname)").strip() + given_names = contrib.xpath("string(./name/given-names)").strip() + full_name = f"{given_names} {surname}".strip() + if full_name: + authors.append(full_name) + return { + "xml_path": xml_path, + "title": title, + "authors": authors, + "authors_text": "; ".join(authors[:5]), + "doi": doi, + "pid": pid, + } diff --git a/apps/spsvalidator/src/spsvalidator/domain/validation.py b/apps/spsvalidator/src/spsvalidator/domain/validation.py new file mode 100644 index 0000000..ecdf832 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/domain/validation.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +import zipfile +from pathlib import PurePosixPath + +from spsvalidator.domain.metadata import extract_article_snapshot + + +def _extract_journal_data(xmltree): + from packtools.sps.models.article_license import ArticleLicense + from packtools.sps.pid_provider.models.journal_meta import ( + JournalID, + Publisher, + Title, + ) + + try: + license_code = None + for license_item in ArticleLicense(xmltree).licenses: + code = license_item.get("code") + if code: + license_code = code + break + return { + "abbrev_journal_title": Title(xmltree).abbreviated_journal_title, + "publisher_name_list": Publisher(xmltree).publishers_names, + "nlm_journal_title": JournalID(xmltree).nlm_ta, + "license_code": license_code, + } + except Exception: + return {} + + +def _iter_zip_xml_metadata(zip_path: str): + with zipfile.ZipFile(zip_path) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + suffix = PurePosixPath(member.filename).suffix.lower() + if suffix != ".xml": + continue + yield extract_article_snapshot(archive.read(member), member.filename) + + +def validate_sps_zip(zip_path: str) -> dict: + from packtools.sps.pid_provider.xml_sps_lib import XMLWithPre + from packtools.sps.validation.xml_validator import get_validation_results + + if not os.path.isfile(zip_path): + raise FileNotFoundError(zip_path) + rows = [] + exceptions = [] + issues_by_parent = {} + for xml_with_pre in XMLWithPre.create(path=zip_path): + xmltree = xml_with_pre.xmltree + rules = {"journal_data": _extract_journal_data(xmltree)} + for result in get_validation_results(xmltree, rules): + if not result: + continue + if result.get("response") == "exception": + exceptions.append(result) + continue + if result.get("response") == "OK": + continue + group = result.get("group", "") + item = result.get("item") or "" + sub_item = result.get("sub_item") or "" + attribute = "/".join(filter(None, [item, sub_item])) + row = { + "group": group, + "title": result.get("title"), + "parent": result.get("parent"), + "parent_id": result.get("parent_id"), + "parent_article_type": result.get("parent_article_type"), + "item": item, + "sub_item": sub_item, + "attribute": attribute, + "validation_type": result.get("validation_type"), + "response": result.get("response"), + "expected_value": result.get("expected_value"), + "got_value": result.get("got_value"), + "advice": result.get("advice"), + } + rows.append(row) + parent_key = row.get("parent") or row.get("parent_id") or "" + if parent_key: + issues_by_parent[parent_key] = issues_by_parent.get(parent_key, 0) + 1 + articles = list(_iter_zip_xml_metadata(zip_path)) + for article in articles: + path_key = article.get("xml_path", "") + article["issue_count"] = issues_by_parent.get(path_key, 0) + article["article_status"] = "issue" if article["issue_count"] else "ok" + return {"rows": rows, "exceptions": exceptions, "articles": articles} diff --git a/apps/spsvalidator/src/spsvalidator/main.py b/apps/spsvalidator/src/spsvalidator/main.py new file mode 100644 index 0000000..ad153c4 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/main.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import socket +import threading +import time +from pathlib import Path +from wsgiref.simple_server import make_server + +import webview + +from spsvalidator.app import create_app +from spsvalidator.desktop_api import DesktopApi +from spsvalidator.version import APP_DISPLAY_NAME + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _serve(app, host: str, port: int) -> None: + server = make_server(host, port, app) + server.serve_forever() + + +def main() -> None: + parser = argparse.ArgumentParser(prog="spsvalidator") + parser.add_argument("--browser", action="store_true") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + args = parser.parse_args() + + app = create_app() + if args.browser: + app.run(host=args.host, port=args.port or 5000, debug=False) + return + + db_path = app.config["DB_PATH"] + host = args.host + port = args.port or _free_port() + thread = threading.Thread(target=_serve, args=(app, host, port), daemon=True) + thread.start() + time.sleep(0.4) + icon_path = Path(__file__).resolve().parent / "web" / "static" / "img" / "icon.png" + window_kwargs = { + "title": APP_DISPLAY_NAME, + "url": f"http://{host}:{port}", + "width": 1360, + "height": 900, + } + if icon_path.is_file(): + window_kwargs["icon"] = str(icon_path) + window_kwargs["js_api"] = DesktopApi(db_path) + webview.create_window(**window_kwargs) + webview.start() + + +if __name__ == "__main__": + main() diff --git a/apps/spsvalidator/src/spsvalidator/services/__init__.py b/apps/spsvalidator/src/spsvalidator/services/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/services/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/apps/spsvalidator/src/spsvalidator/services/validation_service.py b/apps/spsvalidator/src/spsvalidator/services/validation_service.py new file mode 100644 index 0000000..7cf4379 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/services/validation_service.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import hashlib +import os +import tempfile +from pathlib import Path + +from spsvalidator.db.repository import insert_validation_result +from spsvalidator.domain.validation import validate_sps_zip + + +def _sha256_of_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as file_pointer: + for chunk in iter(lambda: file_pointer.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _compute_status(rows: list[dict], exceptions: list[dict]) -> str: + if rows: + return "invalid" + if exceptions: + return "error" + return "valid" + + +def run_validation( + db_path: str, uploaded_file, zip_only_message: str | None = None +) -> dict: + filename = uploaded_file.filename or "" + if not filename.lower().endswith(".zip"): + raise ValueError(zip_only_message or "Only SPS .zip files are supported.") + with tempfile.TemporaryDirectory(prefix="spsvalidator-") as temp_dir: + zip_path = os.path.join(temp_dir, Path(filename).name) + uploaded_file.save(zip_path) + result = validate_sps_zip(zip_path) + rows = result["rows"] + exceptions = result["exceptions"] + articles = result["articles"] + status = _compute_status(rows, exceptions) + history_id = insert_validation_result( + db_path=db_path, + package_name=Path(filename).name, + package_sha256=_sha256_of_file(zip_path), + rows=rows, + exceptions=exceptions, + articles=articles, + status=status, + ) + return { + "history_id": history_id, + "status": status, + "rows": rows, + "exceptions": exceptions, + "articles": articles, + "issues_count": len(rows), + "exceptions_count": len(exceptions), + "xml_count": len(articles), + } diff --git a/apps/spsvalidator/src/spsvalidator/version.py b/apps/spsvalidator/src/spsvalidator/version.py new file mode 100644 index 0000000..b9d68d5 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/version.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import tomllib +from pathlib import Path + + +def _load_version() -> str: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_path.is_file(): + with pyproject_path.open("rb") as file_pointer: + data = tomllib.load(file_pointer) + return str(data["project"]["version"]) + from spsvalidator import build_info + + return build_info.APP_VERSION + + +__version__ = _load_version() +APP_DISPLAY_NAME = f"SPSValidator-v{__version__}" diff --git a/apps/spsvalidator/src/spsvalidator/web/__init__.py b/apps/spsvalidator/src/spsvalidator/web/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/web/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/apps/spsvalidator/src/spsvalidator/web/i18n.py b/apps/spsvalidator/src/spsvalidator/web/i18n.py new file mode 100644 index 0000000..4298d15 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/web/i18n.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +SUPPORTED_LANGUAGES = ("pt", "en", "es") + +LANGUAGE_OPTIONS = ( + {"code": "pt", "flag": "🇧🇷", "label": "Português"}, + {"code": "en", "flag": "🇺🇸", "label": "English"}, + {"code": "es", "flag": "🇪🇸", "label": "Español"}, +) + +TRANSLATIONS = { + "pt": { + "html_lang": "pt-BR", + "subtitle": "Validação de pacotes SPS", + "validate_package": "Validar pacote SPS", + "validate": "Validar", + "select_zip": "Selecione um arquivo .zip para validar.", + "zip_only": "Apenas arquivos .zip SPS sao suportados.", + "validated_packages": "Pacotes validados", + "no_packages_yet": "Nenhum pacote validado ainda.", + "date": "Data", + "package": "Pacote", + "status": "Status", + "xmls": "XMLs", + "issues": "Issues", + "exceptions": "Exceptions", + "download_csv": "Baixar CSV", + "csv_saved": "Arquivo salvo em {path}", + "csv_download_failed": "Falha ao baixar CSV.", + "validation_result": "Resultado da validação", + "articles_found": "Artigos encontrados", + "xml_file": "Arquivo XML", + "title": "Título", + "authors": "Autores", + "validation_issues": "Issues de validação", + "no_exceptions": "Sem exceptions.", + "detail": "Detalhe", + "footer_built_for_macos": "Compilado para macOS {version}", + "footer_dev_build": "Build de desenvolvimento ({platform})", + "status_valid": "valid", + "status_invalid": "invalid", + "status_error": "error", + "status_ok": "ok", + "status_issue": "issue", + }, + "en": { + "html_lang": "en", + "subtitle": "SPS package validation", + "validate_package": "Validate SPS package", + "validate": "Validate", + "select_zip": "Select a .zip file to validate.", + "zip_only": "Only SPS .zip files are supported.", + "validated_packages": "Validated packages", + "no_packages_yet": "No packages validated yet.", + "date": "Date", + "package": "Package", + "status": "Status", + "xmls": "XMLs", + "issues": "Issues", + "exceptions": "Exceptions", + "download_csv": "Download CSV", + "csv_saved": "File saved to {path}", + "csv_download_failed": "Failed to download CSV.", + "validation_result": "Validation result", + "articles_found": "Articles found", + "xml_file": "XML file", + "title": "Title", + "authors": "Authors", + "validation_issues": "Validation issues", + "no_exceptions": "No exceptions.", + "detail": "Detail", + "footer_built_for_macos": "Built for macOS {version}", + "footer_dev_build": "Development build ({platform})", + "status_valid": "valid", + "status_invalid": "invalid", + "status_error": "error", + "status_ok": "ok", + "status_issue": "issue", + }, + "es": { + "html_lang": "es", + "subtitle": "Validación de paquetes SPS", + "validate_package": "Validar paquete SPS", + "validate": "Validar", + "select_zip": "Seleccione un archivo .zip para validar.", + "zip_only": "Solo se admiten archivos .zip SPS.", + "validated_packages": "Paquetes validados", + "no_packages_yet": "Ningún paquete validado todavía.", + "date": "Fecha", + "package": "Paquete", + "status": "Estado", + "xmls": "XMLs", + "issues": "Issues", + "exceptions": "Exceptions", + "download_csv": "Descargar CSV", + "csv_saved": "Archivo guardado en {path}", + "csv_download_failed": "Error al descargar CSV.", + "validation_result": "Resultado de la validación", + "articles_found": "Artículos encontrados", + "xml_file": "Archivo XML", + "title": "Título", + "authors": "Autores", + "validation_issues": "Issues de validación", + "no_exceptions": "Sin exceptions.", + "detail": "Detalle", + "footer_built_for_macos": "Compilado para macOS {version}", + "footer_dev_build": "Build de desarrollo ({platform})", + "status_valid": "valid", + "status_invalid": "invalid", + "status_error": "error", + "status_ok": "ok", + "status_issue": "issue", + }, +} + + +def normalize_language(code: str | None) -> str: + if not code: + return "pt" + normalized = code.lower().split("-")[0] + if normalized in SUPPORTED_LANGUAGES: + return normalized + return "pt" + + +def get_translations(language: str) -> dict[str, str]: + return TRANSLATIONS[normalize_language(language)] diff --git a/apps/spsvalidator/src/spsvalidator/web/routes.py b/apps/spsvalidator/src/spsvalidator/web/routes.py new file mode 100644 index 0000000..c3294a5 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/web/routes.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from pathlib import Path + +from flask import ( + Blueprint, + abort, + current_app, + make_response, + redirect, + render_template, + request, + send_from_directory, + url_for, +) + +from spsvalidator.db.repository import get_validation_details, list_validations +from spsvalidator.domain.export import build_validation_csv +from spsvalidator.services.validation_service import run_validation +from spsvalidator.web.i18n import get_translations, normalize_language + +web_blueprint = Blueprint( + "web", + __name__, + template_folder="templates", + static_folder="static", + static_url_path="/static", +) + + +def _current_translations(): + return get_translations(normalize_language(request.cookies.get("lang"))) + + +def _render_index(**context): + return render_template( + "index.html", + history_items=list_validations(current_app.config["DB_PATH"]), + error_message=None, + **context, + ) + + +def _redirect_with_lang(endpoint: str, **values): + response = make_response(redirect(url_for(endpoint, **values))) + language = normalize_language(request.cookies.get("lang")) + response.set_cookie("lang", language, max_age=60 * 60 * 24 * 365) + return response + + +@web_blueprint.get("/") +def index(): + selected_id = request.args.get("history_id") + details = ( + get_validation_details(current_app.config["DB_PATH"], selected_id) + if selected_id + else None + ) + return _render_index(latest_result=details) + + +@web_blueprint.post("/validate") +def validate(): + translations = _current_translations() + uploaded_file = request.files.get("package_zip") + if uploaded_file is None or not uploaded_file.filename: + return _render_index( + latest_result=None, + error_message=translations["select_zip"], + ) + try: + result = run_validation( + current_app.config["DB_PATH"], + uploaded_file, + zip_only_message=translations["zip_only"], + ) + except Exception as exc: + return _render_index(latest_result=None, error_message=str(exc)) + return _redirect_with_lang("web.index", history_id=result["history_id"]) + + +@web_blueprint.get("/validation//report.csv") +def download_csv(history_id: str): + details = get_validation_details(current_app.config["DB_PATH"], history_id) + if details is None: + abort(404) + csv_content = build_validation_csv(details["rows"]) + response = make_response(csv_content.encode("utf-8")) + response.headers["Content-Type"] = "application/octet-stream" + package_stem = details["package_name"].rsplit(".", 1)[0] + response.headers["Content-Disposition"] = ( + f'attachment; filename="{package_stem}.validation.csv"' + ) + response.headers["Cache-Control"] = "no-store" + return response + + +@web_blueprint.get("/language/") +def set_language(language_code: str): + language = normalize_language(language_code) + redirect_target = request.args.get("next") or url_for("web.index") + response = make_response(redirect(redirect_target)) + response.set_cookie("lang", language, max_age=60 * 60 * 24 * 365) + return response + + +@web_blueprint.get("/favicon.ico") +def favicon(): + static_dir = Path(__file__).resolve().parent / "static" / "img" + return send_from_directory(static_dir, "icon.png", mimetype="image/png") diff --git a/apps/spsvalidator/src/spsvalidator/web/static/img/icon.png b/apps/spsvalidator/src/spsvalidator/web/static/img/icon.png new file mode 100644 index 0000000..87719d5 Binary files /dev/null and b/apps/spsvalidator/src/spsvalidator/web/static/img/icon.png differ diff --git a/apps/spsvalidator/src/spsvalidator/web/static/img/logo-scielo-no-label.svg b/apps/spsvalidator/src/spsvalidator/web/static/img/logo-scielo-no-label.svg new file mode 100644 index 0000000..f208ac8 --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/web/static/img/logo-scielo-no-label.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/spsvalidator/src/spsvalidator/web/templates/index.html b/apps/spsvalidator/src/spsvalidator/web/templates/index.html new file mode 100644 index 0000000..703555e --- /dev/null +++ b/apps/spsvalidator/src/spsvalidator/web/templates/index.html @@ -0,0 +1,353 @@ + + + + + + {{ app_display_name }} + + + + +
+
+ SciELO +
+

{{ app_display_name }}

+

{{ t.subtitle }}

+
+
+
+ {% for option in language_options %} + {{ option.flag }} + {% endfor %} +
+
+ +
+
+

{{ t.validate_package }}

+
+ + +
+ {% if error_message %} +

{{ error_message }}

+ {% endif %} +
+ +
+

{{ t.validated_packages }}

+ {% if history_items %} + + + + + + + + + + + + + + {% for item in history_items %} + + + + + + + + + + {% endfor %} + +
{{ t.date }}{{ t.package }}{{ t.status }}{{ t.xmls }}{{ t.issues }}{{ t.exceptions }}{{ t.download_csv }}
{{ item.validated_at }}{{ item.package_name }}{{ item.status }}{{ item.xml_count }}{{ item.issues_count }}{{ item.exceptions_count }} + {{ t.download_csv }} +
+ {% else %} +

{{ t.no_packages_yet }}

+ {% endif %} + +
+ + {% if latest_result %} +
+

{{ t.validation_result }}

+

+ {{ t.status }}: + {{ latest_result.status }} + | {{ t.xmls }}: {{ latest_result.xml_count }} + | {{ t.issues }}: {{ latest_result.issues_count }} + | {{ t.exceptions }}: {{ latest_result.exceptions_count }} +

+
+

{{ t.articles_found }}

+ + + + + + + + + + + + + {% for article in latest_result.articles %} + + + + + + + + + {% endfor %} + +
{{ t.xml_file }}{{ t.title }}{{ t.authors }}DOIPID{{ t.status }}
{{ article.xml_path }}{{ article.title }}{{ article.authors_text }}{{ article.doi }}{{ article.pid }}{{ article.article_status }}
+
+ +

{{ t.validation_issues }}

+ + + + + + + + + + + + + + + + + + + + {% for row in latest_result.rows %} + + + + + + + + + + + + + + + + {% endfor %} + +
grouptitleparentparent_idparent_article_typeitemsub_itemattributevalidation_typeresponseexpected_valuegot_valueadvice
{{ row.group }}{{ row.title }}{{ row.parent }}{{ row.parent_id }}{{ row.parent_article_type }}{{ row.item }}{{ row.sub_item }}{{ row.attribute }}{{ row.validation_type }}{{ row.response }}{{ row.expected_value }}{{ row.got_value }}{{ row.advice }}
+ +

{{ t.exceptions }}

+ {% if latest_result.exceptions %} + + + + + + + + + + + {% for exception in latest_result.exceptions %} + + + + + + + {% endfor %} + +
responsetitlegroup{{ t.detail }}
{{ exception.response }}{{ exception.title }}{{ exception.group }}{{ exception.got_value or exception.detail or exception | tojson }}
+ {% else %} +

{{ t.no_exceptions }}

+ {% endif %} +
+ {% endif %} +
+ +
+

{{ footer_build_label }}

+
+ + + diff --git a/apps/spsvalidator/tests/test_desktop_api.py b/apps/spsvalidator/tests/test_desktop_api.py new file mode 100644 index 0000000..33ae9a1 --- /dev/null +++ b/apps/spsvalidator/tests/test_desktop_api.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from spsvalidator.app import create_app +from spsvalidator.desktop_api import DesktopApi +from spsvalidator.services import validation_service + + +def test_save_validation_csv_writes_file(monkeypatch, tmp_path): + app = create_app(str(tmp_path)) + db_path = app.config["DB_PATH"] + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr( + "spsvalidator.desktop_api._reveal_in_file_manager", + lambda path: None, + ) + + def fake_validate(zip_path: str): + return { + "rows": [{"group": "g", "title": "t", "response": "ERROR"}], + "exceptions": [], + "articles": [], + } + + monkeypatch.setattr(validation_service, "validate_sps_zip", fake_validate) + + class UploadedFile: + filename = "package.zip" + + def save(self, destination): + Path(destination).write_bytes(b"zip") + + result = validation_service.run_validation(db_path, UploadedFile()) + api = DesktopApi(db_path) + response = api.save_validation_csv(result["history_id"]) + target = tmp_path / "Downloads" / "package.validation.csv" + assert response["ok"] is True + assert target.is_file() + assert "group,title" in target.read_text(encoding="utf-8") diff --git a/apps/spsvalidator/tests/test_export.py b/apps/spsvalidator/tests/test_export.py new file mode 100644 index 0000000..caba6e6 --- /dev/null +++ b/apps/spsvalidator/tests/test_export.py @@ -0,0 +1,30 @@ +import csv +import io + +from spsvalidator.domain.export import build_validation_csv + + +def test_build_validation_csv_includes_all_columns(): + rows = [ + { + "group": "meta", + "title": "Title check", + "parent": "article.xml", + "parent_id": "art1", + "parent_article_type": "research-article", + "item": "title", + "sub_item": "", + "attribute": "title", + "validation_type": "value", + "response": "ERROR", + "expected_value": "x", + "got_value": "y", + "advice": "fix it", + } + ] + content = build_validation_csv(rows) + parsed = list(csv.DictReader(io.StringIO(content))) + assert len(parsed) == 1 + assert parsed[0]["group"] == "meta" + assert parsed[0]["response"] == "ERROR" + assert parsed[0]["advice"] == "fix it" diff --git a/apps/spsvalidator/tests/test_i18n.py b/apps/spsvalidator/tests/test_i18n.py new file mode 100644 index 0000000..b7b1ab4 --- /dev/null +++ b/apps/spsvalidator/tests/test_i18n.py @@ -0,0 +1,14 @@ +from spsvalidator.web.i18n import get_translations, normalize_language + + +def test_normalize_language_defaults_to_portuguese(): + assert normalize_language(None) == "pt" + assert normalize_language("en-US") == "en" + assert normalize_language("xx") == "pt" + + +def test_translations_available_for_all_languages(): + for language in ("pt", "en", "es"): + translations = get_translations(language) + assert translations["validate_package"] + assert translations["footer_built_for_macos"] diff --git a/apps/spsvalidator/tests/test_metadata.py b/apps/spsvalidator/tests/test_metadata.py new file mode 100644 index 0000000..12ad00c --- /dev/null +++ b/apps/spsvalidator/tests/test_metadata.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from spsvalidator.domain.metadata import extract_article_snapshot + + +def test_extract_article_snapshot_reads_title_and_authors(): + xml_path = ( + Path(__file__).resolve().parents[3] / "fixtures" / "xml" / "dias_2023.xml" + ) + snapshot = extract_article_snapshot(xml_path.read_bytes(), "dias_2023.xml") + assert snapshot["title"] + assert "Differentiating diversity" in snapshot["title"] + assert snapshot["authors"] + assert "Carlos Henrique Saraiva DIAS" in snapshot["authors_text"] diff --git a/apps/spsvalidator/tests/test_service_and_web.py b/apps/spsvalidator/tests/test_service_and_web.py new file mode 100644 index 0000000..acc06d1 --- /dev/null +++ b/apps/spsvalidator/tests/test_service_and_web.py @@ -0,0 +1,110 @@ +import io +import zipfile +from pathlib import Path + +from spsvalidator.app import create_app +from spsvalidator.services import validation_service + + +def _zip_fixture_xml() -> io.BytesIO: + fixture_path = ( + Path(__file__).resolve().parents[3] / "fixtures" / "xml" / "dias_2023.xml" + ) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("dias_2023.xml", fixture_path.read_bytes()) + buffer.seek(0) + return buffer + + +def test_run_validation_persists_result(monkeypatch, tmp_path): + app = create_app(str(tmp_path)) + db_path = app.config["DB_PATH"] + + def fake_validate(zip_path: str): + return { + "rows": [{"group": "g", "title": "t", "response": "ERROR"}], + "exceptions": [], + "articles": [ + { + "xml_path": "dias_2023.xml", + "title": "Article", + "authors_text": "A B", + "doi": "10.1/2", + "pid": "abc", + "article_status": "issue", + "issue_count": 1, + } + ], + } + + monkeypatch.setattr(validation_service, "validate_sps_zip", fake_validate) + payload = _zip_fixture_xml() + + class UploadedFile: + filename = "package.zip" + + def save(self, destination): + Path(destination).write_bytes(payload.getvalue()) + + result = validation_service.run_validation(db_path, UploadedFile()) + assert result["status"] == "invalid" + assert result["issues_count"] == 1 + assert result["xml_count"] == 1 + + client = app.test_client() + csv_response = client.get(f"/validation/{result['history_id']}/report.csv") + assert csv_response.status_code == 200 + assert csv_response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in csv_response.headers["Content-Disposition"] + assert b"group,title" in csv_response.data + assert b"ERROR" in csv_response.data + + +def test_set_language_switches_ui_text(tmp_path): + app = create_app(str(tmp_path)) + client = app.test_client() + response = client.get("/language/en") + assert response.status_code == 302 + home = client.get("/", headers={"Cookie": "lang=en"}) + html = home.get_data(as_text=True) + assert "SPS package validation" in html + assert "Built for macOS" in html or "Development build" in html + + +def test_validate_route_processes_upload(monkeypatch, tmp_path): + app = create_app(str(tmp_path)) + app.testing = True + + def fake_validate(zip_path: str): + return { + "rows": [], + "exceptions": [], + "articles": [ + { + "xml_path": "dias_2023.xml", + "title": "T", + "authors_text": "Author", + "doi": "", + "pid": "", + "article_status": "ok", + "issue_count": 0, + } + ], + } + + monkeypatch.setattr(validation_service, "validate_sps_zip", fake_validate) + client = app.test_client() + response = client.post( + "/validate", + data={"package_zip": (_zip_fixture_xml(), "package.zip")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + html = response.get_data(as_text=True) + assert response.status_code == 200 + assert "Pacotes validados" in html + assert "package.zip" in html + assert "img/icon.png" in html + assert "SPSValidator-v" in html + assert "lang-option" in html diff --git a/apps/spsvalidator/tests/test_validation_integration.py b/apps/spsvalidator/tests/test_validation_integration.py new file mode 100644 index 0000000..63871f5 --- /dev/null +++ b/apps/spsvalidator/tests/test_validation_integration.py @@ -0,0 +1,29 @@ +import tempfile +import zipfile +from pathlib import Path + +import pytest + +from spsvalidator.domain.validation import validate_sps_zip + + +@pytest.fixture +def fixture_zip_path(tmp_path): + fixture_path = ( + Path(__file__).resolve().parents[3] / "fixtures" / "xml" / "dias_2023.xml" + ) + zip_path = tmp_path / "package.zip" + with zipfile.ZipFile( + zip_path, mode="w", compression=zipfile.ZIP_DEFLATED + ) as archive: + archive.write(fixture_path, "dias_2023.xml") + return zip_path + + +def test_validate_sps_zip_runs_with_packtools(fixture_zip_path): + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / fixture_zip_path.name + target.write_bytes(fixture_zip_path.read_bytes()) + result = validate_sps_zip(str(target)) + assert result["articles"] + assert result["articles"][0]["title"] diff --git a/apps/spsvalidator/tests/test_version.py b/apps/spsvalidator/tests/test_version.py new file mode 100644 index 0000000..6948f7e --- /dev/null +++ b/apps/spsvalidator/tests/test_version.py @@ -0,0 +1,11 @@ +import tomllib +from pathlib import Path + +from spsvalidator.version import APP_DISPLAY_NAME, __version__ + + +def test_version_matches_pyproject(): + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + expected = tomllib.load(pyproject_path.open("rb"))["project"]["version"] + assert __version__ == expected + assert APP_DISPLAY_NAME == f"SPSValidator-v{expected}"