Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d19e601
refactor: modularize GitHub metadata import pipeline
beanbean9339 Aug 8, 2026
cd90409
refactor: extract repeated importer helpers
beanbean9339 Aug 12, 2026
d4c7625
refactor: integrate buildGithubRequestConfig into GitHub metadata imp…
beanbean9339 Aug 14, 2026
503c077
refactor: modularize GitHub API calls and improve metadata handling i…
beanbean9339 Aug 14, 2026
39c4639
refactor: enhance stripWrappingQuotes function to handle mismatched q…
beanbean9339 Aug 14, 2026
0d3e979
refactor: remove redundant firstNonEmpty function and import it from …
beanbean9339 Aug 14, 2026
299f4b7
refactor: fix export statement for addCitationConsistencyWarnings and…
beanbean9339 Aug 14, 2026
123b897
refactor: rename parseJsonSafely to parseJson for consistency
beanbean9339 Aug 14, 2026
3af823b
refactor: simplify rate limit hint message for GitHub authentication
beanbean9339 Aug 14, 2026
6281153
refactor: modularize GitHub metadata import pipeline
beanbean9339 Aug 8, 2026
13063b6
refactor: extract repeated importer helpers
beanbean9339 Aug 12, 2026
43a3f23
refactor: integrate buildGithubRequestConfig into GitHub metadata imp…
beanbean9339 Aug 14, 2026
9b4e7cf
refactor: modularize GitHub API calls and improve metadata handling i…
beanbean9339 Aug 14, 2026
5fbb5a2
refactor: enhance stripWrappingQuotes function to handle mismatched q…
beanbean9339 Aug 14, 2026
6a5fb7c
refactor: remove redundant firstNonEmpty function and import it from …
beanbean9339 Aug 14, 2026
59cc41c
refactor: fix export statement for addCitationConsistencyWarnings and…
beanbean9339 Aug 14, 2026
232efb8
refactor: rename parseJsonSafely to parseJson for consistency
beanbean9339 Aug 14, 2026
b406530
Merge branch 'refactor/github-importer' of https://github.com/Imageom…
beanbean9339 Aug 14, 2026
e3983f7
refactor: enhance metadata handling by tracking touched fields and ex…
beanbean9339 Aug 23, 2026
db0ffec
refactor: enhance metadata validation and contributor filtering in Gi…
beanbean9339 Aug 24, 2026
53eac91
Add GitHub token support and prevent contributor API/CORS issues
beanbean9339 Aug 26, 2026
89248f8
refactor: improve validation logic and enhance test coverage for cita…
beanbean9339 Aug 27, 2026
0617504
refactor: enhance comparison status handling and improve ORCID valida…
beanbean9339 Aug 27, 2026
a1b9268
refactor: integrate Zenodo DOI lookup and enhance date normalization …
beanbean9339 Aug 27, 2026
9eb77da
Merge branch 'feature/github-metadata-import' into refactor/github-im…
beanbean9339 Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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';
import { hasMeaningfulMetadataValues, normalizeFormInput, validateMetadata } from './validation/validation.js';

const CITATION_FILENAME = 'CITATION.cff';
const ZENODO_FILENAME = '.zenodo.json';
Expand Down Expand Up @@ -179,6 +179,11 @@ function formatComparisonStatus(value) {
return 'UNKNOWN';
}

function comparisonStatusClass(value) {
const status = String(value ?? '').toLowerCase().replace(/\s+/g, '-');
return `comparison-status comparison-status-${status}`;
}

function summarizeComparisonIssue(item) {
if (!item) {
return '';
Expand Down Expand Up @@ -381,6 +386,7 @@ export default function App() {
const [activePage, setActivePage] = useState('generator');
const [form, setForm] = useState(initialForm);
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [importStatus, setImportStatus] = useState({ loading: false, warnings: [], errors: [], review: null, healthScan: [], comparisons: [] });
const [isZipping, setIsZipping] = useState(false);
const [isDownloadingCitation, setIsDownloadingCitation] = useState(false);
Expand All @@ -389,6 +395,8 @@ export default function App() {
const [copyState, setCopyState] = useState('idle');
const [orcidSuggestions, setOrcidSuggestions] = useState({});
const [exportNotice, setExportNotice] = useState({ kind: '', message: '', details: [] });
const [touchedFields, setTouchedFields] = useState({});
const [hasImportedMetadata, setHasImportedMetadata] = useState(false);
const importRequestIdRef = useRef(0);
const normalizedForm = useMemo(() => normalizeFormInput(form), [form]);
const normalizedMetadata = useMemo(() => normalizeMetadata(normalizedForm), [normalizedForm]);
Expand Down Expand Up @@ -496,6 +504,7 @@ export default function App() {

function updateField(event) {
const { name, value } = event.target;
setTouchedFields((current) => ({ ...current, [name]: true }));
setForm((current) => ({ ...current, [name]: value }));
}

Expand Down Expand Up @@ -543,6 +552,11 @@ export default function App() {
}

function updateAuthorField(index, field, value) {
setTouchedFields((current) => ({
...current,
authors: true,
[`authors.${index}.${field}`]: true,
}));
setForm((current) => ({
...current,
authors: current.authors.map((author, i) => (i === index ? { ...author, [field]: value } : author)),
Expand Down Expand Up @@ -682,6 +696,7 @@ export default function App() {
try {
const result = await importGithubMetadata(repoUrl, {
contributorFallbackLimit: 5,
authToken: githubToken.trim(),
});

if (importRequestIdRef.current !== requestId) {
Expand All @@ -690,8 +705,9 @@ export default function App() {

let nextForm = metadataToForm(result.metadata);
let nextSuggestions = {};
const importedMeaningfulMetadata = hasMeaningfulMetadataValues(nextForm);

if (result.errors.length === 0) {
if (result.errors.length === 0 && importedMeaningfulMetadata) {
const resolved = await resolveOrcidSuggestionsForAuthors(nextForm);
nextForm = resolved.form;
nextSuggestions = resolved.suggestions;
Expand All @@ -701,15 +717,29 @@ export default function App() {
return;
}

if (!importedMeaningfulMetadata) {
setTouchedFields((current) => ({
...current,
title: true,
authors: true,
license: true,
version: true,
typeOfWork: true,
publicationDate: true,
}));
}

setImportStatus({
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 : [],
review: importedMeaningfulMetadata ? (result.review || null) : null,
healthScan: importedMeaningfulMetadata ? (Array.isArray(result.healthScan) ? result.healthScan : []) : [],
comparisons: importedMeaningfulMetadata ? (Array.isArray(result.comparisons) ? result.comparisons : []) : [],
});

setHasImportedMetadata(importedMeaningfulMetadata);

if (result.errors.length === 0) {
setForm(nextForm);
setOrcidSuggestions(nextSuggestions);
Expand Down Expand Up @@ -1033,8 +1063,8 @@ export default function App() {
<div className="export-notice export-notice-info" role="status" aria-live="polite">
<strong>Reviewed metadata loaded in editor.</strong>
<ul>
<li>Done well: {healthScanSummary.pass}</li>
<li>Needs attention: {healthScanSummary.warning}</li>
<li>Warnings: {healthScanSummary.warning}</li>
<li>Passing checks: {healthScanSummary.pass}</li>
<li>Errors: {healthScanSummary.error}</li>
</ul>
</div>
Expand All @@ -1050,8 +1080,18 @@ export default function App() {
placeholder="https://github.com/imageomics/OpenCite"
/>
</label>
<label>
<span>GitHub token (optional)</span>
<input
type="password"
value={githubToken}
onChange={(event) => setGithubToken(event.target.value)}
placeholder="For higher GitHub API limits"
autoComplete="off"
/>
</label>
<p className="import-note">
This works even when the repository does not have a <strong>CITATION.cff</strong> file.
Use a fine-grained token with public repository read access for higher API limits. This works even when the repository does not have a <strong>CITATION.cff</strong> file.
</p>
<div className="actions">
<button type="button" onClick={handleImportGithubMetadata} disabled={importStatus.loading}>
Expand Down Expand Up @@ -1098,6 +1138,16 @@ export default function App() {
placeholder="https://github.com/imageomics/OpenCite"
/>
</label>
<label>
<span>GitHub token (optional)</span>
<input
type="password"
value={githubToken}
onChange={(event) => setGithubToken(event.target.value)}
placeholder="For higher GitHub API limits"
autoComplete="off"
/>
</label>
<div className="actions">
<button type="button" onClick={handleImportGithubMetadata} disabled={importStatus.loading}>
{importStatus.loading ? 'Importing…' : 'Import GitHub metadata'}
Expand Down Expand Up @@ -1179,7 +1229,7 @@ export default function App() {
<div className="feedback-block feedback-health">
<strong>Citation health scan</strong>
<p className="review-summary">
Done well: {healthScanSummary.pass} | Needs attention: {healthScanSummary.warning} | Errors: {healthScanSummary.error}
Passing checks: {healthScanSummary.pass} | Warnings to review: {healthScanSummary.warning} | Errors to fix: {healthScanSummary.error}
</p>
<div className="actions">
<button type="button" onClick={openReviewedMetadataInGenerator}>
Expand Down Expand Up @@ -1223,7 +1273,7 @@ export default function App() {
<li key={`comparison-${item.file}-${item.field}-${index}`} className="comparison-item">
<div className="comparison-header">
<strong>{item.file} - {item.field}</strong>
<span className="comparison-status">{formatComparisonStatus(item.status)}</span>
<span className={comparisonStatusClass(item.status)}>{formatComparisonStatus(item.status)}</span>
</div>
<p><span>Current:</span> {item.currentValue || '(missing)'}</p>
<p><span>GitHub:</span> {item.githubValue || '(cannot determine)'}</p>
Expand All @@ -1246,6 +1296,8 @@ export default function App() {
licenseOptions={licenseOptions}
grantSuggestions={grantSuggestions}
errors={validationErrors}
touchedFields={touchedFields}
importedMetadataAvailable={hasImportedMetadata}
orcidSuggestions={orcidSuggestions}
updateField={updateField}
appendGrantSuggestion={appendGrantSuggestion}
Expand Down
66 changes: 36 additions & 30 deletions src/components/MetadataForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export function MetadataForm({
licenseOptions,
grantSuggestions = [],
errors = {},
touchedFields = {},
importedMetadataAvailable = false,
orcidSuggestions = {},
updateField,
appendGrantSuggestion,
Expand All @@ -24,6 +26,10 @@ export function MetadataForm({
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);
const hasTouchedField = (field) => Boolean(touchedFields[field]);
const hasTouchedAuthorField = (index, field) => Boolean(touchedFields[`authors.${index}.${field}`]);
const shouldShowFieldError = (field) => Boolean(errors[field]) && (hasTouchedField(field) || importedMetadataAvailable);
const shouldShowAuthorFieldError = (index, field) => Boolean(errors[`authorOrcid`]?.[index]) && (hasTouchedAuthorField(index, field) || importedMetadataAvailable);

function toggleAuthorExpanded(index) {
setExpandedAuthors((current) => ({
Expand All @@ -43,17 +49,17 @@ export function MetadataForm({
<p className="section-lede">Start with what this work is and how people should reference it.</p>
</header>
<div className="section-grid">
<label className={`full-width ${errors.title ? 'field-error' : ''}`}>
<label className={`full-width ${shouldShowFieldError('title') ? 'field-error' : ''}`}>
<span>Title*</span>
<input
className={errors.title ? 'input-error' : ''}
className={shouldShowFieldError('title') ? 'input-error' : ''}
name="title"
value={form.title}
onChange={updateField}
placeholder="Project title"
aria-invalid={Boolean(errors.title)}
aria-invalid={Boolean(shouldShowFieldError('title'))}
/>
{errors.title ? <small className="error-text">{errors.title}</small> : null}
{(hasTouchedField('title') || importedMetadataAvailable) && errors.title ? <small className="error-text">{errors.title}</small> : null}
</label>

<label className="full-width">
Expand All @@ -75,7 +81,7 @@ export function MetadataForm({
<h3 id="section-authors-title">Author list and ORCID</h3>
<p className="section-lede">Add authors in publication order, then enrich with ORCID and affiliation.</p>
</header>
<label className={`full-width ${errors.authors ? 'field-error' : ''}`}>
<label className={`full-width ${shouldShowFieldError('authors') ? 'field-error' : ''}`}>
<span>Authors*</span>
{totalAuthors > AUTHORS_VISIBLE_BY_DEFAULT && (
<div className="authors-toolbar">
Expand All @@ -94,7 +100,7 @@ export function MetadataForm({
<div className="authors-list">
{form.authors.slice(0, visibleAuthorCount).map((author, index) => {
const displayName = [author.givenNames, author.familyNames].filter(Boolean).join(' ').trim() || `Author ${index + 1}`;
const hasOrcidError = Boolean(errors.authorOrcid?.[index]);
const hasOrcidError = shouldShowAuthorFieldError(index, 'orcid');
const shouldExpandByDefault = showAllAuthors || index < AUTHORS_VISIBLE_BY_DEFAULT || hasOrcidError;
const isExpanded = Object.prototype.hasOwnProperty.call(expandedAuthors, index)
? expandedAuthors[index]
Expand Down Expand Up @@ -129,10 +135,10 @@ export function MetadataForm({
value={author.orcid}
onChange={(event) => updateAuthorField(index, 'orcid', event.target.value)}
placeholder="ORCID (optional)"
className={errors.authorOrcid?.[index] ? 'input-error' : ''}
aria-invalid={Boolean(errors.authorOrcid?.[index])}
className={shouldShowAuthorFieldError(index, 'orcid') ? 'input-error' : ''}
aria-invalid={Boolean(shouldShowAuthorFieldError(index, 'orcid'))}
/>
{errors.authorOrcid?.[index] ? <small className="error-text">{errors.authorOrcid[index]}</small> : null}
{(hasTouchedAuthorField(index, 'orcid') || importedMetadataAvailable) && errors.authorOrcid?.[index] ? <small className="error-text">{errors.authorOrcid[index]}</small> : null}
<div className="orcid-tools">
<button
type="button"
Expand Down Expand Up @@ -202,7 +208,7 @@ export function MetadataForm({
<button type="button" className="secondary" onClick={addAuthor}>
Add author
</button>
{errors.authors ? <small className="error-text">{errors.authors}</small> : null}
{(hasTouchedField('authors') || importedMetadataAvailable) && errors.authors ? <small className="error-text">{errors.authors}</small> : null}
</label>
</section>

Expand All @@ -213,22 +219,22 @@ export function MetadataForm({
<p className="section-lede">Use release-aligned values so your exports match what users see on GitHub and Zenodo.</p>
</header>
<div className="section-grid">
<label className={errors.typeOfWork ? 'field-error' : ''}>
<label className={shouldShowFieldError('typeOfWork') ? 'field-error' : ''}>
<span>Type of work*</span>
<select
className={errors.typeOfWork ? 'input-error' : ''}
className={shouldShowFieldError('typeOfWork') ? 'input-error' : ''}
name="typeOfWork"
value={form.typeOfWork}
onChange={updateField}
aria-invalid={Boolean(errors.typeOfWork)}
aria-invalid={Boolean(shouldShowFieldError('typeOfWork'))}
>
{typeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{errors.typeOfWork ? <small className="error-text">{errors.typeOfWork}</small> : null}
{(hasTouchedField('typeOfWork') || importedMetadataAvailable) && errors.typeOfWork ? <small className="error-text">{errors.typeOfWork}</small> : null}
</label>

{form.typeOfWork === 'other' && (
Expand All @@ -243,15 +249,15 @@ export function MetadataForm({
</label>
)}

<label className={errors.version ? 'field-error' : ''}>
<label className={shouldShowFieldError('version') ? 'field-error' : ''}>
<span>Version*</span>
<input
className={errors.version ? 'input-error' : ''}
className={shouldShowFieldError('version') ? 'input-error' : ''}
name="version"
value={form.version}
onChange={updateField}
placeholder="e.g. v1.2.3 or 1.2.3"
aria-invalid={Boolean(errors.version)}
aria-invalid={Boolean(shouldShowFieldError('version'))}
/>
<small>Use Semantic Versioning (MAJOR.MINOR.PATCH), like <strong>1.2.3</strong> or <strong>v1.2.3</strong>. Use the exact value you plan to publish as your GitHub release tag.</small>
<small>
Expand All @@ -260,30 +266,30 @@ export function MetadataForm({
<a href="https://semver.org/" target="_blank" rel="noreferrer">semver.org</a>
.
</small>
{errors.version ? <small className="error-text">{errors.version}</small> : null}
{(hasTouchedField('version') || importedMetadataAvailable) && errors.version ? <small className="error-text">{errors.version}</small> : null}
</label>

<label className={errors.publicationDate ? 'field-error' : ''}>
<label className={shouldShowFieldError('publicationDate') ? 'field-error' : ''}>
<span>Publication date</span>
<input
className={errors.publicationDate ? 'input-error' : ''}
className={shouldShowFieldError('publicationDate') ? 'input-error' : ''}
name="publicationDate"
value={form.publicationDate}
onChange={updateField}
placeholder="YYYY-MM-DD"
aria-invalid={Boolean(errors.publicationDate)}
aria-invalid={Boolean(shouldShowFieldError('publicationDate'))}
/>
{errors.publicationDate ? <small className="error-text">{errors.publicationDate}</small> : null}
{(hasTouchedField('publicationDate') || importedMetadataAvailable) && errors.publicationDate ? <small className="error-text">{errors.publicationDate}</small> : null}
</label>

<label className={errors.license ? 'field-error' : ''}>
<label className={shouldShowFieldError('license') ? 'field-error' : ''}>
<span>License*</span>
<select
className={errors.license ? 'input-error' : ''}
className={shouldShowFieldError('license') ? 'input-error' : ''}
name="license"
value={form.license}
onChange={updateField}
aria-invalid={Boolean(errors.license)}
aria-invalid={Boolean(shouldShowFieldError('license'))}
>
<option value="">Select license (SPDX code)</option>
{licenseOptions.map((license) => (
Expand All @@ -292,7 +298,7 @@ export function MetadataForm({
</option>
))}
</select>
{errors.license ? <small className="error-text">{errors.license}</small> : null}
{(hasTouchedField('license') || importedMetadataAvailable) && errors.license ? <small className="error-text">{errors.license}</small> : null}
</label>

<label>
Expand Down Expand Up @@ -345,16 +351,16 @@ export function MetadataForm({
<p className="section-step">5. Funding</p>
<h3 id="section-funding-title">Grant IDs and acknowledgements</h3>
</header>
<label className={`full-width ${errors.grants ? 'field-error' : ''}`}>
<label className={`full-width ${hasTouchedField('grants') && errors.grants ? 'field-error' : ''}`}>
<span>Grants</span>
<textarea
className={errors.grants ? 'input-error' : ''}
className={hasTouchedField('grants') && errors.grants ? 'input-error' : ''}
name="grants"
value={form.grants}
onChange={updateField}
rows="3"
placeholder="One grant ID per line"
aria-invalid={Boolean(errors.grants)}
aria-invalid={Boolean(hasTouchedField('grants') && errors.grants)}
/>
<div className="grant-suggestions">
{grantSuggestions.map((grant) => (
Expand Down Expand Up @@ -384,7 +390,7 @@ export function MetadataForm({
))}
</ul>
<small>Format: &lt;funder-code&gt;::&lt;grant-number&gt; (e.g., 021nxhr62::2118240)</small>
{errors.grants ? <small className="error-text">{errors.grants}</small> : null}
{hasTouchedField('grants') && errors.grants ? <small className="error-text">{errors.grants}</small> : null}
</label>
</section>
</form>
Expand Down
Loading