From 552a0f4926e416c446e6410c43c2bb4b09e7b3f1 Mon Sep 17 00:00:00 2001 From: Joseph Amlung Date: Tue, 12 May 2026 12:33:19 -0400 Subject: [PATCH] Add AddReferencesDialog component and integrate into RepoHome; update translations --- package-lock.json | 8 +- .../collections/AddReferencesDialog.jsx | 550 ++++++++++++++++++ src/components/repos/RepoHome.jsx | 30 + src/components/search/Search.jsx | 1 + src/i18n/locales/en/translations.json | 25 +- src/i18n/locales/es/translations.json | 27 +- src/i18n/locales/zh/translations.json | 27 +- 7 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 src/components/collections/AddReferencesDialog.jsx diff --git a/package-lock.json b/package-lock.json index 7b4a164b..731db1fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11659,7 +11659,7 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, "fast-uri": { @@ -14005,7 +14005,7 @@ "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, "json-stringify-safe": { @@ -14941,7 +14941,7 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, "negotiator": { @@ -21117,7 +21117,7 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, "through2": { diff --git a/src/components/collections/AddReferencesDialog.jsx b/src/components/collections/AddReferencesDialog.jsx new file mode 100644 index 00000000..bd67b45f --- /dev/null +++ b/src/components/collections/AddReferencesDialog.jsx @@ -0,0 +1,550 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import TextField from '@mui/material/TextField' +import Autocomplete from '@mui/material/Autocomplete' +import CircularProgress from '@mui/material/CircularProgress' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import Box from '@mui/material/Box' +import Alert from '@mui/material/Alert' +import FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import Select from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' +import Checkbox from '@mui/material/Checkbox' +import FormControlLabel from '@mui/material/FormControlLabel' +import Table from '@mui/material/Table' +import TableHead from '@mui/material/TableHead' +import TableBody from '@mui/material/TableBody' +import TableRow from '@mui/material/TableRow' +import TableCell from '@mui/material/TableCell' +import Divider from '@mui/material/Divider' +import Chip from '@mui/material/Chip' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import { DialogContent, DialogActions } from '@mui/material' +import { toLower, includes } from 'lodash' +import APIService from '../../services/APIService' +import Dialog from '../common/Dialog' +import DialogTitle from '../common/DialogTitle' +import CloseIconButton from '../common/CloseIconButton' +import CascadeSelector from '../common/CascadeSelector' +import GroupHeader from '../common/GroupHeader' +import GroupItems from '../common/GroupItems' +import AutocompleteLoading from '../common/AutocompleteLoading' + +// ---- error formatting (same pattern as AddToCollectionDialog) ---- + +const extractLabel = expression => { + if (!expression) return expression + const parts = expression.replace(/\/$/, '').split('/') + for (const resource of ['concepts', 'mappings']) { + const idx = parts.lastIndexOf(resource) + if (idx !== -1 && parts[idx + 1]) return parts[idx + 1] + } + return expression +} + +const formatErrorEntry = entry => { + if (!entry || typeof entry !== 'object') return String(entry) + const lines = [] + if (entry.description) lines.push(entry.description) + if (entry.conflicting_references?.length) { + const refs = entry.conflicting_references.map(r => { + const m = r.match(/\/references\/([^/]+)\/?$/) + return m ? `Reference #${m[1]}` : r + }) + lines.push(`Conflicting: ${refs.join(', ')}`) + } + if (entry.conflicting_concept_id) { + const label = entry.conflicting_concept_name + ? `${entry.conflicting_concept_id} ${entry.conflicting_concept_name}` + : entry.conflicting_concept_id + lines.push(`Conflicting concept: ${label}`) + } + if (entry.conflicting_name) lines.push(`Conflicting name: "${entry.conflicting_name}"`) + return lines.join(' — ') || JSON.stringify(entry) +} + +const formatErrorMessage = message => { + if (!message) return '—' + if (typeof message === 'string') return message + const allErrors = [] + Object.values(message).forEach(val => { + if (val?.errors) val.errors.forEach(e => allErrors.push(formatErrorEntry(e))) + else if (val && typeof val === 'object') allErrors.push(formatErrorEntry(val)) + }) + return allErrors.length ? allErrors.join('\n') : JSON.stringify(message) +} + +// ---- component ---- + +const AddReferencesDialog = ({ open, onClose, collectionUrl, onSuccess }) => { + const { t } = useTranslation() + + // Seed (source or collection to pull from) + const [seeds, setSeeds] = React.useState([]) + const [seedLoading, setSeedLoading] = React.useState(false) + const [seedInput, setSeedInput] = React.useState('') + const [seed, setSeed] = React.useState(null) + + // Version pin + const [pinVersion, setPinVersion] = React.useState(false) + const [versions, setVersions] = React.useState([]) + const [versionsLoading, setVersionsLoading] = React.useState(false) + const [selectedVersion, setSelectedVersion] = React.useState('') + + // Reference configuration + const [resourceType, setResourceType] = React.useState('concepts') + const [mode, setMode] = React.useState('ids') + const [ids, setIds] = React.useState('') + const [expression, setExpression] = React.useState('') + const [includeExclude, setIncludeExclude] = React.useState('include') + const [cascadeParams, setCascadeParams] = React.useState({}) + + // Submission state + const [submitting, setSubmitting] = React.useState(false) + const [results, setResults] = React.useState(null) + const [error, setError] = React.useState(null) + + // Reset on open + React.useEffect(() => { + if (open) { + setSeeds([]) + setSeedInput('') + setSeed(null) + setPinVersion(false) + setVersions([]) + setSelectedVersion('') + setResourceType('concepts') + setMode('ids') + setIds('') + setExpression('') + setIncludeExclude('include') + setCascadeParams({}) + setResults(null) + setError(null) + } + }, [open]) + + // Debounced global repo search + const seedSearchTimer = React.useRef(null) + const handleSeedInputChange = (_, value) => { + setSeedInput(value || '') + clearTimeout(seedSearchTimer.current) + if (!value || value.length < 2) { setSeeds([]); return } + setSeedLoading(true) + seedSearchTimer.current = setTimeout(() => { + APIService.new().overrideURL('/repos/').get(null, null, { q: value, limit: 20, verbose: true }) + .then(response => { + setSeeds(Array.isArray(response?.data) ? response.data : []) + setSeedLoading(false) + }) + }, 300) + } + + // Fetch released versions when seed changes + React.useEffect(() => { + if (!seed?.url) { setVersions([]); setSelectedVersion(''); return } + setVersionsLoading(true) + APIService.new().overrideURL(seed.url + 'versions/').get(null, null, { released: true, limit: 20 }) + .then(response => { + const vList = (Array.isArray(response?.data) ? response.data : []) + .filter(v => v.version !== 'HEAD') + .map(v => v.version || v.id) + setVersions(vList) + setVersionsLoading(false) + if (vList.length) setSelectedVersion(vList[0]) + }) + }, [seed]) + + // Seed the expression field when relevant inputs change in Expression mode + React.useEffect(() => { + if (mode !== 'expression') return + setExpression(buildBasePath()) + }, [seed, pinVersion, selectedVersion, resourceType, mode]) // eslint-disable-line react-hooks/exhaustive-deps + + const buildBasePath = () => { + if (!seed?.url) return '' + const base = seed.url.replace(/\/$/, '') + '/' + const version = pinVersion && selectedVersion ? selectedVersion + '/' : '' + return base + version + resourceType + '/' + } + + const clearResultsOnEdit = () => { if (results !== null) { setResults(null); setError(null) } } + + const buildExpressions = () => { + if (mode === 'expression') return expression.trim() ? [expression.trim()] : [] + const base = buildBasePath() + if (!base) return [] + return ids.split(',').map(id => id.trim()).filter(Boolean).map(id => base + id + '/') + } + + const expressionList = buildExpressions() + + const filterSeedOptions = (options, { inputValue }) => { + if (!inputValue) return options + const q = toLower(inputValue) + return options.filter(o => + includes(toLower(o.name), q) || + includes(toLower(o.id), q) || + includes(toLower(o.short_code), q) || + includes(toLower(o.owner), q) + ) + } + + const handleSubmit = () => { + if (!expressionList.length) return + setSubmitting(true) + setError(null) + setResults(null) + const queryParams = Object.keys(cascadeParams).length ? cascadeParams : undefined + // CollectionVersionReferencesView (matched by /HEAD/references/) is read-only; + // strip any version segment so the request hits CollectionReferencesView which handles PUT. + const refsUrl = collectionUrl.replace(/\/HEAD\//i, '/') + 'references/' + APIService.new().overrideURL(refsUrl) + .put( + { data: { expressions: expressionList, ...(includeExclude === 'exclude' && { exclude: true }) }, cascade: cascadeParams.method || '' }, + null, + {}, + queryParams + ) + .then(response => { + setSubmitting(false) + if (response?.status === 200 || response?.status === 201) { + setResults(Array.isArray(response.data) ? response.data : []) + if (onSuccess) onSuccess() + } else if (response?.status === 202) { + setResults('pending') + if (onSuccess) onSuccess() + } else { + setError((response?.detail || response?.error) || t('common.something_went_wrong')) + } + }) + .catch(() => { + setSubmitting(false) + setError(t('reference.request_failed')) + }) + } + + const isPending = results === 'pending' + const resultList = Array.isArray(results) ? results : [] + const addedCount = resultList.filter(r => r.added).length + const failedCount = resultList.filter(r => !r.added).length + const hasResults = results !== null + // Lock the form only on full success or async-accepted. Failures keep the form editable so + // the user can fix the expression and resubmit without closing and reopening the dialog. + const formLocked = isPending || (hasResults && addedCount > 0 && failedCount === 0) + const canSubmit = !submitting && !formLocked && expressionList.length > 0 + const resolvedVersionLabel = seed + ? (pinVersion && selectedVersion + ? selectedVersion + : versionsLoading ? '…' : versions.length > 0 ? versions[0] : t('reference.latest_released')) + : '' + + return ( + + + + {t('reference.add_references')} + + + + + + + {/* Source / Collection seed + inline open-in-new-tab link */} + + option?.url === value?.url} + getOptionLabel={option => typeof option === 'string' ? option : `${option.name || option.id} (${option.owner})`} + groupBy={option => option.type === 'Collection' ? t('reference.group_collections') : t('reference.group_sources')} + onInputChange={handleSeedInputChange} + onChange={(_, item) => setSeed(item && typeof item !== 'string' ? item : null)} + disabled={submitting || formLocked} + renderInput={params => ( + + {seedLoading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + loadingText={} + noOptionsText={seedInput.length < 2 ? t('reference.type_to_search') : t('common.no_results')} + renderGroup={params => ( +
  • + {params.group} + {params.children} +
  • + )} + /> + {seed && ( + } + label={t('reference.open_repo', { name: seed.name || seed.id })} + variant="outlined" + clickable + onClick={() => window.open(window.location.origin + '/#' + seed.url, '_blank')} + sx={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }} + /> + )} +
    + + {/* Resolve hint: visible when seed is selected */} + {seed && ( + + {t('reference.resolves_to', { version: resolvedVersionLabel })} + + )} + + {/* Version pin row: checkbox + inline dropdown, visible when seed is selected */} + {seed && ( + + { setPinVersion(e.target.checked); if (!e.target.checked) setSelectedVersion('') }} + disabled={submitting || formLocked} + /> + } + label={{t('reference.pin_to_version')}} + sx={{ m: 0, width: 'fit-content' }} + /> + {pinVersion && ( + + {t('common.version')} + + + )} + + )} + + {/* Reference type + Include/Exclude */} + + + {t('reference.type')} + + + + + {t('reference.include_exclude')} + + + + + {/* Mode toggle */} + + {[ + { value: 'ids', label: t('reference.add_by_ids') }, + { value: 'expression', label: t('reference.expression') }, + ].map(({ value, label }) => ( + { if (!submitting && !formLocked) { clearResultsOnEdit(); setMode(value) } }} + color={mode === value ? 'primary' : 'default'} + variant={mode === value ? 'filled' : 'outlined'} + size="small" + sx={{ cursor: submitting || formLocked ? 'default' : 'pointer' }} + /> + ))} + + + {/* Add by ID(s) panel */} + {mode === 'ids' && ( + { clearResultsOnEdit(); setIds(e.target.value) }} + disabled={!seed || submitting || formLocked} + placeholder={seed ? '1234, 5678, 9012' : t('reference.select_source_first')} + helperText={seed ? t('reference.id_helper', { path: buildBasePath() }) : t('reference.select_source_first')} + fullWidth + size="small" + InputProps={{ sx: { fontFamily: 'monospace', fontSize: '0.85rem' } }} + /> + )} + + {/* Expression panel */} + {mode === 'expression' && ( + { clearResultsOnEdit(); setExpression(e.target.value) }} + disabled={submitting || formLocked} + placeholder="/orgs/CIEL/sources/CIEL/concepts/1234/" + helperText={t('reference.expression_hint')} + fullWidth + size="small" + InputProps={{ sx: { fontFamily: 'monospace', fontSize: '0.85rem' } }} + /> + )} + + {/* Cascade selector */} + + + {/* Submitting */} + {submitting && ( + + + {t('reference.adding_references')} + + )} + + {/* Request error */} + {error && {error}} + + {/* Results */} + {hasResults && ( + + {isPending && {t('addToCollection.request_accepted')}} + {!isPending && ( + + + {addedCount > 0 && failedCount === 0 && `${addedCount} reference${addedCount !== 1 ? 's' : ''} added`} + {addedCount > 0 && failedCount > 0 && `${addedCount} added, ${failedCount} failed`} + {addedCount === 0 && failedCount > 0 && `${failedCount} reference${failedCount !== 1 ? 's' : ''} failed`} + {addedCount === 0 && failedCount === 0 && t('addToCollection.no_references_added')} + + + {addedCount > 0 && ( + 0 ? 2 : 0, border: '1px solid', borderColor: 'divider', borderRadius: 1, overflow: 'hidden' }}> + {resultList.filter(r => r.added).map((item, idx, arr) => ( + + + + {item.expression} + + + {idx < arr.length - 1 && } + + ))} + + )} + + {failedCount > 0 && ( + + + + + {t('addToCollection.reference_header')} + + + {t('addToCollection.error_header')} + + + + + {resultList.filter(r => !r.added).map((item, idx) => ( + + + {extractLabel(item.expression)} + + + {formatErrorMessage(item.message)} + + + ))} + +
    + )} +
    + )} +
    + )} +
    + + + + {expressionList.length > 0 && !formLocked && ( + + {expressionList.length} expression{expressionList.length !== 1 ? 's' : ''} · {includeExclude} + + )} + + + {!formLocked && ( + + + + + )} + {formLocked && ( + + )} + + +
    + ) +} + +export default AddReferencesDialog diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index 30a09465..91b777d0 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -5,6 +5,8 @@ import Paper from '@mui/material/Paper' import orderBy from 'lodash/orderBy' import filter from 'lodash/filter' +import Button from '@mui/material/Button' +import AddIcon from '@mui/icons-material/Add' import APIService from '../../services/APIService'; import { dropVersion, toParentURI, toOwnerURI, currentUserHasAccess } from '../../common/utils'; import { WHITE } from '../../common/colors'; @@ -25,6 +27,7 @@ import ReleaseVersion from './ReleaseVersion' import RepoHeader from './RepoHeader'; import CollectionVersionsTab from './CollectionVersionsTab'; import ReferenceHome from '../references/ReferenceHome' +import AddReferencesDialog from '../collections/AddReferencesDialog' const RepoHome = () => { const { t } = useTranslation() @@ -53,6 +56,8 @@ const RepoHome = () => { const [deleteTarget, setDeleteTarget] = React.useState(false) const [releaseTarget, setReleaseTarget] = React.useState(false) const [showSummary, setShowSummary] = React.useState(true) + const [addReferencesOpen, setAddReferencesOpen] = React.useState(false) + const [referencesKey, setReferencesKey] = React.useState(0) const TAB_KEYS = tabs.map(tab => tab.key) const findTab = () => TAB_KEYS.includes(params?.tab || params?.repoVersion) ? params.tab || params.repoVersion : 'concepts' @@ -275,6 +280,7 @@ const RepoHome = () => { { repo?.id && ['concepts', 'mappings', 'references'].includes(tab) && { containerStyle={{padding: 0}} properties={(!tab || tab === 'concepts') ? repo?.meta?.display?.concept_summary_properties : []} propertyFilters={(!tab || tab === 'concepts') ? repo?.filters : []} + toolbarControl={ + isCollection && !isVersion && tab === 'references' + ? ( + + ) + : undefined + } /> } { @@ -357,6 +378,15 @@ const RepoHome = () => { onClose={(postUpsert) => onVersionFormClose(postUpsert)} /> } + { + isCollection && + setAddReferencesOpen(false)} + collectionUrl={getURL()} + onSuccess={() => setReferencesKey(k => k + 1)} + /> + } { repo?.id && { properties={props.properties} propertyFilters={props.propertyFilters} isMatch={isMatchOp} + toolbarControl={props.toolbarControl} /> diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 0af2e32e..0ce803e5 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -77,6 +77,8 @@ "public": "Public", "private": "Private", "close": "Close", + "something_went_wrong": "Something went wrong", + "no_results": "No results", "content": "Content", "short_code": "Short Code", "attribute": "Attribute", @@ -282,7 +284,28 @@ "translation": "Translation", "advanced": "Advanced", "hide_advanced": "Hide advanced", - "transform_tooltip": "Converts the reference to extensional reference(s). References for resolved concepts or mappings will be stored as individual extensional references instead of intensional reference(s)." + "transform_tooltip": "Converts the reference to extensional reference(s). References for resolved concepts or mappings will be stored as individual extensional references instead of intensional reference(s).", + "add_references": "Add References", + "add_reference": "Add Reference", + "source_or_collection": "Source / Collection", + "search_sources_collections": "Search sources and collections...", + "group_collections": "Collections", + "group_sources": "Sources", + "type_to_search": "Type at least 2 characters to search", + "open_repo": "Open {{name}}", + "resolves_to": "Resolves to {{version}}.", + "latest_released": "latest released", + "pin_to_version": "Pin to a specific version", + "include_exclude": "Include / Exclude", + "include": "Include", + "add_by_ids": "Add by ID(s)", + "concept_ids": "Concept IDs", + "mapping_ids": "Mapping IDs", + "select_source_first": "Select a source or collection above to use Add by ID(s)", + "id_helper": "Each ID becomes {{path}}{id}/", + "expression_hint": "Full reference expression. Seeded from the source / collection selector above; edit as needed.", + "adding_references": "Adding references…", + "request_failed": "Request failed — check your network connection and try again." }, "checksums": { "standard": "Standard Checksum", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 075df0f5..0d7b961d 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -53,7 +53,9 @@ "register": "Registrar", "signing_in": "Iniciando sesión...", "sign_in_success": "Ingreso exitoso.", - "sign_in_error": "No se pudo iniciar sesión en este momento." + "sign_in_error": "No se pudo iniciar sesión en este momento.", + "something_went_wrong": "Algo salió mal", + "no_results": "Sin resultados" }, "concept": { "concepts": "Conceptos", @@ -75,7 +77,28 @@ "all_source_concepts_and_mappings": "Todos los conceptos y mapeos de la fuente", "advanced": "Avanzado", "hide_advanced": "Ocultar avanzado", - "transform_tooltip": "Convierte la referencia en referencia(s) extensional(es). Las referencias para conceptos o mapeos resueltos se almacenaran como referencias extensionales individuales en lugar de referencia(s) intensional(es)." + "transform_tooltip": "Convierte la referencia en referencia(s) extensional(es). Las referencias para conceptos o mapeos resueltos se almacenaran como referencias extensionales individuales en lugar de referencia(s) intensional(es).", + "add_references": "Agregar referencias", + "add_reference": "Agregar referencia", + "source_or_collection": "Fuente / Colección", + "search_sources_collections": "Buscar fuentes y colecciones...", + "group_collections": "Colecciones", + "group_sources": "Fuentes", + "type_to_search": "Escribe al menos 2 caracteres para buscar", + "open_repo": "Abrir {{name}}", + "resolves_to": "Resuelve en {{version}}.", + "latest_released": "última versión publicada", + "pin_to_version": "Fijar a una versión específica", + "include_exclude": "Incluir / Excluir", + "include": "Incluir", + "add_by_ids": "Agregar por ID(s)", + "concept_ids": "IDs de conceptos", + "mapping_ids": "IDs de mapeos", + "select_source_first": "Selecciona una fuente o colección para usar Agregar por ID(s)", + "id_helper": "Cada ID se convierte en {{path}}{id}/", + "expression_hint": "Expresión de referencia completa. Precargada desde el selector de fuente / colección; edita según sea necesario.", + "adding_references": "Agregando referencias…", + "request_failed": "La solicitud falló — verifica tu conexión de red e inténtalo de nuevo." }, "repo": { "repos": "Repositorios", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index e4b5a350..a52ab6a9 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -89,7 +89,9 @@ "properties": "特性", "custom": "自定义", "none": "无", - "load_more": "加载更多" + "load_more": "加载更多", + "something_went_wrong": "出了些问题", + "no_results": "无结果" }, "errors": { "404": "很抱歉,未能找到您的页面。", @@ -294,7 +296,28 @@ "all_source_concepts_and_mappings": "所有源概念和映射", "advanced": "高级", "hide_advanced": "隐藏高级", - "transform_tooltip": "将引用转换为外延引用。已解析概念或映射的引用将作为单独的外延引用存储,而不是作为内涵引用存储。" + "transform_tooltip": "将引用转换为外延引用。已解析概念或映射的引用将作为单独的外延引用存储,而不是作为内涵引用存储。", + "add_references": "添加参考", + "add_reference": "添加参考", + "source_or_collection": "来源 / 集合", + "search_sources_collections": "搜索来源和集合...", + "group_collections": "集合", + "group_sources": "来源", + "type_to_search": "请输入至少 2 个字符进行搜索", + "open_repo": "打开 {{name}}", + "resolves_to": "解析为 {{version}}。", + "latest_released": "最新发布版本", + "pin_to_version": "固定到特定版本", + "include_exclude": "包含 / 排除", + "include": "包含", + "add_by_ids": "按 ID 添加", + "concept_ids": "概念 ID", + "mapping_ids": "映射 ID", + "select_source_first": "请在上方选择来源或集合以使用按 ID 添加", + "id_helper": "每个 ID 变为 {{path}}{id}/", + "expression_hint": "完整的参考表达式。由上方来源/集合选择器预填充;根据需要编辑。", + "adding_references": "正在添加参考…", + "request_failed": "请求失败 — 请检查您的网络连接并重试。" }, "search": { "filters": "筛选项",