diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8948cef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md + +Guidance for contributors and coding agents working in this repository. + +## Quick Start + +1. Confirm the request scope and avoid unrelated changes. +2. Read `README.md` and the relevant files in `src/` before editing. +3. Make the smallest viable change; do not restructure folders or rename files unless requested. +4. Run only the checks needed for the change (`npm run test` and/or `npm run build`). +5. Summarize what changed, why it changed, and any follow-up work. + +> When in doubt: prefer existing project patterns over introducing new abstractions. + +## Project Overview + +OpenCite is a Vite + React single-page app that helps users generate two metadata files from one form: +- `CITATION.cff` +- `.zenodo.json` + +OpenCite is browser-based and deployed through GitHub Pages +(`https://imageomics.github.io/OpenCite/`). + +Primary goals: +- Keep metadata exports valid and stable. +- Keep the form workflow predictable and easy to edit. +- Preserve browser-first behavior (no Node-only runtime assumptions in app code). +- Maintain metadata quality through import, validation, comparison, and health + feedback workflows. + +## Tech Stack + +- React 18 +- Vite 5 +- Plain JavaScript (ES modules) +- Node test runner (`node --test`) for utility-level tests + +## Commands + +Run from repo root: + +```bash +npm install +npm run dev +npm run build +npm run test +npm run validate:metadata +npm run preview +``` + +## Imageomics Collaboration Expectations + +Contributions should align with Imageomics principles of: + +- Transparency +- Accountability +- Collaboration +- Safety + +Expected behavior: + +- Communicate clearly and respectfully. +- Keep changes focused and explain assumptions and tradeoffs. +- Provide enough context for others to reproduce and review results. + +## Key File Map + +- `README.md` + - Contributor-facing run instructions plus release/citation metadata workflow guidance. +- `CONTRIBUTING.md` + - Project contribution workflow, branch/PR conventions, and review expectations. +- `src/App.jsx` + - Main app orchestration: form state, previews, export flow, import flow. +- `src/components/MetadataForm.jsx` + - Form UI rendering and field-level interactions. +- `src/validation/validation.js` + - Input normalization and validation checks. +- `src/metadata/normalizeMetadata.js` + - Canonical metadata shaping before export. +- `src/services/citation.js` + - `CITATION.cff` serialization. +- `src/services/zenodo.js` + - `.zenodo.json` serialization. +- `src/services/githubImporter.js` + - GitHub import pipeline; returns `{ metadata, warnings, errors, review, healthScan, comparisons }`. +- `src/services/github.js` + - Legacy re-export surface for GitHub import API compatibility. +- `src/services/orcidSearch.js` + - ORCID candidate lookup and scoring helpers. +- `src/utils/*.js` + - Parsing/normalization helpers for authors, grants, keywords, references, ORCID. + +## Repository Conventions + +1. Use explicit `.js` and `.jsx` import specifiers in source files. +2. Canonical Zenodo export filename is `.zenodo.json` (with leading dot). + - App export uses a direct browser download path for `.zenodo.json`; verify + filename behavior in your browser environment when validating releases. +3. For browser/Vite runtime configuration, prefer `import.meta.env` over `process.env`. +4. Preserve `CITATION.cff` export behavior, including identifiers for release URL and tag tree/commit URL when applicable. +5. Keep changes focused and minimal; avoid unrelated refactors. +6. Do not restructure folders, rename files, or alter public service APIs unless explicitly requested. + +## Workflow Expectations + +1. Read relevant files before editing. +2. Make the smallest viable change that satisfies the request. +3. If the request is documentation-only, update existing docs first (`README.md`, `CONTRIBUTING.md`, `AGENTS.md`) before creating new docs. +4. Run targeted checks: + - `npm run test` for logic changes. + - `npm run build` for UI/export pipeline changes. +5. If behavior changes user output, verify both generated files still render and download correctly. +6. Summarize exactly what changed and why. + +## Scope Boundaries + +- Do not perform broad formatting passes or cleanup-only changes outside the requested area. +- Do not replace existing workflows with new patterns when a localized fix is sufficient. +- Prefer updating existing documentation sections over creating new docs files. + +## Validation and Data Rules + +- Required metadata fields are validated before export. +- ORCID values must pass format/checksum validation when provided. +- Grant IDs use: `::`. +- Publication date should be a real `YYYY-MM-DD` date. +- References may be plain-text lines or structured blocks; do not silently drop entries. + +## UI and Interaction Expectations + +- Keep export notices actionable and specific. +- Do not remove confirmation safeguards that prevent accidental low-quality exports. +- Preserve author ordering tools and ORCID suggestion flow. + +## Backward Compatibility + +- Do not break public function names used across `src/services/*`. +- Keep `src/services/github.js` compatibility exports intact unless migration is requested. +- Avoid changing file names used in download/export without explicit request. + +## Definition of Done for Agent Changes + +- Code compiles (`npm run build`). +- Tests pass (`npm run test`) or test impact is clearly documented. +- No unintended changes to export filenames or core metadata shape. +- Any new assumptions are documented in README or inline code comments where needed. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..73e012a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,148 @@ +# OpenCite Code of Conduct + +OpenCite aligns with the Imageomics community Code of Conduct. All community +members and contributors are expected to engage respectfully, responsibly, and +constructively in project spaces. + +## Scope + +This Code of Conduct applies to all OpenCite project spaces, including: + +- GitHub issues, discussions, pull requests, and code reviews. +- Documentation, examples, and repository contributions. +- Project-related communication channels and community interactions. + +All contributors, maintainers, reviewers, and users participating in these +spaces are expected to follow these guidelines. + +## Principles of Engagement + +- Listen to understand. +- Speak to be understood, and recognize that everyone is learning. +- Embrace "Yes and..." by focusing on possibilities and being inclusive of ideas. +- Take space and make space to encourage balanced participation. +- Beware of blind spots and recognize differences in experience and perspective. +- Respect time and boundaries during discussions. +- Care for each other as members of the community. + +## Repository Contributor Expectations + +In repository spaces, contributors are expected to: + +- Demonstrate empathy and respect in discussions, reviews, and decisions. +- Give and receive constructive feedback focused on ideas, code, and outcomes. +- Take responsibility for mistakes, address impacts, and help resolve issues. +- Use welcoming and inclusive language. +- Avoid harassment, personal attacks, discriminatory language, and disruptive + behavior. + +## Pull Request and Review Guidelines + +Contributors should: + +- Provide clear context about changes, design decisions, and known limitations. +- Keep pull requests focused and manageable when possible. +- Respond to review feedback thoughtfully and respectfully. +- Distinguish technical disagreement from personal disagreement. +- Review contributions based on technical merit, project goals, and community + standards. +- Recognize that feedback is intended to improve the project rather than + criticize individuals. + +## Issues and Discussions + +Contributors should: + +- Provide sufficient context when creating issues or discussions. +- Share reproducible examples when reporting bugs. +- Avoid dismissive, hostile, or unconstructive responses. +- Help maintain discussions that are respectful, focused, and useful for + improving the project. + +## AI-Assisted Contributions + +AI tools may be used during development, but contributors are responsible for +reviewing and understanding all submitted changes. + +Contributors remain responsible for ensuring correctness, licensing compliance, +and appropriate handling of confidential, private, or restricted information. + +When using AI-assisted tools: + +- Verify generated code before submitting a pull request. +- Ensure contributions follow project licensing and attribution requirements. +- Do not include confidential, private, or restricted information in AI tools. +- Treat AI-generated suggestions as reviewable input rather than automatically + trusted output. + +Contributors should disclose substantial AI-assisted contributions when they +meaningfully influence the design, implementation, documentation, or review +process of a change. + +## Learning and Collaboration + +OpenCite welcomes contributors with different levels of experience. +Contributors are encouraged to: + +- Ask questions when clarification is needed. +- Share knowledge and provide constructive guidance. +- Recognize that mistakes are part of the development process. + +## Community Values + +### Transparency + +Be clear about assumptions, uncertainty, and limits. Share information and +processes openly whenever possible. + +### Accountability + +Take responsibility for outcomes and ensure methods align with intended use. + +### Collaboration + +Build welcoming environments, value diverse perspectives, and work together +creatively. Respect the goals of open science, reproducibility, and responsible +software development. + +### Safety + +Practice ethical and responsible approaches, address issues, and promote good +governance. + +## Maintainer Responsibilities + +Project maintainers are responsible for: + +- Helping maintain a welcoming and respectful environment. +- Reviewing contributions fairly and consistently. +- Addressing concerns and reports appropriately. +- Communicating decisions and expectations clearly. +- Focusing discussions on technical decisions, tradeoffs, and project goals + rather than individuals. +- Approaching discussions with good intent while remaining open to correcting + misunderstandings. + +## Enforcement and Reporting + +Misconduct is prohibited in all project spaces and interactions. + +Violations may result in removal from the project and further action as +appropriate. + +If you experience or witness concerning behavior, report it by contacting +project maintainers through the repository support channels. Security concerns +should be reported according to the process described in SECURITY.md. + +Reports will be reviewed promptly, and privacy will be protected as much as +possible. + +## Attribution + +This Code of Conduct is adapted from the Imageomics Guide / Collaborative +Distributed Science Guide developed by the Imageomics Institute and ABC Global +Center. The original community principles were adapted to address repository +contributions, code review, and software development practices. + +Source: +https://imageomics.github.io/Imageomics-guide/CODE_OF_CONDUCT/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9542003 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,159 @@ +# Contributing to OpenCite + +Thank you for contributing to OpenCite. + +This document defines how to contribute safely and consistently to this repository. +It aligns OpenCite contribution practices with Imageomics Institute collaborative +development guidance. + +## Scope + +- Keep pull requests focused on one feature or bug fix. +- Avoid unrelated refactors and formatting-only changes. +- Preserve existing export behavior for `CITATION.cff` and `.zenodo.json` unless your change is explicitly about metadata output. + +## Development Setup + +1. Fork the repository and create a branch from `main`. +2. Install dependencies: + +```bash +npm install +``` + +3. Run the app locally: + +```bash +npm run dev +``` + +## Branching and Commits + +- Do not work directly on `main`. +- Use descriptive branch names, for example: + - `feature/issue-123/grant-autofill` + - `bugfix/issue-88/semver-validation` + - `docs/issue-40/readme-contributing` +- Use clear commit messages that explain the change and intent. + +## Pull Request Checklist + +Before opening a pull request: + +1. Confirm the change matches the issue/task scope. +2. Run checks: + +```bash +npm run test +npm run build +npm run validate:metadata +``` + +`npm run validate:metadata` runs `CITATION.cff` checks and validates +`.zenodo.json` with the pinned Zenodraft CLI version used by CI. + +3. Complete this checklist before submitting: + +**Code changes:** + +- [ ] Change is focused on one feature, fix, or documentation update. +- [ ] Tests were added or updated when applicable. +- [ ] Existing functionality was not unintentionally broken. +- [ ] `npm test` passes. +- [ ] `npm run build` passes. + +**Metadata and citation changes:** + +- [ ] `CITATION.cff` remains valid. +- [ ] `.zenodo.json` remains valid. +- [ ] Citation metadata fields are consistent. +- [ ] Author and contributor information is reviewed. +- [ ] Version and release information are consistent. + +**Documentation:** + +- [ ] User-facing changes are documented. +- [ ] Repository guidance is updated if contributor workflows change. +- [ ] Links to related Imageomics or Collaborative Distributed Science resources are updated when relevant. + +**Release readiness:** + +- [ ] Version follows semantic versioning. +- [ ] Release notes or changelog information is prepared when applicable. +- [ ] GitHub release metadata, `CITATION.cff`, and Zenodo metadata remain consistent. + +4. Verify metadata UX and outputs if you changed form/export behavior: + - version guidance and validation still work + - grant formatting remains valid (`::`) + - `CITATION.cff` and `.zenodo.json` previews still generate + +5. Include in your PR description: + - what changed + - why it changed + - how it was tested + - screenshots or preview snippets for UI/output changes + +6. Confirm governance and policy docs remain consistent with your changes when applicable: + - `README.md` + - `CODE_OF_CONDUCT.md` + - `SECURITY.md` + +## Collaborative Distributed Science Practices + +OpenCite follows Imageomics collaborative development practices. Contributors are +expected to: + +- Support Imageomics community values of transparency, accountability, + collaboration, and safety. +- Communicate clearly about scope, assumptions, and open questions. +- Communicate respectfully and align behavior with the Imageomics Code of + Conduct. +- Prefer small, focused pull requests that are easier to review and validate. +- Give and receive review feedback respectfully and constructively. +- Give appropriate credit to contributors and prior work. +- Maintain reproducible and reusable scientific software practices in code, + metadata, and documentation. + +## Documentation Expectations + +If behavior changes, update docs in the same pull request: + +- `README.md` for getting started and project overview +- `CONTRIBUTING.md` for contributor workflow, branching, and pull request expectations +- `AGENTS.md` for agent workflow or repository guardrails + +For detailed repository, GitHub, and GitHub + PyPI + Zenodo operational +workflows, reference the Imageomics Collaborative Distributed Science guides in +the References section instead of duplicating those instructions here. + +## Release and Metadata Notes + +For release-related work, keep release metadata aligned across: + +- GitHub release tag and description +- generated `CITATION.cff` +- generated `.zenodo.json` + +Use semantic versioning and ensure version/date values are consistent. + +## References + +- Imageomics Repository Guide: + https://imageomics.github.io/Collaborative-distributed-science-guide/wiki-guide/GitHub-Repo-Guide/ +- Imageomics GitHub + PyPI + Zenodo Integration: + https://imageomics.github.io/Collaborative-distributed-science-guide/wiki-guide/GitHub-PyPI-Zenodo-Integration/ +- Semantic Versioning: + https://semver.org/ +- Downstream verification checklist: + ./DOWNSTREAM_VERIFICATION_CHECKLIST.md + +## CI Expectations + +This repository runs a GitHub Actions CI workflow for pull requests and pushes to `main`: + +- `.github/workflows/ci.yml` + +Your pull request should pass: + +- `npm run test` +- `npm run build` diff --git a/DOWNSTREAM_VERIFICATION_CHECKLIST.md b/DOWNSTREAM_VERIFICATION_CHECKLIST.md new file mode 100644 index 0000000..9d46c61 --- /dev/null +++ b/DOWNSTREAM_VERIFICATION_CHECKLIST.md @@ -0,0 +1,75 @@ +# Downstream Verification Checklist + +Use this checklist to verify generated metadata works in downstream release and citation workflows. + +## Prerequisites + +- Root metadata files are up to date: + - `CITATION.cff` + - `.zenodo.json` +- Local checks pass: + +```bash +npm run test +npm run build +npm run validate:metadata +``` + +## In-App Export Verification + +1. Open the deployed application (recommended for user-facing verification): + + https://imageomics.github.io/OpenCite/ + +2. If you need local verification during development, start app: + +```bash +npm run dev +``` + +3. Fill required fields and generate: +- `CITATION.cff` +- `.zenodo.json` +- ZIP export + +4. Confirm ZIP contains: +- `CITATION.cff` +- `.zenodo.json` +- `METADATA_VALIDATION.txt` + +5. Confirm `.zenodo.json` filename is preserved exactly (with leading dot). + +## GitHub Citation Verification + +1. Commit and push `CITATION.cff` to a branch. +2. Open repository page for that branch. +3. Verify the "Cite this repository" panel renders expected metadata. + +## Zenodo Metadata Verification + +1. Open generated `.zenodo.json`. +2. Confirm key fields: +- `title` +- `version` +- `publication_date` +- `creators` +- `grants` + +3. If using GitHub-Zenodo integration, perform a test release/deposition flow and verify no ingestion errors. + +## Provenance Consistency Check + +Confirm release metadata stays consistent across: + +- GitHub release tag and notes +- Generated `CITATION.cff` +- Generated `.zenodo.json` + +## Optional Issue Tracking Prompts + +If failures are found, open issues with: + +- exact reproduction steps +- sample generated files (or sanitized excerpts) +- expected vs observed behavior +- whether failure occurred in app validation, GitHub rendering, or downstream ingestion diff --git a/README.md b/README.md index 1a85647..d908902 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,58 @@ # OpenCite -Minimal Vite + React single-page app for generating `CITATION.cff` and `.zenodo.json` files from one form. +OpenCite is a Vite + React single-page app that generates both `CITATION.cff` and `.zenodo.json` from a single metadata form. +It is browser-first (no Node-only runtime assumptions in app code) and part of +the Imageomics ecosystem. + +Documentation and contributor workflows align with Imageomics principles of +transparency, accountability, collaboration, and safety. + +## Overview + +OpenCite helps maintain high-quality, release-ready citation metadata by letting +you: + +- Generate `CITATION.cff` and `.zenodo.json` metadata from one form. +- Import metadata from GitHub repositories. +- Validate existing metadata files. +- Compare repository metadata against citation metadata. +- Review metadata health checks and recommendations. + +Primary user entry point (deployed application): +https://imageomics.github.io/OpenCite/ + +The app is built to reduce release-time metadata drift by keeping citation fields consistent across: +- GitHub release tags and notes +- `CITATION.cff` +- `.zenodo.json` + +## Supported Metadata Files + +OpenCite focuses on: + +- `CITATION.cff` +- `.zenodo.json` + +These files should remain consistent with repository information, release +version, license, authors, grants, and DOI/references when available. + +## Using OpenCite + +1. Open the deployed application: https://imageomics.github.io/OpenCite/ +2. Enter metadata directly, or import metadata from a GitHub repository. +3. Review generated `CITATION.cff` and `.zenodo.json` outputs. +4. Validate metadata before export. +5. Download generated metadata files (or ZIP export). + +Most users should use the deployed application. Local setup is primarily for +contributors and development/testing workflows. + +## Development Information + +OpenCite is a React + Vite frontend. It is browser-first for runtime use, with +Node-based local tooling for development, tests, and metadata checks. + +### Development Setup - [Open OpenCite](https://imageomics.github.io/OpenCite/) - [Read the Collaborative Distributed Science Guide](https://imageomics.github.io/Collaborative-distributed-science-guide/) @@ -12,6 +64,211 @@ npm install npm run dev ``` +Open the local URL shown by Vite and fill in the form. You can then: +- Generate `CITATION.cff` +- Generate `.zenodo.json` +- Download both together as a ZIP + +### Running Tests and Build Commands + +Run these commands from the repository root: + +```bash +npm run dev # Start local dev server +npm run build # Production build +npm run test # Node test runner +npm run validate:metadata # Validate root CITATION.cff and .zenodo.json +npm run preview # Preview production build +``` + +`.zenodo.json` validation uses the official Zenodraft CLI via a pinned local +dependency. The direct equivalent command is: + +```bash +npm exec -- zenodraft metadata validate .zenodo.json +``` + +During export, OpenCite validates generated `.zenodo.json` metadata. ZIP exports include `METADATA_VALIDATION.txt` for downstream provenance checks. + +## GitHub Import Workflow + +1. Paste a GitHub repository URL into the import field. +2. OpenCite automatically inspects known repository metadata files unless + disabled by integration options. + - Metadata file import reads from the repository's default branch (typically + main). + - OpenCite does not currently fetch CITATION.cff from a specific branch, + tag, or commit referenced in the GitHub URL. +3. Supported import sources include: + - `CITATION.cff` + - `.zenodo.json` + - package metadata (for example `package.json`, `pyproject.toml`, `setup.py`, + `Cargo.toml`, `pom.xml`) + - GitHub repository/release metadata and contributor data +4. Metadata is merged with precedence rules (release/repository provenance, + citation/zenodo/package fields, and contributor enrichment) before loading + the editable form. +5. Imported author lists include contributor-based context and are deduplicated. +6. Review, adjust, and regenerate metadata files before release. + +## Validation Behavior + +OpenCite validates metadata at multiple stages: + +- Form-level validation before export. +- `CITATION.cff` and `.zenodo.json` content validation. +- Comparison checks between repository/GitHub metadata and citation metadata. +- Health checks that compare imported metadata with available repository/archive + signals, including version, repository URL, license, authors, ORCID, DOI, + and release information. +- Missing repository/archive information may produce warnings instead of + failures. +- Author, grant, and reference metadata checks where applicable. +- Repository-level validation via `npm run validate:metadata` for root + `CITATION.cff` and `.zenodo.json`. + - `CITATION.cff` uses in-repo citation validation logic. + - `.zenodo.json` uses the official Zenodraft validator. + +## Metadata Rules + +- Version values should match your planned GitHub release tag. +- Grant IDs must use `::`. +- Publication date should be a real `YYYY-MM-DD` date. +- ORCID values are validated when provided. +- References can be plain lines or structured key/value blocks. + +## Semantic Versioning + +OpenCite follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`). + +- `MAJOR`: breaking or incompatible changes +- `MINOR`: backward-compatible features +- `PATCH`: backward-compatible bug fixes + +Use the exact version/tag string you intend to publish for your GitHub release. + +## Release Workflow + +When preparing a release: + +1. Create the GitHub release and include meaningful release notes. +2. Ensure the release tag matches the version used in OpenCite. +3. Update `CITATION.cff` fields as needed: + - `version` + - `date-released` + - release identifiers/URLs +4. Update `.zenodo.json` metadata when applicable. +5. Verify release metadata is consistent across GitHub, `CITATION.cff`, and `.zenodo.json`. + +OpenCite automatically includes GitHub release and source-tree identifiers in `CITATION.cff` when a version and repository URL are present. + +## Grants Autofill + +The Grants field includes one-click suggestions for: +- `021nxhr62::2118240` (Imageomics NSF grant) +- `021nxhr62::2330423` (ABC NSF grant; NSERC updates may be manual) + +Suggestions can be added individually or all at once, and duplicate values are ignored. + +## Project Structure + +- `src/App.jsx`: main app orchestration and export flow +- `src/components/MetadataForm.jsx`: form UI and field interactions +- `src/validation/validation.js`: input validation and normalization +- `src/metadata/normalizeMetadata.js`: canonical metadata shaping +- `src/services/citation.js`: `CITATION.cff` serialization +- `src/services/zenodo.js`: `.zenodo.json` serialization +- `src/services/githubImporter.js`: GitHub import pipeline + +## Repository Readiness Checklist + +This checklist is based on the Imageomics Collaborative Distributed Science Guide repository recommendations. + +- [x] `README.md` with project overview, usage, release workflow, and citation guidance. +- [x] `AGENTS.md` with code-agent project context and workflow constraints. +- [x] `.gitignore` present. +- [x] machine-readable dependency lockfile (`package-lock.json`) present. +- [x] `CONTRIBUTING.md` present and linked from README. +- [x] `LICENSE` file present in repository root. +- [x] root-level `CITATION.cff` committed for GitHub citation panel. +- [x] root-level `.zenodo.json` committed for release metadata tracking. +- [x] `CODE_OF_CONDUCT.md` present. +- [x] `SECURITY.md` present. +- [x] CI workflow present for pull requests (`.github/workflows/ci.yml`). + +Before initial public release, make sure the unchecked items are completed. + +## Contributing + +Contributions are welcome for this repository. + +See `CONTRIBUTING.md` for project-specific contribution workflow details. + +1. Open an issue (or comment on an existing one) describing the change. +2. Create a branch for your work. +3. Keep changes focused on the requested feature or bug fix. +4. Run checks before opening a PR: + - `npm run test` + - `npm run build` +5. Open a pull request with: + - a clear summary of what changed + - why the change is needed + - any screenshots or notes for UI changes + +### Contribution Scope Guidelines + +- Avoid unrelated refactors or broad formatting-only edits. +- Preserve export behavior for `CITATION.cff` and `.zenodo.json` unless the change explicitly targets that behavior. +- Keep public service APIs stable, especially under `src/services/`. +- Prefer updating existing documentation sections instead of creating duplicate docs. + +## Learn More + +- [Software citation practices (FORCE11 principles)](https://force11.org/info/software-citation-principles/) +- [GitHub release workflow](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) +- [Zenodo GitHub integration guide](https://help.zenodo.org/docs/github/) +- [Citation File Format schema guide](https://github.com/citation-file-format/citation-file-format/blob/main/schema-guide.md) +- [Imageomics Repository Guide](https://imageomics.github.io/Collaborative-distributed-science-guide/wiki-guide/GitHub-Repo-Guide/) +- [Imageomics GitHub + PyPI + Zenodo Integration](https://imageomics.github.io/Collaborative-distributed-science-guide/wiki-guide/GitHub-PyPI-Zenodo-Integration/) +- [Downstream Verification Checklist](DOWNSTREAM_VERIFICATION_CHECKLIST.md) + +## Known Issues + +### .zenodo.json download filename issue + +- The generated Zenodo metadata file should download with the exact filename: + `.zenodo.json`. +- Some browser/file picker flows may remove the leading period during save. +- OpenCite uses a direct browser download path for `.zenodo.json` exports to + preserve the canonical filename where supported. +- Verify saved filenames in your browser environment when preparing releases. + +### Citation Health limitations + +- Health checks were improved to reduce false positives, but some checks remain + heuristic because repositories do not always include complete citation + metadata. +- DOI checks depend on available Zenodo/archive signals. +- Author consistency checks may differ between citation authors and repository + contributors because they represent different concepts. + +### Metadata provenance limitations + +- Health and comparison results are based on merged metadata. +- Field-level source tracking/provenance is not currently exposed in the UI. + +### Citation format limitations + +- `CITATION.cff` parsing supports common structures but does not fully model + every advanced CFF feature. +- `preferred-citation` handling remains limited. + +## Community and Security + +- [Contributing Guide](CONTRIBUTING.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Security Policy](SECURITY.md) + ## Funding and acknowledgements This work was supported by both the [Imageomics Institute](https://imageomics.org/) and the [AI and Biodiversity Change (ABC) Global Center](http://abcresearchcenter.org/). The Imageomics Institute is funded by the U.S. National Science Foundation's Harnessing the Data Revolution (HDR) program under [Award #2118240](https://www.nsf.gov/awardsearch/showAward?AWD_ID=2118240) (Imageomics: A New Frontier of Biological Information Powered by Knowledge-Guided Machine Learning). The ABC Global Center is funded by the U.S. National Science Foundation under [Award No. 2330423](https://www.nsf.gov/awardsearch/showAward?AWD_ID=2330423&HistoricalAwards=false) and Natural Sciences and Engineering Research Council of Canada under [Award No. 585136](https://www.nserc-crsng.gc.ca/ase-oro/Details-Detailles_eng.asp?id=782440). This work draws on research supported by the Social Sciences and Humanities Research Council. Any opinions, findings and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation, Natural Sciences and Engineering Research Council of Canada, or Social Sciences and Humanities Research Council. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6b1b4c0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +OpenCite is currently in active development. Please report security issues for +the latest version on the default branch. + +## Reporting a Vulnerability + +Please do not open public GitHub issues for suspected security vulnerabilities. + +Instead: + +1. Open a private security advisory in this repository (Security tab). +2. Include details to reproduce the issue and any proof-of-concept material. +3. Include potential impact and affected components if known. + +We will acknowledge receipt as soon as possible and work with you to validate, +triage, and remediate the issue. + +## Disclosure + +Please allow time for triage and remediation before public disclosure. +Coordinated disclosure helps protect users and downstream adopters. diff --git a/package-lock.json b/package-lock.json index 6d91beb..afcc5f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "opencite", "version": "0.0.0", "dependencies": { + "jszip": "^3.10.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.8" + "vite": "^5.4.8", + "zenodraft": "0.14.1" } }, "node_modules/@babel/code-frame": { @@ -1090,6 +1092,39 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", @@ -1155,12 +1190,26 @@ } ] }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1178,6 +1227,15 @@ } } }, + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", @@ -1231,6 +1289,28 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1254,6 +1334,21 @@ "node": ">=6.9.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1271,6 +1366,12 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1283,6 +1384,25 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1327,6 +1447,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -1336,6 +1476,11 @@ "node": ">=18" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1370,6 +1515,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1402,6 +1552,29 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -1446,6 +1619,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1463,6 +1641,11 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1472,6 +1655,20 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1502,6 +1699,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1561,11 +1763,48 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/zenodraft": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/zenodraft/-/zenodraft-0.14.1.tgz", + "integrity": "sha512-1jOz5m8rgvnGWvBkxnmZCXbmSj3VB+pO+eNXMJkkrJEjW6+FRmVTUKeOUaFpVOjWRmGIyY8c5BRU8CYwiqHhHA==", + "dev": true, + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "commander": "^7.2.0", + "dotenv": "^10.0.0", + "node-fetch": "^2.6.7" + }, + "bin": { + "zenodraft": "bin/index.js", + "zenodraft-autocomplete": "bin/autocomplete.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + } } } } diff --git a/package.json b/package.json index 63c1d16..eba60ea 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,18 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "node --test", + "validate:metadata": "node ./scripts/validate-metadata.mjs", "preview": "vite preview" }, "dependencies": { + "jszip": "^3.10.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.8" + "vite": "^5.4.8", + "zenodraft": "0.14.1" } -} \ No newline at end of file +} diff --git a/scripts/validate-metadata.mjs b/scripts/validate-metadata.mjs new file mode 100644 index 0000000..68b99fb --- /dev/null +++ b/scripts/validate-metadata.mjs @@ -0,0 +1,81 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { validateCitationCffText } from '../src/services/citationValidation.js'; + +const execAsync = promisify(exec); + +async function readText(relativePath) { + const filePath = path.resolve(process.cwd(), relativePath); + return readFile(filePath, 'utf8'); +} + +function printResult(title, result) { + console.log(`\n${title}`); + console.log('-'.repeat(title.length)); + console.log(`Status: ${result.isValid ? 'PASS' : 'FAIL'}`); + + if (result.errors.length > 0) { + console.log('Errors:'); + for (const error of result.errors) { + console.log(`- ${error}`); + } + } + + if (result.warnings.length > 0) { + console.log('Warnings:'); + for (const warning of result.warnings) { + console.log(`- ${warning}`); + } + } +} + +async function runZenodraftValidation() { + const command = 'npm exec -- zenodraft metadata validate .zenodo.json'; + + console.log('\n.zenodo.json validation (zenodraft)'); + console.log('---------------------------------'); + + try { + const { stdout, stderr } = await execAsync(command, { cwd: process.cwd() }); + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + return true; + } catch (error) { + if (error && typeof error === 'object') { + if ('stdout' in error && error.stdout) { + process.stdout.write(String(error.stdout)); + } + if ('stderr' in error && error.stderr) { + process.stderr.write(String(error.stderr)); + } + } + return false; + } +} + +async function main() { + const citationText = await readText('CITATION.cff'); + + const citationResult = validateCitationCffText(citationText); + + printResult('CITATION.cff validation', citationResult); + const zenodoIsValid = await runZenodraftValidation(); + + const hasErrors = !citationResult.isValid || !zenodoIsValid; + if (hasErrors) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error('Failed to validate metadata files:', error); + process.exitCode = 1; +}); diff --git a/src/App.jsx b/src/App.jsx index 5daa68b..01ce35e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,17 +1,17 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import JSZip from 'jszip'; import { MetadataForm } from './components/MetadataForm.jsx'; import { normalizeMetadata } from './metadata/normalizeMetadata.js'; +import { importGithubMetadata } from './services/githubImporter.js'; +import { pickPreferredOrcidCandidate, searchOrcidCandidates } from './services/orcidSearch.js'; import { toCitationCff } from './services/citation.js'; +import { validateCitationCffText } from './services/citationValidation.js'; import { toZenodoJson } from './services/zenodo.js'; +import { validateZenodoJsonText } from './services/zenodoValidation.js'; import { normalizeFormInput, validateMetadata } from './validation/validation.js'; -const PIPELINE_STEPS = [ - 'Import', - 'Review Metadata', - 'Validate', - 'Export Citation Files', -]; - +const CITATION_FILENAME = 'CITATION.cff'; +const ZENODO_FILENAME = '.zenodo.json'; function createAuthorId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -31,7 +31,7 @@ const initialForm = { keywords: '', typeOfWork: 'software', customTypeOfWork: '', - version: '', + version: '0.1.0', publicationDate: '', repositoryCode: '', doi: '', @@ -56,110 +56,492 @@ const licenseOptions = [ 'GPL-3.0-only', ]; +const grantSuggestions = [ + { + id: '021nxhr62::2118240', + label: 'Imageomics NSF grant', + note: 'Imageomics program grant (NSF funder code 021nxhr62).', + }, + { + id: '021nxhr62::2330423', + label: 'ABC NSF grant', + note: 'ABC NSF grant; NSERC portion requires manual update when applicable.', + }, +]; + +const REQUIRED_FIELD_LABELS = { + title: 'Title', + authors: 'Authors', + license: 'License', + typeOfWork: 'Type', + version: 'Version', + publicationDate: 'Publication date', + grants: 'Grants', +}; + +const WARNING_MESSAGE_MAX_LENGTH = 180; + +function truncateMessage(message, maxLength = WARNING_MESSAGE_MAX_LENGTH) { + const normalized = String(message ?? '').replace(/\s+/g, ' ').trim(); + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +function formatWarningCode(code) { + const value = String(code ?? '').trim(); + if (!value) { + return 'Warning'; + } + + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function formatWarningList(warnings) { + const groups = new Map(); + + for (const warning of warnings) { + const code = String(warning?.code ?? 'warning'); + const shortMessage = truncateMessage(warning?.message ?? 'Warning'); + const group = groups.get(code) ?? { + code, + total: 0, + messages: new Map(), + }; + + group.total += 1; + group.messages.set(shortMessage, (group.messages.get(shortMessage) ?? 0) + 1); + groups.set(code, group); + } + + return [...groups.values()].map((group) => { + const messageParts = [...group.messages.entries()].map(([message, count]) => + count > 1 ? `${message} (${count}x)` : message, + ); + + if (group.total === 1 && messageParts.length === 1) { + return messageParts[0]; + } + + return `${formatWarningCode(group.code)} (${group.total}): ${messageParts.join(' | ')}`; + }); +} + +function formatReviewStatus(value) { + const status = String(value ?? '').toLowerCase(); + + if (status === 'correct') return 'Correct'; + if (status === 'missing') return 'Missing'; + if (status === 'outdated') return 'Outdated'; + if (status === 'invalid') return 'Invalid'; + return 'Review'; +} + +function formatReviewAction(value) { + const action = String(value ?? '').trim(); + + if (action === 'keep-file') return 'Keep file'; + if (action === 'update-field') return 'Update field'; + if (action === 'generate-file') return 'Generate file'; + if (action === 'regenerate-file') return 'Regenerate file'; + return 'Review'; +} + +function formatHealthStatus(value) { + const status = String(value ?? '').toLowerCase(); + if (status === 'pass') return 'PASS'; + if (status === 'warning') return 'WARNING'; + if (status === 'error') return 'ERROR'; + return 'CHECK'; +} + +function healthBadgeClass(value) { + const status = String(value ?? '').toLowerCase(); + if (status === 'pass') return 'health-badge health-badge-pass'; + if (status === 'warning') return 'health-badge health-badge-warning'; + if (status === 'error') return 'health-badge health-badge-error'; + return 'health-badge'; +} + +function formatComparisonStatus(value) { + const status = String(value ?? '').toLowerCase(); + if (status === 'identical') return 'IDENTICAL'; + if (status === 'different') return 'DIFFERENT'; + if (status === 'missing') return 'MISSING'; + if (status === 'cannot determine') return 'CANNOT DETERMINE'; + return 'UNKNOWN'; +} + +function summarizeComparisonIssue(item) { + if (!item) { + return ''; + } + + const fieldLabel = String(item.field ?? '').replace(/_/g, ' '); + if (item.status === 'different') { + return `${fieldLabel} is outdated`; + } + + if (item.status === 'missing') { + return `${fieldLabel} is missing`; + } + + if (item.status === 'cannot determine') { + return `${fieldLabel} cannot be determined from GitHub metadata`; + } + + return ''; +} + +function buildImportReviewSummary(importStatus) { + const warnings = []; + const recommendations = []; + + const healthIssues = (importStatus.healthScan || []).filter((check) => check.status === 'warning' || check.status === 'error'); + for (const issue of healthIssues) { + const text = String(issue.description || '').trim(); + if (text) { + warnings.push(text); + } + + const recommendation = String(issue.recommendation || '').trim(); + if (recommendation) { + recommendations.push(recommendation); + } + } + + const comparisonIssues = (importStatus.comparisons || []).filter((item) => item.status !== 'identical'); + for (const issue of comparisonIssues) { + const text = summarizeComparisonIssue(issue); + if (text) { + warnings.push(text); + } + + const recommendation = String(issue.recommendation || '').trim(); + if (recommendation) { + recommendations.push(recommendation); + } + } + + const uniqueWarnings = [...new Set(warnings)].filter(Boolean); + const uniqueRecommendations = [...new Set(recommendations)].filter(Boolean); + const healthy = uniqueWarnings.length === 0 && (importStatus.errors || []).length === 0; + + return { + healthy, + warningCount: uniqueWarnings.length, + warnings: uniqueWarnings, + recommendations: uniqueRecommendations, + }; +} + +function formatValidationSummary(errors) { + return Object.entries(errors) + .map(([key, value]) => { + if (key === 'authorOrcid' && value && typeof value === 'object') { + const firstEntry = Object.values(value)[0]; + return firstEntry ? `Author ORCID: ${firstEntry}` : 'Author ORCID: fix the highlighted ORCID fields.'; + } + + if (key === 'authors') { + return value; + } + + if (REQUIRED_FIELD_LABELS[key]) { + return `${REQUIRED_FIELD_LABELS[key]}: ${value}`; + } + + return value; + }) + .filter(Boolean); +} + +function getValidationMissingFields(errors) { + const fields = []; + + if (errors.title) fields.push('Title'); + if (errors.authors) fields.push('Authors'); + if (errors.license) fields.push('License'); + if (errors.typeOfWork) fields.push('Type'); + if (errors.version) fields.push('Version'); + if (errors.publicationDate) fields.push('Publication date'); + if (errors.grants) fields.push('Grants'); + + if (errors.authorOrcid && typeof errors.authorOrcid === 'object') { + fields.push('Author ORCID'); + } + + return fields; +} + +function createBlankAuthor() { + return { givenNames: '', familyNames: '', orcid: '', affiliation: '' }; +} + +function metadataToForm(metadata) { + return { + title: String(metadata.title ?? ''), + authors: + Array.isArray(metadata.authors) && metadata.authors.length > 0 + ? metadata.authors.map((author) => ({ + givenNames: String(author.givenNames ?? ''), + familyNames: String(author.familyNames ?? ''), + orcid: String(author.orcid ?? ''), + affiliation: String(author.affiliation ?? ''), + })) + : [createBlankAuthor()], + license: String(metadata.license ?? ''), + keywords: Array.isArray(metadata.keywords) ? metadata.keywords.join(', ') : '', + typeOfWork: String(metadata.typeOfWork ?? 'software'), + customTypeOfWork: String(metadata.customTypeOfWork ?? ''), + version: String(metadata.version ?? ''), + publicationDate: String(metadata.publicationDate ?? ''), + repositoryCode: String(metadata.repositoryCode ?? ''), + doi: String(metadata.doi ?? ''), + abstract: String(metadata.abstract ?? ''), + references: Array.isArray(metadata.references) ? metadata.references.join('\n') : '', + grants: Array.isArray(metadata.grants) ? metadata.grants.join('\n') : '', + }; +} + function downloadFile(filename, content, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); anchor.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); + setTimeout(() => { + anchor.remove(); + URL.revokeObjectURL(url); + }, 1500); } -function wait(milliseconds) { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} +function confirmMissingCitationReferences(citationText) { + const hasReferencesSection = /^references:\s*$/m.test(String(citationText ?? '')); + if (hasReferencesSection) { + return true; + } -function parseRepositoryLabel(url) { - try { - const parsed = new URL(url); - const [owner, repo] = parsed.pathname.split('/').filter(Boolean); - if (owner && repo) { - return `${owner}/${repo.replace(/\.git$/i, '')}`; - } - } catch { - return ''; + const message = 'CITATION.cff currently has no references section. Continue export anyway?'; + + if (typeof window !== 'undefined' && typeof window.confirm === 'function') { + return window.confirm(message); } - return ''; + return true; +} + +function canUseNativeFilePicker() { + return typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function'; +} + +async function createMetadataZipBlob(citationPreview, zenodoPreview, validationReport) { + const zip = new JSZip(); + zip.file(CITATION_FILENAME, citationPreview); + zip.file(ZENODO_FILENAME, zenodoPreview); + zip.file('METADATA_VALIDATION.txt', validationReport); + + return zip.generateAsync({ type: 'blob' }); } -function isGithubRepositoryUrl(url) { - try { - const parsed = new URL(url); - const [owner, repo] = parsed.pathname.split('/').filter(Boolean); - return parsed.hostname.toLowerCase() === 'github.com' && Boolean(owner && repo); - } catch { - return false; +async function saveFileWithPicker(filename, content, mimeType, pickerTypes = []) { + if (canUseNativeFilePicker()) { + try { + const fileHandle = await window.showSaveFilePicker({ + suggestedName: filename, + types: pickerTypes, + }); + const writable = await fileHandle.createWritable(); + const data = content instanceof Blob ? content : new Blob([content], { type: mimeType }); + await writable.write(data); + await writable.close(); + return true; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return false; + } + } } + + downloadFile(filename, content, mimeType); + return true; } export default function App() { + const [activePage, setActivePage] = useState('generator'); const [form, setForm] = useState(initialForm); + const [githubUrl, setGithubUrl] = useState(''); + const [importStatus, setImportStatus] = useState({ loading: false, warnings: [], errors: [], review: null, healthScan: [], comparisons: [] }); + const [isZipping, setIsZipping] = useState(false); + const [isDownloadingCitation, setIsDownloadingCitation] = useState(false); + const [isDownloadingZenodo, setIsDownloadingZenodo] = useState(false); const [previewType, setPreviewType] = useState('citation'); - const [githubImportUrl, setGithubImportUrl] = useState(''); - const [importStatus, setImportStatus] = useState({ - state: 'idle', - message: 'Paste a GitHub repository URL to begin.', - step: 0, - }); - const [downloadNotice, setDownloadNotice] = useState({ kind: '', message: '' }); - const [theme, setTheme] = useState(() => { - if (typeof window === 'undefined') { - return 'light'; - } - - const savedTheme = window.localStorage.getItem('opencite-theme'); - if (savedTheme === 'light' || savedTheme === 'dark') { - return savedTheme; - } - - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }); + const [copyState, setCopyState] = useState('idle'); + const [orcidSuggestions, setOrcidSuggestions] = useState({}); + const [exportNotice, setExportNotice] = useState({ kind: '', message: '', details: [] }); + const importRequestIdRef = useRef(0); const normalizedForm = useMemo(() => normalizeFormInput(form), [form]); const normalizedMetadata = useMemo(() => normalizeMetadata(normalizedForm), [normalizedForm]); const validationErrors = useMemo(() => validateMetadata(normalizedForm, typeOptions), [normalizedForm]); - const hasValidationErrors = Object.keys(validationErrors).length > 0; - const hasMetadataCore = Boolean( - normalizedForm.title.trim() - || normalizedForm.authors.some((author) => author.givenNames || author.familyNames), + const warningDisplayList = useMemo( + () => formatWarningList(importStatus.warnings), + [importStatus.warnings], ); - const currentPipelineStep = useMemo(() => { - if (!normalizedForm.repositoryCode.trim()) { - return 1; + const healthScanSummary = useMemo(() => { + const counts = { pass: 0, warning: 0, error: 0 }; + for (const check of importStatus.healthScan) { + const status = String(check?.status ?? '').toLowerCase(); + if (status === 'pass' || status === 'warning' || status === 'error') { + counts[status] += 1; + } } + return counts; + }, [importStatus.healthScan]); + const healthPassChecks = useMemo( + () => importStatus.healthScan.filter((check) => String(check?.status ?? '').toLowerCase() === 'pass'), + [importStatus.healthScan], + ); + const healthWarningChecks = useMemo( + () => importStatus.healthScan.filter((check) => String(check?.status ?? '').toLowerCase() === 'warning'), + [importStatus.healthScan], + ); + const healthErrorChecks = useMemo( + () => importStatus.healthScan.filter((check) => String(check?.status ?? '').toLowerCase() === 'error'), + [importStatus.healthScan], + ); + const importReviewSummary = useMemo( + () => buildImportReviewSummary(importStatus), + [importStatus], + ); - if (!hasMetadataCore) { - return 2; + const citationPreview = useMemo(() => toCitationCff(normalizedMetadata), [normalizedMetadata]); + const zenodoPreview = useMemo(() => toZenodoJson(normalizedMetadata), [normalizedMetadata]); + const citationValidation = useMemo( + () => validateCitationCffText(citationPreview), + [citationPreview], + ); + const zenodoValidation = useMemo( + () => validateZenodoJsonText(zenodoPreview), + [zenodoPreview], + ); + + const metadataValidationReport = useMemo( + () => [citationValidation.report, '', zenodoValidation.report].join('\n'), + [citationValidation.report, zenodoValidation.report], + ); + + function showValidationNotice(errors) { + const details = formatValidationSummary(errors); + const missingFields = getValidationMissingFields(errors); + setExportNotice({ + kind: 'validation', + message: missingFields.length > 0 + ? `Missing or invalid fields: ${missingFields.join(', ')}.` + : 'Some required values are missing or invalid. Review the highlighted fields below.', + details: details.length > 0 ? details : ['Review the highlighted fields below.'], + }); + + if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') { + window.scrollTo({ top: 0, behavior: 'smooth' }); } + } + + function clearExportNotice() { + setExportNotice({ kind: '', message: '', details: [] }); + } + + function openReviewedMetadataInGenerator() { + setActivePage('generator'); - if (hasValidationErrors) { - return 3; + if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') { + window.scrollTo({ top: 0, behavior: 'smooth' }); } + } - return 4; - }, [normalizedForm.repositoryCode, hasMetadataCore, hasValidationErrors]); - const authorSummary = useMemo(() => { - return normalizedMetadata.authors - .map((author) => author.zenodoName || `${author.citationAuthor?.['family-names'] || ''}, ${author.citationAuthor?.['given-names'] || ''}`.trim()) - .filter(Boolean); - }, [normalizedMetadata.authors]); + function renderHealthCheckCard(check, key) { + return ( +
  • +
    + {formatHealthStatus(check.status)} + {check.title} +
    +

    {check.description}

    + {check.recommendation ? ( +

    + Recommendation: {check.recommendation} +

    + ) : null} +
  • + ); + } - const citationPreview = useMemo(() => toCitationCff(normalizedMetadata), [normalizedMetadata]); - const zenodoPreview = useMemo(() => toZenodoJson(normalizedMetadata), [normalizedMetadata]); + async function downloadCitationFile() { + downloadFile(CITATION_FILENAME, citationPreview, 'text/yaml;charset=utf-8'); + } - useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); - window.localStorage.setItem('opencite-theme', theme); - }, [theme]); + async function downloadZenodoFile() { + // Use direct download to preserve the exact leading-dot filename `.zenodo.json`. + downloadFile(ZENODO_FILENAME, zenodoPreview, 'application/json;charset=utf-8'); + } function updateField(event) { const { name, value } = event.target; setForm((current) => ({ ...current, [name]: value })); } + function appendGrantSuggestion(grantId) { + const normalizedGrantId = String(grantId ?? '').trim(); + if (!normalizedGrantId) { + return; + } + + setForm((current) => { + const existing = String(current.grants ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (existing.includes(normalizedGrantId)) { + return current; + } + + return { + ...current, + grants: [...existing, normalizedGrantId].join('\n'), + }; + }); + } + + function appendAllGrantSuggestions() { + setForm((current) => { + const existing = new Set( + String(current.grants ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean), + ); + + for (const suggestion of grantSuggestions) { + existing.add(suggestion.id); + } + + return { + ...current, + grants: [...existing].join('\n'), + }; + }); + } + function updateAuthorField(index, field, value) { setForm((current) => ({ ...current, @@ -182,279 +564,771 @@ export default function App() { ? current.authors.filter((_, i) => i !== index) : [createAuthor()], })); + setOrcidSuggestions((current) => { + const next = {}; + for (const [key, value] of Object.entries(current)) { + const suggestionIndex = Number(key); + if (!Number.isInteger(suggestionIndex) || suggestionIndex === index) { + continue; + } + + next[suggestionIndex > index ? suggestionIndex - 1 : suggestionIndex] = value; + } + return next; + }); } - function handleDownloadCitation() { - if (hasValidationErrors) { - alert(Object.values(validationErrors).join('\n')); - setDownloadNotice({ kind: 'error', message: 'Fix validation issues before generating citation files.' }); + function reorderAuthor(index, direction) { + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= form.authors.length) { return; } - downloadFile('CITATION.cff', citationPreview, 'text/yaml;charset=utf-8'); - setDownloadNotice({ - kind: 'success', - message: 'Citation metadata generated. CITATION.cff is ready and your export pipeline is active.', + setForm((current) => { + if (nextIndex < 0 || nextIndex >= current.authors.length) { + return current; + } + + const authors = [...current.authors]; + [authors[index], authors[nextIndex]] = [authors[nextIndex], authors[index]]; + + return { + ...current, + authors, + }; + }); + + setOrcidSuggestions((current) => { + const next = { ...current }; + const currentIndexValue = next[index]; + next[index] = next[nextIndex]; + next[nextIndex] = currentIndexValue; + + return next; }); } - function handleDownloadZenodo() { - if (hasValidationErrors) { - alert(Object.values(validationErrors).join('\n')); - setDownloadNotice({ kind: 'error', message: 'Fix validation issues before generating citation files.' }); - return; + async function resolveOrcidSuggestionsForAuthors(nextForm) { + const authors = Array.isArray(nextForm.authors) ? nextForm.authors : []; + const nextAuthors = [...authors]; + const nextSuggestions = {}; + + for (let index = 0; index < authors.length; index += 1) { + const author = authors[index]; + if (String(author?.orcid ?? '').trim()) { + continue; + } + + if (!String(author?.givenNames ?? '').trim() || !String(author?.familyNames ?? '').trim()) { + continue; + } + + try { + const suggestions = await searchOrcidCandidates(author); + const preferred = pickPreferredOrcidCandidate(author, suggestions); + + if (preferred) { + nextAuthors[index] = { + ...nextAuthors[index], + orcid: preferred.orcid, + }; + continue; + } + + if (suggestions.length > 0) { + nextSuggestions[index] = { + loading: false, + suggestions, + error: '', + }; + } + } catch (error) { + nextSuggestions[index] = { + loading: false, + suggestions: [], + error: error instanceof Error ? error.message : 'ORCID search failed.', + }; + } } - downloadFile('.zenodo.json', zenodoPreview, 'application/json;charset=utf-8'); - setDownloadNotice({ - kind: 'success', - message: 'Citation metadata generated. .zenodo.json is ready and export checks passed.', - }); + return { + form: { + ...nextForm, + authors: nextAuthors, + }, + suggestions: nextSuggestions, + }; } - async function handleImportRepository() { - const trimmedUrl = githubImportUrl.trim(); + async function handleImportGithubMetadata() { + const repoUrl = githubUrl.trim(); - if (!trimmedUrl) { - setImportStatus({ state: 'error', message: 'Enter a GitHub repository URL to import metadata.', step: 0 }); + if (!repoUrl) { + setImportStatus({ + loading: false, + warnings: [], + review: null, + healthScan: [], + comparisons: [], + errors: [{ kind: 'error', source: 'ui', code: 'empty-url', message: 'Paste a GitHub repository URL first.' }], + }); return; } - if (!isGithubRepositoryUrl(trimmedUrl)) { + setImportStatus({ loading: true, warnings: [], errors: [], review: null, healthScan: [], comparisons: [] }); + const requestId = importRequestIdRef.current + 1; + importRequestIdRef.current = requestId; + + try { + const result = await importGithubMetadata(repoUrl, { + contributorFallbackLimit: 5, + }); + + if (importRequestIdRef.current !== requestId) { + return; + } + + let nextForm = metadataToForm(result.metadata); + let nextSuggestions = {}; + + if (result.errors.length === 0) { + const resolved = await resolveOrcidSuggestionsForAuthors(nextForm); + nextForm = resolved.form; + nextSuggestions = resolved.suggestions; + } + + if (importRequestIdRef.current !== requestId) { + return; + } + setImportStatus({ - state: 'error', - message: 'Use a valid GitHub URL like https://github.com/user/project.', - step: 0, + loading: false, + warnings: result.warnings, + errors: result.errors, + review: result.review || null, + healthScan: Array.isArray(result.healthScan) ? result.healthScan : [], + comparisons: Array.isArray(result.comparisons) ? result.comparisons : [], + }); + + if (result.errors.length === 0) { + setForm(nextForm); + setOrcidSuggestions(nextSuggestions); + } + } catch (error) { + if (importRequestIdRef.current !== requestId) { + return; + } + + setImportStatus({ + loading: false, + warnings: [], + review: null, + healthScan: [], + comparisons: [], + errors: [{ + kind: 'error', + source: 'import', + code: 'import-failed', + message: error instanceof Error ? error.message : 'GitHub import failed unexpectedly.', + }], }); - return; } + } + + async function suggestOrcid(index) { + const author = form.authors[index] ?? {}; - setImportStatus({ state: 'loading', message: 'Fetching repository metadata...', step: 1 }); - await wait(240); - setImportStatus({ state: 'loading', message: 'Repository found.', step: 1 }); - await wait(240); - setImportStatus({ state: 'loading', message: 'Processing contributors...', step: 2 }); - await wait(240); - setImportStatus({ state: 'loading', message: 'Generating citation defaults...', step: 3 }); - await wait(240); + setOrcidSuggestions((current) => ({ + ...current, + [index]: { loading: true, suggestions: [], error: '' }, + })); - const repositoryLabel = parseRepositoryLabel(trimmedUrl); - const repositoryName = repositoryLabel.split('/')[1] ?? ''; + try { + const suggestions = await searchOrcidCandidates(author); + setOrcidSuggestions((current) => ({ + ...current, + [index]: { + loading: false, + suggestions, + error: suggestions.length > 0 ? '' : 'No ORCID candidates found.', + }, + })); + } catch (error) { + setOrcidSuggestions((current) => ({ + ...current, + [index]: { + loading: false, + suggestions: [], + error: error instanceof Error ? error.message : 'ORCID search failed.', + }, + })); + } + } + function applySuggestedOrcid(index, candidate) { setForm((current) => ({ ...current, - repositoryCode: trimmedUrl, - title: current.title || repositoryName, + authors: current.authors.map((author, i) => { + if (i !== index) { + return author; + } + + return { + ...author, + orcid: candidate.orcid, + }; + }), })); - setImportStatus({ - state: 'success', - message: repositoryLabel - ? `Imported repository context for ${repositoryLabel}. Review metadata and export when ready.` - : 'Repository imported. Review metadata and export when ready.', - step: 4, - }); + setOrcidSuggestions((current) => ({ + ...current, + [index]: { + loading: false, + suggestions: [], + error: '', + }, + })); + } + + function handleDownloadCitation() { + if (Object.keys(validationErrors).length > 0) { + showValidationNotice(validationErrors); + return; + } + + if (!citationValidation.isValid) { + setExportNotice({ + kind: 'validation', + message: 'Cannot export CITATION.cff until validation issues are fixed.', + details: citationValidation.errors, + }); + return; + } + + if (!confirmMissingCitationReferences(citationPreview)) { + return; + } + + clearExportNotice(); + setIsDownloadingCitation(true); + downloadCitationFile(); + setTimeout(() => { + setIsDownloadingCitation(false); + }, 500); + } + + async function handleDownloadZenodo() { + if (Object.keys(validationErrors).length > 0) { + showValidationNotice(validationErrors); + return; + } + + if (!zenodoValidation.isValid) { + setExportNotice({ + kind: 'validation', + message: 'Cannot export .zenodo.json until validation issues are fixed.', + details: zenodoValidation.errors, + }); + return; + } + + clearExportNotice(); + setIsDownloadingZenodo(true); + + try { + await downloadZenodoFile(); + } finally { + setTimeout(() => setIsDownloadingZenodo(false), 500); + } + } + + async function handleDownloadZip() { + if (Object.keys(validationErrors).length > 0) { + showValidationNotice(validationErrors); + return; + } + + if (!citationValidation.isValid) { + setExportNotice({ + kind: 'validation', + message: 'Cannot create ZIP until CITATION.cff validation issues are fixed.', + details: citationValidation.errors, + }); + return; + } + + if (!zenodoValidation.isValid) { + setExportNotice({ + kind: 'validation', + message: 'Cannot create ZIP until .zenodo.json validation issues are fixed.', + details: zenodoValidation.errors, + }); + return; + } + + if (!confirmMissingCitationReferences(citationPreview)) { + return; + } + + clearExportNotice(); + setIsZipping(true); + + try { + const zipBlob = await createMetadataZipBlob( + citationPreview, + zenodoPreview, + metadataValidationReport, + ); + const safeTitle = String(normalizedForm.title || 'opencite-metadata') + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'opencite-metadata'; + + if (canUseNativeFilePicker()) { + const saved = await saveFileWithPicker( + `${safeTitle}.zip`, + zipBlob, + 'application/zip', + [{ description: 'OpenCite metadata ZIP', accept: { 'application/zip': ['.zip'] } }], + ); + + if (saved) { + return; + } + } + + downloadFile(`${safeTitle}.zip`, zipBlob, 'application/zip'); + } catch (error) { + alert(`Could not create ZIP file: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsZipping(false); + } } - function toggleTheme() { - setTheme((current) => (current === 'light' ? 'dark' : 'light')); + async function handleCopyPreview() { + if (copyState !== 'idle') { + return; + } + + const previewText = previewType === 'citation' ? citationPreview : zenodoPreview; + + try { + setCopyState('copying'); + + if (!navigator?.clipboard?.writeText) { + throw new Error('Clipboard API is unavailable in this browser context.'); + } + + await navigator.clipboard.writeText(previewText); + setCopyState('copied'); + } catch (error) { + console.error('Could not copy preview to clipboard:', error); + setCopyState('error'); + } finally { + setTimeout(() => { + setCopyState('idle'); + }, 2000); + } } return (
    -
    -

    OpenCite

    - -
    -

    Generate citation metadata from one clean form.

    +

    OpenCite

    +

    {activePage === 'generator' ? 'Generate citation metadata from one clean form.' : 'Run a citation health review for any GitHub repository.'}

    - Fill in the metadata once, then download both CITATION.cff and - .zenodo.json. + {activePage === 'generator' + ? ( + <> + Fill in the metadata once, then download both CITATION.cff and + .zenodo.json. + + ) + : 'Analyze existing repository metadata and get actionable recommendations before deciding what to update or regenerate.'}

    +
    + +
    +
    -
    -
    - -
    -

    Import from GitHub

    -

    - Paste a repository URL to automatically generate citation metadata. -

    +
    +

    Learn More

    +

    + Reference these guides while preparing release metadata and citation files. +

    + +
    + + {activePage === 'generator' && exportNotice.kind === 'validation' && ( +
    + {exportNotice.message} + {exportNotice.details.length > 0 && ( +
      + {exportNotice.details.slice(0, 5).map((detail, index) => ( +
    • {detail}
    • + ))} +
    + )} +
    + )} + + {activePage === 'generator' && importStatus.healthScan.length > 0 && ( +
    + Reviewed metadata loaded in editor. +
      +
    • Done well: {healthScanSummary.pass}
    • +
    • Needs attention: {healthScanSummary.warning}
    • +
    • Errors: {healthScanSummary.error}
    • +
    +
    + )} + + {activePage === 'generator' && ( +
    + +

    + This works even when the repository does not have a CITATION.cff file. +

    +
    + +
    + {(importStatus.errors.length > 0 || importStatus.warnings.length > 0) && ( +
    + {importStatus.errors.length > 0 && ( +
    + Import errors +
      + {importStatus.errors.map((entry, index) => ( +
    • {entry.message}
    • + ))} +
    +
    + )} + {importStatus.warnings.length > 0 && ( +
    + Import warnings +
      + {warningDisplayList.map((message, index) => ( +
    • {message}
    • + ))} +
    +
    + )} +
    + )}
    -
    +
    + +
    + {(importStatus.errors.length > 0 || importStatus.warnings.length > 0 || importStatus.review || importStatus.healthScan.length > 0 || importStatus.comparisons.length > 0) && ( +
    +
    + Citation Review +

    + {importReviewSummary.healthy + ? '✓ Repository metadata looks healthy' + : `Warnings (${importReviewSummary.warningCount})`} +

    + {importReviewSummary.warnings.length > 0 && ( +
      + {importReviewSummary.warnings.map((item, index) => ( +
    • {item}
    • + ))} +
    + )} + {importReviewSummary.recommendations.length > 0 && ( + <> +

    Recommendations

    +
      + {importReviewSummary.recommendations.map((item, index) => ( +
    • {item}
    • + ))} +
    + + )} +
    + {importStatus.errors.length > 0 && ( +
    + Import errors +
      + {importStatus.errors.map((entry, index) => ( +
    • {entry.message}
    • + ))} +
    +
    + )} + {importStatus.warnings.length > 0 && ( +
    + Import warnings +
      + {warningDisplayList.map((message, index) => ( +
    • {message}
    • + ))} +
    +
    + )} + {importStatus.review && ( +
    + Metadata review +

    + Correct: {importStatus.review.summary?.byStatus?.correct ?? 0} | Missing: {importStatus.review.summary?.byStatus?.missing ?? 0} | Outdated: {importStatus.review.summary?.byStatus?.outdated ?? 0} | Invalid: {importStatus.review.summary?.byStatus?.invalid ?? 0} +

    + {Array.isArray(importStatus.review.recommendations?.actions) && importStatus.review.recommendations.actions.length > 0 && ( +
      + {importStatus.review.recommendations.actions.map((action, index) => ( +
    • {action}
    • + ))} +
    + )} + {Array.isArray(importStatus.review.findings) && importStatus.review.findings.length > 0 && ( +
      + {importStatus.review.findings.map((finding, index) => ( +
    • + [{formatReviewStatus(finding.status)} | {formatReviewAction(finding.action)}] + {finding.file ? ` ${finding.file}:` : ''} {finding.message} +
    • + ))} +
    + )} +
    + )} + {importStatus.healthScan.length > 0 && ( +
    + Citation health scan +

    + Done well: {healthScanSummary.pass} | Needs attention: {healthScanSummary.warning} | Errors: {healthScanSummary.error} +

    +
    + +
    -
    -

    Pipeline

    -
      - {PIPELINE_STEPS.map((label, index) => { - const stepNumber = index + 1; - const state = stepNumber < currentPipelineStep - ? 'complete' - : stepNumber === currentPipelineStep - ? 'current' - : 'upcoming'; - - return ( -
    1. - - {label} -
    2. - ); - })} -
    -
    + {healthErrorChecks.length > 0 && ( +
    +

    Errors (Fix first)

    +
      + {healthErrorChecks.map((check, index) => renderHealthCheckCard(check, `health-error-${index}`))} +
    +
    + )} - - -
    - - -
    + {healthWarningChecks.length > 0 && ( +
    +

    Warnings (Recommended updates)

    +
      + {healthWarningChecks.map((check, index) => renderHealthCheckCard(check, `health-warning-${index}`))} +
    +
    + )} - {downloadNotice.message ? ( -
    -

    ✓ Citation metadata generated

    -

    {downloadNotice.message}

    -

    Ready: CITATION.cff and .zenodo.json

    + {healthPassChecks.length > 0 && ( +
    +

    Done Well

    +
      + {healthPassChecks.map((check, index) => renderHealthCheckCard(check, `health-pass-${index}`))} +
    +
    + )} +
    + )} + {importStatus.comparisons.length > 0 && ( +
    + Field-by-field comparison (existing files vs live GitHub metadata) +
      + {importStatus.comparisons.map((item, index) => ( +
    • +
      + {item.file} - {item.field} + {formatComparisonStatus(item.status)} +
      +

      Current: {item.currentValue || '(missing)'}

      +

      GitHub: {item.githubValue || '(cannot determine)'}

      +

      Recommendation: {item.recommendation}

      +
    • + ))} +
    +
    + )} +
    + )}
    - ) : null} + )} -
    -
    -

    Repository Information

    -

    Name: {normalizedForm.title || 'Pending'}

    -

    URL: {normalizedForm.repositoryCode || 'Not set'}

    -
    -
    -

    Authors

    -

    Count: {authorSummary.length}

    -

    {authorSummary.length > 0 ? authorSummary.slice(0, 3).join(' • ') : 'No authors added yet'}

    -
    -
    -

    Version & License

    -

    Version: {normalizedForm.version || 'Not set'}

    -

    License: {normalizedForm.license || 'Not set'}

    -
    -
    -

    Citation Files

    -

    CITATION.cff

    -

    .zenodo.json

    -
    -
    + {activePage === 'generator' && ( + <> + -
    -
    -

    Preview

    +
    -
    {previewType === 'citation' ? citationPreview : zenodoPreview}
    -
    -
    +

    + Keep the Zenodo filename as .zenodo.json for downstream tooling compatibility. The ZIP now includes METADATA_VALIDATION.txt with export checks. +

    -
    -

    About OpenCite

    -

    - OpenCite helps researchers create standardized citation metadata for research software. -

    -

    - This work was supported by both the{' '} - Imageomics Institute - {' '}and the{' '} - AI and Biodiversity Change (ABC) Global Center - . The Imageomics Institute is funded by the U.S. National Science Foundation's Harnessing the Data Revolution (HDR) program under{' '} - Award #2118240 - {' '}(Imageomics: A New Frontier of Biological Information Powered by Knowledge-Guided Machine Learning). The ABC Global Center is funded by the U.S. National Science Foundation under{' '} - Award No. 2330423 - {' '}and Natural Sciences and Engineering Research Council of Canada under{' '} - Award No. 585136 - . This work draws on research supported by the Social Sciences and Humanities Research Council. Any opinions, findings and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation, Natural Sciences and Engineering Research Council of Canada, or Social Sciences and Humanities Research Council. -

    -

    - Contributors: Isabella Lo and Elizabeth Campolongo. -

    -

    - - View OpenCite on GitHub - - {' • '} - - Cite this project - - {' • '} - - Read the Collaborative Distributed Science Guide - -

    -
    +
    +
    +

    Preview

    +
    + + + +
    +
    {previewType === 'citation' ? citationPreview : zenodoPreview}
    +
    +
    + + )}
    ); diff --git a/src/components/MetadataForm.jsx b/src/components/MetadataForm.jsx index e10aa89..da40dee 100644 --- a/src/components/MetadataForm.jsx +++ b/src/components/MetadataForm.jsx @@ -1,164 +1,392 @@ +import { useState } from 'react'; + export function MetadataForm({ form, typeOptions, licenseOptions, + grantSuggestions = [], + errors = {}, + orcidSuggestions = {}, updateField, + appendGrantSuggestion, + appendAllGrantSuggestions, updateAuthorField, + suggestOrcid, + applySuggestedOrcid, + reorderAuthor, addAuthor, removeAuthor, }) { + const AUTHORS_VISIBLE_BY_DEFAULT = 3; + const [showAllAuthors, setShowAllAuthors] = useState(false); + const [expandedAuthors, setExpandedAuthors] = useState({}); + + const totalAuthors = Array.isArray(form.authors) ? form.authors.length : 0; + const visibleAuthorCount = showAllAuthors ? totalAuthors : Math.min(totalAuthors, AUTHORS_VISIBLE_BY_DEFAULT); + const hiddenAuthorCount = Math.max(0, totalAuthors - visibleAuthorCount); + + function toggleAuthorExpanded(index) { + setExpandedAuthors((current) => ({ + ...current, + [index]: !current[index], + })); + } + return (
    event.preventDefault()}> - - -