Merge remote-tracking branch 'upstream/master' into prefix-button

Conflicts in cmake/qet_compilation_vars.cmake only, where upstream added
terminaluuids.cpp/.h next to the qetlabelsfile.cpp/.h this branch adds :
both are kept, qetlabelsfile first to stay alphabetical.
This commit is contained in:
Kellermorph
2026-09-30 00:25:14 +02:00
99 changed files with 5821 additions and 746 deletions
+18 -1
View File
@@ -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/"
+8 -4
View File
@@ -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: <prefix>/share/qelectrotech/mcp -> <prefix>/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)
+17
View File
@@ -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
;--------------------------------
+64
View File
@@ -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."
+4
View File
@@ -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."
+48
View File
@@ -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}。"
@@ -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]
@@ -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%
+1 -1
View File
@@ -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()
+6
View File
@@ -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
+14
View File
@@ -267,12 +267,19 @@
<file alias="themes/qet/128x128/project.png">128x128/project.png</file>
<file alias="themes/qet/128x128/settings.png">128x128/settings.png</file>
<file alias="themes/qet/128x128/terminalstrip.png">128x128/terminalstrip.png</file>
<file alias="themes/qet/scalable/align-horizontal-center.svg">scalable/align-horizontal-center.svg</file>
<file alias="themes/qet/scalable/align-horizontal-left.svg">scalable/align-horizontal-left.svg</file>
<file alias="themes/qet/scalable/align-horizontal-right.svg">scalable/align-horizontal-right.svg</file>
<file alias="themes/qet/scalable/align-vertical-bottom.svg">scalable/align-vertical-bottom.svg</file>
<file alias="themes/qet/scalable/align-vertical-center.svg">scalable/align-vertical-center.svg</file>
<file alias="themes/qet/scalable/align-vertical-top.svg">scalable/align-vertical-top.svg</file>
<file alias="themes/qet/scalable/diagram.svg">scalable/diagram.svg</file>
<file alias="themes/qet/scalable/folio-delete.svg">scalable/folio-delete.svg</file>
<file alias="themes/qet/scalable/folio-new.svg">scalable/folio-new.svg</file>
<file alias="themes/qet/scalable/folio-properties.svg">scalable/folio-properties.svg</file>
<file alias="themes/qet/scalable/label.svg">scalable/label.svg</file>
<file alias="themes/qet/scalable/pdf-import.svg">scalable/pdf-import.svg</file>
<file alias="themes/qet/scalable/snap-to-grid.svg">scalable/snap-to-grid.svg</file>
<file alias="themes/qet/scalable/edit-opacity.svg">breeze-icons/scalable/apps/hidef/edit-opacity.svg</file>
<file alias="themes/qet/scalable/image-flip-horizontal.svg">breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg</file>
<file alias="themes/qet/scalable/image-flip-vertical.svg">breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg</file>
@@ -457,12 +464,19 @@
<file>themes/qet-dark/48x48/view-pim-journal.png</file>
<file>themes/qet-dark/128x128/plasmagik.png</file>
<file>themes/qet-dark/128x128/settings.png</file>
<file>themes/qet-dark/scalable/align-horizontal-center.svg</file>
<file>themes/qet-dark/scalable/align-horizontal-left.svg</file>
<file>themes/qet-dark/scalable/align-horizontal-right.svg</file>
<file>themes/qet-dark/scalable/align-vertical-bottom.svg</file>
<file>themes/qet-dark/scalable/align-vertical-center.svg</file>
<file>themes/qet-dark/scalable/align-vertical-top.svg</file>
<file>themes/qet-dark/scalable/diagram.svg</file>
<file>themes/qet-dark/scalable/folio-delete.svg</file>
<file>themes/qet-dark/scalable/folio-new.svg</file>
<file>themes/qet-dark/scalable/folio-properties.svg</file>
<file>themes/qet-dark/scalable/label.svg</file>
<file>themes/qet-dark/scalable/pdf-import.svg</file>
<file>themes/qet-dark/scalable/snap-to-grid.svg</file>
<file>themes/qet-dark/scalable/edit-opacity.svg</file>
<file>themes/qet-dark/scalable/image-flip-horizontal.svg</file>
<file>themes/qet-dark/scalable/image-flip-vertical.svg</file>
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 4 5 h 14 v 4 h -14 Z M 7 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 10 1 h 2 v 3 h -2 Z M 10 10 h 2 v 2 h -2 Z M 10 18 h 2 v 3 h -2 Z M 3 4 h 16 v 6 h -16 Z M 4 5 h 14 v 4 h -14 Z M 6 12 h 10 v 6 h -10 Z M 7 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 700 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 5 5 h 13 v 4 h -13 Z M 5 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 2 1 h 2 v 20 h -2 Z M 4 4 h 15 v 6 h -15 Z M 5 5 h 13 v 4 h -13 Z M 4 12 h 10 v 6 h -10 Z M 5 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 654 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 4 5 h 13 v 4 h -13 Z M 9 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 18 1 h 2 v 20 h -2 Z M 3 4 h 15 v 6 h -15 Z M 4 5 h 13 v 4 h -13 Z M 8 12 h 10 v 6 h -10 Z M 9 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 655 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 5 4 h 4 v 13 h -4 Z M 13 9 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 18 h 20 v 2 h -20 Z M 4 3 h 6 v 15 h -6 Z M 5 4 h 4 v 13 h -4 Z M 12 8 h 6 v 10 h -6 Z M 13 9 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 652 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 5 4 h 4 v 14 h -4 Z M 13 7 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 10 h 3 v 2 h -3 Z M 10 10 h 2 v 2 h -2 Z M 18 10 h 3 v 2 h -3 Z M 4 3 h 6 v 16 h -6 Z M 5 4 h 4 v 14 h -4 Z M 12 6 h 6 v 10 h -6 Z M 13 7 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 696 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 5 5 h 4 v 13 h -4 Z M 13 5 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 2 h 20 v 2 h -20 Z M 4 4 h 6 v 15 h -6 Z M 5 5 h 4 v 13 h -4 Z M 12 4 h 6 v 10 h -6 Z M 13 5 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#4d4d4d;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:0.35;stroke:none" d="M 2 0 h 1 v 22 h -1 Z M 8 0 h 1 v 22 h -1 Z M 14 0 h 1 v 22 h -1 Z M 20 0 h 1 v 22 h -1 Z M 0 2 h 22 v 1 h -22 Z M 0 8 h 22 v 1 h -22 Z M 0 14 h 22 v 1 h -22 Z M 0 20 h 22 v 1 h -22 Z M 9 9 h 5 v 5 h -5 Z" class="ColorScheme-Text"/>
<path style="fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 8 8 h 7 v 7 h -7 Z M 9 9 h 5 v 5 h -5 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 742 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 4 5 h 14 v 4 h -14 Z M 7 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 10 1 h 2 v 3 h -2 Z M 10 10 h 2 v 2 h -2 Z M 10 18 h 2 v 3 h -2 Z M 3 4 h 16 v 6 h -16 Z M 4 5 h 14 v 4 h -14 Z M 6 12 h 10 v 6 h -10 Z M 7 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 690 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 5 5 h 13 v 4 h -13 Z M 5 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 2 1 h 2 v 20 h -2 Z M 4 4 h 15 v 6 h -15 Z M 5 5 h 13 v 4 h -13 Z M 4 12 h 10 v 6 h -10 Z M 5 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 644 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 4 5 h 13 v 4 h -13 Z M 9 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 18 1 h 2 v 20 h -2 Z M 3 4 h 15 v 6 h -15 Z M 4 5 h 13 v 4 h -13 Z M 8 12 h 10 v 6 h -10 Z M 9 13 h 8 v 4 h -8 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 645 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 5 4 h 4 v 13 h -4 Z M 13 9 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 18 h 20 v 2 h -20 Z M 4 3 h 6 v 15 h -6 Z M 5 4 h 4 v 13 h -4 Z M 12 8 h 6 v 10 h -6 Z M 13 9 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 642 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 5 4 h 4 v 14 h -4 Z M 13 7 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 10 h 3 v 2 h -3 Z M 10 10 h 2 v 2 h -2 Z M 18 10 h 3 v 2 h -3 Z M 4 3 h 6 v 16 h -6 Z M 5 4 h 4 v 14 h -4 Z M 12 6 h 6 v 10 h -6 Z M 13 7 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 686 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 5 5 h 4 v 13 h -4 Z M 13 5 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 1 2 h 20 v 2 h -20 Z M 4 4 h 6 v 15 h -6 Z M 5 5 h 4 v 13 h -4 Z M 12 4 h 6 v 10 h -6 Z M 13 5 h 4 v 8 h -4 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 641 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">
.ColorScheme-Text {
color:#dcdcdc;
}
</style>
</defs>
<g transform="translate(1,1)">
<path style="fill:#dcdcdc;fill-opacity:0.35;stroke:none" d="M 2 0 h 1 v 22 h -1 Z M 8 0 h 1 v 22 h -1 Z M 14 0 h 1 v 22 h -1 Z M 20 0 h 1 v 22 h -1 Z M 0 2 h 22 v 1 h -22 Z M 0 8 h 22 v 1 h -22 Z M 0 14 h 22 v 1 h -22 Z M 0 20 h 22 v 1 h -22 Z M 9 9 h 5 v 5 h -5 Z" class="ColorScheme-Text"/>
<path style="fill:#dcdcdc;fill-opacity:1;fill-rule:evenodd;stroke:none" d="M 8 8 h 7 v 7 h -7 Z M 9 9 h 5 v 5 h -5 Z" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 732 B

+308 -298
View File
File diff suppressed because it is too large Load Diff
+69 -69
View File
@@ -865,7 +865,7 @@ Note: these options DO NOT allow or block auto numberings, only their update pol
<message>
<location filename="../sources/ui/borderpropertieswidget.ui" line="20"/>
<source>Dimensions du folio</source>
<translation>Dimensions of folio</translation>
<translation>Folio size</translation>
</message>
</context>
<context>
@@ -996,8 +996,8 @@ Note: these options DO NOT allow or block auto numberings, only their update pol
<source>Modifier la couleur de %n conducteur(s)</source>
<comment>undo caption</comment>
<translation>
<numerusform></numerusform>
<numerusform></numerusform>
<numerusform>Change the colour of %n conductor</numerusform>
<numerusform>Change the colour of %n conductors</numerusform>
</translation>
</message>
<message>
@@ -3045,12 +3045,12 @@ The element&apos;s display name is edited separately in the element properties.<
<message>
<location filename="../sources/editor/ui/elementpropertieseditorwidget.cpp" line="209"/>
<source>Renvoi de folio suivant</source>
<translation>Reference folio following</translation>
<translation>Next folio reference</translation>
</message>
<message>
<location filename="../sources/editor/ui/elementpropertieseditorwidget.cpp" line="210"/>
<source>Renvoi de folio précédent</source>
<translation>Previous reference folio</translation>
<translation>Previous folio reference</translation>
</message>
<message>
<location filename="../sources/editor/ui/elementpropertieseditorwidget.cpp" line="211"/>
@@ -3281,7 +3281,7 @@ The element&apos;s display name is edited separately in the element properties.<
<message>
<location filename="../sources/editor/ui/elementpropertieseditorwidget.cpp" line="888"/>
<source>Réf.</source>
<translation>Réf.</translation>
<translation>Ref.</translation>
</message>
<message>
<location filename="../sources/editor/ui/elementpropertieseditorwidget.cpp" line="895"/>
@@ -3629,7 +3629,7 @@ The element&apos;s display name is edited separately in the element properties.<
<message>
<location filename="../sources/dataBase/ui/elementquerywidget.cpp" line="45"/>
<source>Titre du folio</source>
<translation>Title of folio</translation>
<translation>Folio title</translation>
</message>
<message>
<location filename="../sources/dataBase/ui/elementquerywidget.cpp" line="46"/>
@@ -4040,16 +4040,16 @@ By importing this file, you confirm that:
<location filename="../sources/ElementsCollection/elementscollectionwidget.cpp" line="939"/>
<source>%n élément(s), répartie(s)</source>
<translation>
<numerusform>%n element, part</numerusform>
<numerusform>%n elements, parts</numerusform>
<numerusform>%n element, spread</numerusform>
<numerusform>%n elements, spread</numerusform>
</translation>
</message>
<message numerus="yes">
<location filename="../sources/ElementsCollection/elementscollectionwidget.cpp" line="941"/>
<source>dans %n dossier(s).</source>
<translation>
<numerusform>in %n folder.</numerusform>
<numerusform>in %n folders.</numerusform>
<numerusform>across %n folder.</numerusform>
<numerusform>across %n folders.</numerusform>
</translation>
</message>
<message>
@@ -4215,7 +4215,7 @@ By importing this file, you confirm that:
<message>
<location filename="../sources/elementspanelwidget.cpp" line="74"/>
<source>Remonter ce folio</source>
<translation>Move up this folio</translation>
<translation>Move this folio up</translation>
</message>
<message>
<location filename="../sources/elementspanelwidget.cpp" line="75"/>
@@ -4225,17 +4225,17 @@ By importing this file, you confirm that:
<message>
<location filename="../sources/elementspanelwidget.cpp" line="76"/>
<source>Remonter ce folio x10</source>
<translation>Move up this folio x10</translation>
<translation>Move this folio up x10</translation>
</message>
<message>
<location filename="../sources/elementspanelwidget.cpp" line="77"/>
<source>Remonter ce folio x100</source>
<translation>Move up this folio x100</translation>
<translation>Move this folio up x100</translation>
</message>
<message>
<location filename="../sources/elementspanelwidget.cpp" line="78"/>
<source>Remonter ce folio au debut</source>
<translation>Move up this folio to the beginning</translation>
<translation>Move this folio to the beginning</translation>
</message>
<message>
<location filename="../sources/elementspanelwidget.cpp" line="79"/>
@@ -4381,7 +4381,7 @@ By importing this file, you confirm that:
<message>
<location filename="../sources/exportdialog.cpp" line="156"/>
<source>Titre du folio</source>
<translation>Title of folio</translation>
<translation>Folio title</translation>
</message>
<message>
<location filename="../sources/exportdialog.cpp" line="836"/>
@@ -4477,7 +4477,7 @@ By importing this file, you confirm that:
<message>
<location filename="../sources/exportpropertieswidget.cpp" line="188"/>
<source>Exporter entièrement le folio</source>
<translation>Export fully folio</translation>
<translation>Export the whole folio</translation>
</message>
<message>
<location filename="../sources/exportpropertieswidget.cpp" line="191"/>
@@ -4770,7 +4770,7 @@ that you create. Text and number inputs are
<message>
<location filename="../sources/TerminalStrip/ui/freeterminalmodel.cpp" line="228"/>
<source>Référence croisé</source>
<translation>Cross reference</translation>
<translation>Cross-reference</translation>
</message>
<message>
<location filename="../sources/TerminalStrip/ui/freeterminalmodel.cpp" line="229"/>
@@ -5609,8 +5609,8 @@ Any setting other than “No rounding” may cause rendering errors in the proje
<location filename="../sources/qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.cpp" line="424"/>
<source>&lt;center&gt;ATTENTION :&lt;/center&gt;
il manque %1 lignes afin d&apos;afficher l&apos;intégralité des informations</source>
<translation>&lt;center&gt;ATTENTION :&lt;/center&gt;
lines are missing %1 to display all the informations</translation>
<translation>&lt;center&gt;WARNING:&lt;/center&gt;
%1 more rows are needed to display all the information</translation>
</message>
<message>
<location filename="../sources/qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.cpp" line="536"/>
@@ -6195,13 +6195,13 @@ Please use the advanced editor for this.</translation>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="526"/>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="552"/>
<source>N° de folio</source>
<translation>N° of folio</translation>
<translation>Folio no.</translation>
</message>
<message>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="518"/>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="541"/>
<source>Label de folio</source>
<translation>Label folio</translation>
<translation>Folio label</translation>
</message>
<message>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="520"/>
@@ -6209,7 +6209,7 @@ Please use the advanced editor for this.</translation>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="543"/>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="554"/>
<source>Titre de folio</source>
<translation>Title of folio</translation>
<translation>Folio title</translation>
</message>
<message>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="519"/>
@@ -6297,12 +6297,12 @@ Please use the advanced editor for this.</translation>
<message>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="236"/>
<source>Report de folio</source>
<translation>Folio referencing</translation>
<translation>Folio reference</translation>
</message>
<message>
<location filename="../sources/ui/linksingleelementwidget.cpp" line="238"/>
<source>Référence croisée (esclave)</source>
<translation>Cross Reference (slave)</translation>
<translation>Cross-reference (slave)</translation>
</message>
<message>
<location filename="../sources/ui/linksingleelementwidget.ui" line="42"/>
@@ -6364,18 +6364,18 @@ Please use the advanced editor for this.</translation>
<message>
<location filename="../sources/ui/masterpropertieswidget.cpp" line="70"/>
<source>N° de folio</source>
<translation>N° of folio</translation>
<translation>Folio no.</translation>
</message>
<message>
<location filename="../sources/ui/masterpropertieswidget.cpp" line="64"/>
<source>Label de folio</source>
<translation>Label folio</translation>
<translation>Folio label</translation>
</message>
<message>
<location filename="../sources/ui/masterpropertieswidget.cpp" line="65"/>
<location filename="../sources/ui/masterpropertieswidget.cpp" line="71"/>
<source>Titre de folio</source>
<translation>Title of folio</translation>
<translation>Folio title</translation>
</message>
<message>
<location filename="../sources/ui/masterpropertieswidget.cpp" line="66"/>
@@ -6583,12 +6583,12 @@ Do you still want to link this slave contact?</translation>
<message>
<location filename="../sources/ui/configpage/configpages.cpp" line="155"/>
<source>Reports de folio</source>
<translation>Folio referencings</translation>
<translation>Folio references</translation>
</message>
<message>
<location filename="../sources/ui/configpage/configpages.cpp" line="156"/>
<source>Références croisées</source>
<translation>Cross References</translation>
<translation>Cross-references</translation>
</message>
<message>
<location filename="../sources/ui/configpage/configpages.cpp" line="157"/>
@@ -6816,7 +6816,7 @@ Do you still want to link this slave contact?</translation>
<location filename="../sources/autoNum/ui/numparteditorw.cpp" line="291"/>
<location filename="../sources/autoNum/ui/numparteditorw.cpp" line="481"/>
<source>N° folio</source>
<translation>N° scheme</translation>
<translation>Folio no.</translation>
</message>
<message>
<location filename="../sources/autoNum/ui/numparteditorw.cpp" line="146"/>
@@ -7216,7 +7216,7 @@ Voltage / Protocol : %1</translation>
<source>
Numéro : %1</source>
<translation>
Numéro : %1</translation>
Number: %1</translation>
</message>
<message>
<location filename="../sources/ui/potentialselectordialog.cpp" line="205"/>
@@ -7265,7 +7265,7 @@ Conductor section : %1</translation>
Veuillez saisir une formule compatible pour ce potentiel.
Les variables suivantes sont incompatibles :
%sequf_ %seqtf_ %seqhf_ %id %F %M %LM</source>
<translation>The new potential formula contains variables incompatible with the folio reports.
<translation>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</translation>
@@ -8562,7 +8562,7 @@ Available options:
<location filename="../sources/qetdiagrameditor.cpp" line="566"/>
<source>Édite les propriétés du folio (dimensions, informations du cartouche, propriétés des conducteurs...)</source>
<comment>status bar tip</comment>
<translation>Edits the properties of the folio (size, title block informations, conductor properties...)</translation>
<translation>Edits the properties of the folio (size, title block information, conductor properties...)</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="737"/>
@@ -9172,12 +9172,12 @@ Hold Ctrl while moving to place freely.</translation>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="1027"/>
<source>Ajoute une courbe de Bézier sur le folio actuel</source>
<translation>Adds a Bézier curve on the current sheet</translation>
<translation>Adds a Bézier curve to the current folio</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="1028"/>
<source>Ajoute un plan de bornier sur le folio actuel</source>
<translation>Add a terminal plan on the current sheet</translation>
<translation>Adds a terminal plan to the current folio</translation>
</message>
<message>
<location filename="../sources/qetdiagrameditor.cpp" line="1078"/>
@@ -9273,8 +9273,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
<location filename="../sources/qetdiagrameditor.cpp" line="2101"/>
<source>%n objet(s) remis sur la grille</source>
<translation>
<numerusform>%n item placed on the grid</numerusform>
<numerusform>%n item placed on the grid</numerusform>
<numerusform>%n item put back on the grid</numerusform>
<numerusform>%n items put back on the grid</numerusform>
</translation>
</message>
<message>
@@ -9387,8 +9387,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
<location filename="../sources/qetdiagrameditor.cpp" line="3518"/>
<source>%n élément(s) redessiné(s).</source>
<translation>
<numerusform>%n element redesigned.</numerusform>
<numerusform>%n elements redesigned.</numerusform>
<numerusform>%n element redrawn.</numerusform>
<numerusform>%n elements redrawn.</numerusform>
</translation>
</message>
<message numerus="yes">
@@ -9403,8 +9403,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b
<location filename="../sources/qetdiagrameditor.cpp" line="3534"/>
<source>%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).</source>
<translation>
<numerusform>%n element not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved).</numerusform>
<numerusform>%n elements not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved).</numerusform>
<numerusform>%n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved).</numerusform>
<numerusform>%n elements not redrawn: their size, grip point or terminals have changed (a terminal was added, removed or moved).</numerusform>
</translation>
</message>
<message>
@@ -9858,7 +9858,7 @@ Enable scripts? This setting can be changed in Configure QElectroTech &gt; Gener
<message>
<location filename="../sources/editor/ui/qetelementeditor.cpp" line="810"/>
<source>&lt;br&gt;&lt;b&gt;Erreur&lt;/b&gt; :&lt;br&gt;Les reports de folio doivent posséder une seul borne.&lt;br&gt;&lt;b&gt;Solution&lt;/b&gt; :&lt;br&gt;Verifier que l&apos;élément ne possède qu&apos;une seul borne</source>
<translation>&lt;br&gt; &lt;b&gt; Error &lt;/ b&gt;: &lt;br&gt; folio referencings must have a single terminal &lt;br&gt; &lt;b&gt; Solution &lt;/ b&gt; :&lt;br&gt; Check that the element has only one terminal</translation>
<translation>&lt;br&gt;&lt;b&gt;Error&lt;/b&gt;:&lt;br&gt;Folio references must have a single terminal.&lt;br&gt;&lt;b&gt;Solution&lt;/b&gt;:&lt;br&gt;Check that the element has only one terminal</translation>
</message>
<message>
<location filename="../sources/editor/ui/qetelementeditor.cpp" line="843"/>
@@ -9871,8 +9871,8 @@ Enable scripts? This setting can be changed in Configure QElectroTech &gt; Gener
<source> %n erreur(s)</source>
<comment>errors</comment>
<translation>
<numerusform> %n erreur</numerusform>
<numerusform> %n erreurs</numerusform>
<numerusform> %n error</numerusform>
<numerusform> %n errors</numerusform>
</translation>
</message>
<message>
@@ -11138,8 +11138,8 @@ What do you wish to do ?</translation>
<source>%n forme(s)</source>
<comment>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.</comment>
<translation>
<numerusform>%n forme</numerusform>
<numerusform>%n formes</numerusform>
<numerusform>%n shape</numerusform>
<numerusform>%n shapes</numerusform>
</translation>
</message>
<message numerus="yes">
@@ -11252,7 +11252,7 @@ What do you wish to do ?</translation>
<message>
<location filename="../sources/exportdialog.cpp" line="1154"/>
<source>Folio sans titre</source>
<translation>Folio Untitled</translation>
<translation>Untitled folio</translation>
</message>
<message>
<location filename="../sources/exportdialog.cpp" line="1156"/>
@@ -11645,8 +11645,8 @@ the translated name of this folder could not be read, so its folder name is disp
<source>Ajouter %n conducteur(s)</source>
<comment>add a numbers of conductor one or more</comment>
<translation>
<numerusform>add %n conductor</numerusform>
<numerusform>add %n conductors</numerusform>
<numerusform>Add %n conductor</numerusform>
<numerusform>Add %n conductors</numerusform>
</translation>
</message>
<message>
@@ -13849,7 +13849,7 @@ E.g. associating the name &quot;volta&quot; with the value &quot;1745&quot; will
<message>
<location filename="../sources/ui/reportpropertiewidget.ui" line="20"/>
<source>Label de report de folio</source>
<translation>Label of folio referencing</translation>
<translation>Folio reference label</translation>
</message>
<message>
<location filename="../sources/ui/reportpropertiewidget.ui" line="35"/>
@@ -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</source>
<translation>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</translation>
<translation>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</translation>
</message>
</context>
<context>
@@ -14031,7 +14031,7 @@ Create your own text by using the following variables:
<message>
<location filename="../sources/SearchAndReplace/ui/searchandreplacewidget.cpp" line="200"/>
<source>Eléments report de folio</source>
<translation>Folio referencings elements</translation>
<translation>Folio reference elements</translation>
</message>
<message>
<location filename="../sources/SearchAndReplace/ui/searchandreplacewidget.cpp" line="206"/>
@@ -17384,7 +17384,7 @@ The other fields are not used.</translation>
<message>
<location filename="../sources/TerminalStrip/ui/terminalstripmodel.cpp" line="291"/>
<source>Référence croisé</source>
<translation>Cross reference</translation>
<translation>Cross-reference</translation>
</message>
<message>
<location filename="../sources/TerminalStrip/ui/terminalstripmodel.cpp" line="292"/>
@@ -17673,7 +17673,7 @@ The other fields are not used.</translation>
<message>
<location filename="../sources/ui/titleblockpropertieswidget.ui" line="32"/>
<source>Informations des cartouches</source>
<translation>Title block informations</translation>
<translation>Title block information</translation>
</message>
<message>
<location filename="../sources/ui/titleblockpropertieswidget.ui" line="237"/>
@@ -18547,13 +18547,13 @@ associer le nom &quot;volta&quot; et la valeur &quot;1745&quot; remplacera %{vol
%c : le numéro de colonne
%M: Installation
%LM: Localisation </source>
<translation>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 </translation>
<translation>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</translation>
</message>
<message>
<location filename="../sources/ui/xrefpropertieswidget.ui" line="280"/>
+7
View File
@@ -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",
+131 -11
View File
@@ -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 `<prefix>/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 | `<prefix>/share/qelectrotech/mcp/qet_mcp.py` | `<prefix>/bin/qelectrotech`, `<prefix>/share/qelectrotech/elements` |
| Windows installer, MSI, portable folder | `<folder>\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `<folder>\bin\QElectroTech.exe`, `<folder>\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 `<folder>\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
+367 -42
View File
@@ -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 <tool> [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 <terminal> 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 <terminal> 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
<collection>) 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
<terminal> 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:
<prefix>/share/qelectrotech/mcp/ -> <prefix>/bin, <prefix>/share/qelectrotech/elements
(make install: Linux, snap, flatpak, macOS)
<root>/mcp/ -> <root>/bin, <root>/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 <tool> [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 <tool> ['<json arguments>' | -]",
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
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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]
+17 -14
View File
@@ -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%
@@ -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.
@@ -55,6 +55,7 @@ class FileElementCollectionItem : public ElementCollectionItem
void setUpData() override;
void setUpIcon() override;
void clearData() override;
private:
void setPathName(const QString& path_name,
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "terminaluuids.h"
#include <QDomElement>
#include <QHash>
#include <QUuid>
#include <QSet>
#include <QStringList>
namespace {
/**
@return the <terminal> of the definition held by the embedded
collection element @p collection_element
*/
//The <terminal> of the <definition> @p definition
QList<QDomElement> definitionTerminals(const QDomElement &definition)
{
QList<QDomElement> 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<QDomElement> 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 <terminal> of one <definition> get a uuid where missing
int fillDefinition(const QDomElement &definition)
{
const QList<QDomElement> terminals = definitionTerminals(definition);
QSet<QUuid> taken;
for (const QDomElement &t : terminals) {
const QUuid uuid(t.attribute(QStringLiteral("uuid")));
if (!uuid.isNull()) {
taken << uuid;
}
}
int filled = 0;
QHash<QString, int> 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 <element>
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<QString, QStringList> old_uuids;
QSet<QString> 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<QDomElement> new_terminals = terminalsOf(new_element);
QSet<QString> 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
<category> 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
<definition>), 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);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef TERMINALUUIDS_H
#define TERMINALUUIDS_H
#include <QUuid>
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
@@ -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;
}
@@ -76,7 +76,20 @@ void SearchAndReplaceWorker::replaceDiagram(QList<Diagram *> 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));
@@ -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> &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)
@@ -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)
{
@@ -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)
{
@@ -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));
});
+88 -65
View File
@@ -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());
+34 -4
View File
@@ -18,6 +18,7 @@
#ifndef ASSIGNVARIABLES_H
#define ASSIGNVARIABLES_H
#include "../diagramcontext.h"
#include "../diagramposition.h"
#include "numerotationcontext.h"
#include <QPointF>
@@ -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);
+544 -8
View File
@@ -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 <QLocale>
#include <QDate>
#include <QDomDocument>
#include <functional>
#include <QMetaEnum>
#include <QTextDocument>
#include <QFile>
@@ -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<QUuid, DocumentTerminal> terminals;
};
struct DocumentElement
{
QString uuid;
QString diagram_uuid;
QString pos;
QString type;
QString sub_type;
QVariant group;
DiagramContext informations;
QString label;
QHash<QUuid, DocumentTerminal> 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 <sequentialNumbers> 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<Diagram *> 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<DiagramContext> diagram_infos;
QList<QDate> diagram_dates;
QList<QUuid> diagram_uuids;
QList<DocumentElement> elements;
QList<DocumentConductor> conductors;
QSet<QUuid> element_uuids;
QHash<QString, DocumentDefinition> 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<QString, QDomElement> stored;
std::function<void (const QDomElement &, const QString &)> 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<QUuid, int> on_this_folio; //element uuid -> index in elements
QSet<int> 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<int> 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<QString> 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<QString, int> 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(":");
+21
View File
@@ -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<QString, QString> 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<QETProject> m_project;
+56
View File
@@ -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<Conductor *> 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();
}
/**
+9 -1
View File
@@ -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 *)
+48 -1
View File
@@ -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<Element *> 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<Conductor *> 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<QUuid> 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)
+3
View File
@@ -100,6 +100,9 @@ class DiagramContent
DiagramContent& operator+=(const DiagramContent& other);
bool potentialIsManaged(QList<Conductor *>conductors);
bool hasTextEditing();
private:
int removePinnedGroups();
};
QDebug &operator<<(QDebug, DiagramContent &);
#endif
+1 -1
View File
@@ -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);
}
/**
@@ -17,6 +17,7 @@
*/
#include "openelmtcommand.h"
#include "../elementscene.h"
#include "../graphicspart/partterminal.h"
#include <QDomDocument>
#include <QObject>
@@ -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<PartTerminal *>(qgi)->setNewUuid();
}
}
m_scene->qgiManager().manage(m_graphics_item);
m_first_redo = false;
+8
View File
@@ -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())
{
@@ -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);
}
+12 -12
View File
@@ -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,
+27 -1
View File
@@ -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();
}
}
+2
View File
@@ -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<QStatusBar> m_status_bar;
+69 -3
View File
@@ -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);
}
+5
View File
@@ -37,6 +37,10 @@
struct MaterialRecord
{
QMap<QString, QString> 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);
@@ -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<QAbstractButton *>())
{
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<double>(delta) / static_cast<int>(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;
+50 -16
View File
@@ -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<QAction *> 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);
}
+19 -3
View File
@@ -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<ConductorSegmentProfile *> &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<ConductorSegmentProfile *, qreal> segments_lengths;
@@ -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
@@ -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()
+10
View File
@@ -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);
@@ -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();
+40
View File
@@ -33,6 +33,7 @@
#include "../undocommand/promoteshapecommand.h"
#include <QActionGroup>
#include <algorithm>
#include <QCursor>
#include <QMenu>
#include <QStatusBar>
@@ -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<QGraphicsItem *>();
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
+4
View File
@@ -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(
+42 -19
View File
@@ -16,6 +16,7 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#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<int>(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<int>(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
+5
View File
@@ -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;
+14
View File
@@ -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");
+7
View File
@@ -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;
+9
View File
@@ -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);
+1
View File
@@ -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
+19 -8
View File
@@ -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)
{
+1
View File
@@ -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;}
+44 -15
View File
@@ -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, "<index>: <name> (<n> 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, "<index>: <name> (<n> conductor(s)) <uuid>".
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<Terminal *> 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);
+12 -8
View File
@@ -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;
+2 -2
View File
@@ -181,7 +181,7 @@ bool ShortcutManager::trigger(const QString &id) const
return false;
}
for (const QPointer<QObject> &target : qAsConst(it->targets))
for (const QPointer<QObject> &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<QObject> &target : qAsConst(it->targets))
for (const QPointer<QObject> &target : std::as_const(it->targets))
{
auto *action = qobject_cast<QAction *>(target.data());
if (!action) {
+36 -3
View File
@@ -27,6 +27,7 @@
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <algorithm>
/**
@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
+1 -1
View File
@@ -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 &);
+220
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "aiassistantdialog.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QVBoxLayout>
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. "
"<a href=\"%1\">Guide détaillé</a>").arg(QLatin1String(GUIDE_URL)),
this);
intro->setWordWrap(true);
intro->setOpenExternalLinks(true);
layout->addWidget(intro);
if (m_paths.server.isEmpty()) {
auto *missing = new QLabel(
tr("<b>Le serveur MCP n'est pas installé avec cette version de "
"QElectroTech.</b> 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("<b>Python n'est peut-être pas installé.</b> 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("<b>Python est introuvable sur cet ordinateur</b> (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<int>::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();
}
+61
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTDIALOG_H
#define AIASSISTANTDIALOG_H
#include "aiassistantsetup.h"
#include <QDialog>
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
+164
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "aiassistantsetup.h"
#include <QDir>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
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::Client> 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: <root>/mcp/ beside <root>/bin/ (the Windows installers
and portable folder) and <prefix>/share/qelectrotech/mcp/ beside
<prefix>/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));
}
+80
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef AIASSISTANTSETUP_H
#define AIASSISTANTSETUP_H
#include <QString>
#include <QStringList>
#include <functional>
/**
@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<Client> 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<QString(const QString &)>;
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
+29 -5
View File
@@ -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();
}
}
/**
+2 -2
View File
@@ -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);
+13 -3
View File
@@ -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
+4
View File
@@ -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);
+107 -80
View File
@@ -30,6 +30,78 @@
#include <QHash>
#include <QSettings>
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<Entry> entriesOf(const DiagramContent &dc, qreal text_divisor)
{
QList<Entry> 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<QList<int>> unitsOf(const QList<Entry> &entries)
{
QList<QList<int>> units;
QHash<QUuid, int> group_units;
for (int i = 0 ; i < entries.size() ; ++i)
{
const QUuid group = ItemGroups::groupOf(entries.at(i).item);
if (group.isNull()) {
units << QList<int>{i};
continue;
}
if (!group_units.contains(group)) {
group_units.insert(group, units.size());
units << QList<int>();
}
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<Entry> 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<Entry> 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<QGraphicsObject *> members;
QList<Alignment::Item> parts;
QGraphicsObject *snap_item = nullptr; ///< lands on its grid
qreal divisor = 1;
};
QList<Unit> units;
QHash<QUuid, int> 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<QList<int>> 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<Alignment::Item> geometry;
for (const Unit &unit : std::as_const(units))
geometry << Alignment::combined(unit.parts);
for (const QList<int> &unit : units)
{
QList<Alignment::Item> parts;
for (int i : unit)
parts << entries.at(i).geometry;
geometry << Alignment::combined(parts);
}
const QList<QPointF> 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
{
+4 -1
View File
@@ -22,11 +22,12 @@
#include <QUndoCommand>
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<Diagram> m_diagram;
int m_locked_count = 0;
+62
View File
@@ -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=\"$<TARGET_FILE:qelectrotech>\"")
# 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=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\""
"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=\"$<TARGET_FILE:qelectrotech>\""
"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.
+196
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "ui/aiassistantsetup.h"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QtTest>
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<bool>("windows");
QTest::addColumn<QString>("found");
QTest::addColumn<int>("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"
+295
View File
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QDir>
#include <QDomDocument>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// 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<QString>("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"
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
// 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"
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QtTest>
#include <QFile>
#include <QDomDocument>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QUuid>
// 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<QString> 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"
+426
View File
@@ -0,0 +1,426 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "../../sources/ElementsCollection/terminaluuids.h"
#include <QtTest>
#include <QDomDocument>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QProcessEnvironment>
#include <QTemporaryDir>
#include <QUuid>
// 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<QStringList> &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<QDomElement> embeddedSymbols(const QDomDocument &doc)
{
QList<QDomElement> 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<QString>("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"