diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2412235c5..b7ef44a9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -227,6 +227,23 @@ jobs: cp -r "$GITHUB_WORKSPACE/titleblocks" "$FILES/titleblocks" || true cp -r "$GITHUB_WORKSPACE/examples" "$FILES/examples" || true cp -r "$GITHUB_WORKSPACE/fonts" "$FILES/fonts" || true + # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. + mkdir -p "$FILES/mcp" + cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" + # Python for it, so Windows users need not install one: the official + # embeddable package, pinned by version and checksum (python.org's + # own sha256 for this file). The installer offers it unticked; the + # portable folder and the MSI carry it. + PY_EMBED_VERSION=3.14.7 + PY_EMBED_SHA256=d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15 + PY_ZIP="$GITHUB_WORKSPACE/python-embed.zip" + curl -fsSL -o "$PY_ZIP" \ + "https://www.python.org/ftp/python/$PY_EMBED_VERSION/python-$PY_EMBED_VERSION-embed-amd64.zip" + echo "$PY_EMBED_SHA256 $PY_ZIP" | sha256sum -c - \ + || { echo "ERROR: embeddable Python checksum mismatch"; exit 1; } + python3 -m zipfile -e "$PY_ZIP" "$FILES/mcp/python" + [ -f "$FILES/mcp/python/python.exe" ] \ + || { echo "ERROR: python.exe missing from the embeddable package"; exit 1; } # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease @@ -305,7 +322,7 @@ jobs: && echo " OK : $f" \ || echo " MISSING: $f" done - for d in ico elements lang titleblocks fonts examples bin; do + for d in ico elements lang titleblocks fonts examples bin mcp; do [ -d "$FILES/$d" ] \ && echo " OK : $d/" \ || echo " MISSING: $d/" diff --git a/CMakeLists.txt b/CMakeLists.txt index 1694a98c0..120f53a40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,7 +80,7 @@ set(QET_HAS_QTPDF FALSE) find_package(Qt6 QUIET COMPONENTS Pdf) if(TARGET Qt6::Pdf AND NOT Qt6_VERSION VERSION_LESS 6.4.0) set(QET_HAS_QTPDF TRUE) - list(APPEND QET_PRIVATE_LIBRARIES Qt::Pdf) + list(APPEND QET_PRIVATE_LIBRARIES Qt6::Pdf) add_compile_definitions(QET_HAS_QTPDF) else() message(STATUS "QtPdf module not available (or Qt < 6.4): PDF page import feature disabled") @@ -93,10 +93,10 @@ endif() # the application depends on. When it's missing the feature is silently # disabled - see the QET_HAS_SCRIPTING guard in qetscripting.*. set(QET_HAS_SCRIPTING FALSE) -find_package(Qt${QT_VERSION_MAJOR} QUIET COMPONENTS Qml) -if(TARGET Qt${QT_VERSION_MAJOR}::Qml) +find_package(Qt6 QUIET COMPONENTS Qml) +if(TARGET Qt6::Qml) set(QET_HAS_SCRIPTING TRUE) - list(APPEND QET_PRIVATE_LIBRARIES Qt::Qml) + list(APPEND QET_PRIVATE_LIBRARIES Qt6::Qml) add_compile_definitions(QET_HAS_SCRIPTING) else() message(STATUS "Qt Qml module not available: JavaScript scripting (--run) disabled") @@ -358,6 +358,10 @@ if (NOT MINGW) install(DIRECTORY elements DESTINATION share/qelectrotech) install(DIRECTORY examples DESTINATION share/qelectrotech) install(DIRECTORY titleblocks DESTINATION share/qelectrotech) + # The MCP server (misc/qet-mcp) finds this QElectroTech and its elements + # from here: /share/qelectrotech/mcp -> /bin. + install(PROGRAMS misc/qet-mcp/qet_mcp.py DESTINATION share/qelectrotech/mcp) + install(FILES misc/qet-mcp/README.md DESTINATION share/qelectrotech/mcp) install(FILES LICENSE ELEMENTS.LICENSE CREDIT README ChangeLog DESTINATION share/doc/qelectrotech) if(UNIX AND NOT APPLE) install(FILES misc/org.qelectrotech.qelectrotech.desktop DESTINATION share/applications) diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index cc1d81bd0..fe1522457 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -274,6 +274,21 @@ Section "$(Fonts)" SEC05 File /nonfatal /r "./files/fonts" SectionEnd +; The MCP server lets an AI assistant open, check and edit drawings. It does +; nothing until a user sets up an assistant to start it. +Section "$(Mcp)" SEC06 + SetOutPath "$INSTDIR\mcp" + File /nonfatal "./files/mcp/*" +SectionEnd + +; Python for the MCP server, from python.org (about 12 MB). Unticked by +; default: only someone setting up an AI assistant needs it, and then only +; without a Python of their own. Uninstall removes it with $INSTDIR. +Section /o "$(McpPython)" SEC07 + SetOutPath "$INSTDIR\mcp\python" + File /nonfatal /r "./files/mcp/python/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -282,6 +297,8 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC03} $(var3) !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC07} $(var7) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index c76f128fd..2c6dcb45c 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -19,6 +19,10 @@ LangString var3 ${LANG_ENGLISH} "Examples of cartridges" LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" + LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ENGLISH} "Python for the AI assistant" + LangString var7 ${LANG_ENGLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -43,6 +47,10 @@ LangString var3 ${LANG_KOREAN} "표제란 예제" LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" + LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_KOREAN} "Python for the AI assistant" + LangString var7 ${LANG_KOREAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -67,6 +75,10 @@ LangString var3 ${LANG_POLISH} "Przykłady tabliczek rysunkowych" LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" + LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_POLISH} "Python for the AI assistant" + LangString var7 ${LANG_POLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -91,6 +103,10 @@ LangString var3 ${LANG_GREEK} "Παραδείγματα πινακίδων" LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" + LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GREEK} "Python for the AI assistant" + LangString var7 ${LANG_GREEK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -115,6 +131,10 @@ LangString var3 ${LANG_CZECH} "Příklady popisových polí" LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" + LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CZECH} "Python for the AI assistant" + LangString var7 ${LANG_CZECH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -139,6 +159,10 @@ LangString var3 ${LANG_SPANISH} "Ejemplos de cartelas" LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" + LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SPANISH} "Python for the AI assistant" + LangString var7 ${LANG_SPANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -163,6 +187,10 @@ LangString var3 ${LANG_GERMAN} "Schriftfeld-Beispiele" LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" + LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GERMAN} "Python for the AI assistant" + LangString var7 ${LANG_GERMAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -187,6 +215,10 @@ LangString var3 ${LANG_RUSSIAN} "Примеры штампов" LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" + LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_RUSSIAN} "Python for the AI assistant" + LangString var7 ${LANG_RUSSIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -211,6 +243,10 @@ LangString var3 ${LANG_ARABIC} "أمثلة على كتل العنوان" LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" + LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ARABIC} "Python for the AI assistant" + LangString var7 ${LANG_ARABIC} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -235,6 +271,10 @@ LangString var3 ${LANG_CATALAN} "Exemples de cartutxos" LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" + LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CATALAN} "Python for the AI assistant" + LangString var7 ${LANG_CATALAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -259,6 +299,10 @@ LangString var3 ${LANG_ITALIAN} "Cartigli di esempio" LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" + LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ITALIAN} "Python for the AI assistant" + LangString var7 ${LANG_ITALIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -283,6 +327,10 @@ LangString var3 ${LANG_PORTUGUESE} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" + LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESE} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -307,6 +355,10 @@ LangString var3 ${LANG_ROMANIAN} "Exemple de cartușe" LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" + LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ROMANIAN} "Python for the AI assistant" + LangString var7 ${LANG_ROMANIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -331,6 +383,10 @@ LangString var3 ${LANG_CROATIAN} "Primjeri zaglavlja" LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" + LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CROATIAN} "Python for the AI assistant" + LangString var7 ${LANG_CROATIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -355,6 +411,10 @@ LangString var3 ${LANG_DUTCH} "Voorbeelden van titelblokken" LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" + LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DUTCH} "Python for the AI assistant" + LangString var7 ${LANG_DUTCH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -379,6 +439,10 @@ LangString var3 ${LANG_DANISH} "Titel blokke eksempler" LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" + LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DANISH} "Python for the AI assistant" + LangString var7 ${LANG_DANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index 322322b7d..bcfd9988b 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -19,4 +19,8 @@ LangString var3 ${LANG_FRENCH} "Exemples de cartouches" LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" + LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas. Nécessite Python : le vôtre, ou celui proposé ci-dessous" + LangString McpPython ${LANG_FRENCH} "Python pour l'assistant IA" + LangString var7 ${LANG_FRENCH} "Python de python.org, utilisé uniquement par le composant Assistant IA. Inutile si Python est déjà installé (environ 12 Mo)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index 9313f6c90..afae58e03 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -25,6 +25,10 @@ LangString var3 ${LANG_HUNGARIAN} "Rajzfejléc-példák" LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" + LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_HUNGARIAN} "Python for the AI assistant" + LangString var7 ${LANG_HUNGARIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -52,6 +56,10 @@ LangString var3 ${LANG_JAPANESE} "表題欄の例" LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" + LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_JAPANESE} "Python for the AI assistant" + LangString var7 ${LANG_JAPANESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -79,6 +87,10 @@ LangString var3 ${LANG_MONGOLIAN} "Гарчгийн блокын жишээнүүд" LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" + LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_MONGOLIAN} "Python for the AI assistant" + LangString var7 ${LANG_MONGOLIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -106,6 +118,10 @@ LangString var3 ${LANG_NORWEGIAN} "Eksempler på titelblokker" LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" + LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_NORWEGIAN} "Python for the AI assistant" + LangString var7 ${LANG_NORWEGIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -143,6 +159,10 @@ LangString var3 ${LANG_PORTUGUESEBR} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" + LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESEBR} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESEBR} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -170,6 +190,10 @@ LangString var3 ${LANG_SERBIAN} "Примери заглавља" LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" + LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SERBIAN} "Python for the AI assistant" + LangString var7 ${LANG_SERBIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -197,6 +221,10 @@ LangString var3 ${LANG_SLOVAK} "Príklady popisových polí" LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" + LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVAK} "Python for the AI assistant" + LangString var7 ${LANG_SLOVAK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -224,6 +252,10 @@ LangString var3 ${LANG_SLOVENIAN} "Primeri glav risb" LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" + LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVENIAN} "Python for the AI assistant" + LangString var7 ${LANG_SLOVENIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -251,6 +283,10 @@ LangString var3 ${LANG_SWEDISH} "Exempel på ritningshuvuden" LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" + LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SWEDISH} "Python for the AI assistant" + LangString var7 ${LANG_SWEDISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -278,6 +314,10 @@ LangString var3 ${LANG_TURKISH} "Başlık bloğu örnekleri" LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" + LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_TURKISH} "Python for the AI assistant" + LangString var7 ${LANG_TURKISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -305,6 +345,10 @@ LangString var3 ${LANG_UKRAINIAN} "Приклади штампів" LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" + LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_UKRAINIAN} "Python for the AI assistant" + LangString var7 ${LANG_UKRAINIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -332,4 +376,8 @@ LangString var3 ${LANG_SIMPCHINESE} "标题栏示例" LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" + LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SIMPCHINESE} "Python for the AI assistant" + LangString var7 ${LANG_SIMPCHINESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg b/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg index c5d0b0a7f..5de5bbc75 100644 --- a/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg +++ b/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg @@ -1,5 +1,12 @@ Windows Registry Editor Version 5.00 +[-HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] +[-HKEY_CURRENT_USER\Software\Classes\.qet] +[-HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] +[-HKEY_CURRENT_USER\Software\Classes\.elmt] +[-HKEY_CURRENT_USER\Software\Classes\qet_element_file] +[-HKEY_CURRENT_USER\Software\Classes\.titleblock] +[-HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] [-HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] [-HKEY_CLASSES_ROOT\.qet] [-HKEY_CLASSES_ROOT\qet_diagram_file] diff --git a/build-aux/windows/nsis_base/files/register_filetypes.bat b/build-aux/windows/nsis_base/files/register_filetypes.bat index 29b429367..d0f2d69be 100644 --- a/build-aux/windows/nsis_base/files/register_filetypes.bat +++ b/build-aux/windows/nsis_base/files/register_filetypes.bat @@ -5,7 +5,9 @@ rem detecte le dossier courant et suppose que celui-ci contient bin\qelectrotech set current_dir=%~dp0 cd /d %current_dir% -set expected_qet_exe=%current_dir%Lancer QET.bat +rem l'archive portable fournit "Lancer QET_qt6.bat", l'installeur "Lancer QET.bat" +set expected_qet_exe=%current_dir%Lancer QET_qt6.bat +if not exist "%expected_qet_exe%" set expected_qet_exe=%current_dir%Lancer QET.bat set expected_project_ico=%current_dir%ico\application-x-qet-project.ico set expected_element_ico=%current_dir%ico\application-x-qet-element.ico set expected_titleblock_ico=%current_dir%ico\application-x-qet-titleblock.ico @@ -45,49 +47,50 @@ set final_element_ico=%expected_element_ico:\=\\% set final_titleblock_ico=%expected_titleblock_ico:\=\\% rem genere le fichier .reg pour enregistrer les associations de fichiers +rem HKEY_CURRENT_USER : ne demande pas les droits administrateur set reg_file=qet_install_file_associations.reg ( echo Windows Registry Editor Version 5.00 echo. rem Declaration de l'application - echo [HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.qet - echo [HKEY_CLASSES_ROOT\.qet] + echo [HKEY_CURRENT_USER\Software\Classes\.qet] echo @="qet_diagram_file" - echo [HKEY_CLASSES_ROOT\qet_diagram_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] echo @="QET diagram" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_diagram_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\DefaultIcon] echo @="%final_project_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_diagram_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.elmt - echo [HKEY_CLASSES_ROOT\.elmt] + echo [HKEY_CURRENT_USER\Software\Classes\.elmt] echo @="qet_element_file" - echo [HKEY_CLASSES_ROOT\qet_element_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file] echo @="QET element" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_element_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\DefaultIcon] echo @="%final_element_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_element_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.titleblock - echo [HKEY_CLASSES_ROOT\.titleblock] + echo [HKEY_CURRENT_USER\Software\Classes\.titleblock] echo @="qet_titleblock_file" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] echo @="QET title block template" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\DefaultIcon] echo @="%final_titleblock_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" ) > %reg_file% diff --git a/cmake/define_definitions.cmake b/cmake/define_definitions.cmake index c7f326abf..89ea2c6d4 100644 --- a/cmake/define_definitions.cmake +++ b/cmake/define_definitions.cmake @@ -63,7 +63,7 @@ message("QET_DIR :" ${QET_DIR}) message("GIT_COMMIT_SHA :" ${GIT_COMMIT_SHA}) if(BUILD_WITH_KF) - message("KF_GIT_TAG :" ${KF_GIT_TAG}) + message("KF_GIT_TAG :" ${KF_GIT_TAG}) else() add_definitions(-DBUILD_WITHOUT_KF) endif() diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 276e840ac..a3966c5a0 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -477,6 +477,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.h ${QET_DIR}/sources/ElementsCollection/qetlabelsfile.cpp ${QET_DIR}/sources/ElementsCollection/qetlabelsfile.h + ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp + ${QET_DIR}/sources/ElementsCollection/terminaluuids.h ${QET_DIR}/sources/ElementsCollection/xmlelementcollection.cpp ${QET_DIR}/sources/ElementsCollection/xmlelementcollection.h ${QET_DIR}/sources/ElementsCollection/xmlprojectelementcollectionitem.cpp @@ -714,6 +716,10 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/aboutqetdialog.cpp ${QET_DIR}/sources/ui/aboutqetdialog.h + ${QET_DIR}/sources/ui/aiassistantdialog.cpp + ${QET_DIR}/sources/ui/aiassistantdialog.h + ${QET_DIR}/sources/ui/aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.h ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.cpp ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h ${QET_DIR}/sources/ui/alignmenttextdialog.cpp diff --git a/elements b/elements index 08e438a91..b50bdd8d1 160000 --- a/elements +++ b/elements @@ -1 +1 @@ -Subproject commit 08e438a919eb42ec191138d4178a5c0957d7474c +Subproject commit b50bdd8d1b3986370b0b76807e65a4ee2efaa2b4 diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 377a7d4b0..cce9d1fda 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -267,12 +267,19 @@ 128x128/project.png 128x128/settings.png 128x128/terminalstrip.png + scalable/align-horizontal-center.svg + scalable/align-horizontal-left.svg + scalable/align-horizontal-right.svg + scalable/align-vertical-bottom.svg + scalable/align-vertical-center.svg + scalable/align-vertical-top.svg scalable/diagram.svg scalable/folio-delete.svg scalable/folio-new.svg scalable/folio-properties.svg scalable/label.svg scalable/pdf-import.svg + scalable/snap-to-grid.svg breeze-icons/scalable/apps/hidef/edit-opacity.svg breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg @@ -457,12 +464,19 @@ themes/qet-dark/48x48/view-pim-journal.png themes/qet-dark/128x128/plasmagik.png themes/qet-dark/128x128/settings.png + themes/qet-dark/scalable/align-horizontal-center.svg + themes/qet-dark/scalable/align-horizontal-left.svg + themes/qet-dark/scalable/align-horizontal-right.svg + themes/qet-dark/scalable/align-vertical-bottom.svg + themes/qet-dark/scalable/align-vertical-center.svg + themes/qet-dark/scalable/align-vertical-top.svg themes/qet-dark/scalable/diagram.svg themes/qet-dark/scalable/folio-delete.svg themes/qet-dark/scalable/folio-new.svg themes/qet-dark/scalable/folio-properties.svg themes/qet-dark/scalable/label.svg themes/qet-dark/scalable/pdf-import.svg + themes/qet-dark/scalable/snap-to-grid.svg themes/qet-dark/scalable/edit-opacity.svg themes/qet-dark/scalable/image-flip-horizontal.svg themes/qet-dark/scalable/image-flip-vertical.svg diff --git a/ico/scalable/align-horizontal-center.svg b/ico/scalable/align-horizontal-center.svg new file mode 100644 index 000000000..f4dc74ef1 --- /dev/null +++ b/ico/scalable/align-horizontal-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-horizontal-left.svg b/ico/scalable/align-horizontal-left.svg new file mode 100644 index 000000000..80af690e0 --- /dev/null +++ b/ico/scalable/align-horizontal-left.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-horizontal-right.svg b/ico/scalable/align-horizontal-right.svg new file mode 100644 index 000000000..d2c483ebb --- /dev/null +++ b/ico/scalable/align-horizontal-right.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-bottom.svg b/ico/scalable/align-vertical-bottom.svg new file mode 100644 index 000000000..b5c251ffe --- /dev/null +++ b/ico/scalable/align-vertical-bottom.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-center.svg b/ico/scalable/align-vertical-center.svg new file mode 100644 index 000000000..adece279e --- /dev/null +++ b/ico/scalable/align-vertical-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-top.svg b/ico/scalable/align-vertical-top.svg new file mode 100644 index 000000000..dfeb4cd04 --- /dev/null +++ b/ico/scalable/align-vertical-top.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/snap-to-grid.svg b/ico/scalable/snap-to-grid.svg new file mode 100644 index 000000000..7262c14c2 --- /dev/null +++ b/ico/scalable/snap-to-grid.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-center.svg b/ico/themes/qet-dark/scalable/align-horizontal-center.svg new file mode 100644 index 000000000..53131882f --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-left.svg b/ico/themes/qet-dark/scalable/align-horizontal-left.svg new file mode 100644 index 000000000..11cddde30 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-left.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-right.svg b/ico/themes/qet-dark/scalable/align-horizontal-right.svg new file mode 100644 index 000000000..752c2716b --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-right.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-bottom.svg b/ico/themes/qet-dark/scalable/align-vertical-bottom.svg new file mode 100644 index 000000000..57486d607 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-bottom.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-center.svg b/ico/themes/qet-dark/scalable/align-vertical-center.svg new file mode 100644 index 000000000..67f02d699 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-top.svg b/ico/themes/qet-dark/scalable/align-vertical-top.svg new file mode 100644 index 000000000..39af4483b --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-top.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/snap-to-grid.svg b/ico/themes/qet-dark/scalable/snap-to-grid.svg new file mode 100644 index 000000000..6b05eda1b --- /dev/null +++ b/ico/themes/qet-dark/scalable/snap-to-grid.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/lang/qet_de.ts b/lang/qet_de.ts index 3cc47bc69..91abb7e19 100644 --- a/lang/qet_de.ts +++ b/lang/qet_de.ts @@ -667,7 +667,7 @@ Renumber element(s)… - + Element(e) neu nummerieren… @@ -742,7 +742,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Aperçu - Vorschau + Vorschau @@ -791,13 +791,14 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Requête refusée - + Abfrage abgelehnt Erreur dans la requête : %1 - + Fehler in der Abfrage: +%1 @@ -883,12 +884,12 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Rechercher une commande… - + Eine Befehl suchen… Entrée pour lancer · Échap pour fermer - + Enter zum Starten · Esc zum Schließen @@ -930,82 +931,82 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Noir - Schwarz + Schwarz Marron - Braun + Braun Gris - Grau + Grau Bleu - Blau + Blau Vert - Grün + Grün Rouge - Rot + Rot Orange - Orange + Orange Violet - Violett + Violett Blanc - Weiß + Weiß Couleur de conducteur - + Leiterfarbe Applique une couleur aux conducteurs sélectionnés, et l'utilise pour les prochains conducteurs tracés status bar tip - + Wendet eine Farbe auf die ausgewählten Leiter an und verwendet diese für die nächsten gezeichneten Leiter Récemment utilisées - + Zuletzt verwendet Autre couleur… - + Eine andere Farbe… Modifier la couleur de %n conducteur(s) undo caption - - - + + Farbe von %n Leiter ändern + Farbe von %n Leitern ändern Choisir une couleur de conducteur - + Eine Leiterfarbe auswählen @@ -1038,24 +1039,24 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel - Eigenschaften auf alle Verbindungen dieses Potenzials übertragen + Eigenschaften auf alle Verbindungen dieses Potenzials übertragen Modifier les propriétés d'un conducteur undo caption - Leitereigenschaften ändern + Leitereigenschaften ändern Modifier les propriétés de plusieurs conducteurs undo caption - Eigenschaften von mehreren Leitern ändern + Eigenschaften von mehreren Leitern ändern Conducteur - + Leiter @@ -1510,63 +1511,63 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Blanc - Weiß + Weiß Blanc cassé - + Cremefarben Gris clair - Hellgrau + Hellgrau Gris - Grau + Grau Gris foncé - + Dunkelgrau Noir - Schwarz + Schwarz Couleur de fond du folio - + Hintergrundfarbe der Seite Choisir la couleur de fond du folio status bar tip - + Hintergrundfarbe des Seite auswählen Couleur système - + Systemfarbe Récemment utilisées - + Zuletzt verwendet Autre couleur… - + Eine andere Farbe… Choisir une couleur de fond - + Eine Hintergrundfarbe auswählen @@ -1643,7 +1644,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Cliquez pour poser le collage, Échap ou clic droit pour annuler status bar tip while positioning a paste - + Klicken Sie, um einzufügen, ESC oder rechte Maustaste zum Abbrechen. @@ -1969,7 +1970,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Renvoi de folio - + Seitenverweis @@ -2044,42 +2045,42 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Dupliquer - + Duplizieren pas de grille - + kein Raster Espacement : - + Abstand: Haut - Oben + Oben Bas - Unten + Unten Gauche - Linksbündig + Linksbündig Droite - Rechtsbündig + Rechtsbündig Direction : - + Ausrichtung: @@ -2097,7 +2098,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Redimensionner un texte d'élément - + Größe eines Elementtextes ändern @@ -2562,12 +2563,12 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. ex. 80.5 - + Beispiel 80.5 Nombre décimal avec un point comme séparateur (ex. 80.5) - + Dezimalzahl mit einem Punkt als Trennzeichen (z. B. 80.5) @@ -2779,12 +2780,12 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El ex. 80.5 - + Beispiel 80.5 Nombre décimal avec un point comme séparateur (ex. 80.5) - + Dezimalzahl mit einem Punkt als Trennzeichen (z. B. 80.5) @@ -2826,80 +2827,80 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Personnaliser la barre… - + Die Leiste anpassen… Glissez les commandes et les éléments dans la barre, hors de la barre, ou d'une place à l'autre. Un double-clic fait passer une commande ou un élément d'une liste à l'autre. - + Ziehen Sie Befehle und Elemente in die Leiste, aus der Leiste heraus oder von einer Position zur anderen. Durch einen Doppelklick wird ein Befehl oder ein Element von einer Liste in eine andere verschoben. Rechercher un élément… - + Ein Element suchen… Éléments : - + Elemente: Valeurs par défaut - + Standardwerte Annuler - + Abbrechen Terminé - + Fertig Dans la barre : - + In der Leiste: Autres commandes : - + Weitere Kommandos: Entrée pour insérer · Échap pour fermer - + Eingabe zum Einfügen · Esc zum Schließen Glisser pour changer la largeur de la barre - + Ziehen Sie den Rand, um die Breite der Leiste zu ändern Personnaliser la barre de raccourcis : %1 - + Die Schnellzugriffsleiste anpassen: %1 Aucun résultat - + Keine Ergebnisse Tapez pour rechercher un élément · Échap pour fermer - + Tippen Sie hier, um nach einem Element zu suchen · Esc zum Schließen Palette vide — glissez des éléments dans votre collection personnelle, ou tapez pour rechercher - + Leere Palette – Ziehe Elemente in deine persönliche Sammlung oder tippe, um zu suchen @@ -3595,22 +3596,22 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Importer des rapports depuis un fichier - + Berichte aus einer Datei importieren Importer... - + Importieren... Exporter tous les rapports enregistrés vers un fichier - + Alle gespeicherten Berichte in eine Datei exportieren Exporter... - + Exportieren... @@ -3646,24 +3647,24 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Exporter - Exportieren + Exportieren Aucun rapport enregistré à exporter. - + Es sind keine Berichte zum Exportieren gespeichert. Exporter les rapports - + Berichte exportieren Fichiers JSON (*.json) - + JSON-Dateien (*.json) @@ -3671,58 +3672,58 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Erreur - Fehler + Fehler Impossible d'écrire dans %1. - + %1 kann nicht beschrieben werden. Importer des rapports - + Berichte importieren Impossible de lire %1. - + %1 kann nicht gelesen werden. %1 ne contient pas des rapports QElectroTech valides. - + %1 enthält keine gültigen QElectroTech-Berichte. Importer - Import + Import Ce fichier ne contient aucun rapport. - + Diese Datei enthält keine Berichte. Rapport déjà existant - + Bereits vorhandener Bericht Un rapport nommé « %1 » existe déjà. Le remplacer ? - + Ein Bericht mit dem Namen „%1“ existiert bereits. Möchten Sie ihn ersetzen? Impossible d'écrire la configuration locale. - + Die lokale Konfiguration kann nicht geschrieben werden. %1 rapport(s) importé(s), %2 ignoré(s). - + %1 Bericht(e) importiert, %2 ignoriert. @@ -4757,12 +4758,12 @@ Texte und Zahlen sind ebenso erlaubt. Appliquez ou annulez les modifications en cours avant de déplacer - + Übernehmen oder verwerfen Sie die aktuellen Änderungen, bevor Sie den Ordner verschieben Déplacer les bornes sélectionnées vers le bornier choisi - + Die ausgewählten Klemmen an die ausgewählte Klemmenleiste verschieben @@ -4962,12 +4963,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Couleur de l'application - + Farbe der Anwendung Afficher les propriétés d'un conducteur sélectionné dans le panneau Propriétés de la sélection - + Die Eigenschaften eines ausgewählten Leiters im Fenster „Eigenschaften der Auswahl“ anzeigen @@ -4977,32 +4978,32 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Par défaut, un double-clic insère l'élément sur le folio ; l'édition reste accessible par le menu contextuel. - + Standardmäßig wird das Element durch einen Doppelklick in das Folio eingefügt; die Bearbeitung ist weiterhin über das Kontextmenü möglich. Double-cliquer dans la collection ouvre l'éditeur d'élément au lieu de l'insérer - + Ein Doppelklick in der Sammlung öffnet den Element-Editor, anstatt das Element einzufügen Après un clic qui sélectionne un élément ou un conducteur, les commandes de la barre de raccourcis apparaissent près du curseur et s'effacent quand la souris s'éloigne. - + Nach einem Klick, mit dem ein Element oder ein Leiter ausgewählt wird, erscheinen die Befehle der Schnellzugriffsleiste in der Nähe des Cursors und verschwinden wieder, sobald die Maus wegbewegt wird. Afficher les commandes près de la sélection - + Befehle neben der Auswahl anzeigen Maintenir le bouton droit et glisser dans une direction lance une commande de la barre de raccourcis. Un simple clic droit ouvre toujours le menu contextuel, au relâchement du bouton. - + Wenn Sie die rechte Maustaste gedrückt halten und in eine Richtung ziehen, wird ein Befehl aus der Schnellzugriffsleiste ausgeführt. Ein einfacher Rechtsklick öffnet wie gewohnt das Kontextmenü, sobald Sie die Taste loslassen. Gestes de la souris avec le bouton droit - + Mausaktionen mit der rechten Maustaste @@ -5027,12 +5028,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Autoriser l'exécution de scripts JavaScript (Projet > Exécuter un script, et --run) - + Ausführung von JavaScript-Skripten zulassen (Projekt > Skript ausführen und --run) Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. Désactivé par défaut ; n'exécutez que des scripts dont vous connaissez l'origine. - + Ein Skript wird mit Ihren Berechtigungen ausgeführt: Es kann das geöffnete Projekt lesen und ändern sowie Dateien schreiben. Standardmäßig deaktiviert; führen Sie nur Skripte aus, deren Herkunft Sie kennen. @@ -5106,12 +5107,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Grille des textes déplacés à la souris - + Raster der mit der Maus verschobenen Texte Fraction de la grille des folios. Maintenir Ctrl pendant le déplacement pour placer librement. - + Rasterteil der Seiten. Halten Sie beim Verschieben die Strg-Taste gedrückt, um die Elemente frei zu platzieren. @@ -5218,12 +5219,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Désactivée - + Deaktiviert Activé par la variable d'environnement QET_ENABLE_SCRIPTING ; ce réglage est sans effet tant qu'elle est définie. - + Wird durch die Umgebungsvariable QET_ENABLE_SCRIPTING aktiviert; diese Einstellung hat keine Auswirkung, solange sie gesetzt ist. @@ -5746,12 +5747,12 @@ Alle Einstellungen außer „Keine Rundung“ können je nach folgenden Faktoren Libellé - + Wortlaut Texte affiché sous l'image. Il suit l'image quand elle est déplacée, copiée ou tournée. - + Unter dem Bild angezeigter Text. Er folgt dem Bild, wenn dieses verschoben, kopiert oder gedreht wird. @@ -5782,7 +5783,7 @@ Alle Einstellungen außer „Keine Rundung“ können je nach folgenden Faktoren Modifier le libellé d'une image - + Die Bildbeschriftung ändern @@ -6052,22 +6053,22 @@ Veuillez utiliser l'éditeur avancé pour cela. Nom, label ou information de l'élément, ou case (ex. B13 ou 3-B13)… - + Name, Bezeichnung oder Angabe des Elements oder Feldes (z. B. B13 oder 3-B13)… Case %1 - + Feld %1 Folio %1, case %2 - + Seite %1, Feld %2 Folio %1 (%2), case %3 - + Seite %1 (%2), Feld %3 Nom, label ou information de l'élément… @@ -6179,7 +6180,7 @@ Veuillez utiliser l'éditeur avancé pour cela. Masquer les éléments maîtres pleins - + Gefüllte Master-Elemente ausblenden Remarque : les éléments maîtres ayant atteint leur nombre maximal d'esclaves sont masqués. @@ -6604,27 +6605,27 @@ Möchten Sie diesen Slave-Kontakt trotzdem verbinden? Définir les règles de numérotation automatique par défaut pour les nouveaux projets : - + Standardregeln für die automatische Nummerierung neuer Projekte festlegen: Conducteurs - Leiter + Leiter Eléments - Bauteile + Bauteile Folios - Seiten + Seiten Numérotation auto - Auto-Nummerierung + Auto-Nummerierung @@ -7050,7 +7051,7 @@ Möchten Sie diesen Slave-Kontakt trotzdem verbinden? Masquer les éléments connectés - + Verbundene Elemente ausblenden @@ -7323,12 +7324,12 @@ Folgende Variablen sind inkompatibel: Importer depuis un autre projet... - + Aus einem anderen Projekt importieren... Reprendre les numérotations automatiques enregistrées dans un autre projet - + Die in einem anderen Projekt gespeicherten automatischen Nummerierungen übernehmen @@ -7344,75 +7345,75 @@ Folgende Variablen sind inkompatibel: Importer les numérotations d'un projet - + Nummerierungen aus einem Projekt importieren Projet QElectroTech (*.qet) - QElectroTech-Projekt (*.qet) + QElectroTech-Projekt (*.qet) Import impossible - + Import nicht möglich Impossible d'ouvrir %1 - + %1 kann nicht geöffnet werden %1 n'est pas un projet QElectroTech valide. - + %1 ist kein gültiges QElectroTech-Projekt. Aucune numérotation - + Keine Nummerierungvvvvvv Ce projet ne contient aucune numérotation automatique. - + Dieses Projekt enthält keine automatische Nummerierung. Numérotations à importer - + Zu importierende Nummerierungen Numérotations trouvées dans %1 : - + In %1 gefundene Nummerierungen: %1 : %2 (existe déjà) - + %1: %2 (existiert bereits) Remplacer les numérotations de même nom - + Nummerierungen mit demselben Namen ersetzen Import terminé - + Import abgeschlossen %1 numérotation(s) importée(s), %2 conservée(s) telles quelles. - + %1 Nummerierung(en) importiert, %2 unverändert beibehalten. %1 numérotation(s) importée(s). - + %1 importierte Nummerierung(en). @@ -8741,24 +8742,24 @@ Verfügbare Optionen: Dupli&quer - + Duplizieren Copie la sélection, décalée de l'espacement configuré status bar tip - + Kopiert die Auswahl, versetzt um den festgelegten Abstand Configurer la duplication... - + Duplizierung einrichten... Choisir l'espacement et la direction utilisés par Dupliquer status bar tip - + Abstand und Richtung für „Duplizieren“ auswählen @@ -8775,18 +8776,19 @@ Verfügbare Optionen: Grille des textes - + Textübersicht Grille d'accrochage des textes déplacés à la souris. Maintenir Ctrl pendant le déplacement pour placer librement. - + Raster zum Ausrichten von Texten, die mit der Maus verschoben werden. +Halten Sie während des Verschiebens die Strg-Taste gedrückt, um die Texte frei zu platzieren. Désactivée - + Deaktiviert @@ -8801,22 +8803,22 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Garder les en-têtes visibles - + Kopfzeilen sichtbar lassen Garde les numéros de colonne et les lettres de ligne du folio visibles au bord de la vue - + Lass die Spaltennummern und Zeilenbuchstaben des Folios am Rand der Ansicht sichtbar bleiben Afficher les limites des cases - + Rahmen anzeigen Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement - + Zeichne die Begrenzungslinien der Spalten und Zeilen des Folios auf der Zeichnung ein – allerdings nur auf dem Bildschirm @@ -8853,22 +8855,22 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Recharger les dessins des éléments - + Zeichnungen der Elemente neu laden Redessine chaque élément placé d'après sa définition actuelle, sans avoir à fermer et rouvrir le projet (action non annulable) - + Jedes platzierte Element wird entsprechend seiner aktuellen Definition neu gezeichnet, ohne dass das Projekt geschlossen und erneut geöffnet werden muss (Aktion kann nicht rückgängig gemacht werden) Exécuter un script... - + Ein Skript ausführen... Exécute un script JavaScript sur le projet courant (voir qet.* dans le script pour l'API disponible) - + Führt ein JavaScript-Skript für das aktuelle Projekt aus (siehe „qet.*“ im Skript für die verfügbare API) @@ -8901,57 +8903,57 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Insérer le dernier élément - + Das letzte Element einfügen Place à nouveau le dernier élément inséré status bar tip - + Füge das zuletzt eingefügte Element erneut ein Rechercher une commande… - + Einen Befehl suchen… Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer status bar tip - + Geben Sie einen Teil des Befehlsnamens ein und drücken Sie die Eingabetaste, um den Befehl auszuführen Insérer un élément… - + Ein Element einfügen… Ouvre le sélecteur d'éléments à la position du curseur status bar tip - + Öffnet die Elementauswahl an der Cursorposition Barre de raccourcis - + Schnellzugriffsleiste Ouvre à la position du curseur les commandes utiles pour la sélection, et le sélecteur d'éléments status bar tip - + Öffnet an der Cursorposition die für die Auswahl nützlichen Befehle sowie die Elementauswahl Répéter la dernière commande - + Den letzten Befehl wiederholen Relance le dernier outil de dessin ou la dernière insertion d'élément (Entrée sur le folio) status bar tip - + Das zuletzt verwendete Zeichenwerkzeug oder die zuletzt eingefügte Komponente erneut aufrufen (Eingabe auf dem Folio) @@ -8962,35 +8964,35 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Aligner sur la grille - + Am Raster ausrichten Remet les éléments, images et textes sélectionnés sur la grille status bar tip - + Setzt die ausgewählten Elemente, Bilder und Texte wieder auf das Raster Sélectionner tous les conducteurs - + Alle Leiter auswählen Sélectionner tous les champs de texte - + Alle Textfelder auswählen Sélectionne tous les conducteurs du folio, désélectionne le reste status bar tip - + Wählt alle Leiter des Seite aus, hebt die Auswahl der übrigen auf Sélectionne tous les champs de texte du folio, désélectionne le reste status bar tip - + Wählt alle Textfelder des Seite aus, hebt die Auswahl der übrigen Felder auf @@ -9107,17 +9109,17 @@ Maintenir Ctrl pendant le déplacement pour placer librement. A&jouter - + Hinzufügen Aligner - + Ausrichten Lignes et colonnes - + Zeilen und Spalten @@ -9198,21 +9200,21 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene %n objet(s) remis sur la grille - - + + %n Objekt(e) auf das Raster gesetzt La sélection est déjà sur la grille - + Die Auswahl steht bereits in der Startaufstellung (%n objet(s) verrouillé(s) laissé(s) en place) - - + + (%n gesperrte Objekte wurden an Ort und Stelle belassen) @@ -9230,52 +9232,52 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene Répéter : insérer « %1 » - + Wiederholen: „%1“ einfügen Répéter : %1 - + Wiederholen: %1 folio %1 - + Seite %1 %n élément(s) redessiné(s). - - + + %n Element(e) wurden neu gestaltet. %n élément(s) dont la définition est introuvable ou illisible : leur dessin actuel a été conservé. - - + + %n Element(e), deren Definition nicht gefunden werden konnte oder unlesbar ist: Ihre aktuelle Darstellung wurde beibehalten. %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). - - + + %n nicht neu gezeichnetes Element: Seine Größe, sein Griffpunkt oder seine Begrenzungen haben sich geändert (Begrenzung hinzugefügt, gelöscht oder verschoben). Pour les mettre à jour, il faut les supprimer puis les réinsérer. Attention : cette opération supprime les conducteurs déjà reliés à ces éléments, qu'il faudra retracer. - + Um sie zu aktualisieren, müssen sie gelöscht und anschließend erneut eingefügt werden. Achtung: Bei diesem Vorgang werden die bereits mit diesen Elementen verbundenen Leiter gelöscht, die dann neu verbunden werden müssen. Exécuter un script - + v @@ -9284,18 +9286,22 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. N'exécutez que des scripts dont vous connaissez l'origine. Activer les scripts ? Ce réglage est modifiable dans Configurer QElectroTech > Général > Projets. - + Skripte sind deaktiviert. + +Ein Skript wird mit Ihren Berechtigungen ausgeführt: Es kann das geöffnete Projekt lesen und ändern sowie Dateien schreiben. Führen Sie nur Skripte aus, deren Herkunft Sie kennen. + +Skripte aktivieren? Diese Einstellung kann unter „QElectroTech konfigurieren“ > „Allgemein“ > „Projekte“ geändert werden. Scripts JavaScript (*.js);;Tous les fichiers (*) - + JavaScript-Skripte (*.js);;Alle Dateien (*) Textes %1 - + Texte %1 @@ -10369,12 +10375,12 @@ Die erforderlichen Bedingungen wurden nicht erfüllt Renumber elements - + Elemente neu nummerieren Renumber elements (%1) - + Elemente neu nummerieren (%1) @@ -10456,17 +10462,17 @@ Was möchten Sie tun? Impossible de vérifier la requête : la base de données ne peut pas être mise en lecture seule. - + Die Abfrage kann nicht überprüft werden: Die Datenbank kann nicht in den schreibgeschützten Modus versetzt werden. Seules les requêtes en lecture seule sont autorisées : cette requête modifierait la base de données. - + Es sind nur Lesezugriffe zulässig: Dieser Zugriff würde die Datenbank verändern. Requête SQL invalide : %1 - + Ungültige SQL-Abfrage: %1 @@ -11125,7 +11131,7 @@ Was möchten Sie tun? %n élément(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Bauteil %n Bauteile @@ -11134,7 +11140,7 @@ Was möchten Sie tun? %n conducteur(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Leiter %n Leiter @@ -11143,7 +11149,7 @@ Was möchten Sie tun? %n champ(s) de texte Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Textfeld %n Textfelder @@ -11152,16 +11158,16 @@ Was möchten Sie tun? %n image(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - - - + + %n Bild + %n Bilder %n forme(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Form %n Formen @@ -11170,7 +11176,7 @@ Was möchten Sie tun? %n texte(s) d'élément Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Bauteiltext %n Bauteiltexte @@ -11179,7 +11185,7 @@ Was möchten Sie tun? %n tableau(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Tabelle %n Tabellen @@ -11188,9 +11194,9 @@ Was möchten Sie tun? %n plan(s) de bornes Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - - - + + %n Klemmenplan + %n Klemmenpläne @@ -11610,7 +11616,7 @@ Was möchten Sie tun? Macros - + Makros Makros @@ -11712,16 +11718,16 @@ Der QET-Name dieses Ordners konnte nicht gelesen werden; stattdessen wird der Or %n texte(s) - - - + + %n Text + %n Texte Pivoter %1 - + Drehen %1 @@ -11946,17 +11952,17 @@ Möchten Sie sie ersetzen? Largeur [mm] - + Breite [mm] Hauteur [mm] - + Höhe [mm] Profondeur [mm] - + Tiefe [mm] @@ -12679,17 +12685,17 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Grille des textes %1. Relâcher Maj et maintenir Ctrl pour placer librement. - + Textraster %1. Lassen Sie die Umschalttaste los und halten Sie die Strg-Taste gedrückt, um die Texte frei zu platzieren. Grille des textes désactivée. - + Textgitter deaktiviert. %n texte(s) d'élément - + %n Bauteiltext %n Bauteiltexte @@ -12698,9 +12704,9 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta %n groupe(s) de textes - - - + + %n Textgruppe + %n Textgruppen @@ -12708,222 +12714,222 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Déplacer %1 - + %1 verschieben Ajouter une borne à un groupe de bornes - + Eine Klemme zu einer Klemmengruppe hinzufügen Ajouter la borne %1 à un groupe de bornes - + Klemme %1 zu einer Klemmengruppe hinzufügen Ajouter une borne au groupe de bornes %1 - + Eine Klemme zur Klemmengruppe %1 hinzufügen Ajouter la borne %1 au groupe de bornes %2 - + Klemme %1 zur Klemmengruppe %2 hinzufügen Ajouter %n borne(s) à un groupe de bornes - - - + + %n Klemme zu einer Klemmengruppe hinzufügen + %n Klemmen zu einer Klemmengruppe hinzufügen Ajouter %n borne(s) au groupe de bornes %1 - - - + + %n Klemme zur Klemmengruppe %1 hinzufügen + %n Klemmen zur Klemmengruppe %1 hinzufügen Enlever %n borne(s) d'un groupe de bornes - - - + + %n Klemme aus einer Klemmengruppe entfernen + %n Klemmen aus einer Klemmengruppe entfernen Enlever %n borne(s) du groupe de bornes %1 - - - + + %n Klemme aus der Klemmengruppe %1 entfernen + %n Klemmen aus der Klemmengruppe %1 entfernen Déplacer une borne d'un groupe de bornes vers un autre groupe de bornes - + Eine Klemme von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1 - + Einen Klemmenpunkt aus einer Klemmleiste in die Klemmenleiste %1 verschieben Déplacer une borne du groupe de bornes %1 vers un autre groupe de bornes - + Einen Klemmenpunkt aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2 - + Eine Klemme aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben Déplacer la borne %1 d'un groupe de bornes vers un autre groupe de bornes - + Klemme %1 von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2 - + Klemme %1 von einer Klemmenleiste in die Klemmenleiste %2 verschieben Déplacer la borne %1 du groupe de bornes %2 vers un autre groupe de bornes - + Klemme %1 der Klemmenleiste %2 in eine andere Klemmenleiste verschieben Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3 - + Klemme %1 von der Klemmenleiste %2 in die Klemmenleiste %3 verschieben Déplacer %n borne(s) d'un groupe de bornes vers un autre groupe de bornes - - - + + %n Klemme von einer Klemmenleiste in eine andere Klemmenleiste verschieben + %n Klemmen von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1 - - - + + %n Klemme aus einer Klemmenleiste in die Klemmenleiste %1 verschieben + %n Klemmen aus einer Klemmenleiste in die Klemmenleiste %1 verschieben Déplacer %n borne(s) du groupe de bornes %1 vers un autre groupe de bornes - - - + + %n Klemme aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben + %n Klemmen aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2 - - - + + %n Klemme aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben + %n Klemmen aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben Aligner %n objet(s) sur la grille - - - + + %n Objekt am Raster ausrichten + %n Objekte am Raster ausrichten Ajouter %1 - Einfügen von .%1 + Einfügen von .%1 Modifier les propriétés du conducteur - + Eigenschaften des Leiters bearbeiten Modifier un texte - + Einen Text bearbeiten Modifier la couleur d'un texte - Farbe eines Texts ändern + Farbe eines Texts ändern Pivoter un texte - + Einen Text drehen Modifier le trait d'une forme - Linie einer Form ändern + Linie einer Form ändern Modifier le remplissage d'une forme - Füllung einer Form ändern + Füllung einer Form ändern Pivoter une forme - + Eine Form drehen Modifier la forme d'%1 - Form von %1 ändern + Form von %1 ändern Fermer/Ouvrir %1 - + %1 schließen/öffnen Redimensionner une image - Die Größe eines Bildes ändern + Die Größe eines Bildes ändern Pivoter une image - + Ein Bild drehen Modifier un texte d'élément - Text eines Bauteils bearbeiten + Text eines Bauteils bearbeiten Numéroter automatiquement un élément - Ein Element automatisch nummerieren + Ein Element automatisch nummerieren Rechercher et remplacer - + Suchen und Ersetzen Script - + Skript @@ -12932,7 +12938,11 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Un script a accès à l'ensemble du projet et peut écrire des fichiers, aussi cette fonction est-elle désactivée par défaut. Pour l'activer : Configurer QElectroTech > Général > Projets, ou définir la variable d'environnement QET_ENABLE_SCRIPTING=1 pour une exécution sans interface (CI, traitement par lot). - + Skripte sind deaktiviert. + +Ein Skript hat Zugriff auf das gesamte Projekt und kann Dateien schreiben, daher ist diese Funktion standardmäßig deaktiviert. + +So aktivieren Sie sie: QElectroTech konfigurieren > Allgemein > Projekte, oder setzen Sie die Umgebungsvariable QET_ENABLE_SCRIPTING=1 für eine Ausführung ohne Benutzeroberfläche (CI, Stapelverarbeitung). @@ -13409,27 +13419,27 @@ Bitte fügen Sie eine neue Tabelle hinzu oder passen Sie die vorhandene Tabelle Renumber element(s) - + Element(e) neu nummerieren Scope - + Umfang All schemes - + Alle Schemen One scheme - + Ein Schema Scheme: - + Schema: @@ -14517,53 +14527,53 @@ Andere Felder werden nicht verwendet. La barre de raccourcis s'ouvre à la position du curseur (touche S par défaut). Elle montre les commandes choisies ici selon ce qui est sélectionné, puis le sélecteur d'éléments. - + Die Schnellzugriffsleiste öffnet sich an der Cursorposition (standardmäßig Taste „S“). Sie zeigt zunächst die hier ausgewählten Befehle entsprechend der aktuellen Auswahl an und anschließend die Elementauswahl. Ajouter → - + Hinzufügen → ← Retirer - + ← Entfernen Monter - + Nach oben Descendre - + Nach unten Valeurs par défaut - + Standardwerte Commandes disponibles - + Verfügbare Befehle Dans la barre, dans l'ordre - + In der Leiste, in der Reihenfolge Contexte : - + Hintergrund: Barre de raccourcis configuration page title - + Schnellzugriffsleiste @@ -14571,17 +14581,17 @@ Andere Felder werden nicht verwendet. Folio, rien de sélectionné - + Seite, nichts ausgewählt Éléments sélectionnés - + Ausgewählte Elemente Conducteurs sélectionnés - + Ausgewählte Leiter @@ -18491,12 +18501,12 @@ die Variable "volta" kombiniert mit dem Wert "1745" lässt i Afficher dans le peigne de contacts tous les contacts esclaves définis par le maître, même ceux qui ne sont pas encore reliés, dans l'ordre défini par le maître - + Alle vom Master definierten Slave-Kontakte im Kontaktfeld anzeigen, auch diejenigen, die noch nicht verbunden sind, und zwar in der vom Master festgelegten Reihenfolge Afficher tous les esclaves définis par le maître - + Alle vom Master definierten Slaves anzeigen @@ -18641,17 +18651,17 @@ die Variable "volta" kombiniert mit dem Wert "1745" lässt i La requête est vide. - + Die Abfrage ist leer. Une seule requête SELECT est autorisée (le caractère ';' ne peut apparaître qu'à la toute fin). - + Es ist nur eine SELECT-Abfrage zulässig (das Zeichen „;“ darf nur ganz am Ende stehen). Seules les requêtes en lecture seule (SELECT ou WITH ... SELECT) sont autorisées. - + Es sind ausschließlich Leseabfragen (SELECT oder WITH ... SELECT) zulässig. diff --git a/lang/qet_en.ts b/lang/qet_en.ts index 5b308acbb..e76b18c19 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -865,7 +865,7 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Dimensions du folio - Dimensions of folio + Folio size @@ -996,8 +996,8 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Modifier la couleur de %n conducteur(s) undo caption - - + Change the colour of %n conductor + Change the colour of %n conductors @@ -3045,12 +3045,12 @@ The element's display name is edited separately in the element properties.< Renvoi de folio suivant - Reference folio following + Next folio reference Renvoi de folio précédent - Previous reference folio + Previous folio reference @@ -3281,7 +3281,7 @@ The element's display name is edited separately in the element properties.< Réf. - Réf. + Ref. @@ -3629,7 +3629,7 @@ The element's display name is edited separately in the element properties.< Titre du folio - Title of folio + Folio title @@ -4040,16 +4040,16 @@ By importing this file, you confirm that: %n élément(s), répartie(s) - %n element, part - %n elements, parts + %n element, spread + %n elements, spread dans %n dossier(s). - in %n folder. - in %n folders. + across %n folder. + across %n folders. @@ -4215,7 +4215,7 @@ By importing this file, you confirm that: Remonter ce folio - Move up this folio + Move this folio up @@ -4225,17 +4225,17 @@ By importing this file, you confirm that: Remonter ce folio x10 - Move up this folio x10 + Move this folio up x10 Remonter ce folio x100 - Move up this folio x100 + Move this folio up x100 Remonter ce folio au debut - Move up this folio to the beginning + Move this folio to the beginning @@ -4381,7 +4381,7 @@ By importing this file, you confirm that: Titre du folio - Title of folio + Folio title @@ -4477,7 +4477,7 @@ By importing this file, you confirm that: Exporter entièrement le folio - Export fully folio + Export the whole folio @@ -4770,7 +4770,7 @@ that you create. Text and number inputs are Référence croisé - Cross reference + Cross-reference @@ -5609,8 +5609,8 @@ Any setting other than “No rounding” may cause rendering errors in the proje <center>ATTENTION :</center> il manque %1 lignes afin d'afficher l'intégralité des informations - <center>ATTENTION :</center> - lines are missing %1 to display all the informations + <center>WARNING:</center> + %1 more rows are needed to display all the information @@ -6195,13 +6195,13 @@ Please use the advanced editor for this. N° de folio - N° of folio + Folio no. Label de folio - Label folio + Folio label @@ -6209,7 +6209,7 @@ Please use the advanced editor for this. Titre de folio - Title of folio + Folio title @@ -6297,12 +6297,12 @@ Please use the advanced editor for this. Report de folio - Folio referencing + Folio reference Référence croisée (esclave) - Cross Reference (slave) + Cross-reference (slave) @@ -6364,18 +6364,18 @@ Please use the advanced editor for this. N° de folio - N° of folio + Folio no. Label de folio - Label folio + Folio label Titre de folio - Title of folio + Folio title @@ -6583,12 +6583,12 @@ Do you still want to link this slave contact? Reports de folio - Folio referencings + Folio references Références croisées - Cross References + Cross-references @@ -6816,7 +6816,7 @@ Do you still want to link this slave contact? N° folio - N° scheme + Folio no. @@ -7216,7 +7216,7 @@ Voltage / Protocol : %1 Numéro : %1 -Numéro : %1 +Number: %1 @@ -7265,7 +7265,7 @@ Conductor section : %1 Veuillez saisir une formule compatible pour ce potentiel. Les variables suivantes sont incompatibles : %sequf_ %seqtf_ %seqhf_ %id %F %M %LM - The new potential formula contains variables incompatible with the folio reports. + The new potential formula contains variables incompatible with folio references. Please enter a compatible formula for this potential. The following variables are incompatible: %sequf_ %seqtf_ %seqhf_ %id %F %M %LM @@ -8562,7 +8562,7 @@ Available options: Édite les propriétés du folio (dimensions, informations du cartouche, propriétés des conducteurs...) status bar tip - Edits the properties of the folio (size, title block informations, conductor properties...) + Edits the properties of the folio (size, title block information, conductor properties...) @@ -9172,12 +9172,12 @@ Hold Ctrl while moving to place freely. Ajoute une courbe de Bézier sur le folio actuel - Adds a Bézier curve on the current sheet + Adds a Bézier curve to the current folio Ajoute un plan de bornier sur le folio actuel - Add a terminal plan on the current sheet + Adds a terminal plan to the current folio @@ -9273,8 +9273,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n objet(s) remis sur la grille - %n item placed on the grid - %n item placed on the grid + %n item put back on the grid + %n items put back on the grid @@ -9387,8 +9387,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n élément(s) redessiné(s). - %n element redesigned. - %n elements redesigned. + %n element redrawn. + %n elements redrawn. @@ -9403,8 +9403,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). - %n element not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved). - %n elements not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved). + %n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved). + %n elements not redrawn: their size, grip point or terminals have changed (a terminal was added, removed or moved). @@ -9858,7 +9858,7 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener <br><b>Erreur</b> :<br>Les reports de folio doivent posséder une seul borne.<br><b>Solution</b> :<br>Verifier que l'élément ne possède qu'une seul borne - <br> <b> Error </ b>: <br> folio referencings must have a single terminal <br> <b> Solution </ b> :<br> Check that the element has only one terminal + <br><b>Error</b>:<br>Folio references must have a single terminal.<br><b>Solution</b>:<br>Check that the element has only one terminal @@ -9871,8 +9871,8 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener %n erreur(s) errors - %n erreur - %n erreurs + %n error + %n errors @@ -11138,8 +11138,8 @@ What do you wish to do ? %n forme(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - %n forme - %n formes + %n shape + %n shapes @@ -11252,7 +11252,7 @@ What do you wish to do ? Folio sans titre - Folio Untitled + Untitled folio @@ -11645,8 +11645,8 @@ the translated name of this folder could not be read, so its folder name is disp Ajouter %n conducteur(s) add a numbers of conductor one or more - add %n conductor - add %n conductors + Add %n conductor + Add %n conductors @@ -13849,7 +13849,7 @@ E.g. associating the name "volta" with the value "1745" will Label de report de folio - Label of folio referencing + Folio reference label @@ -13861,14 +13861,14 @@ Créer votre propre texte en vous aidant des variables suivantes : %LM : la localisation %l : le numéro de ligne %c : le numéro de colonne - You can define a custom label for folio reports. -Create your own text by using the following variables: -% f: the folio position in the project -% F: the folio number -% M: the installation -% LM: the location -% l: the line number -% c: the column number + You can define a custom label for folio references. +Create your own text using the following variables: +%f: the folio position in the project +%F: the folio number +%M: the installation +%LM: the location +%l: the row number +%c: the column number @@ -14031,7 +14031,7 @@ Create your own text by using the following variables: Eléments report de folio - Folio referencings elements + Folio reference elements @@ -17384,7 +17384,7 @@ The other fields are not used. Référence croisé - Cross reference + Cross-reference @@ -17673,7 +17673,7 @@ The other fields are not used. Informations des cartouches - Title block informations + Title block information @@ -18547,13 +18547,13 @@ associer le nom "volta" et la valeur "1745" remplacera %{vol %c : le numéro de colonne %M: Installation %LM: Localisation - Create your own text by helping you of the following variables : -%f : the folio number -% F: folio label -% l : the line number -% c : column number -% M: Plant -% LM: Location + Create your own text using the following variables: +%f: the folio number +%F: the folio label +%l: the row number +%c: the column number +%M: the installation +%LM: the location diff --git a/misc/make_icon_themes.py b/misc/make_icon_themes.py index 455f6a9e6..5e381c555 100644 --- a/misc/make_icon_themes.py +++ b/misc/make_icon_themes.py @@ -90,12 +90,19 @@ ALIASES = { # SVG icons referenced from sources/qeticons.cpp. ico/scalable/ holds the # ones drawn for QET as vectors; one file serves every size. SVGS = [ + "scalable/align-horizontal-center.svg", + "scalable/align-horizontal-left.svg", + "scalable/align-horizontal-right.svg", + "scalable/align-vertical-bottom.svg", + "scalable/align-vertical-center.svg", + "scalable/align-vertical-top.svg", "scalable/diagram.svg", "scalable/folio-delete.svg", "scalable/folio-new.svg", "scalable/folio-properties.svg", "scalable/label.svg", "scalable/pdf-import.svg", + "scalable/snap-to-grid.svg", "breeze-icons/scalable/apps/hidef/edit-opacity.svg", "breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg", "breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg", diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c8e994d9b..6beb31ff1 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -54,6 +54,9 @@ misc/qet-mcp/qet_mcp.py --list # speak MCP on stdin/stdout misc/qet-mcp/qet_mcp.py + +# run one tool and exit, no MCP client needed +misc/qet-mcp/qet_mcp.py --call qet_project_info '{"path": "drawing.qet"}' ``` Register it with an MCP client, for example: @@ -66,6 +69,7 @@ Register it with an MCP client, for example: "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"], "env": { "QET_MCP_WORKSPACE": "/home/you/drawings", + "QET_BINARY": "/usr/bin/qelectrotech", "QET_ENABLE_SCRIPTING": "1" } } @@ -73,6 +77,97 @@ Register it with an MCP client, for example: } ``` +`QET_BINARY` is the QElectroTech the tools launch. Leave it out when +`qelectrotech` is on your `PATH`, or when the server is installed with +QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also +finds the installed element collection). + +## Installed with QElectroTech + +QElectroTech's packages install the server next to the program, and from +there it finds that QElectroTech and its element collection by itself: + +| Package | Server | Finds | +|---|---|---| +| `make install`, Linux distributions | `/share/qelectrotech/mcp/qet_mcp.py` | `/bin/qelectrotech`, `/share/qelectrotech/elements` | +| Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | + +So a client configuration needs only the path to the server and the +workspace. On Windows, the installer also offers **Python for the AI +assistant** (unticked by default; always in the portable folder and the +MSI): Python from python.org in `\mcp\python`, for anyone without +a Python of their own. Then: + +```json +"command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe", +"args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] +``` + +Snap and flatpak install it too. Their QElectroTech is built to run inside +the package's sandbox, and starting it from the server has not been tested: +the tools that read a file work, exports and edits may not. If they fail, +point `QET_BINARY` at a QElectroTech installed another way. + +## Using it from the Claude app + +A web chat in a browser cannot start a program on your computer, so it +cannot run this server. The Claude desktop app for Windows and macOS can, +and it uses the same account as the website. + +1. Install Python 3.9 or later. Nothing else is needed. +2. In the desktop app, open **Settings → Developer → Edit Config**. This + opens `claude_desktop_config.json`. +3. Add the server, with your own paths: + + ```json + { + "mcpServers": { + "qet": { + "command": "python", + "args": ["C:\\path\\to\\qelectrotech\\misc\\qet-mcp\\qet_mcp.py"], + "env": { + "QET_MCP_WORKSPACE": "C:\\Users\\you\\Documents\\drawings", + "QET_ENABLE_SCRIPTING": "1" + } + } + } + } + ``` + + On macOS use `python3` and ordinary `/` paths. In JSON every `\` in a + Windows path is written `\\`. +4. Quit the app completely and start it again. The tools appear under the + chat box's tools menu. +5. If `qelectrotech` is not on your `PATH`, add `"QET_BINARY"` to the + `env` block with the full path to the executable + (`C:\\Program Files\\...\\qelectrotech.exe`, written with `\\`). + +Only files under `QET_MCP_WORKSPACE` can be read or written (see +[What the server is allowed to touch](#what-the-server-is-allowed-to-touch)). +Leave out `QET_ENABLE_SCRIPTING` if you do not want the assistant to edit +projects; see the next section for what that switches off. + +The test suite runs on Linux. The server uses nothing platform-specific, +but it has not yet been tested on Windows or macOS. + +### With only a browser + +If your web chat can run Python (on claude.ai, code execution), upload +`qet_mcp.py` together with your project and ask the assistant to use +`--call`: + +```bash +python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}' +echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check - +``` + +It prints the tool's JSON result and exits 0, or 1 if the tool reported an +error, or 2 if the call itself was malformed. The workspace rule applies as +it does in a server. The sandbox has no QElectroTech in it, so only the +tools that read files work there: `qet_project_info`, `qet_elements`, +`qet_conductors`, `qet_items`, `qet_diff`, `qet_scan`, +`qet_element_info`, `qet_element_search` and `qet_element_build`. + ## Five tools need scripting switched on A QElectroTech with JavaScript scripting switched off refuses `--run`, and @@ -116,11 +211,22 @@ Set the workspace to the folder your drawings live in. A path outside it is refused with an error naming what was allowed; symlinks are resolved first, so a link planted inside the workspace is judged by where it points. -Two arguments are deliberately **not** confined: `binary` (the -`qelectrotech` executable) and `elements_dir` (the element collection). -Those are configuration, chosen once by whoever runs the server, and both -normally live in `/usr` or a build tree — outside any sensible workspace. -Confining them would reject the ordinary case while stopping nothing. +**The client does not choose what program runs.** The tools that launch +QElectroTech use the one the server found (`QET_BINARY`, the install it +ships in, or `PATH`). A call may still name `binary`, but only as that same +file or one listed by whoever configured the server: + +| | | +|---|---| +| `QET_BINARY` | the QElectroTech to launch | +| `QET_MCP_BINARIES` | other executables a call may name, separated like `QET_MCP_WORKSPACE` (for comparing two builds) | +| `QET_MCP_ALLOW_ANY_BINARY=1` | turns the check off: a call can then run any program | +| `QET_MCP_ELEMENTS` | element collections a call may name as `elements_dir` besides the workspace and the installed one | + +Anything else is refused, even a file inside the workspace: being there +makes it readable, not runnable. Before this rule any executable a call +named was run, with the call's own paths as arguments, so text inside a +project could steer an assistant into starting another program. `QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local filesystem access with this process's privileges. It exists so that is a @@ -158,7 +264,6 @@ the answer a screenshot gave wrongly. ```json {"name": "qet_edit", "arguments": { - "binary": "/path/to/qelectrotech", "project": "in.qet", "output": "out.qet", "elements_dir": "/path/to/qelectrotech/elements", "operations": [ @@ -214,7 +319,7 @@ to emit. ```json {"name": "qet_query", "arguments": { - "binary": "/path/to/qelectrotech", "project": "industrial.qet", + "project": "industrial.qet", "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}} ``` @@ -296,6 +401,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in an index would shift. A folio saved without a uuid shows it empty: QElectroTech gives it one on load and writes it on the next save, so it appears after a first `qet_edit`. Needs `qet.folioIndex()` in the build. + The `"$id"` of an `add_folio` or `insert_folio` works the same way: it + keeps naming that folio after a later `insert_folio` or `remove_folio` in + the same run (on a build without `qet.folioUuid()`, it is the index the + folio had when it was made, as before). - **A conductor can be named by its uuid** (`qet_conductors` reports it): `set_conductor`, `move_conductor_segment` and `delete_conductor` take `"conductor": "{uuid}"` in place of `element` + `terminal`, which works @@ -305,9 +414,19 @@ Python, plus the hang guard on `addConductor` and the database refresh in `qet.conductorEnds()` in the build. A uuid names one wire, but `set_conductor` still changes the whole potential, the same as by terminal. A project saved before conductors carried a uuid has none in - the file: QElectroTech makes a new one on every load and does not save - it, so `qet_conductors` reports `uuid` as empty and those conductors are - named by `element` + `terminal` until wire uuids last (#1103). + the file until it is saved once: since #1107 QElectroTech works one out + from the wire's two ends on load and writes it on the next save, so it + appears after a first `qet_edit`. +- **A terminal can be named by its uuid**: `terminal`, `from_terminal` and + `to_terminal` take the terminal's uuid (as `qet_element_info` lists it) + in place of its index, on the op's own element (for `add_conductor`, on + that end's element). It is turned at run time into the index the call + takes; if the element has no terminal with it the op fails and its + `note` says so. Unlike the index, which is a sort by position, it is + defined between two terminals at the same point. Needs + `qet.terminalIndex()` in the build. A symbol file saved without terminal + uuids lists them empty; QElectroTech gives the terminals of every + project's copy of it a uuid on opening (#1118), written on the next save. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* @@ -347,7 +466,8 @@ Python, plus the hang guard on `addConductor` and the database refresh in changes nothing. `addElement` and the move/delete verbs shipped with the scripting API; `addConductor`, `rotateElement`, `setElementLabel`, `setElementInfo` and `setFolioTitle` are newer. -- **`elements_dir` is not optional for `common://` paths.** The sandboxed +- **`elements_dir` is not optional for `common://` paths** unless the + server is installed with QElectroTech, which fills it in. The sandboxed run has its own empty HOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ran `make install` does not exist. The only symptom is `addElement` reporting that a file plainly diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 303a806d3..f3f92539c 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -47,6 +47,11 @@ PROTOCOL Line-delimited JSON-RPC 2.0 on stdin/stdout, per MCP's stdio transport. Nothing but protocol goes to stdout; diagnostics go to stderr. No third-party dependencies — the MCP SDK is not assumed to be present. + +`--call [arguments]` runs one tool without an MCP client, for an +assistant that can execute Python but cannot launch a server (a web chat +with code execution). It goes through the same dispatcher, so the +workspace policy applies exactly as it does over stdio. """ from __future__ import annotations @@ -126,12 +131,56 @@ def _wires(diagram: ET.Element): def _conductors(root: ET.Element): + definitions = _definition_terminals(root) for i, d in _folios(root): - index = _terminal_index(d) + index = _terminal_index(d, definitions) for c in _wires(d): yield i, c, index +def _uuid_key(value: str) -> str: + return (value or "").strip().strip("{}").lower() + + +# Orientation as a placed symbol's record writes it (an int, +# Qet::Orientation) or as a definition does (n/e/s/w). +_ORIENTATIONS = {"n": 0, "e": 1, "s": 2, "w": 3, "0": 0, "1": 1, "2": 2, "3": 3} + +# Where QElectroTech docks a wire, relative to the terminal's position in +# its definition (Terminal's constructor, Terminal::terminalSize = 4). A +# placed symbol's record is written at that point. +_DOCK_OFFSET = {0: (0.0, 4.0), 1: (-4.0, 0.0), 2: (0.0, -4.0), 3: (4.0, 0.0)} + + +def _definition_terminals(root: ET.Element) -> dict: + """Map each symbol stored in the project ("embed://" + its path in the + ) to its terminals: {terminal uuid: (x, y, orientation)}, + the position being the one in the definition.""" + out = {} + + def walk(node, path): + for child in node: + if child.tag == "category": + walk(child, path + [child.get("name", "")]) + elif child.tag == "element": + terminals = {} + for t in child.findall("definition/description/terminal"): + try: + terminals[_uuid_key(t.get("uuid"))] = ( + float(t.get("x")), float(t.get("y")), + _ORIENTATIONS.get((t.get("orientation") or "n")[:1], 0)) + except (TypeError, ValueError): + continue + terminals.pop("", None) + if terminals: + out["embed://" + "/".join(path + [child.get("name", "")])] = terminals + + collection = root.find("collection") + if collection is not None: + walk(collection, []) + return out + + def _element_row(folio: int, el: ET.Element) -> dict: info = _element_info(el) etype = el.get("type", "") @@ -147,7 +196,7 @@ def _element_row(folio: int, el: ET.Element) -> dict: } -def _terminal_index(diagram: ET.Element) -> dict: +def _terminal_index(diagram: ET.Element, definitions: dict | None = None) -> dict: """Map a folio's terminal ids to an identity that survives a save. A conductor names its ends with terminal1/terminal2, which are plain @@ -170,6 +219,17 @@ def _terminal_index(diagram: ET.Element) -> dict: Conductors in the corpus carry no element1/element2 attribute -- 0 of 47 in ArduinoLCD.qet, 0 of 67 in 741.qet -- so this mapping has to be built from the elements rather than read off the conductor. + + A conductor can also name its ends by terminal uuid (element1 + + terminal1), and QElectroTech writes that form as soon as the terminal + has a uuid -- which, since a project gives every terminal one on + opening, is the first save of any older file. So the same untouched + conductor is written in the numbered form before a save and the uuid + form after it. With @p definitions (from _definition_terminals()), a + uuid end is resolved too, keyed (element uuid, terminal uuid), to the + very same identity as the numbered end: the terminal's definition + position, moved to where the wire docks, is where the placed symbol's + record is. """ index = {} for el in diagram.iter("element"): @@ -184,12 +244,26 @@ def _terminal_index(diagram: ET.Element) -> dict: # marked with a "#" so the caller can see the diff is on the # unstable footing that file forces. continue + records = [] for t in el.iter("terminal"): tid = t.get("id") if tid is None: continue - index[tid] = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}" - f",{t.get('orientation','?')}") + key = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}" + f",{t.get('orientation','?')}") + index[tid] = key + records.append((t, key)) + for tuuid, (x, y, o) in (definitions or {}).get(el.get("type", ""), {}).items(): + dx, dy = _DOCK_OFFSET[o] + for t, key in records: + try: + if (abs(float(t.get("x")) - (x + dx)) < 1e-6 + and abs(float(t.get("y")) - (y + dy)) < 1e-6 + and _ORIENTATIONS.get((t.get("orientation") or "")[:1]) == o): + index[(_uuid_key(uuid), tuuid)] = key + break + except (TypeError, ValueError): + continue return index @@ -214,7 +288,8 @@ def _conductor_key(folio: int, c: ET.Element, index: dict) -> str: tid = c.get(term_attr, "?") owner = c.get(elem_attr) if owner: - ends.append(f"{owner}/{tid or c.get(name_attr, '?')}") + ends.append(index.get((_uuid_key(owner), _uuid_key(tid))) + or f"{owner}/{tid or c.get(name_attr, '?')}") else: # An id with no element behind it stays visible as itself # rather than silently collapsing conductors onto one key. @@ -809,6 +884,44 @@ def tool_element_info(path: str) -> dict: } +def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: + """The executable _run_qet() starts: a private copy, except on Windows. + + The copy gives each run its own SingleApplication key, which is derived + from the executable's path. On Windows a program loads its DLLs from its + own folder, so a copy on its own dies before main() (0xC0000135, DLL not + found) and nothing could ever be exported or edited there. Run the + original instead: every flag this server passes is a CLI export flag or + --run, and QElectroTech handles both and returns before it constructs + SingleApplication (main.cpp), so there is no instance to be handed to. + The copy stays elsewhere for builds from before that early return. + """ + if windows: + return src + exe = sandbox / f"qet-mcp-{os.getpid()}" + shutil.copy2(src, exe) + return exe + + +def _launch_env(env: dict, home: Path, windows: bool) -> dict: + """The environment _run_qet() starts QElectroTech in. + + A private HOME and XDG directories, and, except on Windows, Qt's + offscreen platform so no display is needed. The Windows packages ship + only the qwindows platform plugin: asked for "offscreen", Qt finds no + plugin and stops at a message box nobody can close, so every call hung + until its timeout. The export flags and --run open no window there, so + the default platform is what they need. + """ + env = dict(env, + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + XDG_DATA_HOME=str(home / ".local" / "share")) + if not windows: + env["QT_QPA_PLATFORM"] = "offscreen" + return env + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -850,8 +963,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"not an executable: {src}") with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp: sandbox = Path(tmp) - exe = sandbox / f"qet-mcp-{os.getpid()}" - shutil.copy2(src, exe) + exe = _launch_executable(src, sandbox, os.name == "nt") home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) @@ -868,11 +980,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") args = ["--run", str(script_path), *args] - env = dict(os.environ, - HOME=str(home), - XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share"), - QT_QPA_PLATFORM="offscreen") + env = _launch_env(dict(os.environ), home, os.name == "nt") try: p = subprocess.run([str(exe), *args], env=env, timeout=timeout, capture_output=True, text=True) @@ -1222,14 +1330,14 @@ OPS = { "set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"), ("key", "str"), ("value", "str")]), "add_conductor": ("addConductor", [("folio", "folio"), - ("from", "elmt"), ("from_terminal", "num"), - ("to", "elmt"), ("to_terminal", "num")]), + ("from", "elmt"), ("from_terminal", "term"), + ("to", "elmt"), ("to_terminal", "term")]), "delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]), "set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("property", "str"), + ("terminal", "term"), ("property", "str"), ("value", "str")]), "move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("segment", "num"), + ("terminal", "term"), ("segment", "num"), ("dx", "num"), ("dy", "num")]), "link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"), ("to_folio", "folio"), ("to", "elmt")]), @@ -1250,7 +1358,7 @@ OPS = { # uuid, which does not shift when another one is added. So # it can be named as "$id". Indexes shift when one is added or deleted. "delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num")]), + ("terminal", "term")]), "remove_folio": ("removeFolio", [("folio", "folio")]), "set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"), ("value", "str")]), @@ -1355,6 +1463,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", "indexrev", "folio", "template"] +# The ops that make a folio: their "$id" is an index the next insert_folio +# or remove_folio can shift, so the script also keeps the folio's uuid. +FOLIO_MAKING_OPS = ("add_folio", "insert_folio") + # The ops that address one conductor by element + terminal, and so also # take "conductor": "{uuid}" (qet_conductors reports each one's uuid). CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor") @@ -1394,14 +1506,19 @@ def _build_script(operations: list, output: str) -> str: JavaScript exception. """ refs: set[str] = set() + # "$name"s made by an op that creates a folio: held as the folio's uuid + # too, since a later insert_folio or remove_folio shifts its index. + folio_refs: set[str] = set() # Resolvers a uuid reference needs; required only when one is used, so # an index-only edit still runs on a build that predates them. uuid_methods: set[str] = set() folio_js = "0" element_js = None # the op's element, for lookups scoped to it + owner_js = None # the element a terminal argument belongs to lines = [ "// generated by qet-mcp; do not edit", "var R = {};", # $name -> value from an earlier op + "var F = {};", # $name -> uuid of a folio an op made "var missing = [];", "var need = @NEED@;", "for (var i = 0; i < need.length; i++) {", @@ -1439,6 +1556,16 @@ def _build_script(operations: list, output: str) -> str: "{kind: 'op_note', index: index, note: why}));", " return null;", "}", + # A terminal named by uuid is turned into the index the calls take, + # on its own element; -1, with the reason logged, if that element + # has no terminal with it. + "function qetMcpTerminal(index, key, folio, element, uuid) {", + " var t = qet.terminalIndex(folio, element, uuid);", + " if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', " + "index: index, note: key + ': no terminal ' + uuid + ' on element ' + element + " + "' (or two of its terminals carry it)'}));", + " return t;", + "}", "if (missing.length === 0) {", ] @@ -1464,6 +1591,11 @@ def _build_script(operations: list, output: str) -> str: raise ValueError( f"operation {op_index} refers to {value!r}, which no earlier " f"operation defined (set \"id\": {name!r} on the op that creates it)") + if kind == "folio" and name in folio_refs: + # the folio's index now, not when it was made; the stored + # index on a build that cannot report folio uuids + ref = _js(name) + return f"(F[{ref}] ? qet.folioIndex(F[{ref}]) : R[{ref}])" return f"R[{_js(name)}]" if kind == "num": if not isinstance(value, (int, float)) or isinstance(value, bool): @@ -1491,6 +1623,20 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {op_index}: {key!r} must be a table index " f"or its uuid, got {value!r}") return _js(value) + if kind == "term": + # A terminal's uuid comes from its symbol's definition, so it + # names a terminal only on its element: the op's element, or for + # add_conductor the end's own. Turned into the index the call + # takes at run time; unlike the index, it is defined between two + # terminals at the same point. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + uuid_methods.add("terminalIndex") + return (f"qetMcpTerminal({op_index}, {_js(key)}, {folio_js}, {owner_js}, " + f"{_js(value)})") + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a terminal index " + f"or its uuid, got {value!r}") + return _js(value) if kind == "element_text": # A field's uuid is unique only within its element (copies keep # them), so the lookup takes the op's element too. @@ -1630,6 +1776,7 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i} ({name}) is missing {key!r}") folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None + owner_js = args[-1] if args else None # a terminal's element precedes it args.append(ref_or(op[key], kind, i, key)) if conductor_js is not None: args[1], args[2] = f"e{i}.element", f"e{i}.terminal" @@ -1651,6 +1798,10 @@ def _build_script(operations: list, output: str) -> str: if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") refs.add(ident) + if name in FOLIO_MAKING_OPS: + lines.append(f" F[{_js(ident)}] = (typeof qet.folioUuid === 'function' " + f"&& v{i} >= 0) ? qet.folioUuid(v{i}) : '';") + folio_refs.add(ident) lines.append( f" qet.log({_js(_MARKER)} + JSON.stringify(" f"{{kind: 'op', index: {i}, op: {_js(name)}, " @@ -1712,7 +1863,10 @@ def _parse_script_output(text: str) -> dict: (isinstance(r, int) and not isinstance(r, bool) and r == -1)) ops.append(rec) elif rec.get("kind") == "op_note": - notes[rec.get("index")] = rec.get("note") + # An op can log more than one (add_conductor, one per end): + # keep them all rather than only the last. + idx = rec.get("index") + notes[idx] = (notes[idx] + "; " if idx in notes else "") + str(rec.get("note")) elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) @@ -2518,7 +2672,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string"}, "format": {"type": "string", "enum": sorted(EXPORT_FORMATS)}, "output": {"type": "string"}, @@ -2527,7 +2681,7 @@ TOOLS = [ "without this an existing file is never clobbered"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project", "format", "output"], + "required": ["project", "format", "output"], }, "handler": lambda a: tool_export(a["binary"], a["project"], a["format"], a["output"], a.get("timeout", 180)), @@ -2545,7 +2699,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to start from; not modified"}, "output": {"type": "string", "description": "where to write the edited project"}, "overwrite": {"type": "boolean", "default": False, @@ -2565,8 +2719,14 @@ TOOLS = [ "qet_project_info call 1 is \"folio\": 0 here. A folio can " "be given as its uuid instead (qet_project_info lists them), " "which still names the same folio after an earlier op adds or " - "removes one. Terminals are numbered by their " - "index in the element definition; qet_element_info lists them. " + "removes one; so does the \"$id\" of an add_folio or " + "insert_folio. A terminal (\"terminal\", \"from_terminal\", " + "\"to_terminal\") is given by its index -- qet_element_info " + "lists terminals in index order, top to bottom then left to " + "right -- or by its uuid, which qet_element_info also lists and " + "which, unlike the index, tells apart two terminals at the same " + "point; a terminal uuid names a terminal of the op's own " + "element (for add_conductor, of that end's element). " "set_conductor addresses a conductor as the one on a given " "terminal and applies the change to its whole electrical " "potential, so name a terminal carrying exactly one conductor, " @@ -2758,7 +2918,7 @@ TOOLS = [ }, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project", "output", "operations"], + "required": ["project", "output", "operations"], }, "handler": lambda a: tool_edit(a["binary"], a["project"], a["operations"], a["output"], a.get("elements_dir"), @@ -2776,7 +2936,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to query; never modified"}, "sql": {"type": "string", "description": "a single SELECT or WITH...SELECT. " @@ -2784,7 +2944,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_query(a["binary"], a["project"], a.get("sql", ""), a.get("elements_dir"), a.get("timeout", 180)), @@ -2811,7 +2971,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to check; never modified"}, "folio": {"type": "integer", "description": "check one folio only; omit for the whole project. An index " @@ -2822,7 +2982,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_continuity(a["binary"], a["project"], a.get("folio"), a.get("elements_dir"), a.get("timeout", 180)), @@ -2838,7 +2998,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "output": {"type": "string", "description": "where to write the new .qet"}, "title": {"type": "string", "description": "the project title"}, "folios": {"description": "how many empty folios, or a list of folio titles", @@ -2850,7 +3010,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "output", "title"], + "required": ["output", "title"], }, "handler": lambda a: tool_project_new( a["binary"], a["output"], a["title"], a.get("folios", 1), a.get("author", ""), @@ -2899,7 +3059,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string"}, "checks": {"type": "array", "items": {"type": "string", "enum": sorted(CHECKS)}, "description": "which checks to run; omit for all"}, @@ -2908,7 +3068,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_check(a["binary"], a["project"], a.get("checks"), a.get("sample", 10), a.get("elements_dir"), @@ -2988,17 +3148,21 @@ _BY_NAME = {t["name"]: t for t in TOOLS} # sandboxed HOME each QElectroTech launch gets isolates *settings*, not the # filesystem. # -# So data paths are confined to a workspace. Two kinds of path are treated -# differently, deliberately: +# So data paths are confined to a workspace, and the program the server +# launches is not the client's to choose: # # data chosen by the client per call -- the projects, directories, -# images and outputs below. Confined. -# configuration chosen once by whoever runs the server -- "binary" (the -# qelectrotech executable) and "elements_dir" (the element -# collection). Both normally live in /usr or a build tree, -# i.e. outside any sane workspace, so confining them would -# reject the ordinary case while stopping nothing: they are -# not where a model gets to point the server at /etc. +# images and outputs below. Confined to the workspace. +# executable "binary". Resolved by the server itself (resolve_binary()); +# a client may name it only when it is that same file or one +# whoever configured the server listed in QET_MCP_BINARIES. +# It once counted as configuration and went unchecked, but it +# is a per-call argument: a model steered by text in a +# project could run any program on the machine with it. +# collection "elements_dir". Normally outside the workspace (in /usr or +# a build tree), so allowed there, in the collection of the +# resolved install, or in a directory listed in +# QET_MCP_ELEMENTS. # # Enforced here, at the dispatcher, because this is the trust boundary -- # the point where model-supplied arguments enter. Calling the tool_* helpers @@ -3022,6 +3186,10 @@ _DATA_PATHS = { "qet_element_build": {"write": ("output",)}, } +# Tools that launch QElectroTech, and so take "binary" and "elements_dir". +_LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity", + "qet_check", "qet_project_new"} + # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} @@ -3060,6 +3228,118 @@ def _within_workspace(path: Path, roots: list) -> bool: return False +def _env_paths(name: str) -> list: + """An os.pathsep-separated list of paths from the environment, resolved.""" + out = [] + for part in os.environ.get(name, "").split(os.pathsep): + if part.strip(): + try: + out.append(Path(part).expanduser().resolve()) + except OSError: + continue + return out + + +def _installation() -> tuple | None: + """(program directory, element collection) of the QElectroTech this + script was installed with, or None when it runs from anywhere else. + + Two layouts, both put there by QElectroTech's own packaging: + /share/qelectrotech/mcp/ -> /bin, /share/qelectrotech/elements + (make install: Linux, snap, flatpak, macOS) + /mcp/ -> /bin, /elements + (the Windows installers and portable folder) + """ + here = Path(__file__).resolve().parent + if here.name != "mcp": + return None + if here.parent.name == "qelectrotech" and here.parent.parent.name == "share": + prefix = here.parent.parent.parent + return prefix / "bin", here.parent / "elements" + if (here.parent / "bin").is_dir(): + return here.parent / "bin", here.parent / "elements" + return None + + +def resolve_binary() -> Path | None: + """The QElectroTech this server launches, found without asking the client. + + QET_BINARY first, then the install this script ships in, then + qelectrotech on PATH. None when there is none; the tools that launch + QElectroTech then say how to set it. + """ + env = os.environ.get("QET_BINARY", "").strip() + if env: + return Path(env).expanduser().resolve() + install = _installation() + if install is not None: + # The Windows build names it QElectroTech.exe; only a case-sensitive + # file system tells the spellings apart. + for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"): + cand = install[0] / name + if cand.is_file(): + return cand.resolve() + found = shutil.which("qelectrotech") + return Path(found).resolve() if found else None + + +def default_elements_dir() -> Path | None: + """The element collection of the install this script ships in, if any.""" + install = _installation() + if install is not None and install[1].is_dir(): + return install[1].resolve() + return None + + +def _check_binary(arguments: dict) -> None: + """Fill in "binary", or refuse one that is not the server's own choice.""" + if os.environ.get("QET_MCP_ALLOW_ANY_BINARY") == "1" and arguments.get("binary"): + return + default = resolve_binary() + raw = arguments.get("binary") + if not raw: + if default is None: + raise ValueError( + "no QElectroTech found: set QET_BINARY to the qelectrotech " + "executable in the environment this server is started in") + arguments["binary"] = str(default) + return + if not isinstance(raw, str): + raise ValueError("'binary' must be a path") + given = Path(raw).expanduser().resolve() + allowed = ([default] if default else []) + _env_paths("QET_MCP_BINARIES") + if given not in allowed: + raise ValueError( + f"'binary' is not an allowed QElectroTech: {given}. Leave it out " + "to use " + (str(default) if default else "QET_BINARY") + + "; whoever configured this server can list others in " + "QET_MCP_BINARIES, or set QET_MCP_ALLOW_ANY_BINARY=1 to " + "disable this check (which lets the client run any program).") + arguments["binary"] = str(given) + + +def _check_elements_dir(arguments: dict, roots: list) -> None: + """Fill in "elements_dir" from the install, or confine a given one.""" + raw = arguments.get("elements_dir") + if not raw: + default = default_elements_dir() + if default is not None: + arguments["elements_dir"] = str(default) + return + if not isinstance(raw, str): + raise ValueError("'elements_dir' must be a path") + given = Path(raw).expanduser().resolve() + extra = _env_paths("QET_MCP_ELEMENTS") + default = default_elements_dir() + if default is not None: + extra.append(default) + if roots and not _within_workspace(given, roots + extra): + raise ValueError( + f"'elements_dir' is outside the workspace: {given}. Leave it out " + "to use the installed collection, or list the directory in " + "QET_MCP_ELEMENTS.") + + def _check_path(raw, arg: str, mode: str, roots: list) -> Path: """Resolve one path and refuse it if it leaves the workspace. @@ -3083,12 +3363,20 @@ def _check_path(raw, arg: str, mode: str, roots: list) -> Path: def enforce_path_policy(tool_name: str, arguments: dict) -> None: - """Apply the workspace and overwrite policy to one tool call.""" + """Apply the workspace, executable and overwrite policy to one tool call. + + For a tool that launches QElectroTech this also fills in "binary" and, + when the install has one, "elements_dir", so a client need not know them. + """ spec = _DATA_PATHS.get(tool_name) if spec is None: return roots = workspace_roots() + if tool_name in _LAUNCHES_QET: + _check_binary(arguments) + _check_elements_dir(arguments, roots) + for arg in spec.get("read", ()): if arg in arguments: _check_path(arguments[arg], arg, "read", roots) @@ -3192,11 +3480,48 @@ def serve(stdin=sys.stdin, stdout=sys.stdout) -> None: print(json.dumps(reply, ensure_ascii=False), file=stdout, flush=True) +def call_once(argv: list[str], stdin=sys.stdin, stdout=sys.stdout, + stderr=sys.stderr) -> int: + """--call [arguments]: one tools/call, the result's text on stdout. + + arguments is a JSON object, or "-" to read it from stdin (which spares + the caller from quoting JSON for a shell). Exit status: 0 the tool + succeeded, 1 the tool reported an error, 2 the call itself was malformed. + """ + if not argv or len(argv) > 2: + print("usage: qet_mcp.py --call ['' | -]", + file=stderr) + return 2 + name, raw = argv[0], (argv[1] if len(argv) == 2 else "{}") + if raw == "-": + raw = stdin.read() + try: + arguments = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError as exc: + print(f"arguments are not valid JSON: {exc}", file=stderr) + return 2 + if not isinstance(arguments, dict): + print("arguments must be a JSON object", file=stderr) + return 2 + + reply = handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}) + if "error" in reply: + print(reply["error"]["message"], file=stderr) + return 2 + result = reply["result"] + for part in result["content"]: + print(part["text"], file=stdout) + return 1 if result.get("isError") else 0 + + def main() -> int: if len(sys.argv) > 1 and sys.argv[1] in ("--list", "-l"): for t in TOOLS: print(f"{t['name']}\n {t['description']}\n") return 0 + if len(sys.argv) > 1 and sys.argv[1] == "--call": + return call_once(sys.argv[2:]) serve() return 0 diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a32f7bbee..083becf46 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -30,6 +30,7 @@ import subprocess import sys import tempfile import unittest +from unittest import mock import xml.etree.ElementTree as ET from pathlib import Path @@ -74,6 +75,16 @@ PLC_MASTER = "common://plc_master_test.elmt" PLC_SLAVE = "common://plc_slave_test.elmt" +def fake_qet(bindir: Path, name: str = "qelectrotech") -> Path: + """An executable file standing in for QElectroTech. The policy checks + which file it is, never runs it.""" + bindir.mkdir(parents=True, exist_ok=True) + exe = bindir / name + exe.write_text("#!/bin/sh\nexit 0\n") + exe.chmod(0o755) + return exe + + def png(path: Path) -> None: """A real 64x32 PNG from the standard library, so no imaging dependency.""" import struct @@ -184,7 +195,8 @@ class EditValidation(unittest.TestCase): s = self.build([{"op": "add_folio", "id": "f"}, {"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0}, {"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}]) - self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["f"], R["k"], "{U}"))', s) + f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio + self.assertIn(f'qet.deleteElementText({f}, R["k"], qet.elementTextIndex({f}, R["k"], "{U}"))', s) # the lookups are required only when a uuid is used self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}])) self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}])) @@ -214,6 +226,27 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "folio index, its uuid"): self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}]) + def test_folio_ref_follows_the_folio(self): + """A "$name" made by add_folio or insert_folio is looked up by the + folio's uuid when used as a folio, since a later insert or removal + shifts its index; the stored index is the fallback on a build that + cannot report folio uuids. Used as anything else, it is unchanged.""" + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "insert_folio", "id": "g", "position": 0}, + {"op": "set_folio_title", "folio": "$f", "title": "t"}, + {"op": "set_folio_title", "folio": "$g", "title": "u"}]) + self.assertIn("F[\"f\"] = (typeof qet.folioUuid === 'function' && v0 >= 0) " + "? qet.folioUuid(v0) : '';", s) + self.assertIn('qet.setFolioTitle((F["f"] ? qet.folioIndex(F["f"]) : R["f"]), "t")', s) + self.assertIn('qet.setFolioTitle((F["g"] ? qet.folioIndex(F["g"]) : R["g"]), "u")', s) + # not required: an edit still runs on a build without folio uuids + self.assertNotIn('"folioUuid"', s) + # a "$name" from any other op is untouched + s = self.build([{"op": "add_text", "id": "t", "folio": 0, "text": "x", "x": 0, "y": 0}, + {"op": "delete_text", "folio": 0, "index": "$t"}]) + self.assertIn('R["t"]', s) + self.assertNotIn("F[", s.split("if (missing.length === 0) {")[1]) + def test_conductor_by_uuid(self): """A conductor named by uuid is turned into one of its ends at run time; the lookup is required only then, and "conductor" cannot be @@ -242,6 +275,44 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) + def test_terminal_by_uuid(self): + """A terminal named by uuid is turned into its index at run time, on + the element it belongs to: the op's element, or each add_conductor + end's own. The lookup is required only then; a plain index is + passed through as before.""" + E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}" + T, V = "{31111111-2222-4333-8444-555555555555}", "{41111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "add_conductor", "folio": 1, "from": E, "from_terminal": T, + "to": F, "to_terminal": V}]) + self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, "from_terminal", 1, "{E}", "{T}"), ' + f'"{F}", qetMcpTerminal(0, "to_terminal", 1, "{F}", "{V}"))', s) + self.assertIn('"terminalIndex"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "element": E, "terminal": T, + "property": "num", "value": "W1"}]) + self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"), ' + '"num", "W1")', s) + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": T}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"))', s) + s = self.build([{"op": "move_conductor_segment", "folio": 0, "element": E, + "terminal": T, "segment": 1, "dx": 5, "dy": 0}]) + self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"), 1, 5, 0)', s) + # a $name element and a folio uuid reach the lookup resolved + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0}, + {"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}]) + f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio + self.assertIn(f'qet.deleteConductor({f}, R["a"], qetMcpTerminal(2, "terminal", {f}, R["a"], "{T}"))', s) + s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}]) + self.assertIn(f'qetMcpTerminal(0, "terminal", qet.folioIndex("{V}"), "{E}", "{T}")', s) + # an index is unchanged and needs no lookup + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": 2}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", 2)', s) + self.assertNotIn('"terminalIndex"', s) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": "A1"}]) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": True}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -421,6 +492,80 @@ class EditValidation(unittest.TestCase): need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1)) self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need)) + def test_malformed_arguments_are_refused(self): + """Each argument kind rejects what it cannot take, before any launch. + Found by the mutation audit: forcing any of these checks off went + unnoticed.""" + f = {"op": "add_folio", "id": "f"} + cases = [ + ("indices", {"op": "group_terminals", "strip": 0, "indices": []}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [1, "2"]}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [True]}), + ("indices", {"op": "bridge_terminals", "strip": 0, "indices": 3}), + ("true or false", {"op": "add_polygon", "folio": 0, "closed": 1, + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, "points": "xy"}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": "1", "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": True, "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, [1, 1]]}), + ("at least 2 nodes", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}]}), + ("corner, smooth or symmetric", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, + {"x": 1, "y": 1, "kind": "sharp"}]}), + ("inHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "inHandle": [0, 0]}]}), + ("outHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "outHandle": {"x": 0}}]}), + ("unknown kind", {"op": "search_and_replace", "kind": "folio", "field": "x", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("unknown conductor field", {"op": "search_and_replace", "kind": "conductor", + "field": "label", "pattern": "a", "replacement": "b", + "regex": False, "case_sensitive": False}), + ("non-empty", {"op": "search_and_replace", "kind": "element_info", "field": "", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", + "element": "{11111111-2222-4333-8444-555555555555}"}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", "terminal": 0}), + ("not an object", "add_folio"), + ] + for message, op in cases: + with self.subTest(op=op): + with self.assertRaisesRegex(ValueError, message): + self.build([f, op]) + + def test_valid_shapes_of_those_arguments_pass(self): + """The other side: the same kinds accept what they should, so the + refusals above are not everything failing.""" + s = self.build([ + {"op": "add_polygon", "folio": 0, "closed": False, + "points": [{"x": 0, "y": 0}, {"x": 1.5, "y": -2}]}, + {"op": "add_path", "folio": 0, "closed": True, + "nodes": [{"x": 0, "y": 0, "kind": "smooth", "inHandle": {"x": 1, "y": 1}}, + {"x": 5, "y": 5, "outHandle": {"x": 2, "y": 2}}]}, + {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0, "kind": "anything"}, {"x": 1, "y": 1}]}, + {"op": "search_and_replace", "kind": "conductor", "field": "num", "pattern": "a", + "replacement": "b", "regex": True, "case_sensitive": False}, + {"op": "search_and_replace", "kind": "text", "field": "", "pattern": "a", + "replacement": "b", "regex": False, "case_sensitive": True}]) + self.assertIn("qet.addPolygon(0, [{", s) + self.assertIn("qet.searchAndReplace(\"conductor\", \"num\", \"a\", \"b\", true, false)", s) + self.assertIn("qet.searchAndReplace(\"text\", \"\", \"a\", \"b\", false, true)", s) + + def test_no_id_stores_nothing(self): + s = self.build([{"op": "add_folio"}]) + self.assertNotIn("R[", s.split("if (missing.length === 0) {")[1]) + def test_a_string_index_that_is_not_a_uuid_is_refused(self): for bad in ("3", "{nope}", "11111111-1111"): with self.subTest(index=bad): @@ -484,7 +629,8 @@ class EditValidation(unittest.TestCase): script = self.build([{"op": "add_folio", "id": "f"}, {"op": "set_folio_title", "folio": "$f", "title": nasty}]) # the literal must be valid JSON, so JavaScript reads exactly what was sent - literal = re.search(r'setFolioTitle\(R\["f"\], (".*?")\)', script, re.S).group(1) + literal = re.search(r'setFolioTitle\(\(F\["f"\] \? qet\.folioIndex\(F\["f"\]\) : R\["f"\]\), (".*?")\)', + script, re.S).group(1) self.assertEqual(json.loads(literal), nasty) def test_edit_refuses_in_place_and_empty(self): @@ -529,6 +675,37 @@ class ResultParsing(unittest.TestCase): self.assertEqual(parsed["operations"], []) + def test_capabilities_notes_and_save(self): + """Every field the script reports reaches the result, attached where + it belongs. Found by the mutation audit: only the binary tests read + these back.""" + text = "\n".join([ + self.line(kind="capabilities", missing=["addFolio"]), + self.line(kind="op", index=0, op="a", id=None, result=True), + self.line(kind="op", index=1, op="b", id=None, result=False), + self.line(kind="op_note", index=1, note="why"), + self.line(kind="save", result=True, stopped_early=True), + self.line(kind="something_newer", result=False)]) # not a save + parsed = m._parse_script_output(text) + self.assertEqual(set(parsed), {"missing_methods", "operations", "saved", "stopped_early"}) + self.assertEqual(parsed["missing_methods"], ["addFolio"]) + self.assertTrue(parsed["saved"]) + self.assertTrue(parsed["stopped_early"]) + self.assertNotIn("note", parsed["operations"][0]) + self.assertEqual(parsed["operations"][1]["note"], "why") + # no capabilities line: unknown, not "nothing missing" + self.assertIsNone(m._parse_script_output(self.line(kind="save", result=False))["missing_methods"]) + self.assertEqual(m._parse_script_output(self.line(kind="capabilities", missing=None)) + ["missing_methods"], []) + + def test_a_line_without_the_marker_is_ignored(self): + """Even one that would parse if read from where the marker would be.""" + almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "save", "result": True}) + parsed = m._parse_script_output(almost) + self.assertIsNone(parsed["saved"]) + self.assertFalse(parsed["stopped_early"]) + + class TerminalOrder(unittest.TestCase): """QElectroTech indexes terminals top-to-bottom then left-to-right, not in file order. Getting it wrong wires the wrong end of a coil with no error; @@ -672,6 +849,391 @@ class ElementBuild(unittest.TestCase): self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ') +class ElementFileExact(unittest.TestCase): + """Exact answers for the tools that write and read .elmt files and need + no QElectroTech. Found by a mutation audit: the existing tests checked a + few fields, so dropping any other one from a result, or getting a + header number off by one, went unnoticed.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + m._ELEMENT_INDEX.clear() + + def tearDown(self): + self.tmp.cleanup() + + def test_fmt(self): + self.assertEqual([m._fmt(v) for v in (True, False, 3, 2.0, 2.5, -0.25, "x")], + ["true", "false", "3", "2", "2.5", "-0.25", "x"]) + + def test_part_extent_per_kind(self): + ext = m._part_extent + self.assertEqual(ext("line", {"x1": 1, "y1": 2, "x2": 3, "y2": 4}), [(1, 2), (3, 4)]) + for kind in ("rect", "ellipse", "arc"): + self.assertEqual(ext(kind, {"x": -5, "y": 1, "width": 10, "height": 4}), + [(-5, 1), (5, 5)], kind) + self.assertEqual(ext("circle", {"x": 2, "y": 3, "diameter": 6}), [(2, 3), (8, 9)]) + self.assertEqual(ext("polygon", {"points": [[0, 1], [2, 3], [4, 5]]}), + [(0, 1), (2, 3), (4, 5)]) + self.assertEqual(ext("text", {"x": 7, "y": 8, "text": "a"}), [(7, 8)]) + self.assertEqual(ext("dynamic_text", {"x": 7}), []) + + def test_element_geometry(self): + """Box, hotspot and size, worked out by hand: points reach x -10..20 + and y -15..5; a 5 unit margin, rounded out to tens.""" + g = m._element_geometry( + [{"type": "line", "x1": 0, "y1": 0, "x2": 20, "y2": 0}, + {"type": "rect", "x": -10, "y": -5, "width": 10, "height": 10}], + [{"x": 0, "y": -15, "orientation": "n"}]) + self.assertEqual(g, {"width": 50, "height": 30, "hotspot_x": 20, "hotspot_y": 20, + "bbox": [-10, -15, 20, 5]}) + # a terminal alone is enough; nothing at all is refused + self.assertEqual(m._element_geometry([], [{"x": 0, "y": 0, "orientation": "n"}]), + {"width": 20, "height": 20, "hotspot_x": 10, "hotspot_y": 10, "bbox": [0, 0, 0, 0]}) + with self.assertRaisesRegex(ValueError, "at least one part or terminal"): + m._element_geometry([], []) + + def test_part_element(self): + E = lambda part: dict(m._part_element(part, "{u}").attrib) + self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2.5, 3]]}), + {"uuid": "{u}", "x1": "0", "y1": "1", "x2": "2.5", "y2": "3", + "closed": "true", "antialias": "true", "style": m.DEFAULT_STYLE}) + self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2, 3]], "closed": False, + "antialias": False, "style": "x"})["closed"], "false") + self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1"}), + {"uuid": "{u}", "x": "1", "y": "2", "text": "K1", "rotation": "0", + "font": "Sans Serif,9,-1,5,50,0,0,0,0,0", "color": "#000000"}) + self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1", "size": 12, + "rotation": 90, "color": "red"})["font"], + "Sans Serif,12,-1,5,50,0,0,0,0,0") + rect = E({"type": "rect", "x": 0, "y": 0, "width": 4, "height": 2, "antialias": False}) + self.assertEqual(rect, {"uuid": "{u}", "x": "0", "y": "0", "width": "4", "height": "2", + "antialias": "false", "style": m.DEFAULT_STYLE}) + + def test_build_writes_and_reports_exactly(self): + out = self.root / "k" / "coil.elmt" + U = "{11111111-2222-4333-8444-555555555555}" + r = m.tool_element_build( + str(out), {"fr": "Bobine", "en": "Coil"}, + [{"type": "line", "x1": 0, "y1": -10, "x2": 0, "y2": 10}], + terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "A2"}, + {"x": 0, "y": -20, "orientation": "n", "name": "A1", "type": "Inner"}], + link_type="master", informations={"type": "coil"}, uuid=U) + self.assertEqual(set(r), {"ok", "output", "bytes", "width", "height", "hotspot_x", + "hotspot_y", "bbox", "terminal_index_order", "part_uuids", + "verified"}) + self.assertTrue(r["ok"]) + self.assertEqual(r["output"], str(out)) + self.assertEqual(r["bytes"], out.stat().st_size) + self.assertEqual(r["terminal_index_order"], ["A1", "A2"]) + root = ET.parse(out).getroot() + self.assertEqual(dict(root.attrib), { + "version": "0.100.0", "type": "element", "link_type": "master", + "width": str(r["width"]), "height": str(r["height"]), + "hotspot_x": str(r["hotspot_x"]), "hotspot_y": str(r["hotspot_y"])}) + self.assertEqual(root.find("uuid").get("uuid"), U) + self.assertEqual([(n.get("lang"), n.text) for n in root.iter("name")], + [("en", "Coil"), ("fr", "Bobine")]) + self.assertEqual([(k.get("name"), k.text) for k in root.iter("kindInformation")], + [("type", "coil")]) + t2, t1 = root.findall("description/terminal") + self.assertEqual({k: v for k, v in t1.attrib.items() if k != "uuid"}, + {"x": "0", "y": "-20", "orientation": "n", "type": "Inner", "name": "A1"}) + self.assertEqual(t2.get("type"), "Generic") + self.assertRegex(t1.get("uuid"), m._UUID_RE) + self.assertNotEqual(t1.get("uuid"), t2.get("uuid")) + + def test_build_refusals(self): + out = str(self.root / "x.elmt") + line = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}] + term = [{"x": 0, "y": 0, "orientation": "n"}] + for message, kwargs in [ + ("names must be a non-empty", dict(names={}, parts=line, terminals=term)), + ("unknown link_type", dict(names={"en": "a"}, parts=line, terminals=term, link_type="x")), + ("parts must be a list", dict(names={"en": "a"}, parts={}, terminals=term)), + ("terminal 0 is not an object", dict(names={"en": "a"}, parts=line, terminals=[1])), + ("terminal 0 is missing 'orientation'", dict(names={"en": "a"}, parts=line, + terminals=[{"x": 0, "y": 0}])), + ("orientation is one of", dict(names={"en": "a"}, parts=line, + terminals=[{"x": 0, "y": 0, "orientation": "up"}])), + ("cannot be connected", dict(names={"en": "a"}, parts=line, terminals=[])), + ("same uuid", dict(names={"en": "a"}, terminals=term, parts=[ + dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}"), + dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}")]))]: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + m.tool_element_build(out, **kwargs) + + def test_element_info_exactly(self): + p = self.root / "e.elmt" + p.write_text( + '' + 'E' + ' label ' + '' + '' + '' + '', + encoding="utf-8") + r = m.tool_element_info(str(p)) + self.assertEqual(set(r), {"file", "type", "link_type", "width", "height", "names", + "terminal_count", "terminals", "terminal_order", + "info_fields", "parts", "part_list"}) + self.assertEqual((r["file"], r["type"], r["link_type"], r["width"], r["height"]), + (str(p), "element", "simple", "20", "40")) + self.assertEqual(r["terminal_count"], 3) + self.assertEqual(r["terminals"][0], {"index": 0, "x": "0", "y": "-10", "orientation": "n", + "name": "1", "type": "", "uuid": ""}) + self.assertEqual(r["terminals"][2], {"index": 2, "x": "0", "y": "10", "orientation": "s", + "name": "2", "type": "Generic", "uuid": "{B}"}) + self.assertEqual(r["info_fields"], ["label"]) + self.assertEqual(r["parts"], {"line": 1, "terminal": 3, "arc": 1}) + self.assertEqual(r["part_list"], [{"type": "line", "uuid": "{L}"}, {"type": "arc", "uuid": ""}]) + self.assertNotIn("undefined", r["terminal_order"]) + p.write_text(p.read_text().replace('x="5" y="-10"', 'x="0" y="-10"'), encoding="utf-8") + self.assertIn("undefined", m.tool_element_info(str(p))["terminal_order"]) + + def test_index_entry_exactly(self): + f = self.root / "a" / "k.elmt" + f.parent.mkdir() + f.write_text('' + 'Bobine' + ' coil ' + 'x' + '' + '', + encoding="utf-8") + (self.root / "a" / "broken.elmt").write_text("", encoding="utf-8") + items = m._index_collection(self.root) + self.assertEqual(len(items), 1) + it = {k: v for k, v in items[0].items() if k != "haystack"} + self.assertEqual(it, {"path": "common://a/k.elmt", "file": str(f), "name": "Bobine", + "names": {"fr": "Bobine"}, "link_type": "master", "kind": "coil", + "terminals": 2, "terminal_names": ["A2", "A1"], + "terminal_order_ambiguous": True, "width": "30", "height": "50"}) + # the index is cached until the collection changes + self.assertIs(m._index_collection(self.root), items) + self.assertEqual(m._collection_signature(self.root)[0], 3) + + def test_search_ranking_and_limit(self): + for rel, name in (("a/1.elmt", "Coil latching"), ("a/2.elmt", "Coil"), + ("a/3.elmt", "Remanence coil")): + f = self.root / rel + f.parent.mkdir(exist_ok=True) + f.write_text(f'' + f'{name}' + f'', + encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil") + self.assertEqual([e["name"] for e in r["results"]], + ["Coil", "Coil latching", "Remanence coil"]) + r = m.tool_element_search(str(self.root), "coil", limit=1) + self.assertEqual((r["total_matches"], r["returned"]), (3, 1)) + with self.assertRaisesRegex(ValueError, "limit must be >= 1"): + m.tool_element_search(str(self.root), "coil", limit=0) + self.assertEqual(m.tool_element_search(str(self.root), "coil", limit=1)["results"][0]["name"], + "Coil") + self.assertEqual(set(r), {"query", "total_matches", "returned", "indexed", "results"}) + self.assertEqual((r["query"], r["indexed"]), ("coil", 3)) + self.assertEqual(r["results"][0]["languages"], ["en"]) + + def test_search_puts_the_exact_name_then_names_starting_with_it(self): + for n, name in enumerate(("Relay coil", "Coil relay", "Coil relay X", "A coil relay")): + f = self.root / f"{n}.elmt" + f.write_text(f'' + f'{name}' + f'', + encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil relay") + self.assertEqual([e["name"] for e in r["results"]], + ["Coil relay", "Coil relay X", "Relay coil", "A coil relay"]) + + def test_search_returns_25_by_default(self): + for n in range(26): + (self.root / f"{n:02}.elmt").write_text( + f'Coil {n}' + f'' + f'', encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil") + self.assertEqual((r["total_matches"], r["returned"]), (26, 25)) + + def test_search_exact_name_wins_a_tie(self): + """Same length, same first word: the exact name comes first, not + the one whose path sorts first.""" + for rel, name in (("a.elmt", "Coil-a"), ("z.elmt", "Coil a")): + (self.root / rel).write_text( + f'{name}' + f'' + f'', encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil a") + self.assertEqual([e["name"] for e in r["results"]], ["Coil a", "Coil-a"]) + + def test_validate_part_refusals(self): + ok = lambda part: m._validate_part(0, part) + self.assertEqual(ok({"type": "polygon", "points": [[0, 0], [1, 1]]}), "polygon") + for message, part in [ + ("not an object", ["line"]), + ("at least two points", {"type": "polygon", "points": [[0, 0]]}), + ("at least two points", {"type": "polygon", "points": "ab"}), + ("each point is", {"type": "polygon", "points": [[0, 0], [1, 1, 1]]}), + ("each point is", {"type": "polygon", "points": [[0, 0], 5]}), + ("uuid must look like", {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1, + "uuid": "L1"})]: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + ok(part) + + def test_terminal_order_with_unreadable_coordinates(self): + T = lambda **a: ET.Element("terminal", {k: str(v) for k, v in a.items()}) + ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, y="bad", name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(x="bad", y=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + # a missing coordinate counts as 0 + ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(y=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + + +class CheckAndContinuityAnswers(unittest.TestCase): + """qet_check and qet_continuity turn QElectroTech's log lines into their + answer. With _run_qet replaced by a stub that returns chosen lines, the + whole answer can be checked exactly, without QElectroTech. Found by a + mutation audit: the binary tests look at a finding or two, so a wrong + summary count, a dropped field or a wrong "passed" went unnoticed.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.qet = Path(self.tmp.name) / "p.qet" + self.qet.write_text('', + encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def stub(self, lines, **extra): + out = "\n".join(["noise", m._MARKER + "{not json"] + + [m._MARKER + json.dumps(l) for l in lines]) + return mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": out, "stderr": "", **extra}) + + def test_check_answer_exactly(self): + rows = [{"label": f"K{i}"} for i in range(12)] + lines = [ + {"kind": "check", "name": "duplicate_master_labels", "rows": rows, "error": ""}, + {"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}, + {"kind": "check", "name": "unnumbered_conductors", "rows": [{"n": 1}, {"n": 2}], "error": ""}, + {"kind": "check", "name": "duplicate_simple_labels", "rows": [], "error": ""}, + {"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}, + {"kind": "other", "name": "masters_without_manufacturer_reference", "rows": [1]}, + ] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet)) # sample defaults to 10 + C = m.CHECKS + self.assertEqual(r, { + "ok": False, + "summary": {"errors": 1, "warnings": 1, "info": 1, "passed": 1, "check_failures": 2}, + "findings": [ + {"check": "duplicate_master_labels", "severity": "error", "count": 12, + "note": C["duplicate_master_labels"]["note"], "rows": rows[:10]}, + {"check": "unlabelled_masters", "severity": "warning", "count": 1, + "note": C["unlabelled_masters"]["note"], "rows": [{"x": 1}]}, + {"check": "unnumbered_conductors", "severity": "info", "count": 2, + "note": C["unnumbered_conductors"]["note"], "rows": [{"n": 1}, {"n": 2}]}], + "passed": ["duplicate_simple_labels"], + "check_failures": [ + {"check": "empty_folios", "error": "bad SQL"}, + {"check": "masters_without_manufacturer_reference", "error": "no result came back"}]}) + + def test_check_sorts_by_severity_then_name(self): + lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [1], "error": ""}, + {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios", "unlabelled_masters"]) + self.assertEqual([f["check"] for f in r["findings"]], ["unlabelled_masters", "empty_folios"]) + + def test_check_failure_alone_is_not_ok(self): + lines = [{"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertFalse(r["ok"]) + + def test_check_ignores_a_line_without_the_marker(self): + almost = "x" * (len(m._MARKER) - 1) + json.dumps( + {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}) + with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertEqual(r["check_failures"], [{"check": "empty_folios", "error": "no result came back"}]) + + def test_check_ok_without_errors_and_sample_zero(self): + lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["unlabelled_masters"], sample=0) + self.assertTrue(r["ok"]) # a warning is not a failure + self.assertEqual(r["findings"][0]["rows"], []) + self.assertEqual(r["findings"][0]["count"], 1) + with self.assertRaisesRegex(ValueError, "sample must be >= 0"): + m.tool_check("qet", str(self.qet), sample=-1) + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_check("qet", str(self.qet) + ".missing") + + def test_check_carries_the_launch_hint(self): + with self.stub([], hint="why it did not start"): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertFalse(r["ok"]) + self.assertEqual(r["hint"], "why it did not start") + + def test_continuity_answer_exactly(self): + found = [{"severity": "error", "folio": 1, "what": "a"}, + {"severity": "warning", "folio": 0, "what": "b"}, + {"severity": "info", "folio": "?", "what": "c"}, + {"severity": "info", "what": "d"}, + {"severity": "info", "folio": 1, "what": "e"}] + with self.stub([{"kind": "continuity", "findings": found}, + {"kind": "other", "findings": []}]): + r = m.tool_continuity("qet", str(self.qet), folio=1) + self.assertEqual((r["finding_count"], r["errors"], r["warnings"], r["info"]), (5, 1, 1, 3)) + self.assertEqual([f.get("folio_number") for f in r["findings"]], [2, 1, None, None, 2]) + self.assertNotIn(m._MARKER, r["stdout"]) + self.assertIn("noise", r["stdout"]) + + def test_continuity_passes_the_folio_to_the_script(self): + seen = [] + def fake(binary, args, **kw): + seen.append(kw["script"]) + return {"stdout": m._MARKER + json.dumps({"kind": "continuity", "findings": []}), + "stderr": ""} + with mock.patch.object(m, "_run_qet", fake): + m.tool_continuity("qet", str(self.qet)) + m.tool_continuity("qet", str(self.qet), folio=1) + self.assertIn("qet.checkContinuity(-1)", seen[0]) + self.assertIn("qet.checkContinuity(1)", seen[1]) + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_continuity("qet", str(self.qet) + ".missing") + almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "continuity", "findings": []}) + with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}): + self.assertFalse(m.tool_continuity("qet", str(self.qet))["ok"]) + + def test_continuity_without_findings_is_not_ok(self): + with self.stub([]): + r = m.tool_continuity("qet", str(self.qet)) + self.assertFalse(r["ok"]) + self.assertIn("predate qet.checkContinuity()", r["hint"]) + with self.stub([], hint="launch failed"): + self.assertEqual(m.tool_continuity("qet", str(self.qet))["hint"], "launch failed") + + def test_continuity_folio_bounds(self): + for bad in (-1, 2): + with self.subTest(folio=bad): + with self.assertRaisesRegex(ValueError, f"folio {bad} does not exist"): + m.tool_continuity("qet", str(self.qet), folio=bad) + with self.stub([{"kind": "continuity", "findings": []}]): + self.assertEqual(m.tool_continuity("qet", str(self.qet), folio=0)["finding_count"], 0) + + class ElementSearch(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -901,6 +1463,56 @@ class ReadToolContracts(unittest.TestCase): r = tool(str(big)) self.assertEqual((r["count"], r["truncated"], len(r[key])), (201, True, 200)) + def test_conductor_key_same_in_both_forms(self): + # A wire in the numbered form before a save and the uuid form after + # it (the first save of an older project) is the same wire: both + # ends resolve to the placed symbol's terminal record. The record is + # where the wire docks, 4 from the definition position. + root = ET.fromstring( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + numbered, by_uuid = [m._conductor_row(i, c, ix)["key"] + for i, c, ix in m._conductors(root)] + self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1") + self.assertEqual(by_uuid, numbered) + + def test_conductor_key_same_in_both_forms_in_a_column(self): + """Terminals one above the other share their x: a uuid end must be + matched on x, y and orientation together, not on any one of them. + Found by the mutation audit: an "or" in place of "and" went + unnoticed while every test's terminals differed in every coordinate.""" + root = ET.fromstring( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + numbered, by_uuid = [m._conductor_row(i, c, ix)["key"] + for i, c, ix in m._conductors(root)] + self.assertEqual(numbered, "1:{E}@0,24,0--{E}@30,24,0") + self.assertEqual(by_uuid, numbered) + def test_conductor_row_without_an_index(self): c = ET.fromstring('') self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8") @@ -1706,6 +2318,8 @@ class PathPolicy(unittest.TestCase): self._saved = dict(os.environ) os.environ["QET_MCP_WORKSPACE"] = str(self.root) os.environ.pop("QET_MCP_ALLOW_ANY_PATH", None) + self.qet = fake_qet(Path(self.tmp.name) / "bin") + os.environ["QET_BINARY"] = str(self.qet) def tearDown(self): os.environ.clear() @@ -1739,7 +2353,7 @@ class PathPolicy(unittest.TestCase): def test_write_outside_the_workspace_is_refused(self): with self.assertRaisesRegex(ValueError, "outside the workspace"): m.enforce_path_policy("qet_export", { - "binary": "/usr/bin/qelectrotech", + "binary": str(self.qet), "project": str(self.root / "ok.qet"), "format": "pdf", "output": str(self.outside / "exfiltrated.pdf")}) @@ -1747,7 +2361,7 @@ class PathPolicy(unittest.TestCase): def test_existing_output_is_not_clobbered_without_overwrite(self): target = self.root / "existing.qet" target.write_text("precious") - args = {"binary": "/usr/bin/qelectrotech", "output": str(target), "title": "T"} + args = {"binary": str(self.qet), "output": str(target), "title": "T"} with self.assertRaisesRegex(ValueError, "already exists"): m.enforce_path_policy("qet_project_new", args) # ... and goes through once the caller says so explicitly @@ -1756,12 +2370,12 @@ class PathPolicy(unittest.TestCase): def test_new_output_needs_no_overwrite_flag(self): m.enforce_path_policy("qet_project_new", { - "binary": "/usr/bin/qelectrotech", + "binary": str(self.qet), "output": str(self.root / "brand_new.qet"), "title": "T"}) def test_operation_level_file_paths_are_checked(self): """add_image/add_pdf_page carry their own path, one level down.""" - base = {"binary": "/usr/bin/qelectrotech", + base = {"binary": str(self.qet), "project": str(self.root / "ok.qet"), "output": str(self.root / "out.qet")} outside_png = str(self.outside / "anything.png") @@ -1772,17 +2386,6 @@ class PathPolicy(unittest.TestCase): with self.assertRaisesRegex(ValueError, "outside the workspace"): m.enforce_path_policy("qet_edit", dict(base, operations=[op])) - def test_configuration_paths_are_exempt(self): - """binary and elements_dir are the operator's choice, not the model's. - - Both normally live in /usr or a build tree, so confining them would - reject the ordinary case while stopping nothing. - """ - m.enforce_path_policy("qet_query", { - "binary": "/usr/bin/qelectrotech", - "project": str(self.root / "ok.qet"), - "elements_dir": "/usr/share/qelectrotech/elements"}) - def test_several_roots_may_be_allowed(self): os.environ["QET_MCP_WORKSPACE"] = os.pathsep.join( [str(self.root), str(self.outside)]) @@ -1819,8 +2422,8 @@ class PathPolicy(unittest.TestCase): def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be in the policy, or that path is read or written with no workspace - check at all -- and nothing fails. binary and elements_dir are - configuration, deliberately not confined (see the README).""" + check at all -- and nothing fails. binary and elements_dir have + their own rule (BinaryPolicy).""" pathish = {"path", "project", "before", "after", "output", "directory"} for t in m.TOOLS: with self.subTest(tool=t["name"]): @@ -1841,6 +2444,201 @@ class PathPolicy(unittest.TestCase): f"{name} has no {arg!r} argument to guard") +class BinaryPolicy(unittest.TestCase): + """The program the server launches is not the client's to choose (F063). + + "binary" used to be exempt from every check as "configuration", but it + is a per-call argument: any executable file it named was run, with the + client's own paths as arguments. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.root = base / "workspace" + self.root.mkdir() + (self.root / "ok.qet").write_text("") + self.qet = fake_qet(base / "bin") + self.other = fake_qet(base / "other") + self._saved = dict(os.environ) + os.environ["QET_MCP_WORKSPACE"] = str(self.root) + os.environ["QET_BINARY"] = str(self.qet) + # A PATH with no qelectrotech on it, so only what a test sets counts. + os.environ["PATH"] = str(base / "empty") + for var in ("QET_MCP_ALLOW_ANY_PATH", "QET_MCP_ALLOW_ANY_BINARY", + "QET_MCP_BINARIES", "QET_MCP_ELEMENTS"): + os.environ.pop(var, None) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._saved) + self.tmp.cleanup() + + def call(self, **extra): + args = dict({"project": str(self.root / "ok.qet")}, **extra) + m.enforce_path_policy("qet_query", args) + return args + + def test_left_out_it_is_filled_in(self): + self.assertEqual(self.call()["binary"], str(self.qet.resolve())) + + def test_the_configured_one_is_accepted(self): + self.assertEqual(self.call(binary=str(self.qet))["binary"], str(self.qet.resolve())) + + def test_any_other_program_is_refused(self): + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(self.other)) + + def test_a_program_inside_the_workspace_is_refused_too(self): + """Being inside the workspace makes a file readable, not runnable.""" + planted = fake_qet(self.root, "run.sh") + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(planted)) + + def test_a_symlink_is_judged_by_its_target(self): + link = self.root / "qelectrotech" + link.symlink_to(self.other) + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(link)) + good = self.root / "also-qet" + good.symlink_to(self.qet) + self.call(binary=str(good)) + + def test_others_can_be_listed(self): + os.environ["QET_MCP_BINARIES"] = str(self.other) + self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other.resolve())) + + def test_the_check_can_be_switched_off(self): + os.environ["QET_MCP_ALLOW_ANY_BINARY"] = "1" + self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other)) + + def test_found_on_path(self): + del os.environ["QET_BINARY"] + os.environ["PATH"] = str(self.qet.parent) + self.assertEqual(self.call()["binary"], str(self.qet.resolve())) + + def test_none_found_says_what_to_set(self): + del os.environ["QET_BINARY"] + with self.assertRaisesRegex(ValueError, "set QET_BINARY"): + self.call() + + def test_every_tool_that_takes_binary_is_checked(self): + """A tool whose schema offers "binary" but that the policy skips + would run whatever it was given.""" + takes = {t["name"] for t in m.TOOLS + if "binary" in t["inputSchema"].get("properties", {})} + self.assertEqual(takes, m._LAUNCHES_QET) + for name in takes: + self.assertNotIn("binary", m._BY_NAME[name]["inputSchema"].get("required", []), + f"{name} still requires the client to name a binary") + + def test_elements_dir_outside_the_workspace_is_refused(self): + with self.assertRaisesRegex(ValueError, "'elements_dir' is outside"): + self.call(elements_dir=self.tmp.name) + + def test_elements_dir_can_be_listed(self): + coll = Path(self.tmp.name) / "collection" + coll.mkdir() + os.environ["QET_MCP_ELEMENTS"] = str(coll) + self.call(elements_dir=str(coll / "10_electric")) + + def test_an_install_finds_its_own_qet_and_collection(self): + """Installed as /share/qelectrotech/mcp/qet_mcp.py, the + server needs no configuration at all.""" + import importlib.util + prefix = Path(self.tmp.name) / "prefix" + mcp = prefix / "share" / "qelectrotech" / "mcp" + mcp.mkdir(parents=True) + (prefix / "share" / "qelectrotech" / "elements").mkdir() + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + exe = fake_qet(prefix / "bin") + del os.environ["QET_BINARY"] + spec = importlib.util.spec_from_file_location("qet_mcp_installed", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], + str((prefix / "share" / "qelectrotech" / "elements").resolve())) + + def installed_at(self, root: Path, mcp: Path): + """Import a copy of the server placed at mcp/qet_mcp.py.""" + import importlib.util + mcp.mkdir(parents=True) + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + return inst + + def test_the_windows_layout_is_found_too(self): + """The Windows installers and the portable folder put the server in + /mcp, the program in /bin as QElectroTech.exe, and the + collection in /elements.""" + root = Path(self.tmp.name) / "QElectroTech" + (root / "elements").mkdir(parents=True) + exe = fake_qet(root / "bin", "QElectroTech.exe") + inst = self.installed_at(root, root / "mcp") + del os.environ["QET_BINARY"] + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], str((root / "elements").resolve())) + + def test_a_copy_saved_anywhere_is_not_an_install(self): + """A downloaded copy sitting beside some bin/ folder must not go + looking for QElectroTech there.""" + home = Path(self.tmp.name) / "home" + fake_qet(home / "bin") + inst = self.installed_at(home, home / "Downloads") + self.assertIsNone(inst._installation()) + + def test_a_folder_named_mcp_is_not_an_install(self): + """Without a bin/ beside it, an mcp/ folder is just a folder.""" + inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp") + self.assertIsNone(inst._installation()) + + +class LaunchExecutable(unittest.TestCase): + """Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit + beside the original. Everywhere else the private copy stays.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.src = base / "bin" / "qelectrotech" + self.src.parent.mkdir() + self.src.write_text("#!/bin/sh\nexit 0\n") + self.sandbox = base / "sandbox" + self.sandbox.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def test_windows_runs_the_original(self): + self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src) + self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows") + + def test_windows_keeps_its_own_qt_platform(self): + """The Windows packages have no offscreen plugin; asking for it + leaves QElectroTech stuck at a message box.""" + env = m._launch_env({"PATH": "x"}, self.sandbox, True) + self.assertNotIn("QT_QPA_PLATFORM", env) + self.assertEqual(env["PATH"], "x") + + def test_elsewhere_runs_offscreen(self): + env = m._launch_env({}, self.sandbox, False) + self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") + self.assertEqual(env["HOME"], str(self.sandbox)) + + def test_elsewhere_runs_a_private_copy(self): + exe = m._launch_executable(self.src, self.sandbox, False) + self.assertEqual(exe.parent, self.sandbox) + self.assertNotEqual(exe, self.src) + self.assertEqual(exe.read_text(), self.src.read_text()) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. @@ -1975,6 +2773,24 @@ class PathPolicyOverStdio(unittest.TestCase): self.assertTrue(result.get("isError"), result) self.assertIn("outside the workspace", result["content"][0]["text"]) + def test_a_tool_call_cannot_choose_the_program_to_run(self): + """F063's reproduction: a script in the workspace, named as binary.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "a.qet").write_text('') + planted = fake_qet(root, "argv.py") + reply = self.rpc({"jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": {"name": "qet_export", + "arguments": {"binary": str(planted), + "project": str(root / "a.qet"), + "format": "pdf", + "output": str(root / "o.pdf")}}}, + {"QET_MCP_WORKSPACE": str(root), + "QET_BINARY": str(fake_qet(root / "bin"))}) + result = reply["result"] + self.assertTrue(result.get("isError"), result) + self.assertIn("not an allowed QElectroTech", result["content"][0]["text"]) + def test_the_same_call_succeeds_inside_the_workspace(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "ws" @@ -2135,6 +2951,47 @@ class Protocol(unittest.TestCase): for t in m.TOOLS: self.assertIn(t["name"], out) + def call(self, *args, stdin="", workspace=None): + env = dict(os.environ) + if workspace is not None: + env["QET_MCP_WORKSPACE"] = str(workspace) + return subprocess.run([sys.executable, str(HERE / "qet_mcp.py"), "--call", *args], + input=stdin, capture_output=True, text=True, + timeout=30, env=env) + + def test_call_flag_runs_one_tool(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('') + by_argv = self.call("qet_project_info", json.dumps({"path": str(p)}), + workspace=tmp) + by_stdin = self.call("qet_project_info", "-", + stdin=json.dumps({"path": str(p)}), workspace=tmp) + for proc in (by_argv, by_stdin): + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["title"], "Via --call") + + def test_call_flag_keeps_the_workspace_policy(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "ws" + root.mkdir() + victim = Path(tmp) / "elsewhere.qet" + victim.write_text('') + proc = self.call("qet_project_info", json.dumps({"path": str(victim)}), + workspace=root) + self.assertEqual(proc.returncode, 1) + self.assertIn("outside the workspace", proc.stdout) + self.assertNotIn("not yours", proc.stdout) + + def test_call_flag_exit_codes(self): + tool_error = self.call("qet_project_info", '{"path": "/no/such.qet"}') + self.assertEqual(tool_error.returncode, 1) + for args in (("nope",), ("qet_project_info", "{not json"), + ("qet_project_info", "[1]"), ()): + proc = self.call(*args) + self.assertEqual(proc.returncode, 2, args) + self.assertEqual(proc.stdout, "", args) + # ========================================================================== # integration @@ -2211,6 +3068,62 @@ class Integration(unittest.TestCase): nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]] self.assertEqual(nums, ["W1"]) + def test_add_conductor_by_terminal_uuid(self): + """The coil's file lists A2 before A1, and its index order puts A1 + first: wiring by uuid reaches the terminals named, whatever their + index, and an unknown uuid stops the run with a note.""" + info = m.tool_element_info(str(Path(ELEMENTS) / COIL.removeprefix("common://"))) + by_name = {t["name"]: t for t in info["terminals"]} + a1, a2 = by_name["A1"], by_name["A2"] + self.assertTrue(a1["uuid"] and a2["uuid"]) + self.assertEqual((a1["index"], a2["index"]), (0, 1)) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": 0, "path": COIL, "x": 300, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": a2["uuid"], + "to": "$b", "to_terminal": a1["uuid"]}])) + self.assertTrue(all(o["succeeded"] for o in r["operations"])) + wires = [c for c in ET.parse(r["output"]).getroot().iter("conductor") + if c.get("terminal1") and c.get("element1")] + self.assertEqual(len(wires), 1) + ends = {(wires[0].get("element1"), wires[0].get("terminal1")), + (wires[0].get("element2"), wires[0].get("terminal2"))} + a_uuid, b_uuid = (o["result"] for o in r["operations"][:2]) + self.assertEqual(ends, {(a_uuid, a2["uuid"]), (b_uuid, a1["uuid"])}) + + r = self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", + "from_terminal": "{00000000-0000-4000-8000-000000000001}", + "to": "$a", "to_terminal": a1["uuid"]}]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertIn("from_terminal: no terminal", json.dumps(r["operations"][1])) + + # both ends wrong: both reported, each by its argument + r = self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", + "from_terminal": "{00000000-0000-4000-8000-000000000001}", + "to": "$a", "to_terminal": "{00000000-0000-4000-8000-000000000002}"}]) + note = r["operations"][1].get("note", "") + self.assertIn("from_terminal: no terminal", note) + self.assertIn("to_terminal: no terminal", note) + def test_folio_ref_survives_an_insert_before_it(self): + """add_folio names a folio "$f"; inserting another at position 0 + moves it from index 1 to 2. "$f" must still name it.""" + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "insert_folio", "position": 0}, + {"op": "set_folio_title", "folio": "$f", "title": "MINE"}])) + self.assertEqual(r["operations"][0]["result"], 1) + titles = [f["title"] for f in m.tool_project_info(r["output"])["folios"]] + self.assertEqual(len(titles), 3) + self.assertEqual(titles[2], "MINE", titles) + self.assertNotIn("MINE", titles[:2]) + def test_noop_edit_has_no_conductor_churn(self): """Re-saving renumbers the file's terminal ids; the diff must not report the untouched conductors as removed and re-added.""" diff --git a/misc/qet_uninstall_file_associations.reg b/misc/qet_uninstall_file_associations.reg index c5d0b0a7f..5de5bbc75 100644 --- a/misc/qet_uninstall_file_associations.reg +++ b/misc/qet_uninstall_file_associations.reg @@ -1,5 +1,12 @@ Windows Registry Editor Version 5.00 +[-HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] +[-HKEY_CURRENT_USER\Software\Classes\.qet] +[-HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] +[-HKEY_CURRENT_USER\Software\Classes\.elmt] +[-HKEY_CURRENT_USER\Software\Classes\qet_element_file] +[-HKEY_CURRENT_USER\Software\Classes\.titleblock] +[-HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] [-HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] [-HKEY_CLASSES_ROOT\.qet] [-HKEY_CLASSES_ROOT\qet_diagram_file] diff --git a/misc/register_filetypes.bat b/misc/register_filetypes.bat index 29b429367..d0f2d69be 100755 --- a/misc/register_filetypes.bat +++ b/misc/register_filetypes.bat @@ -5,7 +5,9 @@ rem detecte le dossier courant et suppose que celui-ci contient bin\qelectrotech set current_dir=%~dp0 cd /d %current_dir% -set expected_qet_exe=%current_dir%Lancer QET.bat +rem l'archive portable fournit "Lancer QET_qt6.bat", l'installeur "Lancer QET.bat" +set expected_qet_exe=%current_dir%Lancer QET_qt6.bat +if not exist "%expected_qet_exe%" set expected_qet_exe=%current_dir%Lancer QET.bat set expected_project_ico=%current_dir%ico\application-x-qet-project.ico set expected_element_ico=%current_dir%ico\application-x-qet-element.ico set expected_titleblock_ico=%current_dir%ico\application-x-qet-titleblock.ico @@ -45,49 +47,50 @@ set final_element_ico=%expected_element_ico:\=\\% set final_titleblock_ico=%expected_titleblock_ico:\=\\% rem genere le fichier .reg pour enregistrer les associations de fichiers +rem HKEY_CURRENT_USER : ne demande pas les droits administrateur set reg_file=qet_install_file_associations.reg ( echo Windows Registry Editor Version 5.00 echo. rem Declaration de l'application - echo [HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.qet - echo [HKEY_CLASSES_ROOT\.qet] + echo [HKEY_CURRENT_USER\Software\Classes\.qet] echo @="qet_diagram_file" - echo [HKEY_CLASSES_ROOT\qet_diagram_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] echo @="QET diagram" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_diagram_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\DefaultIcon] echo @="%final_project_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_diagram_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.elmt - echo [HKEY_CLASSES_ROOT\.elmt] + echo [HKEY_CURRENT_USER\Software\Classes\.elmt] echo @="qet_element_file" - echo [HKEY_CLASSES_ROOT\qet_element_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file] echo @="QET element" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_element_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\DefaultIcon] echo @="%final_element_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_element_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.titleblock - echo [HKEY_CLASSES_ROOT\.titleblock] + echo [HKEY_CURRENT_USER\Software\Classes\.titleblock] echo @="qet_titleblock_file" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] echo @="QET title block template" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\DefaultIcon] echo @="%final_titleblock_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" ) > %reg_file% diff --git a/sources/ElementsCollection/fileelementcollectionitem.cpp b/sources/ElementsCollection/fileelementcollectionitem.cpp index 1c8dc410b..7b9d3ea98 100644 --- a/sources/ElementsCollection/fileelementcollectionitem.cpp +++ b/sources/ElementsCollection/fileelementcollectionitem.cpp @@ -480,6 +480,22 @@ void FileElementCollectionItem::setUpIcon() } } +/** + @brief FileElementCollectionItem::clearData + Reset the data, and let setUpIcon() build the icon again. + Without this the guard in setUpIcon() keeps the icon cleared here + empty until the whole collection is reloaded, e.g. after an element + is saved from the element editor (see locationWasSaved()). +*/ +void FileElementCollectionItem::clearData() +{ + // Reset the flag only after the base class has cleared the icon: + // its setIcon() emits dataChanged(), which re-enters setUpIcon(), + // and that call must still see the flag set and return early. + ElementCollectionItem::clearData(); + m_icon_initialized = false; +} + /** @brief FileElementCollectionItem::setPathName Set the name of this item in the file system path. diff --git a/sources/ElementsCollection/fileelementcollectionitem.h b/sources/ElementsCollection/fileelementcollectionitem.h index 6cb78aa08..68cc6d5ba 100644 --- a/sources/ElementsCollection/fileelementcollectionitem.h +++ b/sources/ElementsCollection/fileelementcollectionitem.h @@ -55,6 +55,7 @@ class FileElementCollectionItem : public ElementCollectionItem void setUpData() override; void setUpIcon() override; + void clearData() override; private: void setPathName(const QString& path_name, diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp new file mode 100644 index 000000000..c5b7ba098 --- /dev/null +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -0,0 +1,274 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "terminaluuids.h" + +#include +#include +#include +#include +#include + +namespace { +/** + @return the of the definition held by the embedded + collection element @p collection_element +*/ + //The of the @p definition +QList definitionTerminals(const QDomElement &definition) +{ + QList terminals; + const QDomElement description = + definition.firstChildElement(QStringLiteral("description")); + for (QDomElement t = description.firstChildElement(QStringLiteral("terminal")); + !t.isNull(); + t = t.nextSiblingElement(QStringLiteral("terminal"))) { + terminals << t; + } + return terminals; +} + +QList terminalsOf(const QDomElement &collection_element) +{ + return definitionTerminals( + collection_element.firstChildElement(QStringLiteral("definition"))); +} + + //Place and orientation, "10" and "10.0" being the same place +QString terminalPlace(const QDomElement &terminal) +{ + return QStringLiteral("%1|%2|%3") + .arg(QString::number(terminal.attribute(QStringLiteral("x")).toDouble()), + QString::number(terminal.attribute(QStringLiteral("y")).toDouble()), + terminal.attribute(QStringLiteral("orientation"))); +} + + //Qet::orientationFromString(), without pulling in qet.cpp +int orientationOf(const QDomElement &terminal) +{ + const QString o = terminal.attribute(QStringLiteral("orientation")); + if (o.startsWith(QLatin1Char('e'))) return 1; + if (o.startsWith(QLatin1Char('s'))) return 2; + if (o.startsWith(QLatin1Char('w'))) return 3; + return 0; +} + + //The of one get a uuid where missing +int fillDefinition(const QDomElement &definition) +{ + const QList terminals = definitionTerminals(definition); + QSet taken; + for (const QDomElement &t : terminals) { + const QUuid uuid(t.attribute(QStringLiteral("uuid"))); + if (!uuid.isNull()) { + taken << uuid; + } + } + + int filled = 0; + QHash seen_at; + for (QDomElement t : terminals) { + //Same count as Terminal::setPlaceRank(): every terminal before + //this one at the same point, with or without a uuid + const int rank = seen_at[terminalPlace(t)]++; + if (!QUuid(t.attribute(QStringLiteral("uuid"))).isNull()) { + continue; + } + const qreal x = t.attribute(QStringLiteral("x")).toDouble(); + const qreal y = t.attribute(QStringLiteral("y")).toDouble(); + const int orientation = orientationOf(t); + QUuid uuid; + for (int occurrence = rank ; uuid.isNull() || taken.contains(uuid) ; ++occurrence) { + uuid = TerminalUuids::derived(x, y, orientation, occurrence); + } + taken << uuid; + t.setAttribute(QStringLiteral("uuid"), uuid.toString()); + ++filled; + } + return filled; +} + +int fillDirectory(const QDomElement &directory) +{ + int filled = 0; + for (QDomElement child = directory.firstChildElement(); + !child.isNull(); + child = child.nextSiblingElement()) { + if (child.tagName() == QLatin1String("category")) { + filled += fillDirectory(child); + } else if (child.tagName() == QLatin1String("element")) { + filled += fillDefinition( + child.firstChildElement(QStringLiteral("definition"))); + } + } + return filled; +} + +} + +/** + @brief TerminalUuids::keep + Give each terminal of @p new_element the uuid of the terminal of + @p old_element at the same place and orientation. Both are + of an embedded collection. + + A wire is saved against the uuids of the terminals it joins, and on + loading it is reattached to a terminal with that uuid or not at all. + The symbols already on the folios keep the terminals of the definition + they were built from, so when the project's definition is replaced by + one whose terminal uuids differ -- another copy of the same symbol, or + one saved by a different version of the collection -- every wire on + those symbols was lost the next time the project was opened. + + A terminal of the new definition that already carries one of the old + uuids is that same terminal, perhaps moved: it keeps it, and that uuid + is not given to anything else. Other terminals that moved, and new + ones, keep their own. No uuid is ever given to two terminals. Where the + old definition has two terminals at one place, they are matched in + order. +*/ +void TerminalUuids::keep(const QDomElement &old_element, QDomElement &new_element) +{ + QHash old_uuids; + QSet all_old_uuids; + for (const QDomElement &t : terminalsOf(old_element)) { + const QString uuid = t.attribute(QStringLiteral("uuid")); + if (!uuid.isEmpty()) { + old_uuids[terminalPlace(t)] << uuid; + all_old_uuids << uuid; + } + } + if (old_uuids.isEmpty()) { + return; + } + + const QList new_terminals = terminalsOf(new_element); + QSet taken; + for (const QDomElement &t : new_terminals) { + taken << t.attribute(QStringLiteral("uuid")); + } + + for (QDomElement t : new_terminals) { + const QString own = t.attribute(QStringLiteral("uuid")); + if (all_old_uuids.contains(own)) { + continue; + } + QStringList &uuids = old_uuids[terminalPlace(t)]; + while (!uuids.isEmpty() && taken.contains(uuids.first())) { + uuids.removeFirst(); + } + if (!uuids.isEmpty()) { + const QString uuid = uuids.takeFirst(); + taken.remove(own); + taken << uuid; + t.setAttribute(QStringLiteral("uuid"), uuid); + } + } +} + +/** + @brief TerminalUuids::keepInDirectory + keep() for every symbol of @p new_directory that has a counterpart of + the same name at the same place under @p old_directory. Both are + of an embedded collection; used when a whole category of + the project is replaced. +*/ +void TerminalUuids::keepInDirectory(const QDomElement &old_directory, + QDomElement &new_directory) +{ + for (QDomElement child = new_directory.firstChildElement(); + !child.isNull(); + child = child.nextSiblingElement()) { + if (child.tagName() != QLatin1String("category") + && child.tagName() != QLatin1String("element")) { + continue; + } + const QString name = child.attribute(QStringLiteral("name")); + QDomElement old_child = old_directory.firstChildElement(child.tagName()); + while (!old_child.isNull() + && old_child.attribute(QStringLiteral("name")) != name) { + old_child = old_child.nextSiblingElement(child.tagName()); + } + if (old_child.isNull()) { + continue; + } + if (child.tagName() == QLatin1String("category")) { + keepInDirectory(old_child, child); + } else { + keep(old_child, child); + } + } +} + +/** + @brief TerminalUuids::derived + The identity of a terminal that carries no uuid, worked out from where + it is inside its symbol: its local position and orientation, which are + what the project file itself uses to tell terminals apart. UUID v5 in a + fixed namespace, so the same terminal gets the same value in any + project, on any machine, and it cannot collide with the random (v4) + uuids the element editor gives. + + @param orientation Qet::Orientation, as an int + @param occurrence 0 for the value Terminal::stableUuid() uses; 1, 2... + for a second, third... terminal at the same point of the same symbol +*/ +QUuid TerminalUuids::derived(qreal x, qreal y, int orientation, int occurrence) +{ + //Fixed namespace for terminal identities derived from geometry. + static const QUuid derived_ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); + + QString key = QStringLiteral("%1|%2|%3") + .arg(x, 0, 'f', 4) + .arg(y, 0, 'f', 4) + .arg(orientation); + if (occurrence > 0) { + key += QStringLiteral("|%1").arg(occurrence); + } + return QUuid::createUuidV5(derived_ns, key); +} + +/** + @brief TerminalUuids::fillMissing + Give every terminal without a uuid, in every symbol of the embedded + collection @p collection_root, the value derived() works out for it -- + the one QElectroTech already used for it as Terminal::stableUuid(), so + nothing keyed on terminals changes. Saved with the project, it becomes + a lasting identity: moving the terminal in the symbol editor no longer + changes it. + + Where one symbol has two terminals at one point, the second gets the + next occurrence; a value is never given twice within a symbol. + @return the number of terminals given a uuid +*/ +int TerminalUuids::fillMissing(const QDomElement &collection_root) +{ + return fillDirectory(collection_root); +} + +/** + @brief TerminalUuids::fillMissingInDefinition + fillMissing() for a single element definition (@p definition is its + ), as the element editor reads it from a file: an old + symbol opened and saved there gets the uuids a project gives the same + terminals, not random ones that would make every copy of it differ. + @return the number of terminals given a uuid +*/ +int TerminalUuids::fillMissingInDefinition(const QDomElement &definition) +{ + return fillDefinition(definition); +} diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h new file mode 100644 index 000000000..f9892b0a6 --- /dev/null +++ b/sources/ElementsCollection/terminaluuids.h @@ -0,0 +1,35 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef TERMINALUUIDS_H +#define TERMINALUUIDS_H + +#include + +class QDomElement; + +namespace TerminalUuids +{ + QUuid derived(qreal x, qreal y, int orientation, int occurrence = 0); + int fillMissing(const QDomElement &collection_root); + int fillMissingInDefinition(const QDomElement &definition); + void keep(const QDomElement &old_element, QDomElement &new_element); + void keepInDirectory(const QDomElement &old_directory, + QDomElement &new_directory); +} + +#endif // TERMINALUUIDS_H diff --git a/sources/ElementsCollection/xmlelementcollection.cpp b/sources/ElementsCollection/xmlelementcollection.cpp index b124b72ff..940988da6 100644 --- a/sources/ElementsCollection/xmlelementcollection.cpp +++ b/sources/ElementsCollection/xmlelementcollection.cpp @@ -21,6 +21,7 @@ #include "../qetproject.h" #include "../qetxml.h" #include "elementslocation.h" +#include "terminaluuids.h" /** @brief XmlElementCollection::XmlElementCollection @@ -127,9 +128,13 @@ XmlElementCollection::XmlElementCollection(const QDomElement &dom_element, QObject(project), m_project(project) { - if (dom_element.tagName() == "collection") + if (dom_element.tagName() == "collection") { m_dom_document.appendChild(m_dom_document.importNode( dom_element, true)); + //Before any folio is built from these symbols, so that their + //terminals carry the uuid the next save writes + TerminalUuids::fillMissing(root()); + } else qDebug() << "XmlElementCollection : tagName of dom_element is not collection"; } @@ -869,6 +874,15 @@ ElementsLocation XmlElementCollection::copyDirectory( created_location.setPath(destination.projectCollectionPath() % "/" % new_dir_name); } + //The symbols of the replaced directory keep their terminal uuids, + //see TerminalUuids::keep() + if (!element.isNull()) { + QDomElement new_dir_dom = directory(created_location.collectionPath(false)); + if (!new_dir_dom.isNull()) { + TerminalUuids::keepInDirectory(element, new_dir_dom); + } + } + emit directorieAdded(created_location.collectionPath(false)); return created_location; } @@ -914,6 +928,7 @@ ElementsLocation XmlElementCollection::copyElement( % "/" % new_elmt_name); bool removed = false; if (!element.isNull()) { + TerminalUuids::keep(element, elmt_dom); element.parentNode().removeChild(element); removed = true; } diff --git a/sources/SearchAndReplace/searchandreplaceworker.cpp b/sources/SearchAndReplace/searchandreplaceworker.cpp index 9f74248d7..28c053e4a 100644 --- a/sources/SearchAndReplace/searchandreplaceworker.cpp +++ b/sources/SearchAndReplace/searchandreplaceworker.cpp @@ -76,7 +76,20 @@ void SearchAndReplaceWorker::replaceDiagram(QList diagram_list) } } - new_properties.context.add(m_titleblock_properties.context); + // The replace dialog lists every custom variable of the folios + // (#1113), so an empty value means "leave unchanged", exactly like + // the fields of the main tab. + const DiagramContext &change = m_titleblock_properties.context; + for (const QString &key : change.keys()) { + const QString value = change.value(key).toString(); + if (value.isEmpty()) { + continue; + } + new_properties.context.addValue( + key, + applyChange(new_properties.context.value(key).toString(), + value)); + } if (old_propertie != new_properties) { project->undoStack()->push(new ChangeTitleBlockCommand(d, old_propertie, new_properties)); diff --git a/sources/SearchAndReplace/ui/searchandreplacewidget.cpp b/sources/SearchAndReplace/ui/searchandreplacewidget.cpp index af229b0c5..998e1b2ad 100644 --- a/sources/SearchAndReplace/ui/searchandreplacewidget.cpp +++ b/sources/SearchAndReplace/ui/searchandreplacewidget.cpp @@ -30,6 +30,7 @@ #include "../../qeticons.h" #include "../../qetinformation.h" #include "../../qetproject.h" +#include "../../ui/titleblockpropertieswidget.h" #include "replaceadvanceddialog.h" #include "replaceconductordialog.h" #include "replaceelementdialog.h" @@ -1109,7 +1110,26 @@ void SearchAndReplaceWidget::on_m_previous_pb_clicked() void SearchAndReplaceWidget::on_m_folio_pb_clicked() { ReplaceFolioDialog *dialog = new ReplaceFolioDialog(this); - dialog->setTitleBlockProperties(m_worker.m_titleblock_properties); + + // List the custom variables the folios already use, so the user only + // has to type a value instead of guessing each name (#1113). + TitleBlockProperties properties = m_worker.m_titleblock_properties; + for (const QPointer &diagram : std::as_const(m_diagram_hash)) + { + if (!diagram) { + continue; + } + for (const QString &key : + diagram->border_and_titleblock.exportTitleBlock().context.keys()) { + if (!properties.context.contains(key)) { + properties.context.addValue(key, QString()); + } + } + TitleBlockPropertiesWidget::addTemplateVariables( + properties.context, + diagram->border_and_titleblock.titleBlockTemplate()); + } + dialog->setTitleBlockProperties(properties); int result = dialog->exec(); if (result == QDialogButtonBox::AcceptRole) diff --git a/sources/TerminalStrip/ui/freeterminaleditor.cpp b/sources/TerminalStrip/ui/freeterminaleditor.cpp index 70ebea752..e9863f448 100644 --- a/sources/TerminalStrip/ui/freeterminaleditor.cpp +++ b/sources/TerminalStrip/ui/freeterminaleditor.cpp @@ -52,7 +52,7 @@ FreeTerminalEditor::FreeTerminalEditor(QETProject *project, QWidget *parent) : connect(ui->m_table_view->selectionModel(), &QItemSelectionModel::selectionChanged, this, &FreeTerminalEditor::selectionChanged); - connect(ui->m_table_view, &QAbstractItemView::doubleClicked, this, [=](const QModelIndex &index) + connect(ui->m_table_view, &QAbstractItemView::doubleClicked, this, [this](const QModelIndex &index) { if (m_model->columnTypeForIndex(index) == FreeTerminalModel::XRef) { diff --git a/sources/TerminalStrip/ui/terminalstripeditor.cpp b/sources/TerminalStrip/ui/terminalstripeditor.cpp index 364aa8acc..d0adf173e 100644 --- a/sources/TerminalStrip/ui/terminalstripeditor.cpp +++ b/sources/TerminalStrip/ui/terminalstripeditor.cpp @@ -53,7 +53,7 @@ TerminalStripEditor::TerminalStripEditor(QETProject *project, QWidget *parent) : selectionChanged(); //Go the diagram of double clicked terminal - connect(ui->m_table_widget, &QAbstractItemView::doubleClicked, this, [=](const QModelIndex &index) + connect(ui->m_table_widget, &QAbstractItemView::doubleClicked, this, [this](const QModelIndex &index) { if (m_model->columnTypeForIndex(index) == TerminalStripModel::XRef) { diff --git a/sources/TerminalStrip/ui/terminalstripmodel.cpp b/sources/TerminalStrip/ui/terminalstripmodel.cpp index db7f39d54..116803636 100644 --- a/sources/TerminalStrip/ui/terminalstripmodel.cpp +++ b/sources/TerminalStrip/ui/terminalstripmodel.cpp @@ -110,7 +110,7 @@ TerminalStripModel::TerminalStripModel(TerminalStrip *terminal_strip, QObject *p { fillPhysicalTerminalData(); - connect(terminal_strip, &TerminalStrip::bridgeColorChanged, this, [=] { + connect(terminal_strip, &TerminalStrip::bridgeColorChanged, this, [this] { emit dataChanged(index(0, LEVEL_0_CELL), index(rowCount(), LEVEL_3_CELL)); }); diff --git a/sources/autoNum/assignvariables.cpp b/sources/autoNum/assignvariables.cpp index 6679d68d7..3aa46b54c 100644 --- a/sources/autoNum/assignvariables.cpp +++ b/sources/autoNum/assignvariables.cpp @@ -221,12 +221,56 @@ namespace autonum Diagram *diagram, const Element *elmt, const Conductor *cndr) + { + if (!diagram) { + return formula; + } + + FormulaContext context; + const BorderTitleBlock &border = diagram->border_and_titleblock; + context.folio = border.folio(); + context.folio_index = diagram->folioIndex(); + context.folio_total = border.folioTotal(); + context.plant = border.plant(); + context.locmach = border.locmach(); + context.title_block_fields = border.additionalFields(); + context.project_properties = diagram->project()->projectProperties(); + if (elmt) + { + context.has_element = true; + context.element_position = diagram->convertPosition(elmt->scenePos()); + context.element_prefix = elmt->getPrefix(); + } + if (cndr) + { + context.has_conductor = true; + context.wire_function = cndr->properties().m_function; + context.wire_tension_protocol = cndr->properties().m_tension_protocol; + context.wire_color = cndr->properties().m_wire_color; + context.wire_section = cndr->properties().m_wire_section; + } + return formulaToLabel(std::move(formula), seqStruct, context); + } + + /** + @brief AssignVariables::formulaToLabel + Return the formula with its variables assigned from @p context, + which describes a folio and the element or conductor the formula + belongs to without needing either to be built. + @param formula - the formula to work + @param seqStruct - struct where is stocked int values + (struct is passed as a reference + and modified by this static method) + @param context - what the variables are read from + @return the string with variable assigned. + */ + QString AssignVariables::formulaToLabel(QString formula, + sequentialNumbers &seqStruct, + const FormulaContext &context) { AssignVariables av(std::move(formula), seqStruct, - diagram, - elmt, - cndr); + context); seqStruct = av.m_seq_struct; return av.m_assigned_label; } @@ -348,76 +392,54 @@ namespace autonum AssignVariables::AssignVariables(const QString& formula, const sequentialNumbers& seqStruct, - Diagram *diagram, - const Element *elmt, - const Conductor *cndr): - m_diagram(diagram), + const FormulaContext &context): + m_context(context), m_arg_formula(formula), m_assigned_label(formula), - m_seq_struct(seqStruct), - m_element(elmt), - m_conductor(cndr) + m_seq_struct(seqStruct) { - if (m_diagram) + m_assigned_label.replace("%F", m_context.folio); + m_assigned_label.replace("%f", + QString::number(m_context.folio_index+1)); + m_assigned_label.replace("%id", + QString::number(m_context.folio_index+1)); + m_assigned_label.replace("%total", + QString::number(m_context.folio_total)); + m_assigned_label.replace("%M", m_context.plant); + m_assigned_label.replace("%LM", m_context.locmach); + + QSettings settings; + if (m_context.has_element) { - m_assigned_label.replace("%F", - m_diagram - -> border_and_titleblock - .folio()); - m_assigned_label.replace("%f", - QString::number( - m_diagram - ->folioIndex()+1)); - m_assigned_label.replace("%id", - QString::number( - m_diagram - ->folioIndex()+1)); - m_assigned_label.replace("%total", - QString::number( - m_diagram - ->border_and_titleblock - .folioTotal())); - m_assigned_label.replace("%M", - m_diagram - -> border_and_titleblock - .plant()); - m_assigned_label.replace("%LM", - m_diagram - -> border_and_titleblock - .locmach()); - - QSettings settings; - if (m_element) - { - if (settings.value("border-columns_0", true).toBool()){ - m_assigned_label.replace("%c", QString::number(m_diagram->convertPosition(m_element->scenePos()).number() - 1)); - }else{ - m_assigned_label.replace("%c", QString::number(m_diagram->convertPosition(m_element->scenePos()).number())); - } - m_assigned_label.replace("%l", m_diagram->convertPosition(m_element->scenePos()).letter()); - m_assigned_label.replace("%prefix", m_element->getPrefix()); + if (settings.value("border-columns_0", true).toBool()){ + m_assigned_label.replace("%c", QString::number(m_context.element_position.number() - 1)); + }else{ + m_assigned_label.replace("%c", QString::number(m_context.element_position.number())); } - - if (m_conductor) - { - m_assigned_label.replace("%wf", cndr->properties().m_function); - m_assigned_label.replace("%wv", cndr->properties().m_tension_protocol); - m_assigned_label.replace("%wc", cndr->properties().m_wire_color); - m_assigned_label.replace("%ws", cndr->properties().m_wire_section); - } - - assignTitleBlockVar(); - assignProjectVar(); - assignSequence(); + m_assigned_label.replace("%l", m_context.element_position.letter()); + m_assigned_label.replace("%prefix", m_context.element_prefix); } + + if (m_context.has_conductor) + { + m_assigned_label.replace("%wf", m_context.wire_function); + m_assigned_label.replace("%wv", m_context.wire_tension_protocol); + m_assigned_label.replace("%wc", m_context.wire_color); + m_assigned_label.replace("%ws", m_context.wire_section); + } + + assignTitleBlockVar(); + assignProjectVar(); + assignSequence(); } void AssignVariables::assignTitleBlockVar() { - for (int i = 0; i < m_diagram->border_and_titleblock.additionalFields().count(); i++) + DiagramContext fields = m_context.title_block_fields; + for (int i = 0; i < fields.count(); i++) { - QString folio_variable = m_diagram->border_and_titleblock.additionalFields().keys().at(i); - QVariant folio_value = m_diagram->border_and_titleblock.additionalFields().operator [](folio_variable); + QString folio_variable = fields.keys().at(i); + QVariant folio_value = fields[folio_variable]; if (m_assigned_label.contains(folio_variable)) { m_assigned_label.replace("%{" + folio_variable + "}", folio_value.toString()); @@ -428,10 +450,11 @@ namespace autonum void AssignVariables::assignProjectVar() { - for (int i = 0; i < m_diagram->project()->projectProperties().count(); i++) + DiagramContext properties = m_context.project_properties; + for (int i = 0; i < properties.count(); i++) { - QString folio_variable = m_diagram->project()->projectProperties().keys().at(i); - QVariant folio_value = m_diagram->project()->projectProperties().operator [](folio_variable); + QString folio_variable = properties.keys().at(i); + QVariant folio_value = properties[folio_variable]; if (m_assigned_label.contains(folio_variable)) { m_assigned_label.replace("%{" + folio_variable + "}", folio_value.toString()); diff --git a/sources/autoNum/assignvariables.h b/sources/autoNum/assignvariables.h index 5fffdf43f..255ad6003 100644 --- a/sources/autoNum/assignvariables.h +++ b/sources/autoNum/assignvariables.h @@ -18,6 +18,7 @@ #ifndef ASSIGNVARIABLES_H #define ASSIGNVARIABLES_H #include "../diagramcontext.h" +#include "../diagramposition.h" #include "numerotationcontext.h" #include @@ -57,6 +58,36 @@ namespace autonum QStringList alpha; }; + /** + @brief The FormulaContext struct + Everything a formula's variables are read from: the folio it is on + and, for an element's or a conductor's formula, that item. + AssignVariables::formulaToLabel() fills one from a built folio; the + project database fills one from the project's file. + */ + struct FormulaContext + { + /// border_and_titleblock.folio(), folioIndex(), folioTotal()... + QString folio; + int folio_index = 0; + int folio_total = 0; + QString plant; + QString locmach; + /// the title block's additional fields and the project's properties + DiagramContext title_block_fields; + DiagramContext project_properties; + /// an element's grid cell (%c, %l) and prefix + bool has_element = false; + DiagramPosition element_position; + QString element_prefix; + /// a conductor's %wf, %wv, %wc and %ws + bool has_conductor = false; + QString wire_function; + QString wire_tension_protocol; + QString wire_color; + QString wire_section; + }; + /** @brief The AssignVariables class This class assign variable of a formula string. @@ -66,21 +97,20 @@ namespace autonum { public: static QString formulaToLabel (QString formula, sequentialNumbers &seqStruct, Diagram *diagram, const Element *elmt = nullptr, const Conductor *cndr = nullptr); + static QString formulaToLabel (QString formula, sequentialNumbers &seqStruct, const FormulaContext &context); static QString replaceVariable (const QString &formula, const DiagramContext &dc); static QString genericXref (const Element *element); private: - AssignVariables(const QString& formula, const sequentialNumbers& seqStruct , Diagram *diagram, const Element *elmt = nullptr, const Conductor *cndr = nullptr); + AssignVariables(const QString& formula, const sequentialNumbers& seqStruct, const FormulaContext &context); void assignTitleBlockVar(); void assignProjectVar(); void assignSequence(); - Diagram *m_diagram = nullptr; + const FormulaContext &m_context; QString m_arg_formula; QString m_assigned_label; sequentialNumbers m_seq_struct; - const Element *m_element = nullptr; - const Conductor *m_conductor = nullptr; }; void setSequentialToList(QStringList &list, NumerotationContext &nc, const QString& type); diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 7c6d29d8a..bea454bd4 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -19,6 +19,9 @@ #include "sqlreadonly.h" +#include "../autoNum/assignvariables.h" +#include "../borderproperties.h" +#include "../bordertitleblock.h" #include "../diagram.h" #include "../diagramposition.h" #include "../elementprovider.h" @@ -32,8 +35,16 @@ #include "../qetgraphicsitem/terminal.h" #include "../qetinformation.h" #include "../qetproject.h" +#include "../qet.h" +#include "../titleblockproperties.h" +#include "../ElementsCollection/xmlelementcollection.h" +#include "../properties/elementdata.h" #include +#include +#include + +#include #include #include #include @@ -125,6 +136,480 @@ void projectDataBase::updateDB() emit dataBaseUpdated(); } +/** + @brief projectDataBase::updateDB + updateDB() for a project just read from @p document. + + The diagram, diagram_info, element, element_info, terminal and conductor + tables are filled from the document itself when it carries everything + they need -- see populateFromDocument() -- and from the built folios + otherwise, as updateDB() does. Shapes, texts and pictures always come from + the built folios: their boxes need the fonts and pens a folio renders with. + + The two fills give the same tables (tst_databasefromdocument checks it on + the shipped examples). Reading the document instead of the folios is what + the database needs before a project can be opened without building every + folio; see DB-FROM-XML-SCOPE.md in qelectrotech-docker. +*/ +void projectDataBase::updateDB(const QDomDocument &document) +{ + if (m_update_blocked || !m_content_changed) { + updateDB(); + return; + } + //QET_DATABASE_FROM_FOLIOS=1 keeps the fill from the built folios: + //a way back should the two ever disagree, and what the test that + //says they do not compares against. + QString why; + if (qEnvironmentVariableIntValue("QET_DATABASE_FROM_FOLIOS") == 1) { + why = QStringLiteral("QET_DATABASE_FROM_FOLIOS is set"); + } else if (populateFromDocument(document, &why)) { + qInfo() << "Project database filled from the document"; + populateDrawingItemTables(); + m_content_changed = false; + emit dataBaseUpdated(); + return; + } + qInfo().noquote() << "Project database filled from the folios:" << why; + updateDB(); +} + +namespace { +struct DocumentTerminal +{ + QString uuid; + QString name; + bool master_label = false; +}; + +struct DocumentDefinition +{ + QString type; + QString sub_type; + QHash terminals; +}; + +struct DocumentElement +{ + QString uuid; + QString diagram_uuid; + QString pos; + QString type; + QString sub_type; + QVariant group; + DiagramContext informations; + QString label; + QHash terminals; +}; + + //The sequential values an element or a conductor was saved with, as + //Element::fromXml() and Conductor::fromXml() read them -- false for + //the attributes files written before carry. +bool readSequence(const QDomElement &item, autonum::sequentialNumbers *sequence) +{ + for (const char *name : {"sequ_1", "sequf_1", "seqt_1", "seqtf_1", "seqh_1"}) { + if (item.hasAttribute(QLatin1String(name))) { + return false; + } + } + sequence->fromXml(item.firstChildElement(QStringLiteral("sequentialNumbers"))); + return true; +} + +struct DocumentConductor +{ + QString uuid; + QString diagram_uuid; + QString element1, terminal1, element2, terminal2; + QString text; +}; +} + +/** + @brief projectDataBase::populateFromDocument + Fill the diagram, diagram_info, element, element_info, terminal and + conductor tables from @p document, the project as read from its file, + with the same values the built folios give -- using the same code: + a BorderTitleBlock read from each folio's XML gives the title-block + values and each element's grid cell, the project's embedded collection + gives each element's definition, and the binders are shared. + + Nothing is written unless the whole document can be read this way. It + cannot -- and false is returned, for the caller to fill from the folios + instead -- when a folio, element or conductor carries no saved uuid (the + folios derive one on load), a conductor names its ends the older way, + a folio number uses %autonum, a conductor ends on a terminal that shows + its master's contact label, an element's definition is missing or + not one the folios could build, two elements on a folio number their + terminals alike, or sequential numbers are saved as the attributes + older files carry. A file saved by a current QElectroTech carries + everything else. + + A label or a conductor text made from a formula is worked out again, + as the folios do, with the same AssignVariables code: the one saved + in the file is what the formula gave when it was saved, and a folio + added or moved since changes it. A frozen conductor text is left to the + folios, which work it out part-way through loading. + @return true if the tables were filled +*/ +bool projectDataBase::populateFromDocument(const QDomDocument &document, QString *why) +{ + auto refuse = [why](const QString &reason) { + if (why) *why = reason; + return false; + }; + if (!m_project) { + return refuse(QStringLiteral("no project")); + } + + //The folios, in the order the project read them (readDiagramsXml()), + //each with its saved uuid -- the one the built folio has. + const QDomNodeList diagram_nodes = document.elementsByTagName(QStringLiteral("diagram")); + const QList diagrams = m_project->diagrams(); + if (diagram_nodes.size() != diagrams.size()) { + return refuse(QStringLiteral("the folios are not the document's")); + } + + const DiagramContext project_wide = m_project->projectWideProperties(); + const DiagramContext project_properties = m_project->projectProperties(); + //One border and title block read from each folio's XML in turn: + //only what it gives is kept. + BorderTitleBlock reader; + QList diagram_infos; + QList diagram_dates; + QList diagram_uuids; + QList elements; + QList conductors; + QSet element_uuids; + QHash definitions; //by element type + + //The project's own definitions by "embed://" path, which is how an + //element's type names them: one walk of the embedded collection + //rather than resolving an ElementsLocation per type. + QHash stored; + std::function walk = + [&stored, &walk](const QDomElement &category, const QString &path) { + for (QDomElement child = category.firstChildElement() ; + !child.isNull() ; child = child.nextSiblingElement()) { + const QString name = path + child.attribute(QStringLiteral("name")); + if (child.tagName() == QLatin1String("category")) { + walk(child, name + QLatin1Char('/')); + } else if (child.tagName() == QLatin1String("element")) { + stored.insert(name, child.firstChildElement(QStringLiteral("definition"))); + } + } + }; + if (auto collection = m_project->embeddedElementCollection()) { + walk(collection->root(), QStringLiteral("embed://")); + } + + for (int i = 0 ; i < diagram_nodes.size() ; ++i) + { + const QDomElement diagram_xml = diagram_nodes.at(i).toElement(); + const QUuid diagram_uuid(diagram_xml.attribute(QStringLiteral("uuid"))); + if (diagram_uuid.isNull() || diagram_uuid != diagrams.at(i)->uuid()) { + return refuse(QStringLiteral("a folio has no saved uuid")); + } + + BorderTitleBlock *border = &reader; + //As a new Diagram's border starts, before initFromXml() reads it + border->importBorder(BorderProperties()); + border->importTitleBlock(TitleBlockProperties()); + border->titleBlockFromXml(diagram_xml); + border->borderFromXml(diagram_xml); + if (border->folio().contains(QStringLiteral("%autonum"))) { + return refuse(QStringLiteral("a folio number uses %autonum")); + } + border->setFolioData(i + 1, int(diagram_nodes.size()), QString(), project_wide); + + //What a formula on this folio is worked out from, as + //AssignVariables::formulaToLabel() reads it off a built folio. + autonum::FormulaContext folio_context; + folio_context.folio = border->folio(); + folio_context.folio_index = i; + folio_context.folio_total = border->folioTotal(); + folio_context.plant = border->plant(); + folio_context.locmach = border->locmach(); + folio_context.title_block_fields = border->additionalFields(); + folio_context.project_properties = project_properties; + + QHash on_this_folio; //element uuid -> index in elements + QSet terminal_ids; //the older terminal ids used so far + for (QDomElement element_xml : QET::findInDomElement( + diagram_xml, QStringLiteral("elements"), QStringLiteral("element"))) + { + //Skipped by Diagram::fromXml() as well + if (!Element::valideXml(element_xml)) { + continue; + } + const QUuid uuid(element_xml.attribute(QStringLiteral("uuid"))); + if (uuid.isNull() || element_uuids.contains(uuid)) { + return refuse(QStringLiteral("an element has no saved uuid, or shares one")); + } + element_uuids.insert(uuid); + + //Element::fromXml() refuses an element whose terminals are + //numbered like those of one read before it on the folio; + //which of them it matches needs their geometry, so leave any + //such folio to the folios. A current file numbers them + //across the folio. + QSet ids; + for (QDomElement t : QET::findInDomElement( + element_xml, QStringLiteral("terminals"), QStringLiteral("terminal"))) { + if (Terminal::valideXml(t)) { + ids.insert(t.attribute(QStringLiteral("id")).toInt()); + } + } + if (terminal_ids.intersects(ids)) { + return refuse(QStringLiteral("two elements on a folio number their terminals alike")); + } + terminal_ids.unite(ids); + + //The definition the folio builds the element from, with the + //checks Element::buildFromXml() refuses an element on -- + //read once per type, as many elements share one. + const QString type = element_xml.attribute(QStringLiteral("type")); + auto known = definitions.constFind(type); + if (known == definitions.constEnd()) + { + const QDomElement definition = stored.value(type); + if (definition.isNull()) { + return refuse(QStringLiteral("an element's definition is not in the project")); + } + int number; + if (definition.tagName() != QLatin1String("definition") + || definition.attribute(QStringLiteral("type")) != QLatin1String("element") + || !QET::attributeIsAnInteger(definition, QStringLiteral("width"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("height"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_x"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_y"), &number) + || definition.firstChild().isNull()) { + return refuse(QStringLiteral("an element's definition cannot be built")); + } + DocumentDefinition read; + ElementData data; + data.fromXml(definition); + read.type = data.typeToString(); + read.sub_type = data.masterTypeToString(); + const QDomElement description = definition.firstChildElement(QStringLiteral("description")); + for (QDomElement t = description.firstChildElement(QStringLiteral("terminal")) ; + !t.isNull() ; t = t.nextSiblingElement(QStringLiteral("terminal"))) + { + const QUuid terminal_uuid(t.attribute(QStringLiteral("uuid"))); + if (terminal_uuid.isNull()) { + continue; + } + DocumentTerminal terminal; + terminal.uuid = terminal_uuid.toString(); + terminal.name = t.attribute(QStringLiteral("name")); + terminal.master_label = t.attribute(QStringLiteral("use_master_label")) == QLatin1String("true"); + read.terminals.insert(terminal_uuid, terminal); + } + known = definitions.insert(type, read); + } + + DocumentElement element; + element.uuid = uuid.toString(); + element.diagram_uuid = diagram_uuid.toString(); + DiagramPosition position = border->convertPosition( + QPointF(element_xml.attribute(QStringLiteral("x")).toDouble(), + element_xml.attribute(QStringLiteral("y")).toDouble())); + element.pos = position.toString(); + element.type = known->type; + element.sub_type = known->sub_type; + element.terminals = known->terminals; + const QUuid group = ItemGroups::read(element_xml); + element.group = group.isNull() ? QVariant() : QVariant(group.toString()); + element.informations.fromXml( + element_xml.firstChildElement(QStringLiteral("elementInformations")), + QStringLiteral("elementInformation")); + //Element::actualLabel() + const QString formula = element.informations.value(QStringLiteral("formula")).toString(); + if (formula.isEmpty()) { + element.label = element.informations.value(QStringLiteral("label")).toString(); + } else { + autonum::sequentialNumbers sequence; + if (!readSequence(element_xml, &sequence)) { + return refuse(QStringLiteral("an element's sequential numbers are saved the older way")); + } + autonum::FormulaContext context = folio_context; + context.has_element = true; + context.element_position = position; + context.element_prefix = element_xml.attribute(QStringLiteral("prefix")); + element.label = autonum::AssignVariables::formulaToLabel(formula, sequence, context); + } + + on_this_folio.insert(uuid, int(elements.size())); + elements << element; + } + + //The conductors the folio builds, and only those: an end that is + //not found, a conductor from a terminal to itself and a second + //conductor between the same two terminals are all dropped on load. + QSet joined; + for (QDomElement conductor_xml : QET::findInDomElement( + diagram_xml, QStringLiteral("conductors"), QStringLiteral("conductor"))) + { + if (!Conductor::valideXml(conductor_xml)) { + continue; + } + const QUuid uuid(conductor_xml.attribute(QStringLiteral("uuid"))); + if (uuid.isNull() + || !conductor_xml.hasAttribute(QStringLiteral("element1")) + || !conductor_xml.hasAttribute(QStringLiteral("element2"))) { + return refuse(QStringLiteral("a conductor has no saved uuid, or names its ends the older way")); + } + QString ends[2][2]; + bool found = true; + for (int n = 0 ; n < 2 ; ++n) + { + const QString index = QString::number(n + 1); + const QUuid owner(conductor_xml.attribute(QStringLiteral("element") + index)); + const QUuid terminal(conductor_xml.attribute(QStringLiteral("terminal") + index)); + const int e = on_this_folio.value(owner, -1); + if (e < 0) { + found = false; + break; + } + if (!elements.at(e).terminals.contains(terminal)) { + //The folio would try the terminal's derived uuid; + //leave that to it. + return refuse(QStringLiteral("a conductor ends on a terminal not in its element's definition")); + } + const DocumentTerminal &t = elements.at(e).terminals.value(terminal); + if (t.master_label) { + return refuse(QStringLiteral("a conductor ends on a terminal showing its master's label")); + } + ends[n][0] = elements.at(e).uuid; + ends[n][1] = t.uuid; + } + if (!found) { + continue; + } + const QString a = ends[0][0] + ends[0][1], b = ends[1][0] + ends[1][1]; + if (a == b) { + continue; + } + const QString pair = a < b ? a + b : b + a; + if (joined.contains(pair)) { + continue; + } + joined.insert(pair); + + DocumentConductor conductor; + conductor.uuid = uuid.toString(); + conductor.diagram_uuid = diagram_uuid.toString(); + conductor.element1 = ends[0][0]; + conductor.terminal1 = ends[0][1]; + conductor.element2 = ends[1][0]; + conductor.terminal2 = ends[1][1]; + //ConductorProperties::fromXml()'s text, without reading the + //rest of the properties -- or, as Conductor::refreshText() + //makes it, what its formula gives. + const QString formula = conductor_xml.attribute(QStringLiteral("formula")); + if (formula.isEmpty()) { + conductor.text = conductor_xml.attribute(QStringLiteral("num")); + } else { + autonum::sequentialNumbers sequence; + if (conductor_xml.attribute(QStringLiteral("freezeLabel")) == QLatin1String("true")) { + return refuse(QStringLiteral("a conductor's text made from a formula is frozen")); + } + if (!readSequence(conductor_xml, &sequence)) { + return refuse(QStringLiteral("a conductor's sequential numbers are saved the older way")); + } + autonum::FormulaContext context = folio_context; + context.has_conductor = true; + context.wire_function = conductor_xml.attribute(QStringLiteral("function")); + context.wire_tension_protocol = conductor_xml.attribute(QStringLiteral("tension_protocol")); + context.wire_color = conductor_xml.attribute(QStringLiteral("conductor_color")); + context.wire_section = conductor_xml.attribute(QStringLiteral("conductor_section")); + conductor.text = autonum::AssignVariables::formulaToLabel(formula, sequence, context); + } + conductors << conductor; + } + + diagram_uuids << diagram_uuid; + diagram_infos << border->titleblockInformation(); + diagram_dates << border->date(); + } + + //Everything could be read: write it. + QSqlQuery query(m_data_base); + for (const QString &table : {QStringLiteral("diagram"), QStringLiteral("diagram_info"), + QStringLiteral("element"), QStringLiteral("element_info"), + QStringLiteral("conductor"), QStringLiteral("terminal")}) { + query.exec(QStringLiteral("DELETE FROM ") + table); + } + + for (int i = 0 ; i < diagram_uuids.size() ; ++i) + { + m_insert_diagram_query.bindValue(":uuid", diagram_uuids.at(i).toString()); + m_insert_diagram_query.bindValue(":pos", i + 1); + if (!m_insert_diagram_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument diagram insert error : " << m_insert_diagram_query.lastError(); + } + bindDiagramInfoValues(m_insert_diagram_info_query, diagram_uuids.at(i), + diagram_infos.at(i), diagram_dates.at(i)); + if (!m_insert_diagram_info_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument diagram_info insert error : " << m_insert_diagram_info_query.lastError(); + } + } + + for (const DocumentElement &element : std::as_const(elements)) + { + m_insert_elements_query.bindValue(QStringLiteral(":uuid"), element.uuid); + m_insert_elements_query.bindValue(QStringLiteral(":diagram_uuid"), element.diagram_uuid); + m_insert_elements_query.bindValue(QStringLiteral(":pos"), element.pos); + m_insert_elements_query.bindValue(QStringLiteral(":type"), element.type); + m_insert_elements_query.bindValue(QStringLiteral(":sub_type"), element.sub_type); + m_insert_elements_query.bindValue(QStringLiteral(":group_uuid"), element.group); + if (!m_insert_elements_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument element insert error : " << m_insert_elements_query.lastError(); + } + bindElementInfoValues(m_insert_element_info_query, element.uuid, element.informations, + element.label); + if (!m_insert_element_info_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument element_info insert error : " << m_insert_element_info_query.lastError(); + } + } + + QHash element_index; + for (int i = 0 ; i < elements.size() ; ++i) { + element_index.insert(elements.at(i).uuid, i); + } + for (const DocumentConductor &conductor : std::as_const(conductors)) + { + for (const auto &end : {std::make_pair(conductor.element1, conductor.terminal1), + std::make_pair(conductor.element2, conductor.terminal2)}) { + const DocumentElement &owner = elements.at(element_index.value(end.first)); + insertTerminal(end.second, end.first, + owner.terminals.value(QUuid(end.second)).name); + } + m_insert_conductor_query.bindValue(QStringLiteral(":uuid"), conductor.uuid); + m_insert_conductor_query.bindValue(QStringLiteral(":diagram_uuid"), conductor.diagram_uuid); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal1_uuid"), conductor.terminal1); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal1_element_uuid"), conductor.element1); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal2_uuid"), conductor.terminal2); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal2_element_uuid"), conductor.element2); + m_insert_conductor_query.bindValue(QStringLiteral(":text"), conductor.text); + if (!m_insert_conductor_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument conductor insert error : " << m_insert_conductor_query.lastError(); + } + } + + //While every folio is still built, their conductors are watched for + //property changes as populateConductorTable() does -- the one walk + //over the built folios left here. + for (Diagram *diagram : diagrams) { + for (Conductor *conductor : diagram->conductors()) { + if (conductor->terminal1->parentElement() && conductor->terminal2->parentElement()) { + watchConductor(conductor); + } + } + } + return true; +} + /** @brief projectDataBase::setUpdateBlocked @param blocked : whether updateDB() should skip the full rebuild @@ -1514,9 +1999,21 @@ void projectDataBase::populateConductorTable() */ void projectDataBase::insertTerminal(Terminal *terminal) { - m_insert_terminal_query.bindValue(":uuid", terminal->stableUuid().toString()); - m_insert_terminal_query.bindValue(":element_uuid", terminal->parentElement()->uuid().toString()); - m_insert_terminal_query.bindValue(":name", terminal->name()); + insertTerminal(terminal->stableUuid().toString(), + terminal->parentElement()->uuid().toString(), + terminal->name()); +} + +/** + @brief projectDataBase::insertTerminal + insertTerminal(Terminal *) from values rather than a live terminal. +*/ +void projectDataBase::insertTerminal(const QString &uuid, const QString &element_uuid, + const QString &name) +{ + m_insert_terminal_query.bindValue(":uuid", uuid); + m_insert_terminal_query.bindValue(":element_uuid", element_uuid); + m_insert_terminal_query.bindValue(":name", name); if (!m_insert_terminal_query.exec()) { qDebug() << "projectDataBase::insertTerminal insert error : " << m_insert_terminal_query.lastError(); } @@ -1733,17 +2230,56 @@ void projectDataBase::bindElementInfoValues(QSqlQuery &query, Element *element) } } +/** + @brief projectDataBase::bindElementInfoValues + bindElementInfoValues(QSqlQuery &, Element *) from values: the element's + information and the label to store for it. +*/ +void projectDataBase::bindElementInfoValues(QSqlQuery &query, const QString &element_uuid, + const DiagramContext &informations, + const QString &label) +{ + query.bindValue(QStringLiteral(":uuid"), element_uuid); + for (const auto &key : QETInformation::elementInfoKeys()) { + query.bindValue(QStringLiteral(":") + key, + key == QLatin1String("label") ? label + : informations[key].toString()); + } +} + void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram) { - query.bindValue(":uuid", diagram->uuid()); + bindDiagramInfoValues(query, diagram->uuid(), diagram->border_and_titleblock); +} + +/** + @brief projectDataBase::bindDiagramInfoValues + bindDiagramInfoValues(QSqlQuery &, Diagram *) from a folio's uuid and its + border and title block, which need not belong to a built folio. +*/ +void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const BorderTitleBlock &border) +{ + bindDiagramInfoValues(query, diagram_uuid, border.titleblockInformation(), border.date()); +} + +/** + @brief projectDataBase::bindDiagramInfoValues + The same from a title block's information and date. +*/ +void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const DiagramContext &infos, const QDate &date) +{ + query.bindValue(":uuid", diagram_uuid); - auto infos = diagram->border_and_titleblock.titleblockInformation(); for (auto key : QETInformation::diagramInfoKeys()) { if (key == "date") { - query.bindValue( ":date", - QLocale::system().toDate(infos.value("date").toString(), - QLocale::ShortFormat)); + //The folio's own date, not the title block's text read + //back: that text is the locale's short format, and where + //it has a two-digit year (en_US "M/d/yy") toDate() reads + //2010 back as 1910. + query.bindValue(QStringLiteral(":date"), date); } else { auto value = infos.value(key); auto bind = key.prepend(":"); diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 510d17eb7..67472af91 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -33,6 +33,10 @@ class Diagram; class Conductor; class Terminal; class QGraphicsItem; +class QDomDocument; +class BorderTitleBlock; +class DiagramContext; +class QDate; /** @brief The projectDataBase class @@ -50,6 +54,13 @@ class projectDataBase : public QObject virtual ~projectDataBase() override; void updateDB(); + /** + updateDB() for a project just read from @p document: the + diagram, element, terminal and conductor tables are filled + from the document when it carries everything they need (see + populateFromDocument()), from the built folios otherwise. + */ + void updateDB(const QDomDocument &document); /** Suppress the full rebuild performed by updateDB(). @@ -131,18 +142,28 @@ class projectDataBase : public QObject void populateDiagramInfoTable(); void populateConductorTable(); void populateDrawingItemTables(); + bool populateFromDocument(const QDomDocument &document, QString *why = nullptr); bool writeDrawingItem(QObject *object); void flushDrawingItems(); void forgetDrawingItem(QObject *object); void bindConductorValues(QSqlQuery &query, Conductor *conductor, Diagram *diagram); void watchConductor(Conductor *conductor); void insertTerminal(Terminal *terminal); + void insertTerminal(const QString &uuid, const QString &element_uuid, + const QString &name); void prepareQuery(); static QHash elementInfoToString( Element *elmt); void bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram); + static void bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const BorderTitleBlock &border); + static void bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const DiagramContext &infos, const QDate &date); static void bindElementValues(QSqlQuery &query, Element *element, Diagram *diagram); static void bindElementInfoValues(QSqlQuery &query, Element *element); + static void bindElementInfoValues(QSqlQuery &query, const QString &element_uuid, + const DiagramContext &informations, + const QString &label); private: QPointer m_project; diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 008fa9495..f7c336ed6 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -773,6 +773,18 @@ QUuid Diagram::uuid() return m_uuid; } +/** + @brief Diagram::wiresNotReconnected + @return one line per wire of the loaded file that was left out because + a terminal it joins could not be found, for example after the symbol's + definition in the project was replaced by one whose terminals differ. + Empty for a folio that loaded every wire. +*/ +QStringList Diagram::wiresNotReconnected() const +{ + return m_wires_not_reconnected; +} + /** @brief Diagram::uuidUsedByOtherDiagram A hand-edited or merged project file can contain two folios with the same @@ -1479,6 +1491,14 @@ Terminal* findTerminal(int conductor_index, continue; return terminal; + } + //The uuid a project gave a terminal on opening is worked out + //from where the terminal is in its symbol: if the symbol's + //definition has since been replaced by one whose terminals + //carry other uuids, the terminal at that place is still it. + for (auto terminal: element->terminals()) { + if (terminal->derivedUuid() == terminal_uuid) + return terminal; } qDebug() << "Diagram::fromXml() : " << terminal_index @@ -1837,6 +1857,8 @@ bool Diagram::fromXml(QDomElement &document, } // Load conductor + if (consider_informations) + m_wires_not_reconnected.clear(); QList added_conductors; for (auto f : QET::findInDomElement(root, QStringLiteral("conductors"), @@ -1849,6 +1871,33 @@ bool Diagram::fromXml(QDomElement &document, Terminal* p1 = findTerminal(1, f, table_adr_id, added_elements); Terminal* p2 = findTerminal(2, f, table_adr_id, added_elements); + //Keep a trace of the wire, it will be missing from the next save + if ((!p1 || !p2) && consider_informations) + { + //The symbol's label, else its name. For an end not found, + //only the uuid form of a wire says which symbol it is on. + auto end_label = [&f, &added_elements](const QString &index, + Terminal *found) { + Element *element = found ? found->parentElement() : nullptr; + const QUuid uuid(f.attribute(QStringLiteral("element") + index)); + for (int i = 0 ; !element && !uuid.isNull() + && i < added_elements.size() ; ++i) { + if (added_elements.at(i)->uuid() == uuid) + element = added_elements.at(i); + } + if (!element) + return QStringLiteral("?"); + const QString label = element->actualLabel(); + return label.isEmpty() ? element->name() : label; + }; + QString wire = QStringLiteral("%1 - %2").arg(end_label(QStringLiteral("1"), p1), + end_label(QStringLiteral("2"), p2)); + const QString num = f.attribute(QStringLiteral("num")); + if (!num.isEmpty() && num != QLatin1String("_")) + wire += QStringLiteral(" (%1)").arg(num); + m_wires_not_reconnected << wire; + } + if (p1 && p2 && p1 != p2) { Conductor *c = new Conductor(p1, p2); @@ -1884,6 +1933,12 @@ bool Diagram::fromXml(QDomElement &document, delete c; } } + if (consider_informations && !m_wires_not_reconnected.isEmpty()) { + qWarning().noquote() << "Diagram::fromXml():" + << m_wires_not_reconnected.size() + << "wire(s) not loaded, a terminal they join was not found:" + << m_wires_not_reconnected.join(QStringLiteral(", ")); + } //Filling of falculatory lists if (content_ptr) { @@ -2224,6 +2279,7 @@ void Diagram::setItemGroup(QGraphicsItem *item, const QUuid &group) if (m_project) { m_project->dataBase()->itemGroupChanged(item); } + emit itemGroupChanged(); } /** diff --git a/sources/diagram.h b/sources/diagram.h index 83d408835..ff7ff1f9b 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -148,7 +148,10 @@ class Diagram : public QGraphicsScene bool uuidUsedByOtherDiagram(const QUuid &uuid) const; QUuid derivedUuid(const QDomElement &root, const QString &reason) const; - + + //Wires of the loaded file whose ends could not be found + QStringList m_wires_not_reconnected; + // METHODS protected: void drawBackground(QPainter *, const QRectF &) override; @@ -171,6 +174,7 @@ class Diagram : public QGraphicsScene void correctTextPos(Element* elmt); void restoreText(Element* elmt); QUuid uuid(); + QStringList wiresNotReconnected() const; void setEventInterface (DiagramEventInterface *event_interface); void clearEventInterface(); @@ -321,6 +325,10 @@ class Diagram : public QGraphicsScene void diagramActivated(); void diagramInformationChanged(); + + /// Emitted by setItemGroup(): an item joined or left a group + /// without the selection changing (#1144) + void itemGroupChanged(); }; Q_DECLARE_METATYPE(Diagram *) diff --git a/sources/diagramcontent.cpp b/sources/diagramcontent.cpp index 99e58e476..fc54e83c9 100644 --- a/sources/diagramcontent.cpp +++ b/sources/diagramcontent.cpp @@ -18,6 +18,7 @@ #include "diagramcontent.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductortextitem.h" @@ -261,7 +262,7 @@ void DiagramContent::clear() */ int DiagramContent::removeNonMovableItems() { - int count_ = 0; + int count_ = removePinnedGroups(); const QList elements_set = m_elements; for(Element *elmt : elements_set) { @@ -287,9 +288,55 @@ int DiagramContent::removeNonMovableItems() } } + //A wire whose two ends no longer both move is redrawn, not moved, + //or its text would be carried off with the end that still moves + const QList conductors_to_move = m_conductors_to_move; + for (Conductor *conductor : conductors_to_move) { + if (!m_elements.contains(conductor->terminal1->parentElement()) || + !m_elements.contains(conductor->terminal2->parentElement())) { + m_conductors_to_move.removeAll(conductor); + if (!m_conductors_to_update.contains(conductor)) + m_conductors_to_update << conductor; + } + } + return count_; } +/** + @brief DiagramContent::removePinnedGroups + A group (#1070) with a locked member does not move at all: moving the + rest would pull the group apart around the member that stays (#1146). + Only a locked member that is in this content pins its group. + Called first by removeNonMovableItems(), while the locked items are + still here to be found. + @return the number of removed items +*/ +int DiagramContent::removePinnedGroups() +{ + QSet pinned; + for (Element *elmt : std::as_const(m_elements)) + if (!elmt->isMovable()) + pinned << ItemGroups::groupOf(elmt); + for (DiagramImageItem *img : std::as_const(m_images)) + if (!img->isMovable()) + pinned << ItemGroups::groupOf(img); + for (QetShapeItem *shape : std::as_const(m_shapes)) + if (!shape->isMovable()) + pinned << ItemGroups::groupOf(shape); + pinned.remove(QUuid()); + if (pinned.isEmpty()) + return 0; + + auto isPinned = [&pinned](const QGraphicsItem *item) { + return pinned.contains(ItemGroups::groupOf(item)); + }; + return int(m_elements.removeIf(isPinned) + + m_images.removeIf(isPinned) + + m_shapes.removeIf(isPinned) + + m_text_fields.removeIf(isPinned)); +} + DiagramContent &DiagramContent::operator+=(const DiagramContent &other) { for(Element *elmt : other.m_elements) diff --git a/sources/diagramcontent.h b/sources/diagramcontent.h index 85f3dc7f2..92d7c1caf 100644 --- a/sources/diagramcontent.h +++ b/sources/diagramcontent.h @@ -100,6 +100,9 @@ class DiagramContent DiagramContent& operator+=(const DiagramContent& other); bool potentialIsManaged(QListconductors); bool hasTextEditing(); + + private: + int removePinnedGroups(); }; QDebug &operator<<(QDebug, DiagramContent &); #endif diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 8e49a1fe3..b4c5cf126 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -1752,7 +1752,7 @@ void DiagramView::setEventInterface(DVEventInterface *event_interface) { if (m_event_interface) delete m_event_interface; m_event_interface = event_interface; - connect(m_event_interface, &DVEventInterface::finish, this, [=](){delete this->m_event_interface; this->m_event_interface = nullptr;}, Qt::QueuedConnection); + connect(m_event_interface, &DVEventInterface::finish, this, [this](){delete this->m_event_interface; this->m_event_interface = nullptr;}, Qt::QueuedConnection); } /** diff --git a/sources/editor/UndoCommand/openelmtcommand.cpp b/sources/editor/UndoCommand/openelmtcommand.cpp index f94ab4c2f..ff28b4adc 100644 --- a/sources/editor/UndoCommand/openelmtcommand.cpp +++ b/sources/editor/UndoCommand/openelmtcommand.cpp @@ -17,6 +17,7 @@ */ #include "openelmtcommand.h" #include "../elementscene.h" +#include "../graphicspart/partterminal.h" #include #include @@ -52,6 +53,15 @@ void OpenElmtCommand::redo() if (m_first_redo) { m_scene->fromXml(m_document, QPointF(), true, &m_graphics_item); + //The terminals are added to an element that may already hold + //the same ones -- importing the same symbol twice, or two old + //symbols whose terminals get the same derived uuid -- so they + //get new uuids, as a paste gives them (PastePartsCommand). + for (QGraphicsItem *qgi : std::as_const(m_graphics_item)) { + if (qgi->type() == PartTerminal::Type) { + static_cast(qgi)->setNewUuid(); + } + } m_scene->qgiManager().manage(m_graphics_item); m_first_redo = false; diff --git a/sources/editor/elementscene.cpp b/sources/editor/elementscene.cpp index 534d435c9..c0105ae83 100644 --- a/sources/editor/elementscene.cpp +++ b/sources/editor/elementscene.cpp @@ -17,6 +17,7 @@ */ #include "elementscene.h" +#include "../ElementsCollection/terminaluuids.h" #include "../NameList/ui/namelistdialog.h" #include "../NameList/ui/namelistwidget.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" @@ -1196,6 +1197,13 @@ ElementContent ElementScene::loadContent(const QDomDocument &xml_document) if (root.tagName() != "definition" || root.attribute("type") != "element") return(loaded_parts); + //Terminals saved without a uuid get the one a project gives them + //(see TerminalUuids::fillMissing()), not a random one: the same old + //symbol then has the same terminal uuids wherever it is copied. + //On a copy, the document is the caller's. A paste renews them all. + root = root.cloneNode(true).toElement(); + TerminalUuids::fillMissingInDefinition(root); + //Load the graphic description of the element for (QDomNode node = root.firstChild() ; !node.isNull() ; node = node.nextSibling()) { diff --git a/sources/editor/graphicspart/partdynamictextfield.cpp b/sources/editor/graphicspart/partdynamictextfield.cpp index c65c955df..9c26a70d6 100644 --- a/sources/editor/graphicspart/partdynamictextfield.cpp +++ b/sources/editor/graphicspart/partdynamictextfield.cpp @@ -47,7 +47,7 @@ PartDynamicTextField::PartDynamicTextField(QETElementEditor *editor, QGraphicsIt //Option when text is displayed in multiple line QTextOption option = document() -> defaultTextOption(); - option.setAlignment(Qt::AlignHCenter); + option.setAlignment(m_alignment & Qt::AlignHorizontal_Mask); option.setWrapMode(QTextOption::WordWrap); document() -> setDefaultTextOption(option); } @@ -462,6 +462,10 @@ void PartDynamicTextField::setPlainText(const QString &text) { void PartDynamicTextField::setAlignment(Qt::Alignment alignment) { m_alignment = alignment; + //Lines of a multi-line text follow the horizontal alignment + QTextOption option = document() -> defaultTextOption(); + option.setAlignment(m_alignment & Qt::AlignHorizontal_Mask); + document() -> setDefaultTextOption(option); emit alignmentChanged(m_alignment); } diff --git a/sources/editor/ui/dynamictextfieldeditor.cpp b/sources/editor/ui/dynamictextfieldeditor.cpp index 7cd15dd62..3f444a8e3 100644 --- a/sources/editor/ui/dynamictextfieldeditor.cpp +++ b/sources/editor/ui/dynamictextfieldeditor.cpp @@ -199,18 +199,18 @@ void DynamicTextFieldEditor::setUpConnections() disconnectConnections(); //Setup the connection - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::colorChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::fontChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::taggChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textFromChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::infoNameChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::frameChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textWidthChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::compositeTextChanged,this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::keepVisualRotationChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationPointCenterChanged, this, [=](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::colorChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::fontChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::taggChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textFromChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::infoNameChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::frameChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textWidthChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::compositeTextChanged,this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::keepVisualRotationChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationPointCenterChanged, this, [this](){this -> updateForm();}); // Refresh info combo when element data changes (e.g. type switched to PLC-Slave) m_connection_list << connect(elementEditor()->elementScene(), &ElementScene::elementInfoChanged, diff --git a/sources/elementsmover.cpp b/sources/elementsmover.cpp index 28c8c344d..d8127cae9 100644 --- a/sources/elementsmover.cpp +++ b/sources/elementsmover.cpp @@ -20,6 +20,7 @@ #include "autobreakconductor.h" #include "conductorautonumerotation.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/diagramimageitem.h" @@ -61,6 +62,8 @@ bool ElementsMover::isReady() const */ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) { + m_driver_held = false; + // They must be no movement in progress if (m_movement_running) return(-1); @@ -86,6 +89,17 @@ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) m_moved_content = DiagramContent(diagram); m_moved_content.removeNonMovableItems(); + //A grouped driver left out of the move belongs to a group that one + //locked member holds in place: it stays with the group + m_driver_held = driver_item + && !ItemGroups::groupOf(driver_item).isNull() + && !m_moved_content.items().contains(driver_item); + if (m_driver_held && m_status_bar) { + m_status_bar->showMessage(QObject::tr( + "Ce groupe ne peut pas être déplacé : " + "la position d'un de ses éléments est verrouillée.")); + } + //Remove element text and text group, if the parent element is selected. const auto element_text{m_moved_content.m_element_texts}; for(const auto &deti : element_text) { @@ -109,6 +123,17 @@ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) return(m_moved_content.count()); } +/** + @brief ElementsMover::holds + @return true if @a item is the item the user drags and it must not move: + it is in a group that a locked member keeps in place (#1146). Each item + that drives a movement asks before moving itself. +*/ +bool ElementsMover::holds(const QGraphicsItem *item) const +{ + return m_driver_held && item && item == m_movement_driver; +} + /** @brief ElementsMover::continueMovement Add a move to the current movement. @@ -255,7 +280,8 @@ void ElementsMover::endMovement() m_movement_running = false; m_moved_content.clear(); - if (m_status_bar) { + //Keep saying why a held group did not move + if (m_status_bar && !m_driver_held) { m_status_bar->clearMessage(); } } diff --git a/sources/elementsmover.h b/sources/elementsmover.h index a8fd1857d..2074a88eb 100644 --- a/sources/elementsmover.h +++ b/sources/elementsmover.h @@ -52,6 +52,7 @@ class ElementsMover { int beginMovement(Diagram *, QGraphicsItem * = nullptr); void continueMovement(const QPointF &); void endMovement(); + bool holds(const QGraphicsItem *item) const; // attributes private: @@ -59,6 +60,7 @@ class ElementsMover { QPointF m_current_movement; Diagram *m_diagram{nullptr}; QGraphicsItem *m_movement_driver{nullptr}; + bool m_driver_held{false}; DiagramContent m_moved_content; QPointer m_status_bar; diff --git a/sources/materiallist/materiallist.cpp b/sources/materiallist/materiallist.cpp index e84b1ec1c..b9f8a2b8d 100644 --- a/sources/materiallist/materiallist.cpp +++ b/sources/materiallist/materiallist.cpp @@ -278,6 +278,44 @@ QString MaterialList::elementInfoKey(const QString &column, int block) return column + QStringLiteral("_auxiliary%1").arg(block); } +/** + @brief MaterialList::isArticleBound + Tell whether a column describes the article itself, rather than what + the element does or where it stands. + + An empty cell of such a column clears the field it feeds when the + entry is applied: an element cannot carry the manufacturer of two + parts at once, so the value of the article picked before has to go. + Every other column (function, comment, notes, plant, location, and + quantity and unity, which say how many pieces this element takes and + in which unit) is left alone by an empty cell, and so is a column the + file does not hold at all: a file which mentions nothing about a + field is no reason to empty it. + @param column a column of the material file + @return true when an empty cell of that column clears the field +*/ +bool MaterialList::isArticleBound(const QString &column) +{ + static const QStringList article_columns = { + QStringLiteral("description"), + QStringLiteral("designation"), + QStringLiteral("manufacturer"), + QStringLiteral("manufacturer_reference"), + QStringLiteral("machine_manufacturer_reference"), + QStringLiteral("supplier"), + QStringLiteral("model"), + QStringLiteral("category"), + QStringLiteral("voltage_rating"), + QStringLiteral("current_rating"), + QStringLiteral("width"), + QStringLiteral("height"), + QStringLiteral("depth"), + QStringLiteral("auxiliary") + }; + + return article_columns.contains(column); +} + /** @brief MaterialList::translatedColumn @param column a column of the material file @@ -443,6 +481,10 @@ QChar MaterialList::detectSeparator(const QString &first_line) const int commas = first_line.count(QLatin1Char(',')); if (semicolons == 0 && tabs == 0 && commas == 0) { + //A header holding no separator character at all is a file of a + //single column : falling back on the semicolon is deliberate, + //it is the one QElectroTech writes itself, and such a file + //needs no detection anyway. Leave this alone. return QLatin1Char(';'); } if (tabs >= semicolons && tabs >= commas && tabs > 0) { @@ -616,14 +658,25 @@ bool MaterialList::load(const QString &path, MaterialListData *data, QString *er //The label line is followed by the machine header QElectroTech //writes itself : the canonical names, so that the file is read the - //same way whatever the language it was written in. + //same way whatever the language it was written in. That machine + //line is the one naming the columns when it is there : the label + //line above it may be written in another language than the one + //running now, and would then match nothing. The label line stays + //on disk, it is only what the user reads. + QStringList machine_line; if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) { - rows.takeFirst(); + machine_line = rows.takeFirst(); } QStringList used; - for (const QString &cell : header) + for (int i = 0; i < header.size(); ++i) { + //An empty machine cell means the column is named by the label + //above it (a column added by hand shows up in both lines). + QString cell = header.at(i); + if (i < machine_line.size() && !machine_line.at(i).isEmpty()) { + cell = machine_line.at(i); + } const QString column = canonicalColumn(cell, used, alias_map); used.append(column); data->columns.append(column); @@ -640,6 +693,16 @@ bool MaterialList::load(const QString &path, MaterialListData *data, QString *er empty = false; } record.setValue(data->columns.at(i), value); + } + //Cells written past the header are not thrown away : they are + //written back the way they were read, and they are content too, + //so a line only they fill is kept as well. + for (int i = data->columns.size(); i < row.size(); ++i) + { + if (!row.at(i).isEmpty()) { + empty = false; + } + record.extra.append(row.at(i)); } if (!empty) { data->records.append(record); @@ -673,6 +736,9 @@ bool MaterialList::writeFile(const QString &path, const MaterialListData &data, for (const QString &column : data.columns) { row.append(record.value(column)); } + //The cells the file holds past the header, if any, follow the + //columns : a line wider than the header stays that wide. + row.append(record.extra); rows.append(row); } diff --git a/sources/materiallist/materiallist.h b/sources/materiallist/materiallist.h index 66b5cda77..6e219c586 100644 --- a/sources/materiallist/materiallist.h +++ b/sources/materiallist/materiallist.h @@ -37,6 +37,10 @@ struct MaterialRecord { QMap values; + //Cells written after the last column of the header, kept as they + //are so that appending an article never shortens a line the user + //wrote wider than the header itself. + QStringList extra; QString value(const QString &column) const {return values.value(column);} void setValue(const QString &column, const QString &value) {values.insert(column, value);} @@ -91,6 +95,7 @@ class MaterialList static QStringList defaultColumns(); static QStringList columnsForBlock(int block); static QString elementInfoKey(const QString &column, int block); + static bool isArticleBound(const QString &column); static QString translatedColumn(const QString &column); static QStringList translatedHeader(const QStringList &columns); diff --git a/sources/materiallist/materialselectiondialog.cpp b/sources/materiallist/materialselectiondialog.cpp index d7265de7e..2a6ba24cf 100644 --- a/sources/materiallist/materialselectiondialog.cpp +++ b/sources/materiallist/materialselectiondialog.cpp @@ -173,6 +173,11 @@ void MaterialFilterProxy::setTokens(const QStringList &tokens) A row is kept when each token is found in at least one of its cells : the words may be spread over different columns, as a search for "Hilfsschalter Schneider" expects. + + Every token walks every cell of the row, which means the whole file + on every keystroke : well within reach for a catalogue, to be + revisited before someone imports a supplier export of tens of + thousands of lines. @param source_row @param source_parent @return @@ -235,6 +240,11 @@ MaterialSelectionDialog::MaterialSelectionDialog(const QString &path, //The row numbers are the order of the file : clicking them, or the //corner just above them, sorts the table by them, which means the //table is no longer sorted at all. + // + //QTableView keeps that corner button to itself, so the only way to + //reach it is the name Qt gives that private class. Nothing breaks + //if the name ever changes : the row numbers below stay connected + //and only this shortcut disappears. for (QAbstractButton *button : ui->m_table_view->findChildren()) { if (button->inherits("QTableCornerButton")) { @@ -373,7 +383,7 @@ bool MaterialSelectionDialog::eventFilter(QObject *watched, QEvent *event) static int wheelAmount(int delta, bool counting_items) { if (counting_items) { - return 3 * qRound(double(delta) / QWheelEvent::DefaultDeltasPerStep); + return 3 * qRound(static_cast(delta) / static_cast(QWheelEvent::DefaultDeltasPerStep)); } return delta; } @@ -511,6 +521,40 @@ MaterialRecord MaterialSelectionDialog::selectedRecord() const return m_model->record(source.row()); } +/** + @brief sameEntry + Tell whether two records describe the same article of the file. + + The maps are not compared as they are: an entry built by the entry + form leaves the empty columns out, load() gives every column to every + record, and a file may hold spaces the form has trimmed. Comparing + per column makes both shapes say the same thing. Cells the file holds + past the header are not part of it: they say nothing about which + article a line is. + @param lhs + @param rhs + @return true when both records describe the same article +*/ +static bool sameEntry(const MaterialRecord &lhs, const MaterialRecord &rhs) +{ + QStringList columns = lhs.values.keys(); + for (const QString &column : rhs.values.keys()) + { + if (!columns.contains(column)) { + columns.append(column); + } + } + + for (const QString &column : columns) + { + if (lhs.value(column).trimmed() != rhs.value(column).trimmed()) { + return false; + } + } + + return true; +} + /** @brief MaterialSelectionDialog::selectRecord Select the row holding that record, scrolling to it. @@ -525,7 +569,7 @@ bool MaterialSelectionDialog::selectRecord(const MaterialRecord &record) //the line which was just written. for (int i = records.size() - 1; i >= 0; --i) { - if (records.at(i) == record) + if (sameEntry(records.at(i), record)) { row = i; break; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 80d52f4dd..f08d00519 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -612,7 +612,7 @@ void QETDiagramEditor::setUpActions() //Add a nomenclature item m_add_nomenclature = new QAction(QET::Icons::TableOfContent, tr("Ajouter une nomenclature"), this); - connect(m_add_nomenclature, &QAction::triggered, this, [=]() { + connect(m_add_nomenclature, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddNomenclature(this->currentDiagramView()->diagram()); } @@ -620,14 +620,14 @@ void QETDiagramEditor::setUpActions() //Add a summary item m_add_summary = new QAction(QET::Icons::TableOfContent, tr("Ajouter un sommaire"), this); - connect(m_add_summary, &QAction::triggered, this, [=]() { + connect(m_add_summary, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddSummary(this->currentDiagramView()->diagram()); } }); m_terminal_strip_dialog = new QAction(QET::Icons::TerminalStrip, tr("Gestionnaire de borniers (DEV)"), this); - connect(m_terminal_strip_dialog, &QAction::triggered, this, [=]() + connect(m_terminal_strip_dialog, &QAction::triggered, this, [this]() { if (auto project = this->currentProject()) { @@ -938,26 +938,27 @@ void QETDiagramEditor::setUpActions() //Align actions. No default shortcut: they are reached from the //Edit menu, the selection's context menu and the command search, //and a user can bind one if they want. - QAction *snap_to_grid = m_align_actions_group.addAction(tr("Aligner sur la grille")); + QAction *snap_to_grid = m_align_actions_group.addAction(QET::Icons::SnapToGrid, tr("Aligner sur la grille")); ShortcutManager::instance().registerAction(snap_to_grid, "diagrameditor.snap_selection_to_grid", tr("Éditeur de schémas"), QKeySequence()); snap_to_grid->setStatusTip(tr("Remet les éléments, images et textes sélectionnés sur la grille", "status bar tip")); snap_to_grid->setData("snap_selection_to_grid"); const struct { const char *id; + const QIcon &icon; QString text; QString tip; } align_actions[] = { - {"align_left", tr("Aligner à gauche"), tr("Aligne les bords gauches des objets sélectionnés", "status bar tip")}, - {"align_hcenter", tr("Centrer horizontalement"), tr("Aligne les objets sélectionnés sur une même verticale, par leur point d'origine pour les éléments", "status bar tip")}, - {"align_right", tr("Aligner à droite"), tr("Aligne les bords droits des objets sélectionnés", "status bar tip")}, - {"align_top", tr("Aligner en haut"), tr("Aligne les bords supérieurs des objets sélectionnés", "status bar tip")}, - {"align_vcenter", tr("Centrer verticalement"), tr("Aligne les objets sélectionnés sur une même horizontale, par leur point d'origine pour les éléments", "status bar tip")}, - {"align_bottom", tr("Aligner en bas"), tr("Aligne les bords inférieurs des objets sélectionnés", "status bar tip")} + {"align_left", QET::Icons::AlignLeft, tr("Aligner à gauche"), tr("Aligne les bords gauches des objets sélectionnés", "status bar tip")}, + {"align_hcenter", QET::Icons::AlignHCenter, tr("Centrer horizontalement"), tr("Aligne les objets sélectionnés sur une même verticale, par leur point d'origine pour les éléments", "status bar tip")}, + {"align_right", QET::Icons::AlignRight, tr("Aligner à droite"), tr("Aligne les bords droits des objets sélectionnés", "status bar tip")}, + {"align_top", QET::Icons::AlignTop, tr("Aligner en haut"), tr("Aligne les bords supérieurs des objets sélectionnés", "status bar tip")}, + {"align_vcenter", QET::Icons::AlignVCenter, tr("Centrer verticalement"), tr("Aligne les objets sélectionnés sur une même horizontale, par leur point d'origine pour les éléments", "status bar tip")}, + {"align_bottom", QET::Icons::AlignBottom, tr("Aligner en bas"), tr("Aligne les bords inférieurs des objets sélectionnés", "status bar tip")} }; for (const auto &a : align_actions) { - QAction *action = m_align_actions_group.addAction(a.text); + QAction *action = m_align_actions_group.addAction(a.icon, a.text); ShortcutManager::instance().registerAction(action, QStringLiteral("diagrameditor.") + QLatin1String(a.id), tr("Éditeur de schémas"), QKeySequence()); action->setStatusTip(a.tip); action->setData(QString::fromLatin1(a.id)); @@ -1672,6 +1673,34 @@ bool QETDiagramEditor::openAndAddProject( ); } + //Report wires left out of the load because a terminal they join + //was not found: they would otherwise vanish on the next save + //without the user ever being told. + QStringList lost_wires; + for (Diagram *diagram : project->diagrams()) { + for (const QString &wire : diagram->wiresNotReconnected()) { + lost_wires << tr("Folio %1 : %2").arg(diagram->folioIndex() + 1).arg(wire); + } + } + if (interactive && !lost_wires.isEmpty()) + { + QMessageBox box(QMessageBox::Warning, + tr("Conducteurs non chargés", "message box title"), + tr("%n conducteur(s) n'ont pas pu être reliés à leurs" + " bornes et n'ont pas été chargés. La définition de" + " l'élément dans le projet a probablement été remplacée" + " par une autre dont les bornes diffèrent.\n\n" + "Si vous enregistrez le projet, ces conducteurs" + " disparaîtront du fichier. Fermez-le sans enregistrer pour" + " conserver le fichier tel quel.", + "message box content", + lost_wires.size()), + QMessageBox::Ok, + this); + box.setDetailedText(lost_wires.join(QLatin1Char('\n'))); + box.exec(); + } + BackupDialog backup_dialog(this); if (backup_dialog.exec() == QDialog::Accepted) { @@ -2179,7 +2208,7 @@ void QETDiagramEditor::alignGroupTriggered(QAction *action) if (mode == AlignSelectionCommand::SnapToGrid) message = tr("La sélection est déjà sur la grille"); else if (command->itemCount() < 2) - message = tr("Sélectionnez au moins deux éléments, images, textes ou groupes non verrouillés"); + message = tr("Sélectionnez au moins deux éléments, images, textes, formes ou groupes non verrouillés"); else message = tr("La sélection est déjà alignée, à la grille près"); delete command; @@ -2459,11 +2488,10 @@ void QETDiagramEditor::slot_updateComplexActions() | DiagramContent::Images); m_depth_action_group->setEnabled(list.isEmpty()? false : true); - //Align actions: symbols, pictures and free texts take part. + //Align actions: symbols, pictures, free texts and shapes take part, + //counted the way the command counts them, a group as one. //Snapping needs one of them, lining them up needs two. - const int alignable = selected_elements_count - + selected_image - + dc.count(DiagramContent::TextFields); + const int alignable = AlignSelectionCommand::unitCount(dc); m_align_actions_group.setEnabled(!ro && alignable); const QList align_actions = m_align_actions_group.actions(); for (QAction *action : align_actions.mid(1)) @@ -3157,6 +3185,12 @@ void QETDiagramEditor::diagramWasAdded(DiagramView *dv) this, &QETDiagramEditor::selectionChanged, Qt::DirectConnection); + //Grouping leaves the selection as it is, so without this Group + //and Ungroup would keep the state from before (#1144) + connect(dv->diagram(), + &Diagram::itemGroupChanged, + this, + &QETDiagramEditor::slot_updateComplexActions); connect(dv, &DiagramView::modeChanged, this, &QETDiagramEditor::slot_updateModeActions); connect(dv, &DiagramView::elementPlacementStarted, this, &QETDiagramEditor::rememberPlacedElement); } diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index f97fb97ec..4eb9009bc 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -228,9 +228,6 @@ void Conductor::segmentsToPath() @param o2 Orientation de la borne 2 */ void Conductor::updateConductorPath(const QPointF &p1, Qet::Orientation o1, const QPointF &p2, Qet::Orientation o2) { - Q_UNUSED(o1); - Q_UNUSED(o2); - ConductorProfile &conductor_profile = conductor_profiles[currentPathType()]; Q_ASSERT_X(conductor_profile.segmentsCount(QET::Both) > 1, "Conductor::priv_modifieConductor", "pas de points a modifier"); @@ -252,6 +249,25 @@ void Conductor::updateConductorPath(const QPointF &p1, Qet::Orientation o1, cons qreal h_diff = (qAbs(new_rect.width()) - qAbs(profile_width) ) * getSign(profile_width); qreal v_diff = (qAbs(new_rect.height()) - qAbs(profile_height)) * getSign(profile_height); + // A profile with no segment of non-zero length along an axis has nowhere + // to put a difference along that axis: it would be dropped and the last + // point would join the terminal diagonally. Every straight conductor + // loaded from a file is like this, as a zero-length segment is saved as + // horizontal (bugtracker #105). Generate a new path instead, as when + // there is no profile for this path type. + const auto can_absorb = [](qreal diff, const QList &segments_list) { + if (qAbs(diff) <= 0.01) return(true); + for (ConductorSegmentProfile *csp : segments_list) { + if (csp -> length) return(true); + } + return(false); + }; + if (!can_absorb(h_diff, conductor_profile.horizontalSegments()) || + !can_absorb(v_diff, conductor_profile.verticalSegments())) { + generateConductorPath(p1, o1, p2, o2); + return; + } + // applique les differences aux segments // apply the differences to the segments QMultiHash segments_lengths; diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index 771a3d42d..6e1a7aa02 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -366,6 +366,12 @@ void DiagramTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if(diagram_ && m_first_move) diagram_->elementsMover().beginMovement(diagram_, this); + if (diagram_ && diagram_->elementsMover().holds(this)) { + m_first_move = false; + event->accept(); + return; + } + QPointF old_pos = pos(); //Set the actual pos diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index fda123da9..5c5578ed9 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -63,9 +63,17 @@ DynamicElementTextItem::DynamicElementTextItem(Element *parent_element) : //Option when text is displayed in multiple line QTextOption option = document()->defaultTextOption(); - option.setAlignment(Qt::AlignHCenter); + option.setAlignment(alignment() & Qt::AlignHorizontal_Mask); option.setWrapMode(QTextOption::WordWrap); document()->setDefaultTextOption(option); + + //Lines of a multi-line text follow the horizontal alignment + connect(this, &DiagramTextItem::alignmentChanged, [this](Qt::Alignment alignment) + { + QTextOption option = document()->defaultTextOption(); + option.setAlignment(alignment & Qt::AlignHorizontal_Mask); + document()->setDefaultTextOption(option); + }); } DynamicElementTextItem::~DynamicElementTextItem() diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index eb9dbfbe2..a91e78433 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -697,6 +697,16 @@ Terminal *Element::parseTerminal(const QDomElement &dom_element) } Terminal *new_terminal = new Terminal(data, this); + //Terminals are parsed in the order of the definition, and the list + //is not kept in that order (sort below) + int rank = 0; + for (Terminal *t : std::as_const(m_terminals)) { + if (t->dock_elmt_ == new_terminal->dock_elmt_ + && t->orientation() == new_terminal->orientation()) { + ++rank; + } + } + new_terminal->setPlaceRank(rank); m_terminals << new_terminal; connect(new_terminal, &Terminal::conductorWasAdded, this, &Element::updateConductorTexts); diff --git a/sources/qetgraphicsitem/qetgraphicsitem.cpp b/sources/qetgraphicsitem/qetgraphicsitem.cpp index 505c4cc37..631ee8d85 100644 --- a/sources/qetgraphicsitem/qetgraphicsitem.cpp +++ b/sources/qetgraphicsitem/qetgraphicsitem.cpp @@ -144,6 +144,11 @@ void QetGraphicsItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) //It's the first movement, we signal it to parent diagram diagram()->elementsMover().beginMovement(diagram(), this); } + if (diagram() && diagram()->elementsMover().holds(this)) { + m_first_move = false; + event->accept(); + return; + } //we apply the mouse movement QPointF old_pos = pos(); diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index 81295c571..a6d0202a9 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -33,6 +33,7 @@ #include "../undocommand/promoteshapecommand.h" #include +#include #include #include #include @@ -602,6 +603,45 @@ QPainterPath QetShapeItem::outline() const return path; } +/** + @brief QetShapeItem::sceneOutlineRect + @return the box around the shape as drawn, in scene coordinates: + without the pen width, the selection margin of boundingRect() or the + wider outline shape() gives a hovered shape. +*/ +QRectF QetShapeItem::sceneOutlineRect() const +{ + return mapToScene(outline()).boundingRect(); +} + +/** + @brief QetShapeItem::setPos + Called with the unsnapped position while the shape is dragged. Dragged + on its own, or with other shapes only, the shape goes on the grid by the + top-left corner of what is drawn, as Snap to grid does: its pos() cannot + be seen, and it is off the corner whenever the shape was drawn or + resized with Ctrl held, or rotated. Dragged together with anything + else, it snaps by pos() as before: the whole selection follows this + shape's movement, and correcting the corner would take the symbols off + the grid instead. + @param p the new position of the item +*/ +void QetShapeItem::setPos(const QPointF &p) +{ + const auto selection = scene() ? scene()->selectedItems() : QList(); + const bool shapes_only = std::all_of(selection.cbegin(), selection.cend(), + [](const QGraphicsItem *item) { return item->type() == QetShapeItem::Type; }); + if (!shapes_only || !isMovable()) { + QetGraphicsItem::setPos(p); + return; + } + + const QPointF corner = sceneOutlineRect().topLeft() + (p - pos()); + const QPointF snapped = p + Diagram::snapToGrid(corner) - corner; + if (snapped != pos()) + QGraphicsItem::setPos(snapped); +} + /** @brief QetShapeItem::shape @return the shape of this item diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h index fe7408e71..610f06bf5 100644 --- a/sources/qetgraphicsitem/qetshapeitem.h +++ b/sources/qetgraphicsitem/qetshapeitem.h @@ -219,6 +219,10 @@ class QetShapeItem : public QetGraphicsItem QRectF boundingRect() const override; QPainterPath shape() const override; + QRectF sceneOutlineRect() const; + + using QetGraphicsItem::setPos; + void setPos(const QPointF &p) override; protected: void paint( diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index a1c9af780..e3ba0198d 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "../qetgraphicsitem/terminal.h" +#include "../ElementsCollection/terminaluuids.h" #include "../qet.h" #include "../qetproject.h" #include "../conductorautonumerotation.h" @@ -849,11 +850,11 @@ QUuid Terminal::uuid() const read from the definition and is not touched by moving the element on the folio, so the result is stable across loads, saves and folio moves, and it is unique within an element except where a definition genuinely declares - two terminals at the same point -- three cases in the whole example corpus, - and harmless, because two terminals sharing a position and orientation are - indistinguishable in every observable respect: they merge to one terminal - row and every conductor on either of them still resolves to the right - element and name. + two terminals at the same point -- three cases in the whole example + corpus. Opening a project gives every terminal of its symbols a uuid + (TerminalUuids::fillMissing()), the same value for all but the second + of such a pair, so in a project this is the fallback for terminals of a + symbol imported since it was opened. Derived values are UUID v5 in a fixed namespace, so they are reproducible without being written to the file, and cannot collide with the v4 uuids @@ -866,23 +867,45 @@ QUuid Terminal::stableUuid() const if (!d->m_uuid.isNull()) { return d->m_uuid; } + return derivedUuid(); +} - //Fixed namespace for terminal identities derived from geometry. - static const QUuid derived_ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); +/** + @brief Terminal::derivedUuid + The identity worked out from this terminal's local position and + orientation, whether or not it carries a uuid of its own: the value + stableUuid() gives when it has none, and the one a project gives it + when it is opened (TerminalUuids::fillMissing()). - //Position and orientation only. The name is deliberately excluded: it - //is not stable across a save cycle -- QET rewrites a terminal named - //"_" as unnamed, which would silently change the identity of 1421 of - //industrial.qet's 1790 terminals on the first resave. It is also not - //needed: keying on geometry alone produces exactly the same number of - //collisions across the example corpus, and it means renaming a - //terminal does not change what it is. - const QString key = QStringLiteral("%1|%2|%3") - .arg(d->m_pos.x(), 0, 'f', 4) - .arg(d->m_pos.y(), 0, 'f', 4) - .arg(static_cast(d->m_orientation)); + Position and orientation only. The name is deliberately excluded: it + is not stable across a save cycle -- QET rewrites a terminal named "_" + as unnamed, which would silently change the identity of 1421 of + industrial.qet's 1790 terminals on the first resave. It is also not + needed: keying on geometry alone produces exactly the same number of + collisions across the example corpus, and it means renaming a terminal + does not change what it is. - return QUuid::createUuidV5(derived_ns, key); + A second terminal at the same point with the same orientation gets the + next occurrence (see setPlaceRank()), as TerminalUuids::fillMissing() + gives it, so the two terminals of such a pair are told apart. +*/ +QUuid Terminal::derivedUuid() const +{ + return TerminalUuids::derived(d->m_pos.x(), + d->m_pos.y(), + static_cast(d->m_orientation), + m_place_rank); +} + +/** + @brief Terminal::setPlaceRank + @param rank : how many terminals of the definition, before this one in + document order, sit at the same point with the same orientation. + Set by Element::parseTerminal(). +*/ +void Terminal::setPlaceRank(int rank) +{ + m_place_rank = rank; } QString Terminal::name() const diff --git a/sources/qetgraphicsitem/terminal.h b/sources/qetgraphicsitem/terminal.h index a6eae784c..b491f1ec2 100644 --- a/sources/qetgraphicsitem/terminal.h +++ b/sources/qetgraphicsitem/terminal.h @@ -76,6 +76,8 @@ class Terminal : public QGraphicsObject Element *parentElement () const; QUuid uuid () const; QUuid stableUuid () const; + QUuid derivedUuid () const; + void setPlaceRank (int rank); QString name () const; QString baseName () const; TerminalData::Type terminalType() const; @@ -142,6 +144,9 @@ class Terminal : public QGraphicsObject Terminal *m_previous_terminal = nullptr; /// Whether the mouse pointer is hovering the terminal bool m_hovered = false; + /// How many terminals of the definition, before this one, sit at + /// the same point with the same orientation; see derivedUuid() + int m_place_rank = 0; /// Color used for the hover effect QColor m_hovered_color = Terminal::neutralColor; diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index a605c74a7..aa8b86c79 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -23,6 +23,12 @@ namespace QET { namespace Icons { QIcon Add; QIcon AddFolio; + QIcon AlignBottom; + QIcon AlignHCenter; + QIcon AlignLeft; + QIcon AlignRight; + QIcon AlignTop; + QIcon AlignVCenter; QIcon Allowed; QIcon ApplicationExit; QIcon ArrowLeft; @@ -169,6 +175,7 @@ namespace QET { QIcon SendBackward; QIcon Settings; QIcon SinglePage; + QIcon SnapToGrid; QIcon South; QIcon Start; QIcon TableOfContent; @@ -395,6 +402,12 @@ void QET::Icons::initIcons() Add = QIcon::fromTheme("list-add"); AddFolio = QIcon::fromTheme("folio-new"); + AlignBottom = QIcon::fromTheme("align-vertical-bottom"); + AlignHCenter = QIcon::fromTheme("align-horizontal-center"); + AlignLeft = QIcon::fromTheme("align-horizontal-left"); + AlignRight = QIcon::fromTheme("align-horizontal-right"); + AlignTop = QIcon::fromTheme("align-vertical-top"); + AlignVCenter = QIcon::fromTheme("align-vertical-center"); Allowed = QIcon::fromTheme("user-online"); ApplicationExit = QIcon::fromTheme("application-exit"); ArrowLeft = QIcon::fromTheme("arrow-left"); @@ -581,6 +594,7 @@ void QET::Icons::initIcons() SendBackward = QIcon::fromTheme("send_backward"); Settings = QIcon::fromTheme("settings"); SinglePage = QIcon::fromTheme("single_page"); + SnapToGrid = QIcon::fromTheme("snap-to-grid"); South = QIcon::fromTheme("south"); Start = QIcon::fromTheme("start"); TableOfContent = QIcon::fromTheme("table-of-content"); diff --git a/sources/qeticons.h b/sources/qeticons.h index af21e8e54..31a4d0aa8 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -30,6 +30,12 @@ namespace QET { // namespace extern QIcon Add; extern QIcon AddFolio; + extern QIcon AlignBottom; + extern QIcon AlignHCenter; + extern QIcon AlignLeft; + extern QIcon AlignRight; + extern QIcon AlignTop; + extern QIcon AlignVCenter; extern QIcon Allowed; extern QIcon ApplicationExit; extern QIcon ArrowLeft; @@ -177,6 +183,7 @@ namespace QET { extern QIcon SendBackward; extern QIcon Settings; extern QIcon SinglePage; + extern QIcon SnapToGrid; extern QIcon South; extern QIcon Start; extern QIcon TableOfContent; diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index b3553d9d6..1436f2668 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -26,6 +26,7 @@ #include "qetmainwindow.h" #include "qeticons.h" +#include "ui/aiassistantdialog.h" #include "shortcutmanager.h" #include "qetapp.h" #include "qetdiagrameditor.h" @@ -111,6 +112,13 @@ void QETMainWindow::initCommonActions() ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1); + connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this); + connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip")); + connect(connect_ai_, &QAction::triggered, this, [this]() { + AiAssistantDialog dialog(this); + dialog.exec(); + }); + youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this); youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip")); @@ -170,6 +178,7 @@ void QETMainWindow::initCommonMenus() help_menu_ -> addAction(whatsthis_action_); help_menu_ -> addSeparator(); help_menu_ -> addAction(manual_online_); + help_menu_ -> addAction(connect_ai_); help_menu_ -> addAction(youtube_); help_menu_ -> addAction(upgrade_); help_menu_ -> addAction(upgrade_M); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index 9ad1862f0..c3820c4bb 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow { QAction *donate_; ///< Launch browser to donate link QAction *about_qt_; ///< launch the "About Qt" dialog QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5) + QAction *connect_ai_; ///< Show how to connect an AI assistant (MCP server) QMenu *settings_menu_; ///< Settings menu QMenu *help_menu_; ///< Help menu QMenu *display_toolbars_; ///< Show/hide toolbars/docks diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index b89acc626..a44e127ef 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -346,7 +346,7 @@ void QETProject::init() { int ms = autosave_interval*60*1000; m_autosave_timer.setInterval(ms); - connect(&m_autosave_timer, &QTimer::timeout, this, [=]() + connect(&m_autosave_timer, &QTimer::timeout, this, [this]() { if(!this->m_file_path.isEmpty()) this->write(); @@ -580,7 +580,7 @@ void QETProject::setFilePath(const QString &filepath) m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); @@ -1428,7 +1428,7 @@ QETResult QETProject::write() m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); emit projectInformationsChanged(this); @@ -1880,7 +1880,7 @@ void QETProject::readProjectXml(QDomDocument &xml_project) m_data_base.blockSignals(false); m_data_base.setUpdateBlocked(false); - m_data_base.updateDB(); + m_data_base.updateDB(xml_project); const qint64 database_ms = phase_timer.elapsed(); qInfo().nospace() @@ -2473,14 +2473,25 @@ bool QETProject::projectWasModified() Indique a chaque schema du projet quel est son numero de folio et combien de folio le projet contient. */ +/** + @brief QETProject::projectWideProperties + @return the project's properties as every folio's title block sees them: + the user's project properties plus the project's title, path and file name. +*/ +DiagramContext QETProject::projectWideProperties() +{ + DiagramContext project_wide_properties = m_project_properties; + project_wide_properties.addValue("projecttitle", title()); + project_wide_properties.addValue("projectpath", filePath()); + project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).completeBaseName()); + return project_wide_properties; +} + void QETProject::updateDiagramsFolioData() { int total_folio = m_diagrams_list.count(); - DiagramContext project_wide_properties = m_project_properties; - project_wide_properties.addValue("projecttitle", title()); - project_wide_properties.addValue("projectpath", filePath()); - project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).baseName()); + const DiagramContext project_wide_properties = projectWideProperties(); for (int i = 0 ; i < total_folio ; ++ i) { diff --git a/sources/qetproject.h b/sources/qetproject.h index 35830912c..b30b07075 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -221,6 +221,7 @@ class QETProject : public QObject bool projectWasModified(); bool projectOptionsWereModified(); DiagramContext projectProperties(); + DiagramContext projectWideProperties(); void setProjectProperties(const DiagramContext &); QUndoStack* undoStack() {return m_undo_stack;} diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 8cbbd926a..53f3a41aa 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -739,18 +739,19 @@ QString QetScriptApi::elementName(int folioIndex, const QString &elementUuid) co /** @brief QetScriptApi::elementTerminals The element's terminals, in the order addConductor() indexes them: one - entry per terminal, ": ( conductor(s))". Descriptive - rather than structured because its only job is to let a script -- or a - human reading a script's output -- see which index is which before - wiring anything to it. + entry per terminal, ": ( conductor(s)) ". + Descriptive rather than structured because its only job is to let a + script -- or a human reading a script's output -- see which index is + which before wiring anything to it. - Indexes, not uuids, because a terminal uuid does not address a terminal - on a folio. Terminal::uuid() comes from the catalog .elmt definition - (see Terminal::stableUuid()), so it is empty for most of the installed - base, and where it is not, every instance of that same element carries - the same one -- two coils of one type placed side by side have - byte-identical terminal uuids, which is plainly visible in the saved - file of any project written through this API. + The calls take the index; the uuid is what to hold instead, and + terminalIndex() turns it back into the index. It is Terminal:: + stableUuid(): the terminal's own uuid, which every terminal of an + opened project has (TerminalUuids::fillMissing()), or for a symbol + imported since the project was opened the value the next opening will + give it. It comes from the symbol's definition, so it names a terminal + only together with its element: two coils of one type placed side by + side have the same terminal uuids. The index is the terminal's place in Element::terminals(), and that is @b not the order the .elmt lists them in. Element::parseTerminal() @@ -772,14 +773,42 @@ QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elemen for (int i = 0 ; i < terminals.count() ; ++i) { Terminal *t = terminals.at(i); - list << QStringLiteral("%1: %2 (%3 conductor(s))") - .arg(i) - .arg(t->name().isEmpty() ? QStringLiteral("-") : t->name()) - .arg(t->conductorsCount()); + //One multi-argument arg(): chained ones would also replace a + //"%3" or "%4" inside the terminal's name + list << QStringLiteral("%1: %2 (%3 conductor(s)) %4") + .arg(QString::number(i), + t->name().isEmpty() ? QStringLiteral("-") : t->name(), + QString::number(t->conductorsCount()), + t->stableUuid().toString()); } return list; } +/** + @brief QetScriptApi::terminalIndex + The current index in elementTerminals() of the terminal of the element + @p elementUuid whose uuid is @p terminalUuid, or -1 -- also when two of + its terminals carry that uuid, since the index would then be a guess. + The index is what addConductor() and the conductor calls take; unlike + the uuid, it is undefined between two terminals at the same point. +*/ +int QetScriptApi::terminalIndex(int folioIndex, const QString &elementUuid, + const QString &terminalUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + const QUuid wanted(terminalUuid); + if (!element || wanted.isNull()) return -1; + + int found = -1; + const QList terminals = element->terminals(); + for (int i = 0 ; i < terminals.count() ; ++i) { + if (terminals.at(i)->stableUuid() != wanted) continue; + if (found >= 0) return -1; + found = i; + } + return found; +} + QString QetScriptApi::elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const { Element *element = findElement(folioIndex, elementUuid); diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 62240935e..dff3225e7 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -96,14 +96,16 @@ class QetGraphicsTableItem; no way to connect them. Terminals are addressed by their @b index in Element::terminals(), - not by uuid, and elementTerminals() prints that indexing so a script - can see what it is about to wire. The index is the terminal's place - in the element's own top-to-bottom, left-to-right ordering, not the - order its definition file lists them. Terminal uuids look like the - obvious key and are not one: Terminal::uuid() is a property of the - catalog .elmt definition, empty for most of the installed base and, - where present, identical across every instance of that element -- so - it does not distinguish one placed coil's A1 from another's. + and elementTerminals() prints that indexing so a script can see what + it is about to wire. The index is the terminal's place in the + element's own top-to-bottom, left-to-right ordering, not the order + its definition file lists them, and it is undefined between two + terminals at the same point. What does not move is the terminal's + uuid together with its element's: each elementTerminals() line ends + with the terminal's uuid, and terminalIndex() turns element uuid + + terminal uuid back into the index. A terminal uuid alone is not an + address -- it comes from the symbol's definition, so every placed + instance of one symbol has the same ones. - @b Conductor properties and @b cross-references: set a conductor's number, formula, colour or section (and its look: style normal/ dashed/dashdotted, two-colour mode and second colour, dash size, @@ -384,6 +386,8 @@ class QetScriptApi : public QObject Q_INVOKABLE QStringList elementUuids(int folioIndex) const; Q_INVOKABLE QString elementName(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE QStringList elementTerminals(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE int terminalIndex(int folioIndex, const QString &elementUuid, + const QString &terminalUuid) const; // -- element information, through ChangeElementInformationCommand -- Q_INVOKABLE QString elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const; diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 56d43b228..0ea6f2acd 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -181,7 +181,7 @@ bool ShortcutManager::trigger(const QString &id) const return false; } - for (const QPointer &target : qAsConst(it->targets)) + for (const QPointer &target : std::as_const(it->targets)) { if (!target) { continue; @@ -213,7 +213,7 @@ QAction *ShortcutManager::action(const QString &id, const QObject *owner) const return nullptr; } - for (const QPointer &target : qAsConst(it->targets)) + for (const QPointer &target : std::as_const(it->targets)) { auto *action = qobject_cast(target.data()); if (!action) { diff --git a/sources/titleblocktemplate.cpp b/sources/titleblocktemplate.cpp index 79d6b0adf..69214702e 100644 --- a/sources/titleblocktemplate.cpp +++ b/sources/titleblocktemplate.cpp @@ -27,6 +27,7 @@ #include #include +#include /** @brief TitleBlockTemplate::TitleBlockTemplate Constructor @@ -1818,14 +1819,46 @@ QString TitleBlockTemplate::interpreteVariables( const QString &string, const DiagramContext &diagram_context) const { + // A variable nobody has given a value to yet -- e.g. one just added to + // the template, which is not in the folio's Custom tab until its + // properties are opened (#1113) -- must render blank rather than as its + // own name, the same as an auto-added but unset one already does (#973). + // Collect those from the template text before substituting, so a value + // that happens to contain "%something" is never touched. + // A bare "%name" is unset only if no key is a prefix of it, because the + // substitution below replaces "%key" wherever it appears. + static const QRegularExpression rx( + QStringLiteral("%\\{([a-z0-9_-]+)\\}|%([a-z0-9_-]+)")); + const QStringList keys = + diagram_context.keys(DiagramContext::DecreasingLength); + QStringList unset; + auto it = rx.globalMatch(string); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + const QString name = m.captured(1).isEmpty() + ? m.captured(2) : m.captured(1); + bool known = diagram_context.contains(name); + if (!known && m.captured(1).isEmpty()) { + for (const QString &key : keys) { + if (name.startsWith(key)) { known = true; break; } + } + } + if (!known) unset << m.captured(0); + } + QString interpreted_string = string; - foreach (QString key, - diagram_context.keys(DiagramContext::DecreasingLength)) { + foreach (QString key, keys) { interpreted_string.replace("%{" % key % "}", diagram_context[key].toString()); interpreted_string.replace("%" % key, diagram_context[key].toString()); } + std::sort(unset.begin(), unset.end(), + [](const QString &a, const QString &b) { + return a.length() > b.length(); + }); + for (const QString &placeholder : unset) + interpreted_string.remove(placeholder); return(interpreted_string); } @@ -1834,7 +1867,7 @@ QString TitleBlockTemplate::interpreteVariables( Get list of variables @return The list of string with variables */ -QStringList TitleBlockTemplate::listOfVariables() +QStringList TitleBlockTemplate::listOfVariables() const { QStringList list; // Match both the braced "%{name}" form and the bare "%name" form diff --git a/sources/titleblocktemplate.h b/sources/titleblocktemplate.h index a7f6c9686..8bc1821d6 100644 --- a/sources/titleblocktemplate.h +++ b/sources/titleblocktemplate.h @@ -188,7 +188,7 @@ class TitleBlockTemplate : public QObject { void applyCellSpan(TitleBlockCell *); void applyRowColNums(); void rowColsChanged(); - QStringList listOfVariables(); + QStringList listOfVariables() const; protected: void loadInformation(const QDomElement &); diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp new file mode 100644 index 000000000..6d8542d7e --- /dev/null +++ b/sources/ui/aiassistantdialog.cpp @@ -0,0 +1,220 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "aiassistantdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +namespace { +const char *const GUIDE_URL = + "https://github.com/qelectrotech/qelectrotech-source-mirror/wiki/ai_assistants"; +} + +AiAssistantDialog::AiAssistantDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Connecter un assistant IA")); + resize(720, 560); + +#ifdef Q_OS_WIN + const bool windows = true; +#else + const bool windows = false; +#endif + m_paths = AiAssistantSetup::detect(QCoreApplication::applicationDirPath(), + QCoreApplication::applicationFilePath(), + windows); + + auto *layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Un assistant IA (Claude, GitHub Copilot, Gemini…) peut ouvrir, " + "vérifier et modifier vos schémas grâce au serveur MCP de " + "QElectroTech, qui fonctionne sur cet ordinateur. Copiez le texte " + "ci-dessous dans la configuration de votre assistant. " + "Guide détaillé").arg(QLatin1String(GUIDE_URL)), + this); + intro->setWordWrap(true); + intro->setOpenExternalLinks(true); + layout->addWidget(intro); + + if (m_paths.server.isEmpty()) { + auto *missing = new QLabel( + tr("Le serveur MCP n'est pas installé avec cette version de " + "QElectroTech. Le guide explique comment l'obtenir."), + this); + missing->setWordWrap(true); + layout->addWidget(missing); + } + + if (!m_paths.server.isEmpty() + && m_paths.python_status != AiAssistantSetup::PythonStatus::Found) { + QString text; + if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut) + text = tr("Python n'est peut-être pas installé. Seul le raccourci " + "« python » du Microsoft Store a été trouvé : sans Python, il " + "ouvre le Store au lieu de lancer le serveur."); + else + text = tr("Python est introuvable sur cet ordinateur (commande " + "« %1 »). Le serveur en a besoin.").arg(m_paths.python); + if (windows) + text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et " + "cochez « Python pour l'assistant IA », ou " + "installez Python depuis python.org."); + else + text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire " + "de paquets de votre système."); + auto *python = new QLabel(text, this); + python->setWordWrap(true); + layout->addWidget(python); + } + + auto *form = new QFormLayout(); + m_client = new QComboBox(this); + m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); + m_client->addItem(QStringLiteral("Claude Code"), int(Client::ClaudeCode)); + m_client->addItem(QStringLiteral("GitHub Copilot (VS Code)"), int(Client::VsCode)); + m_client->addItem(QStringLiteral("Cursor"), int(Client::Cursor)); + m_client->addItem(QStringLiteral("Gemini CLI"), int(Client::GeminiCli)); + m_client->addItem(QStringLiteral("Codex CLI"), int(Client::CodexCli)); + m_client->addItem(QStringLiteral("LM Studio"), int(Client::LmStudio)); + form->addRow(tr("Assistant :"), m_client); + + auto *workspace_row = new QHBoxLayout(); + m_workspace = new QLineEdit(this); + m_workspace->setPlaceholderText(tr("Le dossier de vos schémas")); + auto *browse = new QPushButton(tr("Parcourir…"), this); + workspace_row->addWidget(m_workspace); + workspace_row->addWidget(browse); + form->addRow(tr("Dossier accessible :"), workspace_row); + + m_allow_edit = new QCheckBox(tr("Autoriser l'assistant à modifier les schémas"), this); + form->addRow(QString(), m_allow_edit); + layout->addLayout(form); + + auto *scope = new QLabel( + tr("L'assistant ne peut lire et écrire que dans ce dossier. Sans " + "modification autorisée, il peut seulement lire, comparer et " + "exporter. Un assistant lit le texte des projets (repères, " + "notes…) : un texte écrit comme une instruction peut " + "l'influencer. Laissez les modifications désactivées pour les " + "schémas reçus d'autres personnes."), + this); + scope->setWordWrap(true); + layout->addWidget(scope); + + m_where = new QLabel(this); + m_where->setWordWrap(true); + m_where->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_where); + + m_text = new QPlainTextEdit(this); + m_text->setReadOnly(true); + m_text->setLineWrapMode(QPlainTextEdit::NoWrap); + m_text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + layout->addWidget(m_text); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + m_copy = buttons->addButton(tr("Copier"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + + connect(m_client, QOverload::of(&QComboBox::currentIndexChanged), + this, &AiAssistantDialog::refresh); + connect(m_workspace, &QLineEdit::textChanged, this, &AiAssistantDialog::refresh); + connect(m_allow_edit, &QCheckBox::toggled, this, &AiAssistantDialog::refresh); + connect(browse, &QPushButton::clicked, this, &AiAssistantDialog::chooseWorkspace); + connect(m_copy, &QPushButton::clicked, this, &AiAssistantDialog::copy); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + refresh(); +} + +void AiAssistantDialog::refresh() +{ + const auto client = Client(m_client->currentData().toInt()); + m_where->setText(whereItGoes(client) + QLatin1Char(' ') + + tr("Si le fichier contient déjà d'autres serveurs, " + "ajoutez seulement l'entrée « qet ».")); + + const QString workspace = m_workspace->text().trimmed(); + const bool ready = !m_paths.server.isEmpty() && !workspace.isEmpty(); + m_copy->setEnabled(ready); + if (m_paths.server.isEmpty()) + m_text->setPlainText(QString()); + else if (workspace.isEmpty()) + m_text->setPlainText(tr("Choisissez d'abord le dossier de vos schémas.")); + else + m_text->setPlainText(AiAssistantSetup::configuration( + client, m_paths, workspace, m_allow_edit->isChecked())); +} + +void AiAssistantDialog::chooseWorkspace() +{ + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Dossier accessible à l'assistant"), m_workspace->text()); + if (!dir.isEmpty()) + m_workspace->setText(QDir::toNativeSeparators(dir)); +} + +void AiAssistantDialog::copy() +{ + QApplication::clipboard()->setText(m_text->toPlainText()); + m_copy->setText(tr("Copié")); +} + +QString AiAssistantDialog::whereItGoes(Client client) const +{ + switch (client) { + case Client::ClaudeDesktop: + return tr("Dans Claude Desktop : Paramètres → Développeur → " + "Modifier la configuration. Puis quittez et relancez Claude."); + case Client::ClaudeCode: + return tr("Enregistrez-le sous le nom .mcp.json dans le dossier de vos schémas."); + case Client::VsCode: + return tr("Enregistrez-le sous le nom .vscode/mcp.json dans le dossier " + "ouvert dans VS Code. Copilot utilise les outils en mode agent."); + case Client::Cursor: + return tr("Ajoutez-le au fichier .cursor/mcp.json de votre dossier " + "personnel."); + case Client::GeminiCli: + return tr("Ajoutez-le au fichier .gemini/settings.json de votre dossier " + "personnel. Gemini CLI demande de faire confiance au dossier " + "la première fois."); + case Client::CodexCli: + return tr("Ajoutez-le au fichier .codex/config.toml de votre dossier " + "personnel."); + case Client::LmStudio: + return tr("Dans LM Studio : onglet Program → Install → Edit mcp.json."); + } + return QString(); +} diff --git a/sources/ui/aiassistantdialog.h b/sources/ui/aiassistantdialog.h new file mode 100644 index 000000000..18a3f7f1a --- /dev/null +++ b/sources/ui/aiassistantdialog.h @@ -0,0 +1,61 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef AIASSISTANTDIALOG_H +#define AIASSISTANTDIALOG_H + +#include "aiassistantsetup.h" + +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; + +/** + @brief The AiAssistantDialog class + Help > Connect an AI assistant: shows the configuration an assistant + needs to start QElectroTech's MCP server, with this installation's + paths filled in, for the user to copy into that assistant. It writes + nothing and starts nothing. +*/ +class AiAssistantDialog : public QDialog +{ + Q_OBJECT + + public: + explicit AiAssistantDialog(QWidget *parent = nullptr); + + private: + void refresh(); + void chooseWorkspace(); + void copy(); + QString whereItGoes(AiAssistantSetup::Client client) const; + + AiAssistantSetup::Paths m_paths; + QComboBox *m_client = nullptr; + QLineEdit *m_workspace = nullptr; + QCheckBox *m_allow_edit = nullptr; + QLabel *m_where = nullptr; + QPlainTextEdit *m_text = nullptr; + QPushButton *m_copy = nullptr; +}; + +#endif // AIASSISTANTDIALOG_H diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp new file mode 100644 index 000000000..6bb0923dc --- /dev/null +++ b/sources/ui/aiassistantsetup.cpp @@ -0,0 +1,164 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +/** + The first of \a candidates that is a file, as a clean absolute path, + or an empty string. +*/ +QString firstFile(const QStringList &candidates) +{ + for (const QString &c : candidates) { + const QFileInfo info(c); + if (info.isFile()) + return QDir::cleanPath(info.absoluteFilePath()); + } + return QString(); +} + +QJsonObject environment(const AiAssistantSetup::Paths &paths, + const QString &workspace, + bool allow_edit) +{ + QJsonObject env; + env.insert(QStringLiteral("QET_MCP_WORKSPACE"), QDir::toNativeSeparators(workspace)); + env.insert(QStringLiteral("QET_BINARY"), QDir::toNativeSeparators(paths.qet_binary)); + if (allow_edit) + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + return env; +} + +/// A TOML basic string: backslashes and quotes escaped. +QString toml(const QString &s) +{ + QString out = s; + out.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + out.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + return QLatin1Char('"') + out + QLatin1Char('"'); +} + +} // namespace + +QList AiAssistantSetup::clients() +{ + return {Client::ClaudeDesktop, Client::ClaudeCode, Client::VsCode, + Client::Cursor, Client::GeminiCli, Client::CodexCli, + Client::LmStudio}; +} + +/** + @brief AiAssistantSetup::detect + Finds the server beside this QElectroTech, in the two places its + packages put it: /mcp/ beside /bin/ (the Windows installers + and portable folder) and /share/qelectrotech/mcp/ beside + /bin/ (make install). On Windows the Python the installer can + add is preferred to one on PATH. + @param application_dir : QCoreApplication::applicationDirPath() + @param application_file : QCoreApplication::applicationFilePath() + @param windows : true on Windows, where Python is "python", not "python3" + @param find_executable : looks a program up on PATH; empty means + QStandardPaths::findExecutable(). Given in tests. +*/ +AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, + const QString &application_file, + bool windows, + const ExecutableFinder &find_executable) +{ + const QDir bin(application_dir); + Paths paths; + paths.qet_binary = QDir::cleanPath(application_file); + paths.server = firstFile({ + bin.filePath(QStringLiteral("../mcp/qet_mcp.py")), + bin.filePath(QStringLiteral("../share/qelectrotech/mcp/qet_mcp.py"))}); + + paths.python = windows ? QStringLiteral("python") : QStringLiteral("python3"); + if (windows) { + const QString bundled = firstFile({ + bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); + if (!bundled.isEmpty()) { + paths.python = bundled; + paths.python_status = PythonStatus::Found; + return paths; + } + } + + const ExecutableFinder find = find_executable + ? find_executable + : ExecutableFinder([](const QString &name) { + return QStandardPaths::findExecutable(name); }); + const QString found = find(paths.python); + if (found.isEmpty()) + paths.python_status = PythonStatus::Missing; + // Windows 10 and 11 put a python.exe in WindowsApps that only opens the + // Microsoft Store. A Python installed from the Store answers from there + // too, so this cannot tell the two apart: say which it may be. + else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/')) + .contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive)) + paths.python_status = PythonStatus::StoreShortcut; + else + paths.python_status = PythonStatus::Found; + return paths; +} + +/** + @brief AiAssistantSetup::configuration + @return the text to paste into \a client's configuration: JSON for all + but Codex CLI, which reads TOML. Windows paths come out with native + separators, escaped as each format requires. +*/ +QString AiAssistantSetup::configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit) +{ + const QString python = QDir::toNativeSeparators(paths.python); + const QString server = QDir::toNativeSeparators(paths.server); + const QJsonObject env = environment(paths, workspace, allow_edit); + + if (client == Client::CodexCli) { + QString text = QStringLiteral("[mcp_servers.qet]\n"); + text += QStringLiteral("command = ") + toml(python) + QLatin1Char('\n'); + text += QStringLiteral("args = [") + toml(server) + QStringLiteral("]\n\n"); + text += QStringLiteral("[mcp_servers.qet.env]\n"); + for (auto it = env.constBegin(); it != env.constEnd(); ++it) + text += it.key() + QStringLiteral(" = ") + toml(it.value().toString()) + QLatin1Char('\n'); + return text; + } + + QJsonObject server_entry; + if (client == Client::VsCode || client == Client::Cursor) + server_entry.insert(QStringLiteral("type"), QStringLiteral("stdio")); + server_entry.insert(QStringLiteral("command"), python); + server_entry.insert(QStringLiteral("args"), QJsonArray{server}); + server_entry.insert(QStringLiteral("env"), env); + + // VS Code names the list "servers"; every other client, "mcpServers". + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + const QJsonObject root{{key, QJsonObject{{QStringLiteral("qet"), server_entry}}}}; + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h new file mode 100644 index 000000000..799e91c63 --- /dev/null +++ b/sources/ui/aiassistantsetup.h @@ -0,0 +1,80 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef AIASSISTANTSETUP_H +#define AIASSISTANTSETUP_H + +#include +#include + +#include + +/** + @brief What an AI assistant needs to start QElectroTech's MCP server + (misc/qet-mcp), and the configuration text each assistant expects. + + No widgets and no application object, so it can be tested alone. It + reads the file system and nothing else: it writes nothing and starts + nothing. AiAssistantDialog shows its result. +*/ +namespace AiAssistantSetup +{ + /// The assistants a configuration can be written for. + enum class Client { + ClaudeDesktop, + ClaudeCode, + VsCode, + Cursor, + GeminiCli, + CodexCli, + LmStudio + }; + + QList clients(); + + /// Whether the Python the configuration names can actually run. + enum class PythonStatus { + Found, + Missing, + /// Only Windows' "python.exe" shortcut in WindowsApps, which opens + /// the Microsoft Store when Python is not installed. + StoreShortcut + }; + + /// Where the pieces are. An empty server means none was found. + struct Paths { + QString server; ///< qet_mcp.py + QString python; ///< the Python to run it with + QString qet_binary; ///< this QElectroTech + PythonStatus python_status = PythonStatus::Missing; + }; + + /// Finds a program on PATH; QStandardPaths::findExecutable() by default. + using ExecutableFinder = std::function; + + Paths detect(const QString &application_dir, + const QString &application_file, + bool windows, + const ExecutableFinder &find_executable = ExecutableFinder()); + + QString configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit); +} + +#endif // AIASSISTANTSETUP_H diff --git a/sources/ui/elementinfowidget.cpp b/sources/ui/elementinfowidget.cpp index f009bb374..78aab5395 100644 --- a/sources/ui/elementinfowidget.cpp +++ b/sources/ui/elementinfowidget.cpp @@ -438,9 +438,14 @@ void ElementInfoWidget::materialFromFile(int block) Write a catalogue entry into the fields of one block. Only the fields of that block are touched: applied to an auxiliary - article, the entry never reaches the main article. And a cell which is - empty in the file leaves the current value alone, so picking an entry - describing only the order reference never wipes a comment. + article, the entry never reaches the main article. + + An empty cell of a column describing the article (MaterialList:: + isArticleBound) clears the field: keeping the manufacturer of the + article picked before would describe a part which does not exist. + An empty cell of any other column, and any column the file does not + hold at all, leaves the field alone, so picking an entry describing + only the order reference never wipes a comment. @param record the entry taken from the material file @param block 0 for the main article, 1 to 4 for an auxiliary article */ @@ -459,8 +464,19 @@ void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int bl for (const QString &column : MaterialList::columnsForBlock(block)) { + //A column the file does not hold says nothing about the + //article: the field of the element stays as it is. + if (!record.values.contains(column)) { + continue; + } + const QString value = record.value(column); - if (value.isEmpty()) { + + //An empty cell only matters for the columns describing the + //article itself, where the previous value belongs to another + //part. For the others (function, comment, quantity...) an + //empty cell means the file has nothing to say about them. + if (value.isEmpty() && !MaterialList::isArticleBound(column)) { continue; } @@ -478,7 +494,15 @@ void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int bl enableLiveEdit(); } - apply(); + //Outside of the live edit the fields only carry the article: it + //is the properties window which applies them when the user presses + //"Apply" (ElementPropertiesWidget::apply() takes the undo command + //from these very fields). Applying right away would push the change + //on the undo stack before he has decided anything, and "Cancel" + //would give the fields back but never the element. + if (live_edit) { + apply(); + } } /** diff --git a/sources/ui/linksingleelementwidget.cpp b/sources/ui/linksingleelementwidget.cpp index 322e955bf..67817dcfa 100644 --- a/sources/ui/linksingleelementwidget.cpp +++ b/sources/ui/linksingleelementwidget.cpp @@ -73,7 +73,7 @@ LinkSingleElementWidget::LinkSingleElementWidget(Element *elmt, connect(ui->m_hide_full_masters_cb, &QCheckBox::toggled, this, &LinkSingleElementWidget::hideFullMastersToggled); - connect(m_show_qtwi, &QAction::triggered, this, [=]() + connect(m_show_qtwi, &QAction::triggered, this, [this]() { this->on_m_tree_widget_itemDoubleClicked(this->m_qtwi_at_context_menu, 0); }); @@ -83,7 +83,7 @@ LinkSingleElementWidget::LinkSingleElementWidget(Element *elmt, this, &LinkSingleElementWidget::linkTriggered); - connect(m_show_element, &QAction::triggered, this, [=]() + connect(m_show_element, &QAction::triggered, this, [this]() { this->m_element->diagram()->showMe(); this->m_element->setHighlighted(true); diff --git a/sources/ui/titleblockpropertieswidget.cpp b/sources/ui/titleblockpropertieswidget.cpp index b3ff972cc..929b557c2 100644 --- a/sources/ui/titleblockpropertieswidget.cpp +++ b/sources/ui/titleblockpropertieswidget.cpp @@ -483,13 +483,23 @@ TitleBlockTemplate *TitleBlockPropertiesWidget::templateForIndex(int index) cons Add to @p context every CUSTOM variable used by the currently selected template that is not already present, with an empty value — so the user only has to fill in the values instead of declaring the variables (#271). - The standard fields (title, author, date, …) are handled by their own - widgets and are skipped. Existing values in @p context are preserved. */ void TitleBlockPropertiesWidget::addTemplateVariables( DiagramContext &context, int index) const { - TitleBlockTemplate *tpl = templateForIndex(index); + addTemplateVariables(context, templateForIndex(index)); +} + +/** + @brief TitleBlockPropertiesWidget::addTemplateVariables + Add to @p context every CUSTOM variable used by @p tpl that is not already + present, with an empty value. + The standard fields (title, author, date, …) are handled by their own + widgets and are skipped. Existing values in @p context are preserved. +*/ +void TitleBlockPropertiesWidget::addTemplateVariables( + DiagramContext &context, const TitleBlockTemplate *tpl) +{ if (!tpl) return; // Variables rendered from the dedicated standard-field widgets; they must diff --git a/sources/ui/titleblockpropertieswidget.h b/sources/ui/titleblockpropertieswidget.h index 00e8c1758..0622da8a5 100644 --- a/sources/ui/titleblockpropertieswidget.h +++ b/sources/ui/titleblockpropertieswidget.h @@ -71,6 +71,10 @@ class TitleBlockPropertiesWidget : public QWidget void setTitleBlockTemplatesVisible(const bool &visible); void setReadOnly (const bool &ro); + static void addTemplateVariables( + DiagramContext &context, + const TitleBlockTemplate *tpl); + private: void addCollection ( TitleBlockTemplatesCollection *tbt_collection); diff --git a/sources/undocommand/alignselectioncommand.cpp b/sources/undocommand/alignselectioncommand.cpp index f3a63c4b7..f05267066 100644 --- a/sources/undocommand/alignselectioncommand.cpp +++ b/sources/undocommand/alignselectioncommand.cpp @@ -30,6 +30,78 @@ #include #include +namespace { + /** + One selected item that takes part: where its edges and centre + are, and the point that goes on the grid, on the grid its own + drag uses (divided by @a divisor, as TextGrid::snap() does). + */ + struct Entry { + QGraphicsObject *item; + Alignment::Item geometry; + QPointF snap_point; + qreal divisor; + }; + + /** + @return the items of @a dc that take part, symbols first so a + group with a symbol in it snaps on that symbol. + */ + QList entriesOf(const DiagramContent &dc, qreal text_divisor) + { + QList entries; + //A symbol's edges are its own outline, without its texts, and + //its centre is its origin point: that is where its wires leave. + for (Element *element : std::as_const(dc.m_elements)) + entries << Entry{element, {element->sceneBoundingRect(), element->pos()}, + element->pos(), 1}; + //A picture's edges are the picture's, without its caption + for (DiagramImageItem *image : std::as_const(dc.m_images)) + { + const QRectF rect = image->mapRectToScene(image->imageRect()); + entries << Entry{image, {rect, rect.center()}, image->pos(), 1}; + } + for (IndependentTextItem *text : std::as_const(dc.m_text_fields)) + entries << Entry{text, {text->sceneBoundingRect(), text->sceneBoundingRect().center()}, + text->pos(), text_divisor}; + //A shape's edges are the shape as drawn. What goes on the grid is + //the top-left corner of that box: a rectangle's corner, an + //ellipse's box, a line's end. Its pos() says nothing a user can + //see, and a rotated shape's is never on the grid anyway. + for (QetShapeItem *shape : std::as_const(dc.m_shapes)) + { + const QRectF rect = shape->sceneOutlineRect(); + entries << Entry{shape, {rect, rect.center()}, rect.topLeft(), 1}; + } + return entries; + } + + /** + @return @a entries sorted into the pieces that line up: a group + (#1070) is one piece, every other item a piece of its own. Each + piece is the list of its indexes into @a entries. + */ + QList> unitsOf(const QList &entries) + { + QList> units; + QHash group_units; + for (int i = 0 ; i < entries.size() ; ++i) + { + const QUuid group = ItemGroups::groupOf(entries.at(i).item); + if (group.isNull()) { + units << QList{i}; + continue; + } + if (!group_units.contains(group)) { + group_units.insert(group, units.size()); + units << QList(); + } + units[group_units.value(group)] << i; + } + return units; + } +} + /** @brief AlignSelectionCommand::AlignSelectionCommand Works out the movement of every selected item for @a mode. Nothing @@ -53,28 +125,9 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"), Diagram::yGrid).toInt(); const qreal text_divisor = settings.value(TextGrid::settings_key, 1).toReal(); - //Each kind goes where dragging it would have left it: symbols and - //pictures on the folio grid, free texts on the text grid. - //Shapes are left out: they are made of several points and no single - //one of them is the obvious one to snap. - struct Entry { - QGraphicsObject *item; - Alignment::Item geometry; - qreal divisor; - }; - QList entries; - //A symbol's edges are its own outline, without its texts, and its - //centre is its origin point: that is where its wires leave. - for (Element *element : std::as_const(dc.m_elements)) - entries << Entry{element, {element->sceneBoundingRect(), element->pos()}, 1}; - //A picture's edges are the picture's, without its caption - for (DiagramImageItem *image : std::as_const(dc.m_images)) - { - const QRectF rect = image->mapRectToScene(image->imageRect()); - entries << Entry{image, {rect, rect.center()}, 1}; - } - for (IndependentTextItem *text : std::as_const(dc.m_text_fields)) - entries << Entry{text, {text->sceneBoundingRect(), text->sceneBoundingRect().center()}, text_divisor}; + //Each kind goes where dragging it would have left it: symbols, + //pictures and shapes on the folio grid, free texts on the text grid. + const QList entries = entriesOf(dc, text_divisor); m_item_count = entries.size(); auto move = [this](QGraphicsObject *item, const QPointF &offset) @@ -86,60 +139,15 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC if (mode == SnapToGrid) { for (const Entry &entry : std::as_const(entries)) - move(entry.item, Alignment::gridOffset(entry.item->pos(), x_grid, y_grid, entry.divisor)); + move(entry.item, Alignment::gridOffset(entry.snap_point, x_grid, y_grid, entry.divisor)); setText(QObject::tr("Aligner %n objet(s) sur la grille", "", childCount())); return; } //A group (#1070) lines up as one piece: its edges are its members' //together, its centre the middle of that box, and all its members - //move by the same amount, so the group keeps its shape. Shapes in a - //group come along; shapes outside one are left out, as above. - struct Unit { - QList members; - QList parts; - QGraphicsObject *snap_item = nullptr; ///< lands on its grid - qreal divisor = 1; - }; - QList units; - QHash group_units; - //Returns a reference into units, which a later call can grow: - //use it before calling again, never keep it - auto unitFor = [&](QGraphicsObject *item) -> Unit & - { - const QUuid group = ItemGroups::groupOf(item); - if (group.isNull()) { - units << Unit(); - return units.last(); - } - if (!group_units.contains(group)) { - group_units.insert(group, units.size()); - units << Unit(); - } - return units[group_units.value(group)]; - }; - for (const Entry &entry : std::as_const(entries)) - { - Unit &unit = unitFor(entry.item); - unit.members << entry.item; - unit.parts << entry.geometry; - //Symbols come first in entries, so a group with one snaps on it - if (!unit.snap_item) { - unit.snap_item = entry.item; - unit.divisor = entry.divisor; - } - } - for (QetShapeItem *shape : std::as_const(dc.m_shapes)) - { - if (ItemGroups::groupOf(shape).isNull()) - continue; - Unit &unit = unitFor(shape); - unit.members << shape; - const QRectF rect = shape->sceneBoundingRect(); - unit.parts << Alignment::Item{rect, rect.center()}; - if (!unit.snap_item) - unit.snap_item = shape; - } + //move by the same amount, so the group keeps its shape. + const QList> units = unitsOf(entries); m_item_count = units.size(); //Lining up a single item on itself would only snap it @@ -158,21 +166,39 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC } QList geometry; - for (const Unit &unit : std::as_const(units)) - geometry << Alignment::combined(unit.parts); + for (const QList &unit : units) + { + QList parts; + for (int i : unit) + parts << entries.at(i).geometry; + geometry << Alignment::combined(parts); + } const QList offsets = Alignment::alignOffsets(geometry, edge); - for (int i = 0 ; i < units.size() ; ++i) + for (int u = 0 ; u < units.size() ; ++u) { - const Unit &unit = units.at(i); - const QPointF offset = Alignment::snappedOffset(unit.snap_item->pos(), offsets.at(i), edge, - x_grid, y_grid, unit.divisor); - for (QGraphicsObject *member : unit.members) - move(member, offset); + //The piece lands on the grid of its first member + const Entry &first = entries.at(units.at(u).first()); + const QPointF offset = Alignment::snappedOffset(first.snap_point, offsets.at(u), edge, + x_grid, y_grid, first.divisor); + for (int i : units.at(u)) + move(entries.at(i).item, offset); } setText(QObject::tr("Aligner %n objet(s)", "", childCount())); } +/** + @brief AlignSelectionCommand::unitCount + @return the number of pieces the selection in @a dc lines up as: + every symbol, picture, free text and shape, a group counting as one. + Locked items are counted too, so the command can say why they did + not move. +*/ +int AlignSelectionCommand::unitCount(const DiagramContent &dc) +{ + return unitsOf(entriesOf(dc, 1)).size(); +} + /** @brief AlignSelectionCommand::undo */ @@ -220,8 +246,9 @@ int AlignSelectionCommand::lockedCount() const } /** - @return the number of things that took part: symbols, pictures and - free texts whose position is not locked, a group counting as one. + @return the number of things that took part: symbols, pictures, free + texts and shapes whose position is not locked, a group counting as + one. */ int AlignSelectionCommand::itemCount() const { diff --git a/sources/undocommand/alignselectioncommand.h b/sources/undocommand/alignselectioncommand.h index de782ee4e..88ec1a392 100644 --- a/sources/undocommand/alignselectioncommand.h +++ b/sources/undocommand/alignselectioncommand.h @@ -22,11 +22,12 @@ #include class Diagram; +class DiagramContent; /** @brief The AlignSelectionCommand class Moves each selected item by its own amount, as one undo step. - Symbols, pictures and free texts take part; locked items are left + Symbols, pictures, free texts and shapes take part; locked items are left where they are and counted, so the caller can say so. */ class AlignSelectionCommand : public QUndoCommand @@ -52,6 +53,8 @@ class AlignSelectionCommand : public QUndoCommand int lockedCount() const; int itemCount() const; + static int unitCount(const DiagramContent &dc); + private: QPointer m_diagram; int m_locked_count = 0; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e2fde811c..c8c159b15 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -212,6 +212,16 @@ add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting) target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui) +# AiAssistantSetup: finding the MCP server beside an installation, and the +# configuration text for each AI assistant. Compiles the one file alone. +add_executable( + tst_aiassistantsetup + tst_aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.cpp) +add_test(NAME tst_aiassistantsetup COMMAND tst_aiassistantsetup) +target_include_directories(tst_aiassistantsetup PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_aiassistantsetup PRIVATE Qt::Test Qt::Core) + # QetSettings::sheetBackground() -- the sheet (folio) background the diagram # editor remembers between runs. Compiles qetsettings.cpp alone for the same # reason as the test above; Qt::Gui because the pair carries a QColor. @@ -343,6 +353,44 @@ if(QET_HAS_SCRIPTING) target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + + # The project database filled from the file as it is read holds exactly + # what it holds filled from the built folios (QET_DATABASE_FROM_FOLIOS=1), + # on every example saved once; an older file falls back, saying why. + add_executable( + tst_databasefromdocument + tst_databasefromdocument.cpp) + add_test(NAME tst_databasefromdocument COMMAND tst_databasefromdocument) + add_dependencies(tst_databasefromdocument qelectrotech) + target_link_libraries(tst_databasefromdocument PRIVATE Qt::Test Qt::Xml) + target_compile_definitions(tst_databasefromdocument PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + + # The project database keeps a folio's date with its century: 741.qet's + # 2010-09-21, read through project_summary_view under LC_ALL=en_US.UTF-8. + add_executable( + tst_dbfoliodate + tst_dbfoliodate.cpp) + add_test(NAME tst_dbfoliodate COMMAND tst_dbfoliodate) + add_dependencies(tst_dbfoliodate qelectrotech) + target_link_libraries(tst_dbfoliodate PRIVATE Qt::Test) + target_compile_definitions(tst_dbfoliodate PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + + # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() + # turns element uuid + terminal uuid back into the index the conductor + # calls take, on examples/perceuse.qet (two terminals at one point). + add_executable( + tst_scriptterminaluuid + tst_scriptterminaluuid.cpp) + add_test(NAME tst_scriptterminaluuid COMMAND tst_scriptterminaluuid) + add_dependencies(tst_scriptterminaluuid qelectrotech) + target_link_libraries(tst_scriptterminaluuid PRIVATE Qt::Test Qt::Xml) + target_compile_definitions(tst_scriptterminaluuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") endif() # A symbol saved without a uuid gets the same one on every load, and @@ -385,6 +433,20 @@ target_compile_definitions(tst_resaveunchanged PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# Replacing a symbol's definition in a project keeps its terminal uuids, so +# the wires saved against them still load: TerminalUuids::keep() on its own, +# then the real binary's --info on an example whose symbols were replaced. +add_executable( + tst_terminaluuids + tst_terminaluuids.cpp + ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp) +add_test(NAME tst_terminaluuids COMMAND tst_terminaluuids) +add_dependencies(tst_terminaluuids qelectrotech) +target_link_libraries(tst_terminaluuids PRIVATE Qt::Test Qt::Xml) +target_compile_definitions(tst_terminaluuids PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml # for element definitions in the collection) give the same values: stray # spaces trimmed, accents kept. diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp new file mode 100644 index 000000000..b7b566bcf --- /dev/null +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -0,0 +1,196 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "ui/aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +class TestAiAssistantSetup : public QObject +{ + Q_OBJECT + + static void touch(const QString &path) + { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + } + + static AiAssistantSetup::Paths windowsPaths() + { + AiAssistantSetup::Paths p; + p.server = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"); + p.python = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe"); + p.qet_binary = QStringLiteral("C:\\Program Files\\QElectroTech\\bin\\QElectroTech.exe"); + return p; + } + + static QJsonObject entry(Client client, const AiAssistantSetup::Paths &p, bool edit) + { + const QByteArray text = AiAssistantSetup::configuration( + client, p, QStringLiteral("D:\\Drawings"), edit).toUtf8(); + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(text, &error); + if (error.error != QJsonParseError::NoError) + qWarning() << error.errorString() << text; + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + return doc.object().value(key).toObject().value(QStringLiteral("qet")).toObject(); + } + + private slots: + void windowsInstallIsFound() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("bin/QElectroTech.exe"))); + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.server, QDir::cleanPath(root.filePath(QStringLiteral("mcp/qet_mcp.py")))); + QCOMPARE(p.python, QDir::cleanPath(root.filePath(QStringLiteral("mcp/python/python.exe")))); + QCOMPARE(p.qet_binary, QDir::cleanPath(root.filePath(QStringLiteral("bin/QElectroTech.exe")))); + } + + void windowsWithoutBundledPythonUsesPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.python, QStringLiteral("python")); + } + + void unixInstallIsFound() + { + QTemporaryDir prefix; + touch(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py"))); + // A bundled-Python layout is only a Windows thing. + touch(prefix.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(prefix.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(prefix.filePath(QStringLiteral("bin")), + prefix.filePath(QStringLiteral("bin/qelectrotech")), false); + QCOMPARE(p.server, QDir::cleanPath(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py")))); + QCOMPARE(p.python, QStringLiteral("python3")); + } + + void bundledPythonIsFoundWithoutLookingOnPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + bool looked = false; + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true, + [&looked](const QString &) { looked = true; return QString(); }); + QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found); + QVERIFY(!looked); + } + + void pythonStatusFromPath_data() + { + QTest::addColumn("windows"); + QTest::addColumn("found"); + QTest::addColumn("status"); + using S = AiAssistantSetup::PythonStatus; + QTest::newRow("windows, none") << true << QString() << int(S::Missing); + QTest::newRow("windows, python.org") << true + << QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found); + QTest::newRow("windows, Store shortcut") << true + << QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut); + QTest::newRow("windows, Store shortcut, backslashes") << true + << QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut); + QTest::newRow("linux, none") << false << QString() << int(S::Missing); + QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found); + QTest::newRow("linux, a folder named WindowsApps") << false + << QStringLiteral("/opt/WindowsApps/python3") << int(S::Found); + } + + void pythonStatusFromPath() + { + QFETCH(bool, windows); + QFETCH(QString, found); + QFETCH(int, status); + QTemporaryDir build; + QString asked; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), windows, + [&](const QString &name) { asked = name; return found; }); + QCOMPARE(int(p.python_status), status); + QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3")); + } + + void noServerIsReportedAsEmpty() + { + QTemporaryDir build; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), false); + QVERIFY(p.server.isEmpty()); + } + + void everyJsonClientGetsValidJson() + { + for (Client c : AiAssistantSetup::clients()) { + if (c == Client::CodexCli) + continue; + const QJsonObject e = entry(c, windowsPaths(), false); + QVERIFY2(!e.isEmpty(), qPrintable(QString::number(int(c)))); + // Backslashes survive: parsing the text gives the path back. + QCOMPARE(e.value(QStringLiteral("args")).toArray().at(0).toString(), windowsPaths().server); + QCOMPARE(e.value(QStringLiteral("command")).toString(), windowsPaths().python); + const QJsonObject env = e.value(QStringLiteral("env")).toObject(); + QCOMPARE(env.value(QStringLiteral("QET_MCP_WORKSPACE")).toString(), QStringLiteral("D:\\Drawings")); + QCOMPARE(env.value(QStringLiteral("QET_BINARY")).toString(), windowsPaths().qet_binary); + const bool typed = c == Client::VsCode || c == Client::Cursor; + QCOMPARE(e.contains(QStringLiteral("type")), typed); + } + } + + void editingIsOffUnlessAllowed() + { + for (Client c : AiAssistantSetup::clients()) { + const QString off = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), false); + const QString on = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), true); + QVERIFY(!off.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + QVERIFY(on.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + } + } + + void codexGetsEscapedToml() + { + const QString t = AiAssistantSetup::configuration( + Client::CodexCli, windowsPaths(), QStringLiteral("D:\\Drawings"), true); + QVERIFY(t.startsWith(QStringLiteral("[mcp_servers.qet]\n"))); + QVERIFY(t.contains(QStringLiteral("args = [\"C:\\\\Program Files\\\\QElectroTech\\\\mcp\\\\qet_mcp.py\"]"))); + QVERIFY(t.contains(QStringLiteral("[mcp_servers.qet.env]\n"))); + QVERIFY(t.contains(QStringLiteral("QET_MCP_WORKSPACE = \"D:\\\\Drawings\""))); + QVERIFY(t.contains(QStringLiteral("QET_ENABLE_SCRIPTING = \"1\""))); + } +}; + +QTEST_GUILESS_MAIN(TestAiAssistantSetup) +#include "tst_aiassistantsetup.moc" diff --git a/tests/qttest/tst_databasefromdocument.cpp b/tests/qttest/tst_databasefromdocument.cpp new file mode 100644 index 000000000..d2febebf4 --- /dev/null +++ b/tests/qttest/tst_databasefromdocument.cpp @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A project's database, filled from the file as it is read, holds exactly +// what the same database filled from the built folios holds. Every example +// is saved once (so that it carries the uuids a current QElectroTech +// writes), then opened twice through the real binary's --run: once as is, +// once with QET_DATABASE_FROM_FOLIOS=1, and the six tables compared. +class tst_databasefromdocument : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + QProcessEnvironment env() + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QDir().mkpath(home); + QProcessEnvironment e = QProcessEnvironment::systemEnvironment(); + e.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + e.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + e.insert(QStringLiteral("HOME"), home); + e.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + e.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + e.insert(QStringLiteral("TMPDIR"), m_dir.path()); + e.remove(QStringLiteral("QET_DATABASE_FROM_FOLIOS")); + return e; + } + + QString run(const QStringList &args, bool from_folios = false) + { + QProcessEnvironment e = env(); + if (from_folios) + e.insert(QStringLiteral("QET_DATABASE_FROM_FOLIOS"), QStringLiteral("1")); + QProcess proc; + proc.setProcessEnvironment(e); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args); + if (!proc.waitForFinished(180000)) return {}; + return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + } + + // The six tables, each as a sorted list of its rows, and which way the + // database was filled. + QJsonObject dump(const QString &project, bool from_folios, QString *how) + { + const QString out = run({QStringLiteral("--run"), m_dir.filePath(QStringLiteral("dump.js")), + project}, from_folios); + QJsonObject tables; + for (const QString &line : out.split(QLatin1Char('\n'))) { + if (line.contains(QStringLiteral("Project database filled"))) + *how = line.mid(line.indexOf(QStringLiteral("Project database filled"))); + const int i = line.indexOf(QStringLiteral("DUMP ")); + if (i >= 0) { + const QJsonObject raw = QJsonDocument::fromJson(line.mid(i + 5).toUtf8()).object(); + for (auto it = raw.begin(); it != raw.end(); ++it) { + QStringList rows; + for (const QJsonValue &row : it.value().toArray()) + rows << QString::fromUtf8(QJsonDocument(row.toObject()).toJson(QJsonDocument::Compact)); + rows.sort(); + tables.insert(it.key(), QJsonArray::fromStringList(rows)); + } + } + } + return tables; + } + + // tremie_vibrante.qet saved once, as a document to change + QDomDocument resaved(const QString &saved) + { + run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved}); + QFile file(saved); + QDomDocument document; + if (file.open(QIODevice::ReadOnly)) + document.setContent(&file); + return document; + } + + static bool write(const QString &path, const QDomDocument &document) + { + QFile file(path); + return file.open(QIODevice::WriteOnly | QIODevice::Truncate) + && file.write(document.toByteArray()) > 0; + } + + // Both fills of @p saved, which must come from the document, agree. + void compareBothWays(const QString &saved) + { + QString how_document, how_folios; + const QJsonObject document = dump(saved, false, &how_document); + const QJsonObject folios = dump(saved, true, &how_folios); + QVERIFY2(how_document == QLatin1String("Project database filled from the document"), + qPrintable(how_document)); + QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios)); + QCOMPARE(document.keys().size(), 6); + for (const QString &table : folios.keys()) { + const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray(); + QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios") + .arg(table).arg(a.size()).arg(b.size()))); + } + m_last = document; + } + + QJsonObject m_last; // the document's tables, from compareBothWays() + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + QFile js(m_dir.filePath(QStringLiteral("dump.js"))); + QVERIFY(js.open(QIODevice::WriteOnly)); + js.write("var out = {};\n" + "['diagram', 'diagram_info', 'element', 'element_info', 'terminal', 'conductor']" + ".forEach(function (t) { out[t] = qet.query('SELECT * FROM ' + t); });\n" + "qet.log('DUMP ' + JSON.stringify(out));\n"); + } + + void sameTablesBothWays_data() + { + QTest::addColumn("project"); + const QDir examples(QStringLiteral(QET_EXAMPLES_DIR)); + for (const QString &f : examples.entryList({QStringLiteral("*.qet")}, QDir::Files, QDir::Name)) + QTest::newRow(f.toUtf8().constData()) << examples.filePath(f); + } + + void sameTablesBothWays() + { + QFETCH(QString, project); + const QString saved = m_dir.filePath(QStringLiteral("saved%1.qet").arg(m_run)); + run({QStringLiteral("--resave"), project, saved}); + QVERIFY2(QFile::exists(saved), "--resave failed"); + compareBothWays(saved); + } + + // A label or a conductor text made from a formula is what the formula + // gives now, not what it gave when the file was saved: every folio's + // first element and first conductor is given a formula using each kind + // of variable, and a saved label no formula gives. + void formulasAreWorkedOut() + { + const QString saved = m_dir.filePath(QStringLiteral("formulas-saved.qet")); + run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved}); + QFile file(saved); + QVERIFY(file.open(QIODevice::ReadOnly)); + QDomDocument document; + QVERIFY(document.setContent(&file)); + file.close(); + + auto property = [&document](QDomElement parent, const QString &name, const QString &value) { + QDomElement properties = parent.firstChildElement(QStringLiteral("properties")); + if (properties.isNull()) + properties = parent.appendChild(document.createElement(QStringLiteral("properties"))).toElement(); + QDomElement p = document.createElement(QStringLiteral("property")); + p.setAttribute(QStringLiteral("name"), name); + p.appendChild(document.createTextNode(value)); + properties.appendChild(p); + }; + auto sequence = [&document](QDomElement item, const QString &unit) { + item.removeChild(item.firstChildElement(QStringLiteral("sequentialNumbers"))); + QDomElement s = document.createElement(QStringLiteral("sequentialNumbers")); + QDomElement u = document.createElement(QStringLiteral("unit")); + u.appendChild(document.createTextNode(unit)); + s.appendChild(u); + item.appendChild(s); + }; + + property(document.documentElement(), QStringLiteral("site"), QStringLiteral("S")); + const QDomNodeList diagrams = document.elementsByTagName(QStringLiteral("diagram")); + QCOMPARE(diagrams.size(), 3); + for (int i = 0 ; i < diagrams.size() ; ++i) + { + QDomElement diagram = diagrams.at(i).toElement(); + diagram.setAttribute(QStringLiteral("folio"), QStringLiteral("F%id")); + diagram.setAttribute(QStringLiteral("plant"), QStringLiteral("P")); + diagram.setAttribute(QStringLiteral("locmach"), QStringLiteral("L")); + property(diagram, QStringLiteral("zone"), QStringLiteral("Z%1").arg(i)); + + QDomElement element = diagram.firstChildElement(QStringLiteral("elements")) + .firstChildElement(QStringLiteral("element")); + QVERIFY(!element.isNull()); + element.setAttribute(QStringLiteral("prefix"), QStringLiteral("X")); + sequence(element, QStringLiteral("7")); + QDomElement informations = element.firstChildElement(QStringLiteral("elementInformations")); + if (informations.isNull()) + informations = element.appendChild(document.createElement(QStringLiteral("elementInformations"))).toElement(); + while (!informations.firstChild().isNull()) + informations.removeChild(informations.firstChild()); + for (const auto &info : {std::make_pair(QStringLiteral("formula"), + QStringLiteral("K%total-%f-%F-%M-%LM-%c%l-%prefix-%{zone}-%{site}-%sequ_1")), + std::make_pair(QStringLiteral("label"), QStringLiteral("OLD"))}) { + QDomElement e = document.createElement(QStringLiteral("elementInformation")); + e.setAttribute(QStringLiteral("name"), info.first); + e.setAttribute(QStringLiteral("show"), QStringLiteral("1")); + e.appendChild(document.createTextNode(info.second)); + informations.appendChild(e); + } + + QDomElement conductor = diagram.firstChildElement(QStringLiteral("conductors")) + .firstChildElement(QStringLiteral("conductor")); + QVERIFY(!conductor.isNull()); + conductor.setAttribute(QStringLiteral("formula"), QStringLiteral("W%total-%id-%wf-%{zone}-%sequ_1")); + conductor.setAttribute(QStringLiteral("function"), QStringLiteral("N")); + conductor.setAttribute(QStringLiteral("num"), QStringLiteral("OLD")); + sequence(conductor, QStringLiteral("3")); + } + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(document.toByteArray()); + file.close(); + + compareBothWays(saved); + // ...and they were worked out, not left as saved + const QString info = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("element_info")).toArray()) + .toJson(QJsonDocument::Compact)); + const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray()) + .toJson(QJsonDocument::Compact)); + QVERIFY2(info.contains(QStringLiteral("K3-1-F1-P-L-")), qPrintable(info.left(400))); + QVERIFY2(info.contains(QStringLiteral("-X-Z0-S-7")), qPrintable(info.left(400))); + QVERIFY2(wires.contains(QStringLiteral("W3-3-N-Z2-3")), qPrintable(wires.left(400))); + QVERIFY(!info.contains(QStringLiteral("OLD"))); + QVERIFY(!wires.contains(QStringLiteral("OLD"))); + } + + // An element the folio does not build, and so the conductors ending on + // it, are left out the same way. + void unbuiltElementIsLeftOut() + { + const QString saved = m_dir.filePath(QStringLiteral("unbuilt.qet")); + QDomDocument document = resaved(saved); + QDomElement conductor = document.elementsByTagName(QStringLiteral("conductor")).at(0).toElement(); + QVERIFY(!conductor.isNull()); + const QString uuid = conductor.attribute(QStringLiteral("element1")); + const QDomNodeList elements = document.elementsByTagName(QStringLiteral("element")); + bool found = false; + for (int i = 0 ; i < elements.size() ; ++i) { + QDomElement e = elements.at(i).toElement(); + if (e.attribute(QStringLiteral("uuid")) == uuid) { + e.setAttribute(QStringLiteral("x"), QStringLiteral("nan")); + found = true; + } + } + QVERIFY(found); + QVERIFY(write(saved, document)); + compareBothWays(saved); + const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray()) + .toJson(QJsonDocument::Compact)); + QVERIFY(!wires.contains(conductor.attribute(QStringLiteral("uuid")))); + } + + // Two elements on a folio numbering their terminals alike: which one the + // folio then refuses depends on the terminals' geometry, so the folios + // fill the database. + void clashingTerminalIdsFallBack() + { + const QString saved = m_dir.filePath(QStringLiteral("clash.qet")); + QDomDocument document = resaved(saved); + const QDomElement diagram = document.elementsByTagName(QStringLiteral("diagram")).at(0).toElement(); + QDomElement first = diagram.firstChildElement(QStringLiteral("elements")).firstChildElement(QStringLiteral("element")); + QDomElement second = first.nextSiblingElement(QStringLiteral("element")); + const QString id = first.firstChildElement(QStringLiteral("terminals")) + .firstChildElement(QStringLiteral("terminal")).attribute(QStringLiteral("id")); + QDomElement terminal = second.firstChildElement(QStringLiteral("terminals")) + .firstChildElement(QStringLiteral("terminal")); + QVERIFY(!id.isEmpty() && !terminal.isNull()); + terminal.setAttribute(QStringLiteral("id"), id); + QVERIFY(write(saved, document)); + QString how; + dump(saved, false, &how); + QCOMPARE(how, QStringLiteral("Project database filled from the folios: " + "two elements on a folio number their terminals alike")); + } + + // A file whose items carry no saved uuid is filled from the folios, + // which work those uuids out as they are built, and says why. + void olderFileFallsBack() + { + QString how; + dump(QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), false, &how); + QCOMPARE(how, QStringLiteral("Project database filled from the folios: a folio has no saved uuid")); + } +}; + +QTEST_APPLESS_MAIN(tst_databasefromdocument) + +#include "tst_databasefromdocument.moc" diff --git a/tests/qttest/tst_dbfoliodate.cpp b/tests/qttest/tst_dbfoliodate.cpp new file mode 100644 index 000000000..4a99dce71 --- /dev/null +++ b/tests/qttest/tst_dbfoliodate.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include + +// The project database holds each folio's own date. It used to hold the +// title block's text read back through the locale's short format, which in a +// locale with a two-digit year (en_US "M/d/yy") turned 2010 into 1910 -- in +// diagram_info and in project_summary_view, which the summary table reads. +// Runs a script through the real binary's --run on examples/741.qet, whose +// folio is dated 20100921, with LC_ALL=en_US.UTF-8. +class tst_dbfoliodate : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void folioDateKeepsItsCentury() + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("qet.log('PROBE ' + JSON.stringify(" + "qet.query('SELECT date FROM project_summary_view')));\n"); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + env.insert(QStringLiteral("LC_ALL"), QStringLiteral("en_US.UTF-8")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, + QStringLiteral(QET_EXAMPLES_DIR "/741.qet")}); + QVERIFY(proc.waitForFinished(120000)); + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + QJsonArray rows; + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(QStringLiteral("PROBE ")); + if (i >= 0) + rows = QJsonDocument::fromJson(line.mid(i + 6).toUtf8()).array(); + } + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.at(0).toObject().value(QStringLiteral("date")).toString(), + QStringLiteral("2010-09-21")); + } +}; + +QTEST_APPLESS_MAIN(tst_dbfoliodate) + +#include "tst_dbfoliodate.moc" diff --git a/tests/qttest/tst_scriptterminaluuid.cpp b/tests/qttest/tst_scriptterminaluuid.cpp new file mode 100644 index 000000000..316282493 --- /dev/null +++ b/tests/qttest/tst_scriptterminaluuid.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// qet.elementTerminals() ends each line with the terminal's uuid, and +// qet.terminalIndex(folio, element, terminalUuid) turns it back into the +// index the conductor calls take. Runs a script through the real binary's +// --run on perceuse.qet, which has symbols with two terminals at one point: +// their order in the index is undefined, their uuids are not. +class tst_scriptterminaluuid : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on @p project in a sandbox of its own and return the + // JSON object it logged. + QJsonObject run(const QString &script, const QString &project) + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(script.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, project}); + if (!proc.waitForFinished(120000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void everyTerminalFoundByItsUuid() + { + const QJsonObject r = run(QStringLiteral( + "var rows = [], pick = null;\n" + "for (var f = 0; f < qet.folioCount(); f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length; e++) {\n" + " var lines = qet.elementTerminals(f, els[e]);\n" + " var back = lines.map(function (l) {\n" + " return qet.terminalIndex(f, els[e], l.substring(l.lastIndexOf(' ') + 1)); });\n" + " rows.push({lines: lines, back: back});\n" + " if (!pick && lines.length) pick = {f: f, e: els[e], t: lines[0].split(' ').pop()};\n" + " }\n" + "}\n" + "qet.log('PROBE ' + JSON.stringify({rows: rows,\n" + " unknown: qet.terminalIndex(pick.f, pick.e, '{00000000-0000-0000-0000-000000000001}'),\n" + " junk: qet.terminalIndex(pick.f, pick.e, 'not a uuid'),\n" + " noElement: qet.terminalIndex(pick.f, '{00000000-0000-0000-0000-000000000002}', pick.t),\n" + " badFolio: qet.terminalIndex(99, pick.e, pick.t)}));\n"), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + + const QRegularExpression line_re(QStringLiteral( + "^(\\d+): .* \\(\\d+ conductor\\(s\\)\\) (\\{[0-9a-f-]{36}\\})$")); + int terminals = 0; + for (const QJsonValue &row : r.value(QStringLiteral("rows")).toArray()) { + const QJsonArray lines = row.toObject().value(QStringLiteral("lines")).toArray(); + const QJsonArray back = row.toObject().value(QStringLiteral("back")).toArray(); + QCOMPARE(back.size(), lines.size()); + QSet uuids; + for (int i = 0; i < lines.size(); ++i) { + const QString line = lines.at(i).toString(); + const QRegularExpressionMatch m = line_re.match(line); + QVERIFY2(m.hasMatch(), qPrintable(line)); + QCOMPARE(m.captured(1).toInt(), i); + QVERIFY(!QUuid(m.captured(2)).isNull()); + uuids.insert(m.captured(2)); + // the uuid finds this very terminal again + QCOMPARE(back.at(i).toInt(), i); + ++terminals; + } + QCOMPARE(uuids.size(), lines.size()); + } + QVERIFY(terminals > 200); + + QCOMPARE(r.value(QStringLiteral("unknown")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("junk")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("noElement")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("badFolio")).toInt(), -1); + } + + // A "%3" or "%4" in a terminal's name is listed as written, not + // replaced by the conductor count or the uuid. + void percentInNameKept() + { + QFile in(QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY(in.open(QIODevice::ReadOnly)); + QDomDocument doc; + QVERIFY(bool(doc.setContent(&in))); + const QDomNodeList terminals = doc.documentElement() + .firstChildElement(QStringLiteral("collection")) + .elementsByTagName(QStringLiteral("terminal")); + QVERIFY(!terminals.isEmpty()); + terminals.at(0).toElement().setAttribute(QStringLiteral("name"), + QStringLiteral("x%3y%4")); + const QString project = m_dir.filePath(QStringLiteral("percent.qet")); + QFile out(project); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write(doc.toByteArray()); + out.close(); + + const QJsonObject r = run(QStringLiteral( + "var hits = [];\n" + "for (var f = 0; f < qet.folioCount(); f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length; e++)\n" + " qet.elementTerminals(f, els[e]).forEach(function (l) {\n" + " if (/: x.*y.* \\(/.test(l)) hits.push(l); });\n" + "}\n" + "qet.log('PROBE ' + JSON.stringify({hits: hits}));\n"), project); + const QJsonArray hits = r.value(QStringLiteral("hits")).toArray(); + QVERIFY2(!hits.isEmpty(), "the renamed terminal was not listed"); + for (const QJsonValue &h : hits) + QVERIFY2(h.toString().contains(QStringLiteral(": x%3y%4 (")), qPrintable(h.toString())); + } +}; + +QTEST_APPLESS_MAIN(tst_scriptterminaluuid) + +#include "tst_scriptterminaluuid.moc" diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp new file mode 100644 index 000000000..35caba47f --- /dev/null +++ b/tests/qttest/tst_terminaluuids.cpp @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "../../sources/ElementsCollection/terminaluuids.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Replacing a symbol's definition in a project must not cost the wires on +// it. A wire is saved against the uuids of the terminals it joins and is +// reattached on load to a terminal with that uuid, or not at all; the +// replacement is another copy of the symbol, whose terminals usually carry +// other uuids. TerminalUuids::keep() carries the old uuids over. +namespace { + +QDomElement symbol(QDomDocument &doc, const QList &terminals) +{ + QDomElement element = doc.createElement(QStringLiteral("element")); + QDomElement definition = doc.createElement(QStringLiteral("definition")); + QDomElement description = doc.createElement(QStringLiteral("description")); + element.appendChild(definition); + definition.appendChild(description); + for (const QStringList &t : terminals) { + QDomElement terminal = doc.createElement(QStringLiteral("terminal")); + terminal.setAttribute(QStringLiteral("x"), t.at(0)); + terminal.setAttribute(QStringLiteral("y"), t.at(1)); + terminal.setAttribute(QStringLiteral("orientation"), t.at(2)); + if (t.size() > 3) + terminal.setAttribute(QStringLiteral("uuid"), t.at(3)); + description.appendChild(terminal); + } + return element; +} + +QStringList uuids(const QDomElement &element) +{ + QStringList out; + const QDomNodeList nodes = element.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < nodes.size(); ++i) + out << nodes.at(i).toElement().attribute(QStringLiteral("uuid")); + return out; +} + +QList embeddedSymbols(const QDomDocument &doc) +{ + QList out; + const QDomNodeList nodes = doc.documentElement() + .firstChildElement(QStringLiteral("collection")) + .elementsByTagName(QStringLiteral("element")); + for (int i = 0; i < nodes.size(); ++i) + out << nodes.at(i).toElement(); + return out; +} + +} + +class tst_terminaluuids : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + // The real binary with @p args, in a sandbox of its own (so a running + // QElectroTech cannot answer instead); false if it failed. + bool runQet(const QStringList &args, QByteArray *out, QString *log) + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QDir().mkpath(home); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args); + if (!proc.waitForFinished(120000) || proc.exitCode() != 0) + return false; + *log = QString::fromUtf8(proc.readAllStandardError()); + *out = proc.readAllStandardOutput(); + return true; + } + + // --info on @p project; returns the number of wires loaded and puts + // what was written on stderr in @p log. + int loadedWires(const QString &project, QString *log) + { + QByteArray out; + if (!runQet({QStringLiteral("--info"), project}, &out, log)) + return -1; + const QJsonDocument json = QJsonDocument::fromJson(out.mid(out.indexOf('{'))); + return json.object().value(QStringLiteral("conductors")).toInt(-1); + } + + // --resave @p in to a new file; returns its path, empty on failure. + QString resave(const QString &in) + { + const QString out = m_dir.filePath(QStringLiteral("resaved%1.qet").arg(m_run)); + QByteArray stdout_; + QString log; + if (!runQet({QStringLiteral("--resave"), in, out}, &stdout_, &log)) + return {}; + return out; + } + + static QDomDocument load(const QString &path) + { + QDomDocument doc; + QFile file(path); + if (file.open(QIODevice::ReadOnly)) + doc.setContent(&file); + return doc; + } + + static QByteArray bytes(const QString &path) + { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + + // 2612_ats_singlephase.qet with each embedded symbol replaced by a copy + // whose terminals carry new uuids, passed through keep() or not. + QString replacedSymbols(bool keep_uuids) + { + QFile in(QStringLiteral(QET_EXAMPLES_DIR "/2612_ats_singlephase.qet")); + if (!in.open(QIODevice::ReadOnly)) return {}; + QDomDocument doc; + if (!doc.setContent(&in)) return {}; + for (const QDomElement &old_symbol : embeddedSymbols(doc)) { + QDomElement new_symbol = old_symbol.cloneNode().toElement(); + const QDomNodeList terminals = new_symbol.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"), + QUuid::createUuid().toString()); + if (keep_uuids) + TerminalUuids::keep(old_symbol, new_symbol); + old_symbol.parentNode().replaceChild(new_symbol, old_symbol); + } + const QString path = m_dir.filePath(keep_uuids ? QStringLiteral("kept.qet") + : QStringLiteral("lost.qet")); + QFile out(path); + if (!out.open(QIODevice::WriteOnly)) return {}; + out.write(doc.toByteArray()); + return path; + } + +private slots: + void samePlaceTakesOldUuid() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}, + {"20", "10", "s", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"20.0", "10", "s", "{y}"}, + {"0", "10.0", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{b}", "{a}"})); + } + + void movedOrNewTerminalKeepsItsOwn() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "e", "{x}"}, + {"0", "20", "n", "{y}"}, + {"0", "30", "n"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{x}", "{y}", ""})); + } + + void oldWithoutUuidChangesNothing() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{x}"})); + } + + void twoAtOnePlaceMatchedInOrder() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}, + {"0", "10", "n", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"}, + {"0", "10", "n", "{y}"}, + {"0", "10", "n", "{z}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{b}", "{z}"})); + } + + // The new revision moved terminal {a} and put a new one where it was: + // {a} is still {a}, and the new one is not given {a} a second time. + void noUuidGivenTwice() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "0", "n", "{a}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "5", "n", "{a}"}, + {"0", "0", "n", "{c}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{c}"})); + } + + // A terminal that already carries an old uuid is never renamed, even + // when it moved onto the place of another old terminal. + void movedTerminalNotRenamed() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "0", "n", "{a}"}, + {"0", "5", "n", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "5", "n", "{a}"}, + {"0", "9", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{x}"})); + } + + // Replacing a whole category: every symbol with a counterpart of the + // same name, at any depth, keeps its terminal uuids. + void keepInDirectory() + { + QDomDocument doc; + auto category = [&doc](const QString &name) { + QDomElement c = doc.createElement(QStringLiteral("category")); + c.setAttribute(QStringLiteral("name"), name); + return c; + }; + auto named = [](QDomElement e, const QString &name) { + e.setAttribute(QStringLiteral("name"), name); + return e; + }; + + QDomElement old_dir = category(QStringLiteral("dir")); + QDomElement old_sub = category(QStringLiteral("sub")); + old_dir.appendChild(named(symbol(doc, {{"0", "0", "n", "{a}"}}), "top.elmt")); + old_dir.appendChild(old_sub); + old_sub.appendChild(named(symbol(doc, {{"0", "0", "n", "{b}"}}), "deep.elmt")); + + QDomElement new_dir = category(QStringLiteral("dir")); + QDomElement new_sub = category(QStringLiteral("sub")); + const QDomElement top = named(symbol(doc, {{"0", "0", "n", "{x}"}}), "top.elmt"); + const QDomElement other = named(symbol(doc, {{"0", "0", "n", "{y}"}}), "other.elmt"); + const QDomElement deep = named(symbol(doc, {{"0", "0", "n", "{z}"}}), "deep.elmt"); + new_dir.appendChild(top); + new_dir.appendChild(other); + new_dir.appendChild(new_sub); + new_sub.appendChild(deep); + + TerminalUuids::keepInDirectory(old_dir, new_dir); + QCOMPARE(uuids(top), QStringList{"{a}"}); + QCOMPARE(uuids(other), QStringList{"{y}"}); + QCOMPARE(uuids(deep), QStringList{"{b}"}); + } + + // The recipe is Terminal::stableUuid()'s, which the project database + // and the wire uuids worked out from their ends already use: changing + // it would change every such identity in every project. + void derivedRecipeUnchanged() + { + const QUuid ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); + QCOMPARE(TerminalUuids::derived(0, 10, 2), + QUuid::createUuidV5(ns, QStringLiteral("0.0000|10.0000|2"))); + QCOMPARE(TerminalUuids::derived(-2.5, 4, 3, 1), + QUuid::createUuidV5(ns, QStringLiteral("-2.5000|4.0000|3|1"))); + } + + // Only terminals without a uuid get one, the derived value; two at one + // point get distinct ones; a value already used in the symbol is never + // given again; symbols in sub-categories are reached. + void fillMissing() + { + QDomDocument doc; + QDomElement root = doc.createElement(QStringLiteral("collection")); + QDomElement sub = doc.createElement(QStringLiteral("category")); + root.appendChild(sub); + const QString taken = TerminalUuids::derived(0, 0, 0).toString(); + const QString own = QStringLiteral("{0f5d4b0c-2f7e-4a55-9a51-8c3a3e1c2d11}"); + QDomElement a = symbol(doc, {{"0", "10", "s"}, + {"5", "0", "e", own}, + {"0", "10", "s"}, + {"0", "0", "n"}, + {"9", "9", "w", taken}}); + QDomElement b = symbol(doc, {{"1", "2", "w"}}); + root.appendChild(a); + sub.appendChild(b); + + QCOMPARE(TerminalUuids::fillMissing(root), 4); + QCOMPARE(uuids(a), (QStringList{ + TerminalUuids::derived(0, 10, 2).toString(), + own, + TerminalUuids::derived(0, 10, 2, 1).toString(), + TerminalUuids::derived(0, 0, 0, 1).toString(), + taken})); + QCOMPARE(uuids(b), QStringList{TerminalUuids::derived(1, 2, 3).toString()}); + QCOMPARE(TerminalUuids::fillMissing(root), 0); + } + + // The element editor fills a definition read from a file with exactly + // what a project fills the same definition with. + void fillMissingInDefinitionMatchesProject() + { + QDomDocument doc; + const QString own = QStringLiteral("{0f5d4b0c-2f7e-4a55-9a51-8c3a3e1c2d11}"); + QDomElement in_project = symbol(doc, {{"0", "10", "s"}, + {"0", "10", "s"}, + {"5", "0", "e", own}, + {"-3", "0", "w"}}); + QDomElement root = doc.createElement(QStringLiteral("collection")); + root.appendChild(in_project); + QDomElement definition = in_project.firstChildElement(QStringLiteral("definition")) + .cloneNode(true).toElement(); + + QCOMPARE(TerminalUuids::fillMissingInDefinition(definition), 3); + QCOMPARE(TerminalUuids::fillMissing(root), 3); + QCOMPARE(uuids(definition), uuids(in_project)); + QCOMPARE(uuids(definition).at(2), own); + QCOMPARE(uuids(definition).at(1), TerminalUuids::derived(0, 10, 2, 1).toString()); + QCOMPARE(TerminalUuids::fillMissingInDefinition(definition), 0); + } + + // An example whose symbols have no terminal uuids and whose wires are + // all in the numbered form: once saved, every terminal has a uuid, + // every wire names its ends by uuid, nothing is lost, and saving again + // changes nothing. + void resaveGivesEveryTerminalAUuid() + { + const QString original = QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"); + QString log; + const int wires = loadedWires(original, &log); + QVERIFY(wires > 0); + + const QString saved = resave(original); + QVERIFY2(!saved.isEmpty(), "--resave failed"); + const QDomDocument doc = load(saved); + int terminals = 0; + for (const QDomElement &e : embeddedSymbols(doc)) { + for (const QString &uuid : uuids(e)) { + ++terminals; + QVERIFY2(!QUuid(uuid).isNull(), "a terminal has no uuid"); + } + } + QVERIFY(terminals > 0); + const QDomNodeList conductors = doc.elementsByTagName(QStringLiteral("conductor")); + QCOMPARE(conductors.size(), wires); + for (int i = 0; i < conductors.size(); ++i) { + const QDomElement c = conductors.at(i).toElement(); + QVERIFY2(c.hasAttribute(QStringLiteral("element1")) + && c.hasAttribute(QStringLiteral("element2")), + "a wire is still in the numbered form"); + } + + QCOMPARE(loadedWires(saved, &log), wires); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + const QString again = resave(saved); + QVERIFY2(!again.isEmpty(), "second --resave failed"); + QVERIFY2(bytes(again) == bytes(saved), "the second save changed the file"); + } + + // The uuids written on opening are derived from where each terminal + // is: a wire saved against one still finds its terminal after the + // symbol's definition was replaced by one with other terminal uuids. + // perceuse.qet and industrial.qet have wires on the second of two + // terminals at one point of a symbol, which get the next occurrence. + void derivedUuidFoundAfterReplacement_data() + { + QTest::addColumn("project"); + for (const char *name : {"tremie_vibrante.qet", "perceuse.qet", "industrial.qet"}) + QTest::newRow(name) << QStringLiteral(QET_EXAMPLES_DIR "/") + QLatin1String(name); + } + + void derivedUuidFoundAfterReplacement() + { + QFETCH(QString, project); + const QString saved = resave(project); + QVERIFY(!saved.isEmpty()); + QString log; + const int wires = loadedWires(saved, &log); + QVERIFY(wires > 0); + + QDomDocument doc = load(saved); + for (QDomElement e : embeddedSymbols(doc)) { + const QDomNodeList terminals = e.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"), + QUuid::createUuid().toString()); + } + const QString replaced = m_dir.filePath(QStringLiteral("replaced%1.qet").arg(m_run)); + QFile out(replaced); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write(doc.toByteArray()); + out.close(); + + QCOMPARE(loadedWires(replaced, &log), wires); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + } + + // The real loader, on a project whose 131 wires are saved against + // terminal uuids: replaced symbols lose 119 of them unless keep() ran, + // and the ones lost are reported. + void wiresSurviveReplacedSymbols() + { + QVERIFY(m_dir.isValid()); + QString log; + + const QString lost = replacedSymbols(false); + QVERIFY(!lost.isEmpty()); + QCOMPARE(loadedWires(lost, &log), 12); + QVERIFY2(log.contains(QStringLiteral("55 wire(s) not loaded")) + && log.contains(QStringLiteral("64 wire(s) not loaded")), + "the lost wires were not reported"); + + const QString kept = replacedSymbols(true); + QVERIFY(!kept.isEmpty()); + QCOMPARE(loadedWires(kept, &log), 131); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + } +}; + +QTEST_APPLESS_MAIN(tst_terminaluuids) + +#include "tst_terminaluuids.moc"