From 8fc179fff5de19af684919943b60d1f8f6338c78 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:16:25 -0600 Subject: [PATCH 1/4] feat(trees): add tree repair utility Integrates fixes requested in https://github.com/specify/specify7/pull/7307 --- specifyweb/backend/trees/extras.py | 42 ++- specifyweb/backend/trees/urls.py | 1 + specifyweb/backend/trees/views.py | 54 ++++ .../js_src/lib/components/Atoms/Icons.tsx | 1 + .../components/Header/userToolDefinitions.ts | 2 +- .../lib/components/Permissions/definitions.ts | 14 +- .../lib/components/Toolbar/TreeActions.tsx | 265 ++++++++++++++++++ .../lib/components/Toolbar/TreeRepair.tsx | 61 ++-- .../js_src/lib/localization/common.ts | 9 + .../js_src/lib/localization/header.ts | 9 + .../frontend/js_src/lib/localization/tree.ts | 70 +++++ .../frontend/js_src/lib/utils/treeRebuild.ts | 30 ++ 12 files changed, 525 insertions(+), 33 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx create mode 100644 specifyweb/frontend/js_src/lib/utils/treeRebuild.ts diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py index 52d16aec7d2..259c9898797 100644 --- a/specifyweb/backend/trees/extras.py +++ b/specifyweb/backend/trees/extras.py @@ -560,7 +560,13 @@ def definition_joins(table, depth): for j in range(depth) ]) -def set_fullnames(treedef, null_only=False, node_number_range=None): +def set_fullnames( + treedef, + null_only: bool = False, + node_number_range=None, + include_synonyms: bool = False, + synonyms_only: bool = False, +): table = treedef.treeentries.model._meta.db_table depth = treedef.treedefitems.count() reverse = treedef.fullnamedirection == -1 @@ -569,6 +575,24 @@ def set_fullnames(treedef, null_only=False, node_number_range=None): if depth < 1: return cursor = connection.cursor() + + if synonyms_only: + accepted_filter = "and t0.acceptedid is not null" + elif include_synonyms: + accepted_filter = "" + else: + accepted_filter = "and t0.acceptedid is null" + + fullname_sql_expr = fullname_expr(depth, reverse) + + diff_condition = ( + "and (t0.fullname is null OR t0.fullname <> {expr})".format( + expr=fullname_sql_expr + ) + if not null_only + else "" + ) + sql = ( "update {table} t0\n" "{parent_joins}\n" @@ -576,22 +600,30 @@ def set_fullnames(treedef, null_only=False, node_number_range=None): "set {set_expr}\n" "where t{root}.parentid is null\n" "and t0.{table}treedefid = {treedefid}\n" - "and t0.acceptedid is null\n" + "{accepted_filter}\n" "{null_only}\n" "{node_number_range}\n" + "{diff_condition}\n" ).format( root=depth-1, table=table, treedefid=treedefid, - set_expr=f"t0.fullname = {fullname_expr(depth, reverse)}", + set_expr=f"t0.fullname = {fullname_sql_expr}", parent_joins=parent_joins(table, depth), definition_joins=definition_joins(table, depth), + accepted_filter=accepted_filter, null_only="and t0.fullname is null" if null_only else "", - node_number_range=f"and t0.nodenumber between {node_number_range[0]} and {node_number_range[1]}" if not (node_number_range is None) else '' + node_number_range=( + f"and t0.nodenumber between {node_number_range[0]} and {node_number_range[1]}" + if node_number_range is not None + else '' + ), + diff_condition=diff_condition, ) logger.debug('fullname update sql:\n%s', sql) - return cursor.execute(sql) + cursor.execute(sql) + return cursor.rowcount def predict_fullname(table, depth, parentid, defitemid, name, reverse=False): cursor = connection.cursor() diff --git a/specifyweb/backend/trees/urls.py b/specifyweb/backend/trees/urls.py index 825068ed747..a673a06aff4 100644 --- a/specifyweb/backend/trees/urls.py +++ b/specifyweb/backend/trees/urls.py @@ -13,6 +13,7 @@ path('/bulk_move/', views.bulk_move), path('/synonymize/', views.synonymize), path('/desynonymize/', views.desynonymize), + path('/rebuild-fullname/', views.rebuild_fullname), path('/tree_rank_item_count/', views.tree_rank_item_count), path('/predict_fullname/', views.predict_fullname), re_path(r'^(?P\d+)/(?P\w+)/stats/$', views.tree_stats), diff --git a/specifyweb/backend/trees/views.py b/specifyweb/backend/trees/views.py index 2a503ead2f0..51569a8cb57 100644 --- a/specifyweb/backend/trees/views.py +++ b/specifyweb/backend/trees/views.py @@ -411,6 +411,54 @@ def repair_tree(request, tree: TREE_TABLE): extras.renumber_tree(table) extras.validate_tree_numbering(table) +@login_maybe_required +@require_POST +@transaction.atomic +def rebuild_fullname(request, tree: TREE_TABLE, id: int): + """Rebuild fullname values for the specified tree definition. + + By default only accepted (preferred) nodes are processed. Pass + ?rebuild_synonyms=true to also rebuild fullname values for synonym nodes. + """ + check_permission_targets( + request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).rebuild_fullname], + ) + + rebuild_synonyms = request.GET.get('rebuild_synonyms', 'false').lower() == 'true' + + tree_name = tree.title() + treedef = get_object_or_404(f"{tree_name}treedef", id=id) + + accepted_changed = extras.set_fullnames( + treedef, + null_only=False, + include_synonyms=False, + synonyms_only=False, + ) + + synonyms_changed = 0 + if rebuild_synonyms: + synonyms_changed = extras.set_fullnames( + treedef, + null_only=False, + include_synonyms=True, + synonyms_only=True, + ) + + payload = { + "success": True, + "rebuild_synonyms": rebuild_synonyms, + "changed": { + "accepted": accepted_changed, + "synonyms": synonyms_changed, + "total": accepted_changed + synonyms_changed, + }, + } + + return HttpResponse(toJson(payload), content_type="application/json") + @tree_mutation def add_root(request, tree, treeid): "Creates a root node in a specific tree." @@ -550,6 +598,7 @@ class TaxonMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() class GeographyMutationPT(PermissionTarget): @@ -559,6 +608,7 @@ class GeographyMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() class StorageMutationPT(PermissionTarget): @@ -569,6 +619,7 @@ class StorageMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() class GeologictimeperiodMutationPT(PermissionTarget): @@ -578,6 +629,7 @@ class GeologictimeperiodMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() class LithostratMutationPT(PermissionTarget): @@ -587,6 +639,7 @@ class LithostratMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() class TectonicunitMutationPT(PermissionTarget): resource = "/tree/edit/tectonicunit" @@ -595,6 +648,7 @@ class TectonicunitMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + rebuild_fullname = PermissionTargetAction() def perm_target(tree): return { diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx index 9ee3b99a0a2..83f0b8e7f1c 100644 --- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx +++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx @@ -137,6 +137,7 @@ export const icons = { userCircle: , variable: , viewList: , + wrench: , x: , zoomIn: , zoomOut: , diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index 5083b183c81..2600ba9b1c9 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -89,7 +89,7 @@ const rawUserTools = ensure>>>()({ repairTree: { title: headerText.repairTree(), url: '/specify/overlay/tree-repair/', - icon: icons.checkCircle, + icon: icons.wrench, enabled: () => getDisciplineTrees().some((treeName) => hasPermission(`/tree/edit/${toLowerCase(treeName)}`, 'repair') diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts index 0045e7a884b..cdade147948 100644 --- a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts @@ -24,13 +24,21 @@ export const operationPolicies = { 'copy_from_library', ], '/permissions/library/roles': ['read', 'create', 'update', 'delete'], - '/tree/edit/taxon': ['merge', 'move', 'synonymize', 'desynonymize', 'repair'], + '/tree/edit/taxon': [ + 'merge', + 'move', + 'synonymize', + 'desynonymize', + 'repair', + 'rebuild_fullname', + ], '/tree/edit/geography': [ 'merge', 'move', 'synonymize', 'desynonymize', 'repair', + 'rebuild_fullname', ], '/tree/edit/storage': [ 'merge', @@ -39,6 +47,7 @@ export const operationPolicies = { 'desynonymize', 'repair', 'bulk_move', + 'rebuild_fullname', ], '/tree/edit/geologictimeperiod': [ 'merge', @@ -46,6 +55,7 @@ export const operationPolicies = { 'synonymize', 'desynonymize', 'repair', + 'rebuild_fullname', ], '/tree/edit/lithostrat': [ 'merge', @@ -53,6 +63,7 @@ export const operationPolicies = { 'synonymize', 'desynonymize', 'repair', + 'rebuild_fullname', ], '/tree/edit/tectonicunit': [ 'merge', @@ -60,6 +71,7 @@ export const operationPolicies = { 'synonymize', 'desynonymize', 'repair', + 'rebuild_fullname', ], '/querybuilder/query': [ 'execute', diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx new file mode 100644 index 00000000000..d54f7e93c6c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx @@ -0,0 +1,265 @@ +/** + * Tree actions dropdown and menu + */ + +import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; + +import { commonText } from '../../localization/common'; +import { headerText } from '../../localization/header'; +import { treeText } from '../../localization/tree'; +import { ajax } from '../../utils/ajax'; +import { ping } from '../../utils/ajax/ping'; +import { type RA } from '../../utils/types'; +import { parseRebuildResponse } from '../../utils/treeRebuild'; +import { Button } from '../Atoms/Button'; +import { Portal } from '../Molecules/Portal'; +import { hasPermission } from '../Permissions/helpers'; + +const TREE_RESOURCES = { + Taxon: '/tree/edit/taxon', + Geography: '/tree/edit/geography', + Storage: '/tree/edit/storage', + GeologicTimePeriod: '/tree/edit/geologictimeperiod', + LithoStrat: '/tree/edit/lithostrat', + TectonicUnit: '/tree/edit/tectonicunit', +} as const; + +type TreeNameKey = keyof typeof TREE_RESOURCES; +type ActionKey = 'rebuildAccepted' | 'rebuildSynonyms' | 'repair'; + +type TreeActionsProps = { + readonly treeName: string; + readonly treeDefinition: any; +}; + +type ActionDef = { + readonly key: ActionKey; + readonly can: boolean; + readonly label: () => LocalizedString; + readonly description: () => LocalizedString; + readonly run: () => void; +}; + +function ActionsMenu({ treeName, treeDefinition }: TreeActionsProps): JSX.Element | null { + const [result, setResult] = React.useState<{ + readonly accepted: number; + readonly synonyms: number; + readonly total: number; + } | null>(null); + const [isRunning, setIsRunning] = React.useState(false); + const [repairStatus, setRepairStatus] = React.useState<'idle' | 'success'>('idle'); + const [hoveredAction, setHoveredAction] = React.useState(null); + + if (typeof treeDefinition !== 'object') return null; + if (!(treeName in TREE_RESOURCES)) return null; + + const canRebuild = hasPermission( + TREE_RESOURCES[treeName as TreeNameKey], + 'rebuild_fullname' + ); + const canRepair = hasPermission( + TREE_RESOURCES[treeName as TreeNameKey], + 'repair' + ); + + if (!canRebuild && !canRepair) return null; + + const id = treeDefinition.get('id'); + + const trigger = (withSynonyms: boolean): void => { + setIsRunning(true); + setResult(null); + setRepairStatus('idle'); + ajax( + `/trees/specify_tree/${treeName.toLowerCase()}/${id}/rebuild-fullname/${withSynonyms ? '?rebuild_synonyms=true' : ''}`, + { + method: 'POST', + headers: { Accept: 'application/json' }, + errorMode: 'dismissible', + } + ) + .then((resp) => setResult(parseRebuildResponse(resp))) + .finally(() => setIsRunning(false)); + }; + + const triggerRepair = (): void => { + if (!canRepair) return; + setIsRunning(true); + setResult(null); + setRepairStatus('idle'); + ping(`/trees/specify_tree/${treeName.toLowerCase()}/repair/`, { + method: 'POST', + errorMode: 'dismissible', + }) + .then(() => setRepairStatus('success')) + .finally(() => setIsRunning(false)); + }; + + const actions: RA = [ + { + key: 'repair', + can: canRepair, + label: () => headerText.repairTree(), + description: () => treeText.repairTreeDescription(), + run: triggerRepair, + }, + { + key: 'rebuildAccepted', + can: canRebuild, + label: () => treeText.rebuildNames(), + description: () => treeText.rebuildNamesDescription(), + run: () => trigger(false), + }, + { + key: 'rebuildSynonyms', + can: canRebuild, + label: () => treeText.rebuildNamesSynonyms(), + description: () => treeText.rebuildNamesSynonymsDescription(), + run: () => trigger(true), + }, + ]; + + const visibleActions = actions.filter((action) => action.can); + + const status: React.ReactNode = isRunning ? ( + {commonText.working()} + ) : result && canRebuild ? ( + result.total > 0 ? ( +
+ {treeText.rebuildResult({ + total: result.total, + accepted: result.accepted, + synonyms: result.synonyms, + })} +
+ ) : ( +
{treeText.noFullNamesUpdated()}
+ ) + ) : repairStatus === 'success' && (!canRebuild || !result) ? ( +
+ {headerText.treeRepairComplete()} +
+ ) : hoveredAction ? ( + (() => { + const current = actions.find((action) => action.key === hoveredAction); + return current ? ( +
{current.description()}
+ ) : null; + })() + ) : null; + + return ( +
+
+ {visibleActions.map((action) => ( + { + event.preventDefault(); + action.run(); + }} + onMouseEnter={(): void => setHoveredAction(action.key)} + onMouseLeave={(): void => + setHoveredAction((previousHovered) => + previousHovered === action.key ? null : previousHovered + ) + } + > + <>{action.label()} + + ))} +
+
{status}
+
+ ); +} + +export function TreeActionsDropdown({ treeName, treeDefinition }: TreeActionsProps): JSX.Element | null { + const [open, setOpen] = React.useState(false); + const anchorRef = React.useRef(null); + const menuRef = React.useRef(null); + const [position, setPosition] = React.useState<{ top: number; left: number } | null>(null); + + const hasAnyPermission = React.useMemo(() => { + if (!(treeName in TREE_RESOURCES)) return false; + return ( + hasPermission(TREE_RESOURCES[treeName as TreeNameKey], 'rebuild_fullname') || + hasPermission(TREE_RESOURCES[treeName as TreeNameKey], 'repair') + ); + }, [treeName]); + + const updatePosition = React.useCallback(() => { + const element = anchorRef.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + setPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.right + window.scrollX, + }); + }, []); + + React.useEffect(() => { + if (!open) return; + updatePosition(); + + const onClick = (event: MouseEvent): void => { + if ( + anchorRef.current?.contains(event.target as Node) || + menuRef.current?.contains(event.target as Node) + ) { + return; + } + setOpen(false); + }; + + const onScrollOrResize = (): void => updatePosition(); + + window.addEventListener('mousedown', onClick, { capture: true }); + window.addEventListener('scroll', onScrollOrResize, true); + window.addEventListener('resize', onScrollOrResize); + + return () => { + window.removeEventListener('mousedown', onClick, { capture: true }); + window.removeEventListener('scroll', onScrollOrResize, true); + window.removeEventListener('resize', onScrollOrResize); + }; + }, [open, updatePosition]); + + if (!hasAnyPermission) return null; + + return ( + <> + { + event.preventDefault(); + if (open) { + setOpen(false); + return; + } + anchorRef.current = event.currentTarget as HTMLButtonElement; + setOpen((current) => !current); + setTimeout(updatePosition, 0); + }} + /> + {open && position && ( + +
+ +
+
+ )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeRepair.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeRepair.tsx index 045c39aef29..94a6fc47ad1 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeRepair.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeRepair.tsx @@ -32,6 +32,7 @@ import { TableIcon } from '../Molecules/TableIcon'; import { hasPermission, hasTreeAccess } from '../Permissions/helpers'; import { formatUrl } from '../Router/queryString'; import { OverlayContext } from '../Router/Router'; +import { TreeActionsDropdown } from './TreeActions'; export function TreeSelectOverlay(): JSX.Element { const handleClose = React.useContext(OverlayContext); @@ -108,32 +109,40 @@ export function TreeSelectDialog({
    {treeData.map(([treeName, treeDefinition]) => (
  • -
    - { - if (handleClick === undefined) return; - event.preventDefault(); - loading( - Promise.resolve(handleClick(treeName)).then(() => - typeof confirmationMessage === 'string' - ? setIsFinished() - : handleClose() - ) - ); - }} - > - - {genericTables[treeName].label} - - {typeof treeDefinition === 'object' && ( - globalThis.location.reload()} - /> - )} +
    +
    + { + if (handleClick === undefined) return; + event.preventDefault(); + loading( + Promise.resolve(handleClick(treeName)).then(() => + typeof confirmationMessage === 'string' + ? setIsFinished() + : handleClose() + ) + ); + }} + > + + {genericTables[treeName].label} + + {typeof treeDefinition === 'object' && ( + globalThis.location.reload()} + /> + )} + {permissionName === 'repair' && ( + + )} +
  • ))} diff --git a/specifyweb/frontend/js_src/lib/localization/common.ts b/specifyweb/frontend/js_src/lib/localization/common.ts index 733cdbb6b37..72697664a9f 100644 --- a/specifyweb/frontend/js_src/lib/localization/common.ts +++ b/specifyweb/frontend/js_src/lib/localization/common.ts @@ -139,6 +139,15 @@ export const commonText = createDictionary({ 'de-ch': 'Öffnen', 'pt-br': 'Abrir', }, + working: { + 'en-us': 'Working…', + 'ru-ru': 'Работа…', + 'es-es': 'Trabajando…', + 'fr-fr': 'Travail…', + 'uk-ua': 'Працює…', + 'de-ch': 'Arbeiten…', + 'pt-br': 'Trabalhando…', + }, delete: { 'en-us': 'Delete', 'es-es': 'Eliminar', diff --git a/specifyweb/frontend/js_src/lib/localization/header.ts b/specifyweb/frontend/js_src/lib/localization/header.ts index e880400c843..259bd79254f 100644 --- a/specifyweb/frontend/js_src/lib/localization/header.ts +++ b/specifyweb/frontend/js_src/lib/localization/header.ts @@ -209,6 +209,15 @@ export const headerText = createDictionary({ 'de-ch': 'Die Baumreparatur ist abgeschlossen.', 'pt-br': 'O reparo da árvore está concluído.', }, + treeOptions: { + 'en-us': 'Tree options', + 'ru-ru': 'Параметры дерева', + 'es-es': 'Opciones del árbol', + 'fr-fr': "Options de l'arbre", + 'uk-ua': 'Параметри дерева', + 'de-ch': 'Baumoptionen', + 'pt-br': 'Opções da árvore', + }, choose: { 'en-us': 'Choose', 'de-ch': 'Wählen', diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 57c0ec99021..c6277cbc600 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -63,6 +63,76 @@ export const treeText = createDictionary({ 'de-ch': 'Synonymisieren', 'pt-br': 'Sinônimos', }, + repairTreeDescription: { + 'en-us': 'Repair tree numbering and rank consistency.', + 'ru-ru': 'Исправить нумерацию и согласованность рангов дерева.', + 'es-es': 'Reparar la numeración y coherencia de rangos del árbol.', + 'fr-fr': "Réparer la numérotation et la cohérence des rangs de l’arbre.", + 'uk-ua': 'Виправити нумерацію та узгодженість рангів дерева.', + 'de-ch': 'Reparieren Sie Nummerierung und Rangfolge des Baums.', + 'pt-br': 'Reparar a numeração e a consistência de hierarquia da árvore.', + }, + rebuildNames: { + 'en-us': 'Rebuild full names', + 'ru-ru': 'Пересобрать полные имена', + 'es-es': 'Reconstruir nombres completos', + 'fr-fr': 'Reconstruire les noms complets', + 'uk-ua': 'Перебудувати повні назви', + 'de-ch': 'Vollständige Namen neu aufbauen', + 'pt-br': 'Reconstruir nomes completos', + }, + rebuildNamesDescription: { + 'en-us': 'Update fullName for accepted nodes.', + 'ru-ru': 'Обновить fullName для принятых узлов.', + 'es-es': 'Actualizar fullName para nodos aceptados.', + 'fr-fr': 'Mettre à jour fullName pour les nœuds acceptés.', + 'uk-ua': 'Оновити fullName для прийнятих вузлів.', + 'de-ch': 'Aktualisiere fullName für akzeptierte Knoten.', + 'pt-br': 'Atualizar fullName para nós aceitos.', + }, + rebuildNamesSynonyms: { + 'en-us': 'Rebuild full names (synonyms)', + 'ru-ru': 'Пересобрать полные имена (синонимы)', + 'es-es': 'Reconstruir nombres completos (sinónimos)', + 'fr-fr': 'Reconstruire les noms complets (synonymes)', + 'uk-ua': 'Перебудувати повні назви (синоніми)', + 'de-ch': 'Vollständige Namen neu aufbauen (Synonyme)', + 'pt-br': 'Reconstruir nomes completos (sinônimos)', + }, + rebuildNamesSynonymsDescription: { + 'en-us': 'Include synonym (non-accepted) nodes when rebuilding fullName.', + 'ru-ru': 'Включить синонимы (непринятые узлы) при пересборке fullName.', + 'es-es': 'Incluir nodos sinónimos (no aceptados) al reconstruir fullName.', + 'fr-fr': 'Inclure les nœuds synonymes (non acceptés) lors de la reconstruction de fullName.', + 'uk-ua': 'Включити вузли-синоніми (неприйняті) під час перебудови fullName.', + 'de-ch': 'Synonym-Knoten (nicht akzeptiert) beim Neuaufbau von fullName einbeziehen.', + 'pt-br': 'Incluir nós sinônimos (não aceitos) ao reconstruir fullName.', + }, + rebuildResult: { + 'en-us': + 'Updated {total:number} nodes (accepted: {accepted:number}, synonyms: {synonyms:number}).', + 'ru-ru': + 'Обновлено {total:number} узлов (принято: {accepted:number}, синонимов: {synonyms:number}).', + 'es-es': + 'Se actualizaron {total:number} nodos (aceptados: {accepted:number}, sinónimos: {synonyms:number}).', + 'fr-fr': + 'Mis à jour {total:number} nœuds (acceptés : {accepted:number}, synonymes : {synonyms:number}).', + 'uk-ua': + 'Оновлено {total:number} вузлів (прийняті: {accepted:number}, синоніми: {synonyms:number}).', + 'de-ch': + '{total:number} Knoten aktualisiert (akzeptiert: {accepted:number}, Synonyme: {synonyms:number}).', + 'pt-br': + 'Atualizados {total:number} nós (aceitos: {accepted:number}, sinônimos: {synonyms:number}).', + }, + noFullNamesUpdated: { + 'en-us': 'No full name values were updated.', + 'ru-ru': 'Значения fullName не были обновлены.', + 'es-es': 'No se actualizaron valores de fullName.', + 'fr-fr': 'Aucune valeur fullName mise à jour.', + 'uk-ua': 'Жодних значень fullName не оновлено.', + 'de-ch': 'Keine fullName-Werte wurden aktualisiert.', + 'pt-br': 'Nenhum valor de fullName foi atualizado.', + }, actionFailed: { 'en-us': 'Operation failed', 'ru-ru': 'Операция провалилась', diff --git a/specifyweb/frontend/js_src/lib/utils/treeRebuild.ts b/specifyweb/frontend/js_src/lib/utils/treeRebuild.ts new file mode 100644 index 00000000000..2d3f0f88591 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/utils/treeRebuild.ts @@ -0,0 +1,30 @@ +export type RebuildResponse = { + readonly success?: boolean; + readonly rebuild_synonyms?: boolean; + readonly changed?: { + readonly accepted?: number; + readonly synonyms?: number; + readonly total?: number; + }; +}; + +const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const parseRebuildResponse = ( + response: unknown +): { readonly accepted: number; readonly synonyms: number; readonly total: number } => { + if (typeof response !== 'object' || response === null) { + return { accepted: 0, synonyms: 0, total: 0 }; + } + + const changed = (response as RebuildResponse).changed; + + const accepted = isNumber(changed?.accepted) ? changed!.accepted : 0; + const synonyms = isNumber(changed?.synonyms) ? changed!.synonyms : 0; + const total = isNumber(changed?.total) + ? changed!.total + : accepted + synonyms; + + return { accepted, synonyms, total }; +}; From a477de2091d2b4235431f76d72834e38e8d592cf Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:18:14 -0600 Subject: [PATCH 2/4] fix: make URL consistent --- specifyweb/backend/trees/urls.py | 2 +- .../frontend/js_src/lib/components/Toolbar/TreeActions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/backend/trees/urls.py b/specifyweb/backend/trees/urls.py index a673a06aff4..8f0221389d4 100644 --- a/specifyweb/backend/trees/urls.py +++ b/specifyweb/backend/trees/urls.py @@ -13,7 +13,7 @@ path('/bulk_move/', views.bulk_move), path('/synonymize/', views.synonymize), path('/desynonymize/', views.desynonymize), - path('/rebuild-fullname/', views.rebuild_fullname), + path('/rebuild_fullname/', views.rebuild_fullname), path('/tree_rank_item_count/', views.tree_rank_item_count), path('/predict_fullname/', views.predict_fullname), re_path(r'^(?P\d+)/(?P\w+)/stats/$', views.tree_stats), diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx index d54f7e93c6c..6a2281d680f 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx @@ -72,7 +72,7 @@ function ActionsMenu({ treeName, treeDefinition }: TreeActionsProps): JSX.Elemen setResult(null); setRepairStatus('idle'); ajax( - `/trees/specify_tree/${treeName.toLowerCase()}/${id}/rebuild-fullname/${withSynonyms ? '?rebuild_synonyms=true' : ''}`, + `/trees/specify_tree/${treeName.toLowerCase()}/${id}/rebuild_fullname/${withSynonyms ? '?rebuild_synonyms=true' : ''}`, { method: 'POST', headers: { Accept: 'application/json' }, From 0d7f04503cea5f6b6e980a3004af854bbaf5cd52 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sat, 10 Jan 2026 03:22:08 +0000 Subject: [PATCH 3/4] Lint code with ESLint and Prettier Triggered by a477de2091d2b4235431f76d72834e38e8d592cf on branch refs/heads/issue-7138-v2 --- .../frontend/js_src/lib/components/Toolbar/TreeActions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx index 6a2281d680f..1b631e9bec5 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/TreeActions.tsx @@ -10,8 +10,8 @@ import { headerText } from '../../localization/header'; import { treeText } from '../../localization/tree'; import { ajax } from '../../utils/ajax'; import { ping } from '../../utils/ajax/ping'; -import { type RA } from '../../utils/types'; import { parseRebuildResponse } from '../../utils/treeRebuild'; +import { type RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Portal } from '../Molecules/Portal'; import { hasPermission } from '../Permissions/helpers'; @@ -180,7 +180,7 @@ export function TreeActionsDropdown({ treeName, treeDefinition }: TreeActionsPro const [open, setOpen] = React.useState(false); const anchorRef = React.useRef(null); const menuRef = React.useRef(null); - const [position, setPosition] = React.useState<{ top: number; left: number } | null>(null); + const [position, setPosition] = React.useState<{ readonly top: number; readonly left: number } | null>(null); const hasAnyPermission = React.useMemo(() => { if (!(treeName in TREE_RESOURCES)) return false; From 59eb2d9051235c01bc090e2eb162960666d96f5b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 9 Jan 2026 21:25:29 -0600 Subject: [PATCH 4/4] fix(local): clarify button action --- .../frontend/js_src/lib/localization/tree.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index c6277cbc600..a8bd309c64a 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -91,13 +91,13 @@ export const treeText = createDictionary({ 'pt-br': 'Atualizar fullName para nós aceitos.', }, rebuildNamesSynonyms: { - 'en-us': 'Rebuild full names (synonyms)', - 'ru-ru': 'Пересобрать полные имена (синонимы)', - 'es-es': 'Reconstruir nombres completos (sinónimos)', - 'fr-fr': 'Reconstruire les noms complets (synonymes)', - 'uk-ua': 'Перебудувати повні назви (синоніми)', - 'de-ch': 'Vollständige Namen neu aufbauen (Synonyme)', - 'pt-br': 'Reconstruir nomes completos (sinônimos)', + 'en-us': 'Rebuild full names, including synonyms', + 'ru-ru': 'Пересобрать полные имена, включая синонимы', + 'es-es': 'Reconstruir nombres completos, incluidos los sinónimos', + 'fr-fr': 'Reconstruire les noms complets, y compris les synonymes', + 'uk-ua': 'Перебудувати повні назви, включаючи синоніми', + 'de-ch': 'Vollständige Namen neu aufbauen, einschließlich Synonyme', + 'pt-br': 'Reconstruir nomes completos, incluindo sinônimos', }, rebuildNamesSynonymsDescription: { 'en-us': 'Include synonym (non-accepted) nodes when rebuilding fullName.',